
    Ria                    h   d Z ddlmZ ddlZddlZddlmZ ddlmZ ddl	m
Z
mZmZmZ  ej                  e      Z G d de      Z G d	 d
e      Z G d de      Z G d de      ZddZdddZej0                  j3                  ej0                  j5                  d      d      ZdZdZ G d d      Zy)u  
Playwright 기반 티스토리 자동 발행 모듈.

티스토리 Open API v1이 2024년 2월 완전 폐지됨에 따라
Playwright 브라우저 자동화 + storage_state 세션 관리 방식을 사용.

세션 워크플로우:
  1. 최초 1회: login_interactive() → 수동 카카오 로그인 → storage_state 저장
  2. 이후 실행: storage_state 복원 → 세션 유효성 확인 → 자동 발행
  3. 세션 만료 시: login_interactive() 재실행 안내
    )annotationsN)Path)Any)BrowserBrowserContextPageasync_playwrightc                      e Zd ZdZy)TistoryErroru)   티스토리 발행 모듈 기본 예외.N__name__
__module____qualname____doc__     O/home/jay/workspace/.worktrees/task-2117-dev1/scripts/blog/tistory_publisher.pyr   r      s    3r   r   c                      e Zd ZdZy)SessionExpiredErroru5   세션이 만료되었거나 유효하지 않을 때.Nr   r   r   r   r   r   "   s    ?r   r   c                      e Zd ZdZy)LoginRequiredErroru%   로그인이 필요한 상태일 때.Nr   r   r   r   r   r   &   s    /r   r   c                      e Zd ZdZy)PublishErroru&   글 발행/저장에 실패했을 때.Nr   r   r   r   r   r   *   s    0r   r   c                   i }t        |       }|j                         s|S |j                  d      j                         D ]  }|j	                         }|r|j                  d      r'|j                  d      r|t        d      d }d|vrK|j                  d      \  }}}|j	                         }|j	                         j	                  d      j	                  d      }|||<    |S )	u   
    .env.keys 형식의 파일을 파싱하여 dict 반환.

    지원 형식:
      export KEY=value
      KEY=value
      # 주석
    zutf-8)encoding#zexport N="')r   exists	read_text
splitlinesstrip
startswithlen	partition)env_pathresultpathlinekey_values          r   _load_env_filer.   3   s      F>D;;=0;;= zz|ts+??9%I()Dd?s+Qiik##C(..s3s Mr   c                x    t         j                  j                  |       }|r|S |rt        |      }| |v r||    S |S )uK   환경변수 → .env.keys → 생성자 인자 순서로 설정값 해석.)osenvirongetr.   )r+   constructor_valueenv_file_pathenv_valfile_envs        r   _resolve_configr7   O   sB     jjnnS!G!-0(?C= r   ~z	.env.keysz"https://www.tistory.com/auth/loginz	*/manage*c                      e Zd ZdZdddef	 	 	 	 	 	 	 	 	 ddZedd       Zedd       Zedd       Z	ddZ
dd	Zdd
ZdddZddZ	 	 	 d	 	 	 	 	 	 	 	 	 	 	 ddZ	 	 d	 	 	 	 	 	 	 ddZddZd dZddZd!dZd"dZy)#TistoryPublisheru7   Playwright 기반 티스토리 자동 발행 클래스.NTc                   t        d||      }t        d||      }|st        d      |st        d      || _        || _        || _        d| _        d| _        d| _        t        j                  d| j                  | j                         y)u  
        Args:
            blog_name: 티스토리 블로그 서브도메인 (예: "myblog" → myblog.tistory.com).
                       미입력 시 환경변수 TISTORY_BLOG_NAME 사용.
            session_path: storage_state JSON 파일 경로.
                          미입력 시 환경변수 TISTORY_SESSION_PATH 사용.
            headless: 브라우저 헤드리스 모드 여부.
            env_file: 환경 변수 파일 경로 (기본: ~/.env.keys).
        TISTORY_BLOG_NAMETISTORY_SESSION_PATHuk   blog_name이 지정되지 않았습니다. 인자 또는 TISTORY_BLOG_NAME 환경변수를 설정하세요.uq   session_path가 지정되지 않았습니다. 인자 또는 TISTORY_SESSION_PATH 환경변수를 설정하세요.Nu/   TistoryPublisher 초기화: blog=%s, session=%s)
r7   
ValueError	blog_namesession_pathheadless_browser_context_pageloggerdebug)selfr?   r@   rA   env_fileresolved_blogresolved_sessions          r   __init__zTistoryPublisher.__init__j   s      ((;YQ*+A<QYZ  K  L  L D  ,!1&(,/3"&
FX\XiXijr   c                "    d| j                    dS )Nzhttps://z.tistory.com)r?   rG   s    r   	_base_urlzTistoryPublisher._base_url   s    $..)66r   c                     | j                    dS )Nz/managerN   rM   s    r   _manage_urlzTistoryPublisher._manage_url   s    ..!))r   c                     | j                    dS )Nz/manage/newpost/rP   rM   s    r   _newpost_urlzTistoryPublisher._newpost_url   s    ..!!122r   c                $    | j                    d| S )Nz/manage/post/rP   )rG   post_ids     r   	_edit_urlzTistoryPublisher._edit_url   s    ..!wi88r   c                  K   t         j                  d       t        d       t        d       t        d       t               4 d{   }|j                  j                  d       d{   }|j                          d{   }|j                          d{   }|j                  t               d{    t         j                  d       |j                  t        d	
       d{    t         j                  d| j                         t        | j                        j                  j                  dd       |j!                  | j                         d{    t        d| j                          |j#                          d{    ddd      d{    y7 _7 >7 )7 7 7 7 U7 '7 # 1 d{  7  sw Y   yxY ww)u   
        Headed 모드로 브라우저를 열고 사용자가 직접 로그인하도록 안내.

        카카오 2FA를 포함한 수동 로그인이 완료되면 storage_state를
        session_path에 저장한다.
        u*   대화형 로그인 시작 (headed 모드)u1   
[TistoryPublisher] 브라우저가 열립니다.uM     → 카카오 계정으로 티스토리에 로그인하세요 (2FA 포함).ud     → 로그인 완료 후 관리 페이지로 이동되면 자동으로 세션이 저장됩니다.
NFrA   uN   로그인 완료 대기 중... 관리 페이지 URL 패턴을 감지합니다.i timeoutu1   로그인 감지됨. storage_state 저장 중: %sT)parentsexist_ok)r)   u)   [TistoryPublisher] 세션 저장 완료: )rE   infoprintr	   chromiumlaunchnew_contextnew_pagegoto_TISTORY_LOGIN_URLwait_for_url_MANAGE_URL_PATTERNr@   r   parentmkdirstorage_stateclose)rG   pwbrowsercontextpages        r   login_interactivez"TistoryPublisher.login_interactive   s     	@ABD]_uw#% 	" 	"KK...>>G#//11G ))++D))./// KKhi### $   
 KKKTM^M^_""#**000M''T->->'???=d>O>O=PQR--/!!)	" 	" 	">1+/
 @ ")	" 	" 	" 	"s   AGF&G F<+F),F<F,F<F/F<8F293F<,F4-A3F< F6!/F<F8F<G F:!G)F<,F</F<2F<4F<6F<8F<:G<GGG
Gc                   K   t        | j                        j                         st        d| j                   d      t        j                  d| j                         yw)uX   session_path에서 storage_state를 로드하여 브라우저 컨텍스트를 초기화.u*   세션 파일을 찾을 수 없습니다: uF   
login_interactive()를 먼저 실행하여 세션을 생성하세요.u   세션 로드: %sN)r   r@   r    FileNotFoundErrorrE   rF   rM   s    r   _load_sessionzTistoryPublisher._load_session   s\     D%%&--/#<T=N=N<O PX X  	($*;*;<s   AA c                2  K   | j                   St               j                          d{   }|j                  j	                  | j
                         d{   | _         | j                  Ki }|rt        |      j                         r||d<    | j                   j                  di | d{   | _        | j                  '| j                  j                          d{   | _
        | j                   | j                  | j                  fS 7 7 7 c7 2w)u   
        브라우저 / 컨텍스트 / 페이지를 생성하거나 기존 것을 반환.

        Args:
            storage_state: storage_state JSON 파일 경로 (없으면 익명 세션).
        NrX   ri   r   )rB   r	   startr_   r`   rA   rC   r   r    ra   rD   rb   )rG   ri   rk   kwargss       r   _ensure_browserz TistoryPublisher._ensure_browser   s      == ')//11B"$++"4"4dmm"4"LLDM== %'Fm!4!;!;!=*7'";$--";";"Ef"EEDM::#}}5577DJ}}dmmTZZ77 2L F 8sF   (DD-DDAD/D02D"D#-DDDDc                  K   | j                          d{    	 | j                  | j                         d{   \  }}}|j                  | j                  dd       d{    |j
                  }d|v sd|v rt        j                  d|       y	t        j                  d
|       y7 7 r7 J# t        $ r }t        j                  d|       Y d}~y	d}~ww xY ww)u   
        저장된 세션으로 관리 페이지에 접근하여 유효성 검증.

        Returns:
            True: 세션 유효, False: 세션 만료 또는 로그인 페이지로 리다이렉트.
        Nri   domcontentloaded0u  
wait_untilrZ   z
auth/loginloginu=   세션 만료: 로그인 페이지로 리다이렉트됨 (%s)Fu   세션 유효: %sTu&   세션 유효성 확인 중 오류: %s)
rr   rv   r@   rc   rQ   urlrE   warningrF   	Exception)rG   r,   rn   current_urlexcs        r   _check_session_validz%TistoryPublisher._check_session_valid   s        """	#33$BSBS3TTJAq$))D,,9KU[)\\\((K{*g.D^`klLL,k: 	#T\  	NNCSI	sf   CB,CB2 B.)B2 %B0&.B2 CB2 +C.B2 0B2 2	C;CCCCc                  K   |g }t         j                  d||       | j                          d{   }|st        d      | j	                  | j
                         d{   \  }}}	 |j                  | j                  dd       d{    t         j                  d| j                         d	}	|j                  |	d
       d{    |j                  |	|       d{    t         j                  d       d}
	 |j                  |
d       d{    t         j                  d       d}	 |j                  |d       d{    |j                  ||       d{    t         j                  d       |dk7  r4d}	 |j                  ||       d{    t         j                  d|       |rBd}	 |j                  |dj!                  |             d{    t         j                  d|       |dk(  r3d }	 |j                  |d       d{    t         j                  d!       d#}|j                  |d       d{    t         j                  d$       |j#                  d%| j$                   d&d
       d{    |j&                  }t         j                  d'|       |S 7 7 S7 +7 7 7 # t        $ r t         j                  d       Y w xY w7 7 # t        $ r4 t         j                  d       |j                  d|       d{  7   Y w xY w7 # t        $ r t         j                  d|       Y w xY w7 x# t        $ r t         j                  d       Y w xY w7 g# t        $ r t         j                  d"       Y qw xY w7 ]7 "# t        $ r  t        $ r)}t         j)                  d(       t+        d)|       |d}~ww xY ww)*u  
        글쓰기 페이지에서 새 글을 작성하고 발행한다.

        Args:
            title: 글 제목.
            content: HTML 본문.
            category_id: 카테고리 ID (기본 "0" = 분류 없음).
            tags: 태그 목록.
            visibility: "private" (비공개) 또는 "public" (공개). PoC에서는 private만 사용.

        Returns:
            발행된 글 URL.

        Raises:
            SessionExpiredError: 세션 만료 시.
            PublishError: 발행 실패 시.
        Nu*   글 발행 시작: title=%r, visibility=%s[   세션이 만료되었습니다. login_interactive()를 실행하여 재로그인하세요.rx   networkidlerz   r{   u%   글쓰기 페이지 로드 완료: %s#post-title-inpi:  rY   u   제목 입력 완료zLbutton.btn-html, .editor-mode-btn[data-mode='html'], button:has-text('HTML')  u   HTML 모드 전환 완료uR   HTML 모드 전환 버튼을 찾지 못했습니다. 셀렉터를 확인하세요.z=textarea#content, .CodeMirror textarea, .html-editor textarea'  u$   HTML 본문 입력 완료 (textarea)uC   본문 textarea를 찾지 못했습니다. JavaScript 주입 시도z(content) => {
                        const cm = document.querySelector('.CodeMirror');
                        if (cm && cm.CodeMirror) {
                            cm.CodeMirror.setValue(content);
                        }
                    }0z(select#category, select[name='category'])r-   u   카테고리 설정: %suI   카테고리 선택 실패: category_id=%s. 셀렉터를 확인하세요.z.input#tag, input[name='tag'], .tag-input inputz, u   태그 입력 완료: %su3   태그 입력 실패. 셀렉터를 확인하세요.privateuU   input[value='3'][name='visibility'], label:has-text('비공개'), .visibility-privateu   비공개 설정 완료uP   비공개 설정 버튼을 찾지 못했습니다. 셀렉터를 확인하세요.uG   button#publish-layer-btn, button.btn-publish, button:has-text('발행')u   발행 버튼 클릭z*/z.tistory.com/*u   글 발행 완료: %su   글 발행 중 오류 발생u"   글 발행에 실패했습니다: )rE   r]   r   r   rv   r@   rc   rS   rF   wait_for_selectorfillclickr   r   evaluateselect_optionjoinre   r?   r~   	exceptionr   )rG   titlecontentcategory_idtags
visibilityvalidr,   rn   title_selectorhtml_mode_btn_selectorcontent_selectorcategory_selectortag_selectorprivate_selectorpublish_selectorpublished_urlr   s                     r   publish_postzTistoryPublisher.publish_post   s    2 <D@%T//11%  'D  E  E//d>O>O/PP
1d^	T))D---QW)XXXLL@$BSBST /N(((HHH))NE222LL/0
 &t"ujj!7jGGG89  _,,-=v,NNNii 0':::CD  c!$N!},,->k,RRRLL!:KH
 OZ))L$))D/BBBLL!;TB
 Y& r !w**%5u*EEELL!:;  i**-v*>>>LL/0 ##b(8$GQW#XXX HHMKK/?  } 2 Q Y I2 H u stu O:  demm   	& S  }NN#np{|} C  ZNN#XYZ F  wNN#uvw ?
 Y
 # 	 	T;<!CC5IJPSS	Ts  /O,J20O,"J5#O,,!N. J8<N. 
J;N. $J>%N. K KK 1N. 4K/ 
K)K/ $K,%K/ >N. L2 L/L2 7N. <$M  M!M ;N. N N N 3N. N(=N. 	N+
'N. 1O,5O,8N. ;N. >N. K K&"N. %K&&N. )K/ ,K/ /3L,"L%#L,(N. +L,,N. /L2 2MN. MN. M M=9N. <M==N.  N N%!N. $N%%N. +N. .O) $O$$O))O,c                H  K   t         j                  d|       | j                          d{   }|st        d      | j	                  | j
                         d{   \  }}}	 |j                  | j                  |      dd       d{    |1d}|j                  ||       d{    t         j                  d	       |2d
}	 |j                  ||       d{    t         j                  d       d}	|j                  |	d       d{    t         j                  d|       y7 7 7 7 7 V# t        $ r |j                  d|       d{  7   Y zw xY w7 P# t        $ r  t        $ r)}
t         j                  d       t        d|
       |
d}
~
ww xY ww)uU  
        기존 글을 수정한다.

        Args:
            post_id: 수정할 글의 ID.
            title: 새 제목 (None이면 변경 안 함).
            content: 새 HTML 본문 (None이면 변경 안 함).

        Raises:
            SessionExpiredError: 세션 만료 시.
            PublishError: 저장 실패 시.
        u   글 수정 시작: post_id=%sNr   rx   r   rz   r{   r   u   제목 수정 완료z&textarea#content, .CodeMirror textareaa  (content) => {
                            const cm = document.querySelector('.CodeMirror');
                            if (cm && cm.CodeMirror) {
                                cm.CodeMirror.setValue(content);
                            }
                        }u   본문 수정 완료u_   button#publish-layer-btn, button.btn-save, button:has-text('저장'), button:has-text('수정')r   rY   u$   글 수정 저장 완료: post_id=%su   글 수정 중 오류 발생u"   글 수정에 실패했습니다: )rE   r]   r   r   rv   r@   rc   rV   r   rF   r   r   r   r   r   )rG   rU   r   r   r   r,   rn   r   r   save_selectorr   s              r   	edit_postzTistoryPublisher.edit_post  s    $ 	3W=//11%  'D  E  E//d>O>O/PP
1d&	T))DNN73W])^^^ !2ii66634"#K ))$4g>>> 34
 r  **]F*;;;KK>HM 2 Q _
 7 ?  	--    	" < # 	 	T;<!CC5IJPSS	Ts   *F"D-0F"D0F"'&E$ D2E$ +D4,E$ 
D8 D6 D8 $-E$ E"E$ ,F"0F"2E$ 4E$ 6D8 8EEEE$ EE$ $F6$FFF"c                0  K   t         j                  d|       | j                          d{   }|st        d      | j	                  | j
                         d{   \  }}}	 | j                   d}|j                  |dd       d{    d	| d
| d}|j                  |      }|j                  d      j                  d       d{   }|j                  d      j                  d       d{   }	d|	v rdnd}
| j                   d| }t        |      |j                         |
|d}t         j                  d|       |S 7 !7 7 7 7 \# t        $ r  t        $ r)}t         j                  d       t!        d|       |d}~ww xY ww)u{  
        관리 페이지에서 특정 글의 상태를 확인한다.

        Args:
            post_id: 조회할 글의 ID.

        Returns:
            dict: {"post_id": str, "title": str, "status": "private"|"public", "url": str}

        Raises:
            SessionExpiredError: 세션 만료 시.
            TistoryError: 글 정보를 파싱할 수 없을 때.
        u   글 상태 조회: post_id=%sNr   rx   z/postsr   rz   r{   ztr[data-post-id='z'], li[data-post-id='z']z.post-title, .titler   rY   z.post-status, .statusu	   비공개r   public/)rU   r   statusr~   u   글 상태 조회 결과: %su#   글 상태 조회 중 오류 발생u*   글 상태를 확인할 수 없습니다: )rE   r]   r   r   rv   r@   rQ   rc   locator
inner_textrN   strr#   rF   r   r   r   )rG   rU   r   r,   rn   manage_post_urlrow_selectorrowr   status_textr   post_urlr(   r   s                 r   get_post_statusz TistoryPublisher.get_post_status  s     	3W=//11%  'D  E  E//d>O>O/PP
1d	\ "&!1!1 2&9O))Ov)VVV /wi7LWIUWXL,,|,C++&;<GGPUGVVE #,C D O OX] O ^^K"-"<Y(F..)7)4H w< 	&F LL7@M; 2 Q W W^ # 	 	\BC!KC5QRX[[	\s}   *FE0FEF'&E EAE E(E 9E:AE FFE E E F*$FFFc                0  K   t        |      j                         st        d|       t        j	                  d|       | j                          d{   }|st        d      | j                  | j                         d{   \  }}}	 |j                  | j                  dd       d{    d	}|j                  |      }|j                  d
 d      4 d{   }|j                  |       d{    ddd      d{    j                   d{   }|j                          d{   }	|	j!                  d      xs5 |	j!                  d      xs" |	j!                  di       j!                  dd      }
|
st#        d      t        j	                  d|
       |
S 7 S7 %7 7 7 7 # 1 d{  7  sw Y   xY w7 7 # t        t        t"        f$ r  t$        $ r)}t        j'                  d       t#        d|       |d}~ww xY ww)u  
        글쓰기 페이지의 이미지 업로드 기능을 사용하여 이미지를 업로드한다.

        Args:
            image_path: 업로드할 이미지 파일의 로컬 경로.

        Returns:
            업로드된 이미지의 URL.

        Raises:
            FileNotFoundError: 이미지 파일이 없을 때.
            SessionExpiredError: 세션 만료 시.
            PublishError: 업로드 실패 시.
        u-   이미지 파일을 찾을 수 없습니다: u   이미지 업로드 시작: %sNr   rx   r   rz   r{   z6input[type='file'][accept*='image'], #imageUploadInputc                >    d| j                   v xs d| j                   v S )Nuploadimage)r~   )rs    r   <lambda>z/TistoryPublisher.upload_image.<locals>.<lambda>*  s    (aee+?w!%%/? r   rY   r~   imageUrldata uC   이미지 업로드 응답에서 URL을 파싱할 수 없습니다.u   이미지 업로드 완료: %su%   이미지 업로드 중 오류 발생u+   이미지 업로드에 실패했습니다: )r   r    rq   rE   r]   r   r   rv   r@   rc   rS   r   expect_responseset_input_filesr-   jsonr2   r   r   r   )rG   
image_pathr   r,   rn   file_input_selector
file_inputresponse_inforesponseresponse_json	image_urlr   s               r   upload_imagezTistoryPublisher.upload_image  s&     J&&(#&ST^S_$`aa4jA//11%  'D  E  E//d>O>O/PP
1d#	]))D---QW)XXX #[&9:J++? ,  = =  00<<<	= = +000H2:--/,AM !!%( @ $$Z0@ $$VR044UB?  "#hiiKK8)DG 2 Q Y= =	= = = = 1,A $%6E 	 	]DE!LSERSY\\	]s   AHF&0HF)H!G /F,0/G F. G #F48F09F4=G F2	G G	G 4G5A0G %H)H,G .G 0F42G 4G:F=;GG G H*$HHHc                  K   | j                   )| j                   j                          d{    d| _         | j                  )| j                  j                          d{    d| _        | j                  )| j                  j                          d{    d| _        t        j                  d       y7 7 X7 %w)u'   브라우저 리소스를 정리한다.Nu$   브라우저 리소스 정리 완료)rD   rj   rC   rB   rE   rF   rM   s    r   rj   zTistoryPublisher.closeI  s     ::!**""$$$DJ==$--%%''' DM==$--%%''' DM;< % ( (s3   *B>B84B>!B:"4B>B<"B>:B><B>c                   K   | S wNr   rM   s    r   
__aenter__zTistoryPublisher.__aenter__Z  s     s   c                @   K   | j                          d {    y 7 wr   )rj   )rG   exc_typeexc_valexc_tbs       r   	__aexit__zTistoryPublisher.__aexit__]  s     jjls   )
r?   
str | Noner@   r   rA   boolrH   r   returnNone)r   r   )rU   	str | intr   r   )r   r   r   )ri   r   r   z$tuple[Browser, BrowserContext, Page])r   r   )r   Nr   )r   r   r   r   r   r   r   zlist[str] | Noner   r   r   r   )NN)rU   r   r   r   r   r   r   r   )rU   r   r   dict[str, str])r   r   r   r   )r   z'TistoryPublisher')r   r   r   r   r   r   r   r   )r   r   r   r   _DEFAULT_ENV_FILErK   propertyrN   rQ   rS   rV   ro   rr   rv   r   r   r   r   r   rj   r   r   r   r   r   r:   r:   g   sY   A !%#')"k"k !"k 	"k
 "k 
"kP 7 7 * * 3 39 "D=8,: !%#BTBT BT 	BT
 BT BT 
BTV !"	@T@T @T 	@T
 
@TL3\r=]F="r   r:   )r'   r   r   r   )r   )r+   r   r3   r   r4   r   r   r   )r   
__future__r   loggingr0   pathlibr   typingr   playwright.async_apir   r   r   r	   	getLoggerr   rE   r   r   r   r   r   r.   r7   r)   r   
expanduserr   rd   rf   r:   r   r   r   <module>r      s   
 #  	   P P			8	$49 4@, @0 01< 18& GGLL!3!3C!8+F 9 ! w wr   