# -*- coding: utf-8 -*-
"""
STEP115-01 V2 Publish Worker - SQL LIKE percent escape fix

역할:
- V1 workers/publish_worker.py를 수정하지 않고 V2 전용 발행 워커를 확장한다.
- 글쓰기 이동 → Editor Ready → Recovery → 제목 → Rendered Preview Copy 본문
  → 발행 버튼 탐색 → 발행 버튼 클릭 → 발행 설정창 분석 → 최종 발행 버튼 확인/실행
  → 실제 발행 후 post_url 자동 수집 → 대표이미지/Cafe24 CDN 검증 → Queue/Draft/Publish History DB 반영.

안전 정책:
- --check-final-publish : 최종 발행 버튼 확인만 하고 클릭하지 않는다.
- --execute             : 실제 최종 발행 버튼을 클릭한다.
- publish_worker.py(V1)는 수정하지 않는다.

실행:
  python -m workers.v2_publish_worker --realtor-id 1 --limit 1 --check-final-publish
  python -m workers.v2_publish_worker --queue-id 205 --check-final-publish

STEP115-01 LIGHT VERIFY:
  - 발행 후 post_url/queue/draft/history/대표이미지 경량 검증을 수행한다.
  - 게시글 재접속/검색 노출 추적/장시간 재시도는 후순위로 보류한다.

실제 발행:
  python -m workers.v2_publish_worker --realtor-id 1 --limit 1 --execute
  python -m workers.v2_publish_worker --queue-id 205 --execute
"""

import argparse
import os
import json
import random
import re
import sys
import time
from datetime import datetime
from pathlib import Path

V2_OPS_STABLE_BUILD = "step114_ops_stable_publish_first"

from playwright.sync_api import sync_playwright

STEP115_02_LIGHT_VERIFY_CLEAN_WARNINGS = True
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))

from db import get_conn

try:
    from core.v2_publish_queue_router import record_publish_success, record_publish_failure
except Exception:
    record_publish_success = None
    record_publish_failure = None
from services.v2.human_publish.session import PublishSession
from services.v2.human_publish.logger import BehaviorLogger
from services.v2.human_publish.engine import HumanPublishEngine
from services.v2.publish.navigation import V2PublishNavigation
from services.v2.naver_auto_relogin_v2 import recover_login_page_once_v2
from services.v2.publish.editor import wait_editor_ready
from services.v2.publish.title import input_title as _v2_service_input_title
from services.v2.publish.body import input_body
from services.v2.publish.publish_button import discover_publish_button
from services.v2.publish.publish_action import probe_publish_click
from services.v2.publish.publish_dialog import inspect_publish_dialog
from services.v2.publish.final_publish import final_publish
from services.v2.human_publish.recovery.editor_recovery import recover_editor




def v2_html_has_middle_banner(html):
    html = str(html or "")
    return "middle_banner" in html or "v2-cafe24-middle-banner-block" in html




def v2_log_publish_html_required_blocks_before_paste(html):
    html = str(html or "")
    report = {
        "html_len": len(html),
        "has_legal_disclosure": "realestate-legal-disclosure-table" in html,
        "has_legal_title": "중개대상물 표시" in html,
        "has_office_disclosure": "중개사무소 및 개업공인중개사 명시사항" in html,
        "has_property_disclosure": "중개대상물 명시사항" in html,
        "has_realtor_map": "realestate-realtor-map-image" in html,
        "has_middle_banner": "middle_banner" in html or "middle-banner" in html,
        "has_fin_land": "fin.land.naver.com/map" in html,
    }
    print("[V2 PASTE ONLY HTML REQUIRED BLOCKS]", report)
    return report

def input_body_direct_clipboard_html_v1_style(page, write_frame, html, fallback_text="", logger=None, session=None, verbose=True):
    """
    STEP107-43:
    V1 publish_worker 방식에 가깝게 HTML을 preview 렌더링 없이 바로 text/html 클립보드로 붙여넣는다.

    목적:
    - 중간배너 <a href=fin.land...><img ...></a> 구조를 브라우저 preview에서 클릭/오픈하지 않는다.
    - file:/// preview 페이지를 만들지 않는다.
    - fin.land 전체매물 페이지를 자동으로 열지 않는다.
    """
    html = str(html or "").strip()
    fallback_text = str(fallback_text or "").strip()

    result = {
        "status": "failed",
        "mode": "direct_clipboard_html_v1_style",
        "html_len": len(html),
        "fallback_len": len(fallback_text),
        "verify": None,
        "error": None,
    }

    if verbose:
        print("[V2 BODY INPUT DIRECT CLIPBOARD START]", {
            "html_len": len(html),
            "has_middle_banner": v2_html_has_middle_banner(html),
            "has_fin_land": "fin.land.naver.com/map" in html,
        })

    try:
        # 본문 위치 클릭. 기존 input_body가 하던 것처럼 본문 placeholder 근처를 최대한 안전하게 클릭한다.
        clicked_body = False

        selectors = [
            ".se-section-text .se-module-text",
            ".se-component-content .se-placeholder",
            ".se-section-text",
            "article.se-components-wrap",
        ]

        roots = []
        if write_frame is not None:
            roots.append(("write_frame", write_frame))
        roots.append(("page", page))

        for root_name, root in roots:
            for selector in selectors:
                try:
                    loc = root.locator(selector).first
                    if loc.count() <= 0:
                        continue
                    loc.click(timeout=3000, force=True)
                    page.wait_for_timeout(700)
                    clicked_body = True
                    print("[V2 BODY DIRECT CLICK]", root_name, selector)
                    break
                except Exception:
                    continue
            if clicked_body:
                break

        if not clicked_body:
            page.mouse.click(520, 430)
            page.wait_for_timeout(700)
            print("[V2 BODY DIRECT CLICK FALLBACK] page mouse 520,430")

        if html:
            # V1 방식: navigator.clipboard.write(text/html) 후 Ctrl+V
            target = write_frame if write_frame is not None else page
            target.evaluate("""
            async (html) => {
                const blob = new Blob([html], { type: 'text/html' });
                const data = [new ClipboardItem({ 'text/html': blob })];
                await navigator.clipboard.write(data);
            }
            """, html)

            page.wait_for_timeout(1200)
            page.keyboard.press("Control+V")
            page.wait_for_timeout(5000)
            print("[V2 BODY DIRECT HTML PASTE SUCCESS]")
        else:
            if not fallback_text:
                fallback_text = "매물 상세 내용은 상담을 통해 안내드리겠습니다."
            page.keyboard.type(fallback_text[:2500], delay=20)
            page.wait_for_timeout(2500)
            print("[V2 BODY DIRECT PLAIN TEXT SUCCESS]")

        verify = {"ok": False, "text_len": 0, "image_count": 0, "sample_text": ""}
        try:
            root = write_frame if write_frame is not None else page
            verify = root.evaluate("""
            () => {
                const body = document.body;
                const text = (body && body.innerText || '').trim();
                const imgs = Array.from(document.querySelectorAll('img')).map(img => img.src || '');
                const anchors = Array.from(document.querySelectorAll('a')).map(a => a.href || '');
                return {
                    ok: text.length > 0 || imgs.length > 0,
                    text_len: text.length,
                    image_count: imgs.length,
                    anchor_count: anchors.length,
                    has_fin_land_anchor: anchors.some(u => (u || '').includes('fin.land.naver.com/map')),
                    has_middle_banner_img: imgs.some(u => (u || '').includes('middle_banner')),
                    sample_text: text.slice(0, 500)
                };
            }
            """)
        except Exception as e:
            verify = {"ok": False, "error": str(e)}

        result["status"] = "success" if verify.get("ok") else "verify_failed"
        result["verify"] = verify
        print("[V2 BODY DIRECT VERIFY]", verify)
        return result

    except Exception as exc:
        result["error"] = f"{type(exc).__name__}: {exc}"
        print("[V2 BODY DIRECT ERROR]", result)
        return result


def is_v2_unintended_preview_or_image_page_url(url):
    url = str(url or "").strip().lower()
    if not url:
        return False

    if url.startswith("file:///") and "blog_v2_preview" in url:
        return True

    if "akcdn-pjjh0305cdn.cafe24img.com" in url and any(ext in url for ext in [".png", ".jpg", ".jpeg", ".webp", ".gif"]):
        return True

    # 중간배너 링크는 HTML 안에만 유지되어야 하며, 발행 작업 중 별도 탭으로 열려 있으면 닫는다.
    if "fin.land.naver.com/map" in url:
        return True

    return False


def cleanup_v2_unintended_pages_and_refocus(context, editor_page, label=""):
    """
    STEP107-40:
    Rendered Preview Copy / 이미지 클릭 등으로 file preview 또는 Cafe24 이미지 단독 탭이 열린 경우 닫고
    반드시 네이버 글쓰기 editor page로 포커스를 돌린다.
    """
    result = {
        "ok": True,
        "label": label,
        "closed": [],
        "kept": [],
        "refocused": False,
        "error": None,
    }

    try:
        pages = list(context.pages)

        for p in pages:
            try:
                u = str(p.url or "")
            except Exception:
                u = ""

            if p == editor_page:
                result["kept"].append(u)
                continue

            if is_v2_unintended_preview_or_image_page_url(u):
                try:
                    p.close()
                    result["closed"].append(u)
                except Exception as e:
                    result["closed"].append(f"{u} close_error={e}")
            else:
                result["kept"].append(u)

        try:
            editor_page.bring_to_front()
            result["refocused"] = True
        except Exception as e:
            result["error"] = f"bring_to_front_error: {e}"

        print("[V2 CLEANUP PREVIEW/IMAGE PAGES]", result)
        return result

    except Exception as e:
        result["ok"] = False
        result["error"] = f"{type(e).__name__}: {e}"
        print("[V2 CLEANUP PREVIEW/IMAGE PAGES ERROR]", result)
        return result


def get_v2_pending_queue(conn, realtor_id=None, queue_id=None, exclude_queue_ids=None):
    """
    STEP108-01:
    V2 발행 대상 Queue 1건 조회.

    - --queue-id 지정 시 해당 queue만 직접 조회
    - --realtor-id 지정 시 해당 중개사 queue만 조회
    - batch limit 실행 시 이미 처리/검사한 queue는 exclude_queue_ids로 제외
    """
    exclude_queue_ids = [int(x) for x in (exclude_queue_ids or []) if str(x).isdigit()]

    sql = """
        SELECT
            q.*,
            d.draft_title,
            d.draft_html,
            d.clipboard_html,
            d.plain_text,
            d.source_json AS draft_source_json,
            d.article_no AS draft_article_no,
            CHAR_LENGTH(d.clipboard_html) AS clipboard_len,
            CHAR_LENGTH(d.draft_html) AS draft_len,
            CHAR_LENGTH(d.plain_text) AS plain_len,
            r.office_name,
            ra.first_posted_at AS article_first_posted_at,
            ra.created_at AS article_cache_created_at,
            COALESCE(ra.first_posted_at, ra.created_at, q.created_at) AS publish_sort_at
        FROM blog_publish_queue q
        INNER JOIN blog_article_drafts d ON q.draft_id = d.id
        INNER JOIN blog_realtors r ON q.realtor_id = r.id
        LEFT JOIN blog_realtor_articles ra
            ON ra.realtor_id = q.realtor_id
           AND CONVERT(ra.article_no USING utf8mb4) COLLATE utf8mb4_unicode_ci
               =
               CONVERT(q.article_no USING utf8mb4) COLLATE utf8mb4_unicode_ci
        WHERE q.queue_status = 'pending'
          AND COALESCE(q.execution_target, 'server') IN ('server', 'auto')
          AND COALESCE(r.status, 'active') = 'active'
          AND (q.scheduled_at IS NULL OR q.scheduled_at <= NOW())
          AND (q.next_retry_at IS NULL OR q.next_retry_at <= NOW())
    """
    params = []

    if queue_id:
        sql += " AND q.id = %s "
        params.append(int(queue_id))

    if realtor_id:
        sql += " AND q.realtor_id = %s "
        params.append(int(realtor_id))

    if exclude_queue_ids:
        placeholders = ",".join(["%s"] * len(exclude_queue_ids))
        sql += f" AND q.id NOT IN ({placeholders}) "
        params.extend(exclude_queue_ids)

    # V2 초안/큐가 섞여 있을 수 있으므로 draft source_json 안의 V2 표식을 우선한다.
    # 컬럼이 없어도 source_json 문자열 검색이라 안전하다.
    sql += """
        ORDER BY
            CASE
                WHEN d.source_json LIKE '%%"pipeline_version": "v2"%%' THEN 0
                WHEN d.source_json LIKE '%%"draft_generator": "generate_blog_drafts_v2"%%' THEN 0
                ELSE 1
            END ASC,
            publish_sort_at DESC,
            q.id DESC
        LIMIT 1
    """

    with conn.cursor() as cur:
        cur.execute(sql, params)
        row = cur.fetchone()
    return dict(row) if row else None



def get_publish_title(queue):
    return str(
        queue.get("publish_title")
        or queue.get("draft_title")
        or queue.get("title")
        or ""
    ).strip()



def input_title(page, write_frame=None, title="", logger=None, session=None, verbose=True):
    """
    STEP110-01:
    제목 입력 자체를 Human Typing 방식으로 처리한다.

    이전 STEP110 문제:
    - 제목 placeholder가 recovery 직후 바로 잡히지 않아 title_target_not_found 발생
    - 결국 service fallback으로 빠져서 제목이 너무 빠르게 입력됨

    개선:
    - write_frame/page/전체 frame을 대상으로 제목 영역을 재시도 탐색
    - count() 선판단 대신 locator.click() + 짧은 wait_for 방식 사용
    - 한글 제목을 글자 단위로 입력하고, 공백/구분자에서 자연스러운 pause 추가
    - 실패 시에만 기존 service input_title() fallback
    """
    title = str(title or "").strip()
    result = {
        "status": "failed",
        "title": title,
        "title_len": len(title),
        "current_text": "",
        "method": "human_keyboard_type_v2_retry",
        "typing_profile": None,
        "fallback_used": False,
        "error": None,
    }

    if not title:
        result["error"] = "title_empty"
        if verbose:
            print("[V2 TITLE HUMAN INPUT SKIP]", result)
        return result

    selectors = [
        ".se-section-documentTitle .se-placeholder",
        "span.se-placeholder:has-text('제목')",
        ".se-section-documentTitle [contenteditable='true']",
        ".se-documentTitle [contenteditable='true']",
        ".se-documentTitle",
        ".se-section-documentTitle",
        "[class*='documentTitle'] [contenteditable='true']",
        "[class*='documentTitle']",
    ]

    def iter_title_roots():
        roots = []
        if write_frame is not None:
            roots.append(("write_frame", write_frame))
        roots.append(("page", page))
        try:
            for idx, fr in enumerate(page.frames):
                name = fr.name or f"frame_{idx}"
                url = fr.url or ""
                if fr is write_frame:
                    continue
                if "PostWrite" in url or "blog" in url or name == "mainFrame":
                    roots.append((f"frame:{name}", fr))
        except Exception:
            pass
        return roots

    try:
        if verbose:
            print("[V2 TITLE HUMAN INPUT START]", title)

        clicked = False
        used_selector = ""
        used_root_name = ""
        click_error_samples = []

        # recovery 직후 제목 placeholder가 잠깐 늦게 살아나는 경우가 있어 재시도한다.
        for attempt in range(1, 25):
            for root_name, root in iter_title_roots():
                for selector in selectors:
                    try:
                        loc = root.locator(selector).first()

                        # count가 0이어도 직후 생성될 수 있으므로 짧게 wait 후 클릭
                        try:
                            loc.wait_for(state="attached", timeout=350)
                        except Exception:
                            pass

                        try:
                            loc.click(timeout=650)
                        except Exception as click_exc:
                            click_error_samples.append(f"{root_name} {selector} {type(click_exc).__name__}")
                            continue

                        clicked = True
                        used_selector = selector
                        used_root_name = root_name
                        if verbose:
                            print("[V2 TITLE HUMAN CLICK]", {
                                "attempt": attempt,
                                "root": root_name,
                                "selector": selector,
                            })
                        break
                    except Exception as exc:
                        click_error_samples.append(f"{root_name} {selector} {type(exc).__name__}")
                        continue

                if clicked:
                    break

            if clicked:
                break

            page.wait_for_timeout(random.randint(180, 360))

        if not clicked:
            raise RuntimeError("title_target_not_found samples=" + " | ".join(click_error_samples[-8:]))

        page.wait_for_timeout(random.randint(320, 900))

        # 혹시 임시 텍스트가 남아있으면 정리한다.
        try:
            page.keyboard.press("Control+A")
            page.wait_for_timeout(random.randint(140, 380))
            page.keyboard.press("Backspace")
            page.wait_for_timeout(random.randint(180, 460))
        except Exception:
            pass

        mode = "test"
        try:
            # hpe_profile은 이 함수 인자로 들어오지 않으므로 환경값만 가볍게 본다.
            mode = str(os.environ.get("V2_HPE_MODE") or "test").lower()
        except Exception:
            mode = "test"

        # 실제 사람이 입력하는 수준으로 속도 보강.
        # test도 눈에 보일 정도로 너무 빠르지 않게 둔다.
        if mode == "operation":
            char_delay_range = (95, 240)
            pause_range = (320, 1100)
        elif mode == "conservative":
            char_delay_range = (75, 190)
            pause_range = (240, 850)
        else:
            char_delay_range = (65, 165)
            pause_range = (180, 650)

        result["typing_profile"] = {
            "char_delay_range_ms": char_delay_range,
            "pause_range_ms": pause_range,
            "pause_points": [],
            "selector": used_selector,
            "root": used_root_name,
            "attempts": attempt,
        }

        typed_count = 0
        for idx, ch in enumerate(title):
            delay = random.randint(*char_delay_range)

            # 글자 단위 입력. 한글도 한 글자씩 넣어 화면상 타이핑감이 보이게 한다.
            page.keyboard.type(ch, delay=delay)
            typed_count += 1

            # 사람이 단어/구분자를 인식하고 잠깐 멈추는 패턴
            if ch in [" ", "·", "/", "-", ","]:
                pause_ms = random.randint(*pause_range)
                page.wait_for_timeout(pause_ms)
                result["typing_profile"]["pause_points"].append({
                    "index": idx,
                    "char": ch,
                    "pause_ms": pause_ms,
                    "reason": "separator",
                })
            elif idx > 0 and idx < len(title) - 1 and random.random() < 0.08:
                pause_ms = random.randint(max(120, pause_range[0] // 2), max(220, pause_range[1] // 2))
                page.wait_for_timeout(pause_ms)
                result["typing_profile"]["pause_points"].append({
                    "index": idx,
                    "char": ch,
                    "pause_ms": pause_ms,
                    "reason": "thinking",
                })

            # 타이핑 중간에 아주 작은 마우스 흔들림
            if idx > 3 and idx < len(title) - 2 and random.random() < 0.06:
                try:
                    page.mouse.move(
                        random.randint(520, 980),
                        random.randint(190, 360),
                        steps=random.randint(4, 11),
                    )
                    page.wait_for_timeout(random.randint(80, 220))
                except Exception:
                    pass

        page.wait_for_timeout(random.randint(380, 900))

        # 검증
        current_text = ""
        for root_name, root in iter_title_roots():
            for selector in [
                ".se-documentTitle",
                ".se-section-documentTitle",
                ".se-section-documentTitle [contenteditable='true']",
                "[class*='documentTitle']",
            ]:
                try:
                    loc = root.locator(selector).first()
                    if loc.count() > 0:
                        current_text = (loc.inner_text(timeout=1800) or "").strip()
                        if current_text:
                            break
                except Exception:
                    continue
            if current_text:
                break

        ok = bool(current_text) and (title in current_text or current_text in title)
        result["current_text"] = current_text[:180]
        result["typed_count"] = typed_count

        if ok:
            result["status"] = "success"
            if verbose:
                print("[V2 TITLE HUMAN VERIFY]", True, current_text)
                print("[V2 TITLE HUMAN INPUT RESULT]", result)
            return result

        raise RuntimeError(f"title_verify_failed current={current_text!r}")

    except Exception as exc:
        result["error"] = f"{type(exc).__name__}: {exc}"
        if verbose:
            print("[V2 TITLE HUMAN INPUT WARNING]", result)
            print("[V2 TITLE HUMAN FALLBACK] service input_title")

        try:
            fallback = _v2_service_input_title(
                page=page,
                write_frame=write_frame,
                title=title,
                logger=logger,
                session=session,
                verbose=verbose,
            )

            # STEP110-02:
            # 기존 service input_title이 제목 포커스를 잡는 데 성공한 직후,
            # 같은 포커스 상태를 이용해 Ctrl+A → 삭제 → 천천히 재타이핑한다.
            # 이렇게 하면 title_target 탐색 실패 상황에서도 실제 화면에는 휴먼 타이핑이 보인다.
            retype_result = {
                "attempted": True,
                "ok": False,
                "method": "focused_ctrl_a_slow_retype_after_service_input",
                "title_len": len(title),
                "typed_count": 0,
                "pause_points": [],
                "error": None,
            }

            try:
                page.wait_for_timeout(random.randint(380, 900))
                page.keyboard.press("Control+A")
                page.wait_for_timeout(random.randint(220, 520))
                page.keyboard.press("Backspace")
                page.wait_for_timeout(random.randint(260, 680))

                mode = str(os.environ.get("V2_HPE_MODE") or "test").lower()
                if mode == "operation":
                    char_delay_range = (130, 310)
                    pause_range = (700, 1800)
                elif mode == "conservative":
                    char_delay_range = (110, 270)
                    pause_range = (520, 1350)
                else:
                    # test에서도 육안으로 보일 정도로 느리게 둔다.
                    char_delay_range = (105, 240)
                    pause_range = (420, 1100)

                for idx, ch in enumerate(title):
                    page.keyboard.type(ch, delay=random.randint(*char_delay_range))
                    retype_result["typed_count"] += 1

                    if ch in [" ", "·", "/", "-", ","]:
                        pause_ms = random.randint(*pause_range)
                        page.wait_for_timeout(pause_ms)
                        retype_result["pause_points"].append({
                            "index": idx,
                            "char": ch,
                            "pause_ms": pause_ms,
                            "reason": "separator",
                        })
                    elif idx > 0 and idx < len(title) - 1 and random.random() < 0.10:
                        pause_ms = random.randint(max(240, pause_range[0] // 2), max(420, pause_range[1] // 2))
                        page.wait_for_timeout(pause_ms)
                        retype_result["pause_points"].append({
                            "index": idx,
                            "char": ch,
                            "pause_ms": pause_ms,
                            "reason": "thinking",
                        })

                page.wait_for_timeout(random.randint(450, 1000))
                retype_result["ok"] = True
                retype_result["mode"] = mode
                retype_result["char_delay_range_ms"] = char_delay_range
                retype_result["pause_range_ms"] = pause_range

                if verbose:
                    print("[V2 TITLE HUMAN FOCUSED RETYPE]", retype_result)

            except Exception as retype_exc:
                retype_result["error"] = f"{type(retype_exc).__name__}: {retype_exc}"
                if verbose:
                    print("[V2 TITLE HUMAN FOCUSED RETYPE WARNING]", retype_result)

            if isinstance(fallback, dict):
                fallback["fallback_used"] = True
                fallback["human_input_error"] = result["error"]
                fallback["method"] = "service_fallback_then_focused_human_retype"
                fallback["focused_human_retype"] = retype_result
                return fallback

            return {
                "status": "success",
                "title": title,
                "title_len": len(title),
                "current_text": "",
                "fallback_used": True,
                "human_input_error": result["error"],
                "method": "service_fallback_then_focused_human_retype",
                "focused_human_retype": retype_result,
            }

        except Exception as fallback_exc:
            result["fallback_used"] = True
            result["fallback_error"] = f"{type(fallback_exc).__name__}: {fallback_exc}"
            if verbose:
                print("[V2 TITLE HUMAN FALLBACK ERROR]", result)
            return result


def get_publish_html(queue):
    return str(queue.get("clipboard_html") or queue.get("draft_html") or "").strip()


def get_fallback_text(queue):
    return str(queue.get("plain_text") or "").strip()


def get_session_file(realtor_id):
    return str(ROOT / "storage" / "naver_sessions" / f"naver_session_{int(realtor_id)}.json")


def is_login_page_url(url):
    url = str(url or "").lower()
    return (
        "nid.naver.com" in url
        or "nidlogin.login" in url
        or ("login" in url and "naver.com" in url)
    )




# ---------------------------------------------------------
# STEP109-01: V2 HPE 운영모드 프로파일
# ---------------------------------------------------------
def get_v2_hpe_mode(hpe_mode=None, execute=False, enable_long_hpe_wait=False):
    """
    HPE 모드 결정.

    - test: 기존처럼 빠른 검증용
    - operation: 실제 운영 발행용. 발행 전 검토/스크롤/마우스 행동 강화
    - conservative: operation보다 짧지만 test보다 자연스러운 중간 모드

    기존 호환:
    --enable-long-hpe-wait 가 들어오면 operation으로 간주한다.
    환경변수 V2_HPE_MODE도 사용 가능하다.
    """
    mode = str(
        hpe_mode
        or os.environ.get("V2_HPE_MODE")
        or ""
    ).strip().lower()

    if enable_long_hpe_wait:
        mode = "operation"

    if not mode:
        mode = "test"

    aliases = {
        "ops": "operation",
        "operating": "operation",
        "prod": "operation",
        "production": "operation",
        "real": "operation",
        "live": "operation",
        "normal": "conservative",
    }
    mode = aliases.get(mode, mode)

    if mode not in ["test", "conservative", "operation"]:
        print("[V2 HPE MODE WARN] unknown mode -> test", mode)
        mode = "test"

    return mode


def get_v2_hpe_profile(hpe_mode=None, execute=False, enable_long_hpe_wait=False):
    """
    운영용 HPE 프로파일.

    운영 기본값은 환경변수로 조정 가능:
    - V2_HPE_REVIEW_MIN_SECONDS
    - V2_HPE_REVIEW_MAX_SECONDS
    - V2_HPE_AFTER_BODY_MIN_MS
    - V2_HPE_AFTER_BODY_MAX_MS
    - V2_HPE_BEFORE_FINAL_MIN_MS
    - V2_HPE_BEFORE_FINAL_MAX_MS
    """
    mode = get_v2_hpe_mode(
        hpe_mode=hpe_mode,
        execute=execute,
        enable_long_hpe_wait=enable_long_hpe_wait,
    )

    profiles = {
        "test": {
            "mode": "test",
            "title_wait_ms": (450, 1350),
            "body_wait_ms": (600, 1600),
            "before_publish_button_ms": (350, 1100),
            "before_final_publish_ms": (700, 1800),
            "scroll_events": (2, 4),
            "mouse_drift_steps": (5, 12),
            "review_wait_seconds": (0, 0),
            "review_wait_enabled": False,
            "footer_typing_enabled": True,
        },
        "conservative": {
            "mode": "conservative",
            "title_wait_ms": (900, 2600),
            "body_wait_ms": (1800, 5200),
            "before_publish_button_ms": (1600, 4200),
            "before_final_publish_ms": (2500, 7000),
            "scroll_events": (3, 6),
            "mouse_drift_steps": (8, 18),
            "review_wait_seconds": (45, 120),
            "review_wait_enabled": True,
            "footer_typing_enabled": True,
        },
        "operation": {
            "mode": "operation",
            "title_wait_ms": (1200, 4200),
            "body_wait_ms": (3500, 11000),
            "before_publish_button_ms": (3500, 9500),
            "before_final_publish_ms": (5000, 14000),
            "scroll_events": (4, 9),
            "mouse_drift_steps": (10, 26),
            "review_wait_seconds": (180, 420),
            "review_wait_enabled": True,
            "footer_typing_enabled": True,
        },
    }

    profile = dict(profiles[mode])

    def env_range_seconds(key_min, key_max, current):
        lo, hi = current
        try:
            lo = int(os.environ.get(key_min) or lo)
            hi = int(os.environ.get(key_max) or hi)
        except Exception:
            pass
        if lo < 0:
            lo = 0
        if hi < lo:
            hi = lo
        return (lo, hi)

    def env_range_ms(key_min, key_max, current):
        lo, hi = current
        try:
            lo = int(os.environ.get(key_min) or lo)
            hi = int(os.environ.get(key_max) or hi)
        except Exception:
            pass
        if lo < 0:
            lo = 0
        if hi < lo:
            hi = lo
        return (lo, hi)

    profile["review_wait_seconds"] = env_range_seconds(
        "V2_HPE_REVIEW_MIN_SECONDS",
        "V2_HPE_REVIEW_MAX_SECONDS",
        profile["review_wait_seconds"],
    )
    profile["body_wait_ms"] = env_range_ms(
        "V2_HPE_AFTER_BODY_MIN_MS",
        "V2_HPE_AFTER_BODY_MAX_MS",
        profile["body_wait_ms"],
    )
    profile["before_final_publish_ms"] = env_range_ms(
        "V2_HPE_BEFORE_FINAL_MIN_MS",
        "V2_HPE_BEFORE_FINAL_MAX_MS",
        profile["before_final_publish_ms"],
    )

    return profile


def print_v2_hpe_profile(profile, queue=None, execute=False):
    payload = {
        "mode": profile.get("mode"),
        "execute": bool(execute),
        "queue_id": (queue or {}).get("id") if queue else None,
        "title_wait_ms": profile.get("title_wait_ms"),
        "body_wait_ms": profile.get("body_wait_ms"),
        "before_publish_button_ms": profile.get("before_publish_button_ms"),
        "before_final_publish_ms": profile.get("before_final_publish_ms"),
        "review_wait_seconds": profile.get("review_wait_seconds"),
        "review_wait_enabled": profile.get("review_wait_enabled"),
        "scroll_events": profile.get("scroll_events"),
    }
    print("[V2 HPE PROFILE]", payload)
    return payload




def _v2_safe_json_loads(value):
    if not value:
        return {}
    if isinstance(value, dict):
        return value
    try:
        return json.loads(value)
    except Exception:
        return {}


def get_v2_property_context_from_queue(queue):
    """
    STEP109-02:
    Queue/Draft source_json에서 매물종류, 거래유형, 휴먼 스타일 정보를 뽑아 HPE 타이핑 문구에 사용한다.
    """
    queue = queue or {}
    source = _v2_safe_json_loads(queue.get("draft_source_json"))

    def pick(*keys):
        for key in keys:
            if "." in key:
                cur = source
                ok = True
                for part in key.split("."):
                    if isinstance(cur, dict) and part in cur:
                        cur = cur.get(part)
                    else:
                        ok = False
                        break
                if ok and cur:
                    return str(cur).strip()
            if key in queue and queue.get(key):
                return str(queue.get(key)).strip()
            if isinstance(source, dict) and source.get(key):
                return str(source.get(key)).strip()
        return ""

    complex_name = pick("complex_name", "articleName", "article_name", "detail.complex_name")
    trade_type = pick("trade_type", "tradeTypeName", "trade_type_name", "article.tradeTypeName")
    real_estate_type = pick(
        "real_estate_type_name",
        "realEstateTypeName",
        "article_real_estate_type_name",
        "real_estate_type",
        "property_type",
        "article.realEstateTypeName",
    )
    layout_type = pick("v2_meta.draft.layout_type", "layout_type", "human_style", "draft_style")
    title = str(queue.get("publish_title") or queue.get("draft_title") or "").strip()

    type_text = "매물"
    if any(x in real_estate_type for x in ["아파트", "APT"]) or "아파트" in title:
        type_text = "아파트"
    elif "오피스텔" in real_estate_type or "오피스텔" in title:
        type_text = "오피스텔"
    elif any(x in real_estate_type for x in ["토지", "대지", "임야", "전", "답"]) or any(x in title for x in ["토지", "대지", "임야"]):
        type_text = "토지"
    elif "상가" in real_estate_type or "상가" in title:
        type_text = "상가"
    elif "빌라" in real_estate_type or "빌라" in title:
        type_text = "빌라"

    if not trade_type:
        if "매매" in title:
            trade_type = "매매"
        elif "전세" in title:
            trade_type = "전세"
        elif "월세" in title:
            trade_type = "월세"
        else:
            trade_type = "조건"

    return {
        "complex_name": complex_name,
        "trade_type": trade_type,
        "real_estate_type": real_estate_type,
        "type_text": type_text,
        "layout_type": layout_type,
        "title": title,
    }


def build_v2_hpe_footer_typing_text(queue=None, hpe_profile=None):
    """
    STEP109-02:
    본문 하단에 실제로 짧게 입력했다가 삭제할 HPE 문구를 만든다.
    최종 본문은 변경하지 않는다.
    """
    ctx = get_v2_property_context_from_queue(queue or {})
    type_text = ctx.get("type_text") or "매물"
    trade_type = ctx.get("trade_type") or "조건"
    layout_type = ctx.get("layout_type") or ""

    pool = [
        f"{type_text} {trade_type} 조건 확인",
        "상담 전 가능 여부 확인",
        "입지와 동선 확인",
        "사진과 실제 상태 비교",
        "방문 전 조건 재확인",
    ]

    by_type = {
        "아파트": ["동 호수와 방향 확인", "관리비와 주차 조건 확인", "단지 생활권 확인"],
        "오피스텔": ["관리비와 주차 가능 여부 확인", "업무 동선과 생활 편의 확인", "전입 가능 여부 확인"],
        "토지": ["지목과 도로 접면 확인", "토지 이용계획 확인", "현장 경계 확인"],
        "상가": ["유동인구와 업종 조건 확인", "권리금과 임대 조건 확인", "상권 동선 확인"],
        "빌라": ["주차와 관리 상태 확인", "층수와 채광 확인", "실거주 동선 확인"],
    }

    by_style = {
        "photo_grid": ["사진 순서 확인", "이미지 설명 확인"],
        "wide_visual": ["시각 자료 확인", "전체 구성 확인"],
        "local_life": ["주변 생활권 확인", "편의시설 동선 확인"],
        "gallery_story": ["사진 흐름 확인", "구성 자연스러움 확인"],
        "premium_report": ["조건 요약 확인", "핵심 포인트 확인"],
        "location_focus": ["입지 정보 확인", "교통 동선 확인"],
        "broker_expert": ["상담 포인트 확인", "전문 안내 문구 확인"],
        "interview_style": ["문의 답변 흐름 확인", "상담 문장 확인"],
    }

    pool.extend(by_type.get(type_text, []))
    pool.extend(by_style.get(layout_type, []))
    pool = [p for p in pool if p and len(p) <= 28]

    if not pool:
        pool = ["상담 전 조건 확인"]

    return random.choice(pool)


def hpe_footer_human_typing_edit(page, write_frame=None, queue=None, hpe_profile=None, verbose=True):
    """
    STEP109-02:
    본문 끝에서 매물종류/휴먼스타일 기반 짧은 문구를 타이핑했다가 삭제한다.
    """
    profile = hpe_profile or {}
    result = {
        "attempted": True,
        "ok": False,
        "text": "",
        "chars": 0,
        "method": "",
        "error": None,
        "hpe_mode": profile.get("mode") or "unknown",
    }

    try:
        text_to_type = build_v2_hpe_footer_typing_text(queue=queue, hpe_profile=profile)
        result["text"] = text_to_type
        result["chars"] = len(text_to_type)

        target = None
        selectors = [
            ".se-section-text .se-module-text",
            "article.se-components-wrap .se-module-text",
            "article.se-components-wrap",
            ".se-body",
        ]
        for selector in selectors:
            try:
                loc = write_frame.locator(selector).last() if write_frame is not None else page.locator(selector).last()
                if loc.count() > 0:
                    target = loc
                    break
            except Exception:
                continue

        if target is not None:
            target.click(timeout=3000)
            page.wait_for_timeout(random.randint(180, 450))

        page.keyboard.press("Control+End")
        page.wait_for_timeout(random.randint(250, 700))
        page.keyboard.press("Enter")
        page.wait_for_timeout(random.randint(150, 420))

        delay = random.randint(35, 95)
        if profile.get("mode") == "operation":
            delay = random.randint(55, 135)
        elif profile.get("mode") == "conservative":
            delay = random.randint(45, 110)

        page.keyboard.type(text_to_type, delay=delay)
        page.wait_for_timeout(random.randint(350, 1200 if profile.get("mode") != "test" else 750))

        for _ in range(len(text_to_type)):
            page.keyboard.press("Backspace")
            page.wait_for_timeout(random.randint(8, 28))

        page.keyboard.press("Backspace")
        page.wait_for_timeout(random.randint(150, 420))

        result["ok"] = True
        result["method"] = "type_then_backspace"
        if verbose:
            print("[V2 HPE FOOTER HUMAN TYPING]", {
                "ok": True,
                "text": text_to_type[:40],
                "chars": len(text_to_type),
                "hpe_mode": profile.get("mode"),
                "method": result["method"],
            })
        return result

    except Exception as e:
        result["error"] = f"{type(e).__name__}: {e}"
        if verbose:
            print("[V2 HPE FOOTER HUMAN TYPING ERROR]", result)
        return result






def build_v2_bottom_summary_hpe_text(queue=None, body_result=None):
    """STEP113: 본문 하단 관련 키워드 위 안내문 블록 재타이핑용 문구 생성."""
    queue = queue or {}
    title = str(queue.get("publish_title") or queue.get("draft_title") or queue.get("title") or "").strip()

    parts = [p.strip() for p in re.split(r"\s*·\s*", title) if p.strip()]
    complex_name = ""
    trade_type = ""
    price_text = ""

    if parts:
        complex_name = re.sub(r"\s+\d+동.*$", "", parts[0]).strip() or parts[0]
    if len(parts) >= 2:
        trade_type = parts[1]
    if len(parts) >= 3:
        price_text = parts[2]

    complex_name = complex_name or "해당 매물"
    trade_type = trade_type or "매물"
    headline = f"{complex_name} {trade_type}".strip()

    office_name = str(queue.get("office_name") or queue.get("realtor_name") or "").strip() or "담당 중개사무소"

    if price_text:
        paragraph = (
            f"{headline} 정보를 찾고 계신 분들께 도움이 되는 매물 안내입니다. "
            f"가격은 {price_text} 기준으로 안내되는 매물입니다. "
            f"{headline} 문의 전에는 현재 매물 가능 여부와 세부 조건을 다시 확인하는 것이 안전합니다. "
            f"자세한 상담은 {office_name}를 통해 현재 기준의 최신 정보로 안내 받으실 수 있습니다."
        )
    else:
        paragraph = (
            f"{headline} 정보를 찾고 계신 분들께 도움이 되는 매물 안내입니다. "
            f"{headline} 문의 전에는 현재 매물 가능 여부와 세부 조건을 다시 확인하는 것이 안전합니다. "
            f"자세한 상담은 {office_name}를 통해 현재 기준의 최신 정보로 안내 받으실 수 있습니다."
        )

    return f"{headline}\n\n{paragraph}"


def hpe_type_multiline_human(page, text, hpe_profile=None, verbose=True, label="body_block"):
    """STEP113: 여러 줄 텍스트를 사람처럼 입력한다."""
    profile = hpe_profile or {}
    mode = profile.get("mode") or "test"
    text = str(text or "")

    if mode == "operation":
        char_delay_range = (55, 180)
        short_pause_range = (260, 950)
        sentence_pause_range = (700, 1800)
    elif mode == "conservative":
        char_delay_range = (45, 145)
        short_pause_range = (220, 760)
        sentence_pause_range = (550, 1400)
    else:
        char_delay_range = (38, 120)
        short_pause_range = (180, 620)
        sentence_pause_range = (420, 1050)

    result = {
        "ok": False,
        "mode": mode,
        "label": label,
        "chars": len(text),
        "typed_count": 0,
        "pause_points": [],
        "char_delay_range_ms": char_delay_range,
        "error": None,
    }

    try:
        for idx, ch in enumerate(text):
            if ch == "\n":
                page.keyboard.press("Enter")
                result["typed_count"] += 1
                pause_ms = random.randint(*sentence_pause_range)
                page.wait_for_timeout(pause_ms)
                result["pause_points"].append({"index": idx, "char": "\\n", "pause_ms": pause_ms, "reason": "newline"})
                continue

            page.keyboard.type(ch, delay=random.randint(*char_delay_range))
            result["typed_count"] += 1

            if ch in [" ", "·", "/", "-", ","] and random.random() < 0.55:
                pause_ms = random.randint(*short_pause_range)
                page.wait_for_timeout(pause_ms)
                result["pause_points"].append({"index": idx, "char": ch, "pause_ms": pause_ms, "reason": "separator"})
            elif ch in [".", "!", "?"] and random.random() < 0.55:
                pause_ms = random.randint(*sentence_pause_range)
                page.wait_for_timeout(pause_ms)
                result["pause_points"].append({"index": idx, "char": ch, "pause_ms": pause_ms, "reason": "sentence"})
            elif idx > 0 and idx < len(text) - 1 and random.random() < 0.025:
                pause_ms = random.randint(180, 620 if mode == "test" else 1100)
                page.wait_for_timeout(pause_ms)
                result["pause_points"].append({"index": idx, "char": ch, "pause_ms": pause_ms, "reason": "thinking"})

        result["ok"] = True
        if verbose:
            print("[V2 HPE MULTILINE HUMAN TYPE]", {
                "ok": True,
                "label": label,
                "mode": mode,
                "chars": len(text),
                "typed_count": result["typed_count"],
                "pause_count": len(result["pause_points"]),
            })
        return result
    except Exception as e:
        result["error"] = f"{type(e).__name__}: {e}"
        if verbose:
            print("[V2 HPE MULTILINE HUMAN TYPE ERROR]", result)
        return result


def hpe_bottom_summary_block_retype(page, write_frame=None, queue=None, body_result=None, hpe_profile=None, verbose=True):
    """
    STEP113:
    본문 하단 '관련 키워드' 바로 위 안내문 블록만 선택/삭제 후 Human Typing으로 재입력한다.
    실패 시 skip하고 전체 발행 흐름은 유지한다.
    """
    profile = hpe_profile or {}
    mode = profile.get("mode") or "test"
    result = {
        "attempted": True,
        "ok": False,
        "skipped": False,
        "mode": mode,
        "target_found": False,
        "method": "bottom_summary_block_select_delete_retype",
        "typed": None,
        "error": None,
    }

    try:
        root = write_frame if write_frame is not None else page
        hpe_text = build_v2_bottom_summary_hpe_text(queue=queue, body_result=body_result)
        result["target_text_len"] = len(hpe_text)

        js = r"""
        () => {
            function visibleText(el) {
                return (el && el.innerText ? el.innerText : '').trim();
            }

            const all = Array.from(document.querySelectorAll('p, div, section, article, span'));
            let keyword = null;
            for (const el of all) {
                const t = visibleText(el);
                if (t === '관련 키워드' || t.includes('관련 키워드')) {
                    keyword = el;
                    break;
                }
            }
            if (!keyword) return {ok:false, reason:'keyword_not_found'};

            const kRect = keyword.getBoundingClientRect();
            let candidates = Array.from(document.querySelectorAll('p, div.se-module-text, div[contenteditable="true"], span'))
                .filter(el => {
                    const t = visibleText(el);
                    if (!t || t.includes('관련 키워드')) return false;
                    if (t.length < 20) return false;
                    if (!/매매|전세|월세|문의|상담|가격|면적|정보/.test(t)) return false;
                    const r = el.getBoundingClientRect();
                    return r.top < kRect.top && r.width > 20 && r.height > 8;
                })
                .sort((a, b) => b.getBoundingClientRect().top - a.getBoundingClientRect().top);

            const target = candidates[0];
            if (!target) return {ok:false, reason:'target_block_not_found', candidate_count:candidates.length};

            target.scrollIntoView({block:'center', inline:'nearest'});

            const range = document.createRange();
            range.selectNodeContents(target);
            const sel = window.getSelection();
            sel.removeAllRanges();
            sel.addRange(range);
            target.focus && target.focus();

            return {
                ok:true,
                reason:'selected',
                text: visibleText(target).slice(0, 200),
                text_len: visibleText(target).length,
                tag: target.tagName,
                className: String(target.className || '')
            };
        }
        """

        select_result = root.evaluate(js)
        result["select_result"] = select_result

        if not isinstance(select_result, dict) or not select_result.get("ok"):
            result["skipped"] = True
            result["error"] = f"select_failed:{select_result}"
            if verbose:
                print("[V2 HPE BOTTOM SUMMARY RETYPE SKIP]", result)
            return result

        result["target_found"] = True
        if verbose:
            print("[V2 HPE BOTTOM SUMMARY BLOCK SELECTED]", select_result)

        page.wait_for_timeout(random.randint(280, 820))
        page.keyboard.press("Backspace")
        page.wait_for_timeout(random.randint(350, 950))

        if mode == "operation":
            page.wait_for_timeout(random.randint(700, 1600))
        elif mode == "conservative":
            page.wait_for_timeout(random.randint(450, 1100))
        else:
            page.wait_for_timeout(random.randint(280, 720))

        typed_result = hpe_type_multiline_human(
            page=page,
            text=hpe_text,
            hpe_profile=profile,
            verbose=verbose,
            label="bottom_summary_block",
        )
        result["typed"] = typed_result
        result["ok"] = bool(typed_result.get("ok"))

        page.wait_for_timeout(random.randint(600, 1400 if mode == "test" else 2600))
        try:
            page.mouse.move(random.randint(520, 980), random.randint(360, 760), steps=random.randint(8, 20))
        except Exception:
            pass

        if verbose:
            print("[V2 HPE BOTTOM SUMMARY RETYPE DONE]", result)
        return result

    except Exception as e:
        result["error"] = f"{type(e).__name__}: {e}"
        if verbose:
            print("[V2 HPE BOTTOM SUMMARY RETYPE ERROR]", result)
        return result



def hpe_body_reading_break(page, write_frame=None, body_result=None, hpe_profile=None, label="body_reading_break", verbose=True):
    """
    STEP111:
    본문 붙여넣기 이후 사람이 내용을 읽고 확인하는 체류 시간을 만든다.

    목적:
    - 본문 입력 직후 바로 발행 버튼으로 이동하지 않음
    - 글 길이/이미지 수에 따라 짧은 읽기 시간 부여
    - 중간 스크롤/이미지 정지/마우스 미세 이동 포함

    test 모드는 검증용으로 짧게,
    operation 모드는 실제 운영용으로 더 길게 작동한다.
    """
    profile = hpe_profile or {}
    mode = profile.get("mode") or "test"
    body_result = body_result or {}

    verify = body_result.get("verify") or {}
    copy_info = body_result.get("copy") or {}
    stats = copy_info.get("stats") or {}

    text_len = int(verify.get("text_len") or stats.get("text_len") or 0)
    image_count = int(verify.get("image_count") or stats.get("image_count") or 0)

    if mode == "operation":
        base_seconds = random.randint(8, 18)
        text_seconds = min(28, max(0, text_len // random.randint(450, 750)))
        image_seconds = min(24, image_count * random.randint(1, 3))
        total_seconds = base_seconds + text_seconds + image_seconds
        total_seconds = max(18, min(total_seconds, 70))
        rounds = random.randint(5, 10)
    elif mode == "conservative":
        base_seconds = random.randint(5, 12)
        text_seconds = min(16, max(0, text_len // random.randint(600, 900)))
        image_seconds = min(12, image_count * random.randint(1, 2))
        total_seconds = base_seconds + text_seconds + image_seconds
        total_seconds = max(10, min(total_seconds, 40))
        rounds = random.randint(4, 7)
    else:
        # test에서도 육안 확인 가능한 정도로만 짧게
        total_seconds = random.randint(3, 7)
        rounds = random.randint(2, 4)

    events = []
    started = time.time()

    try:
        if verbose:
            print("[V2 HPE BODY READING BREAK START]", {
                "mode": mode,
                "label": label,
                "text_len": text_len,
                "image_count": image_count,
                "target_seconds": total_seconds,
                "rounds": rounds,
            })

        # 본문 첫 부분에서 시작
        try:
            page.keyboard.press("Control+Home")
            page.wait_for_timeout(random.randint(240, 620))
        except Exception:
            pass

        # 읽는 시간 동안 자연스러운 스크롤과 정지
        for i in range(rounds):
            elapsed = time.time() - started
            if elapsed >= total_seconds:
                break

            if i == 0:
                delta = random.randint(260, 620)
            else:
                # 가끔 되돌아보는 행동
                if random.random() < 0.22:
                    delta = -random.randint(180, 520)
                else:
                    delta = random.randint(220, 760)

            try:
                if write_frame is not None:
                    write_frame.evaluate("(dy) => window.scrollBy(0, dy)", delta)
                    target = "write_frame"
                else:
                    page.mouse.wheel(0, delta)
                    target = "page"
            except Exception:
                page.mouse.wheel(0, delta)
                target = "page_fallback"

            # 이미지/표/문단 확인하듯 대기
            remaining = max(0.3, total_seconds - (time.time() - started))
            max_wait_ms = int(min(4200, max(700, remaining * 1000 / max(1, rounds - i))))
            min_wait_ms = 450 if mode == "test" else 900
            wait_ms = random.randint(min_wait_ms, max(min_wait_ms + 100, max_wait_ms))
            page.wait_for_timeout(wait_ms)

            # 마우스 시선 이동
            if random.random() < 0.8:
                x = random.randint(380, 1040)
                y = random.randint(240, 760)
                page.mouse.move(x, y, steps=random.randint(7, 20))
                micro_wait = random.randint(120, 520 if mode == "test" else 900)
                page.wait_for_timeout(micro_wait)
            else:
                x = y = None
                micro_wait = 0

            events.append({
                "round": i + 1,
                "target": target,
                "delta_y": delta,
                "wait_ms": wait_ms,
                "mouse": [x, y] if x is not None else None,
                "micro_wait_ms": micro_wait,
            })

        # 마지막에 발행 전 다시 중간/하단 확인 느낌
        if random.random() < 0.65:
            delta = random.choice([1, -1]) * random.randint(180, 460)
            try:
                if write_frame is not None:
                    write_frame.evaluate("(dy) => window.scrollBy(0, dy)", delta)
                    target = "write_frame"
                else:
                    page.mouse.wheel(0, delta)
                    target = "page"
            except Exception:
                page.mouse.wheel(0, delta)
                target = "page_fallback"
            wait_ms = random.randint(300, 900 if mode == "test" else 1600)
            page.wait_for_timeout(wait_ms)
            events.append({"round": "final", "target": target, "delta_y": delta, "wait_ms": wait_ms})

        elapsed = round(time.time() - started, 2)
        result = {
            "ok": True,
            "label": label,
            "mode": mode,
            "text_len": text_len,
            "image_count": image_count,
            "target_seconds": total_seconds,
            "elapsed_seconds": elapsed,
            "events": events,
        }
        if verbose:
            print("[V2 HPE BODY READING BREAK DONE]", result)
        return result

    except Exception as e:
        elapsed = round(time.time() - started, 2)
        result = {
            "ok": False,
            "label": label,
            "mode": mode,
            "elapsed_seconds": elapsed,
            "events": events,
            "error": f"{type(e).__name__}: {e}",
        }
        if verbose:
            print("[V2 HPE BODY READING BREAK ERROR]", result)
        return result



def hpe_natural_content_review(page, write_frame=None, hpe_profile=None, label="content_review", verbose=True):
    """
    STEP109-03:
    본문/이미지 영역을 사람이 훑어보는 것처럼 자연스럽게 스크롤하고 잠깐 멈춘다.
    최종 본문은 변경하지 않는다.
    """
    profile = hpe_profile or {}
    mode = profile.get("mode") or "test"

    if mode == "operation":
        pause_range = (650, 2200)
        rounds = random.randint(4, 8)
    elif mode == "conservative":
        pause_range = (450, 1500)
        rounds = random.randint(3, 6)
    else:
        pause_range = (220, 900)
        rounds = random.randint(2, 4)

    events = []
    try:
        for i in range(rounds):
            direction = 1
            if i > 0 and random.random() < 0.35:
                direction = -1
            delta = direction * random.randint(220, 680)
            try:
                if write_frame is not None:
                    write_frame.evaluate("(dy) => window.scrollBy(0, dy)", delta)
                    target_name = "write_frame"
                else:
                    page.mouse.wheel(0, delta)
                    target_name = "page"
            except Exception:
                page.mouse.wheel(0, delta)
                target_name = "page_fallback"

            wait_ms = random.randint(*pause_range)
            page.wait_for_timeout(wait_ms)

            if random.random() < 0.75:
                x = random.randint(420, 980)
                y = random.randint(260, 760)
                page.mouse.move(x, y, steps=random.randint(6, 18))
                page.wait_for_timeout(random.randint(120, 480))

            events.append({"target": target_name, "delta_y": delta, "wait_ms": wait_ms})

        if random.random() < 0.7:
            delta = -random.randint(180, 520)
            try:
                if write_frame is not None:
                    write_frame.evaluate("(dy) => window.scrollBy(0, dy)", delta)
                    target_name = "write_frame"
                else:
                    page.mouse.wheel(0, delta)
                    target_name = "page"
            except Exception:
                page.mouse.wheel(0, delta)
                target_name = "page_fallback"

            wait_ms = random.randint(*pause_range)
            page.wait_for_timeout(wait_ms)
            events.append({"target": target_name, "delta_y": delta, "wait_ms": wait_ms, "final_recheck": True})

        result = {"ok": True, "label": label, "mode": mode, "events": events}
        if verbose:
            print("[V2 HPE NATURAL CONTENT REVIEW]", result)
        return result

    except Exception as e:
        result = {"ok": False, "label": label, "mode": mode, "events": events, "error": f"{type(e).__name__}: {e}"}
        if verbose:
            print("[V2 HPE NATURAL CONTENT REVIEW ERROR]", result)
        return result


def hpe_publish_button_approach(page, write_frame=None, publish_target=None, hpe_profile=None, label="publish_button_approach", verbose=True):
    """
    STEP109-03:
    발행 버튼으로 바로 순간 이동하지 않고, 주변을 지나는 마우스 경로를 만든다.
    """
    profile = hpe_profile or {}
    mode = profile.get("mode") or "test"

    try:
        if mode == "operation":
            steps = random.randint(18, 34)
            pause = random.randint(500, 1600)
        elif mode == "conservative":
            steps = random.randint(12, 24)
            pause = random.randint(300, 1000)
        else:
            steps = random.randint(7, 16)
            pause = random.randint(120, 500)

        path = [
            (random.randint(450, 850), random.randint(260, 620)),
            (random.randint(760, 1120), random.randint(180, 420)),
            (random.randint(960, 1240), random.randint(90, 220)),
        ]

        for x, y in path:
            page.mouse.move(x, y, steps=steps)
            page.wait_for_timeout(random.randint(120, pause))

        result = {"ok": True, "label": label, "mode": mode, "path": path}
        if verbose:
            print("[V2 HPE PUBLISH BUTTON APPROACH]", result)
        return result

    except Exception as e:
        result = {"ok": False, "label": label, "mode": mode, "error": f"{type(e).__name__}: {e}"}
        if verbose:
            print("[V2 HPE PUBLISH BUTTON APPROACH ERROR]", result)
        return result


def hpe_final_dialog_review(page, write_frame=None, hpe_profile=None, label="final_dialog_review", verbose=True):
    """
    STEP109-03:
    최종 발행 다이얼로그가 열린 뒤, 공개설정/태그/발행버튼을 눈으로 확인하는 듯한 짧은 행동.
    실제 클릭은 하지 않는다.
    """
    profile = hpe_profile or {}
    mode = profile.get("mode") or "test"

    try:
        if mode == "operation":
            loops = random.randint(3, 6)
            wait_range = (900, 2600)
        elif mode == "conservative":
            loops = random.randint(2, 4)
            wait_range = (600, 1800)
        else:
            loops = random.randint(1, 3)
            wait_range = (250, 900)

        path = []
        for _ in range(loops):
            x = random.randint(760, 1220)
            y = random.randint(220, 760)
            page.mouse.move(x, y, steps=random.randint(8, 22))
            wait_ms = random.randint(*wait_range)
            page.wait_for_timeout(wait_ms)
            path.append({"x": x, "y": y, "wait_ms": wait_ms})

        result = {"ok": True, "label": label, "mode": mode, "path": path}
        if verbose:
            print("[V2 HPE FINAL DIALOG REVIEW]", result)
        return result

    except Exception as e:
        result = {"ok": False, "label": label, "mode": mode, "error": f"{type(e).__name__}: {e}"}
        if verbose:
            print("[V2 HPE FINAL DIALOG REVIEW ERROR]", result)
        return result



def hpe_random_wait(page, min_ms=350, max_ms=1200, label=""):
    """HPE용 짧은 랜덤 대기. 테스트/실행 흐름을 막지 않는 범위에서만 사용한다."""
    wait_ms = random.randint(int(min_ms), int(max_ms))
    if label:
        print(f"[V2 HPE WAIT] {label} {wait_ms}ms")
    try:
        page.wait_for_timeout(wait_ms)
    except Exception:
        pass
    return wait_ms


def hpe_small_mouse_drift(page, verbose=True, hpe_profile=None):
    """자동화 티를 줄이기 위한 작은 마우스 이동."""
    try:
        profile = hpe_profile or {}
        steps_range = profile.get("mouse_drift_steps") or (5, 12)
        x1 = random.randint(360, 980)
        y1 = random.randint(220, 760)
        x2 = x1 + random.randint(-80, 80)
        y2 = y1 + random.randint(-60, 60)
        page.mouse.move(x1, y1, steps=random.randint(int(steps_range[0]), int(steps_range[1])))
        page.wait_for_timeout(random.randint(120, 520 if profile.get("mode") != "test" else 380))
        page.mouse.move(x2, y2, steps=random.randint(max(4, int(steps_range[0]) - 2), max(8, int(steps_range[1]) - 2)))
        if verbose:
            print("[V2 HPE MOUSE DRIFT]", {"from": [x1, y1], "to": [x2, y2]})
        return {"ok": True, "from": [x1, y1], "to": [x2, y2]}
    except Exception as exc:
        return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}


def hpe_random_scroll(page, write_frame=None, label="", verbose=True, hpe_profile=None):
    """본문 작성 후/발행 전 사람이 훑어보는 형태의 랜덤 스크롤."""
    events = []
    try:
        profile = hpe_profile or {}
        event_range = profile.get("scroll_events") or (2, 4)
        for i in range(random.randint(int(event_range[0]), int(event_range[1]))):
            delta = random.choice([1, 1, 1, -1]) * random.randint(180, 620)
            page.mouse.wheel(0, delta)
            events.append({"target": "page", "delta_y": delta})
            page.wait_for_timeout(random.randint(250, 850))
        if write_frame is not None and random.random() < 0.65:
            try:
                frame_delta = random.randint(120, 520)
                write_frame.evaluate("(dy) => window.scrollBy(0, dy)", frame_delta)
                events.append({"target": "write_frame", "delta_y": frame_delta})
            except Exception as exc:
                events.append({"target": "write_frame", "error": f"{type(exc).__name__}: {exc}"})
        if verbose:
            print("[V2 HPE RANDOM SCROLL]", {"label": label, "events": events})
        return {"ok": True, "events": events}
    except Exception as exc:
        return {"ok": False, "events": events, "error": f"{type(exc).__name__}: {exc}"}


def hpe_after_title_input(page, write_frame=None, logger=None, session=None, verbose=True, hpe_profile=None):
    """제목 입력 직후 사람처럼 잠깐 멈추고 화면을 보는 행동."""
    profile = hpe_profile or get_v2_hpe_profile()
    title_wait = profile.get("title_wait_ms") or (450, 1350)
    result = {"checked": True, "wait_ms": None, "mouse": None, "hpe_mode": profile.get("mode")}
    with session.step("hpe.title_human_after_input", logger=logger, meta={"purpose": "title_typing_humanization", "hpe_mode": profile.get("mode")}) if session else NullContext():
        result["wait_ms"] = hpe_random_wait(page, title_wait[0], title_wait[1], "after_title")
        result["mouse"] = hpe_small_mouse_drift(page, verbose=verbose, hpe_profile=profile)
    return result


def hpe_after_body_input(page, write_frame=None, logger=None, session=None, verbose=True, hpe_profile=None, queue=None, body_result=None):
    """본문 입력 후 하단 확인, 짧은 수정 제스처(스페이스→백스페이스), 랜덤 스크롤을 수행한다.

    최종 본문 내용은 변경하지 않으면서 실제 키 입력 이벤트를 남기는 목적이다.
    """
    profile = hpe_profile or get_v2_hpe_profile()
    body_wait = profile.get("body_wait_ms") or (600, 1600)
    before_btn_wait = profile.get("before_publish_button_ms") or (350, 1100)
    result = {
        "checked": True,
        "wait_ms": None,
        "scroll": None,
        "footer_typing": {"attempted": False, "ok": False, "error": None},
        "human_typing": None,
        "natural_review": None,
        "body_reading_break": None,
        "bottom_summary_retype": None,
        "mouse": None,
    }
    result["hpe_mode"] = profile.get("mode")
    with session.step("hpe.body_footer_typing_and_scroll", logger=logger, meta={"purpose": "body_footer_typing_scroll_humanization", "hpe_mode": profile.get("mode")}) if session else NullContext():
        result["wait_ms"] = hpe_random_wait(page, body_wait[0], body_wait[1], "after_body")
        result["scroll"] = hpe_random_scroll(page, write_frame=write_frame, label="after_body", verbose=verbose, hpe_profile=profile)
        try:
            result["human_typing"] = hpe_footer_human_typing_edit(
                page=page,
                write_frame=write_frame,
                queue=queue,
                hpe_profile=profile,
                verbose=verbose,
            )
            result["footer_typing"] = {
                "attempted": True,
                "ok": bool((result.get("human_typing") or {}).get("ok")),
                "type": "property_style_type_then_delete",
                "error": (result.get("human_typing") or {}).get("error"),
            }
            if verbose:
                print("[V2 HPE BODY FOOTER TYPING]", result["footer_typing"])
        except Exception as exc:
            result["footer_typing"] = {"attempted": True, "ok": False, "error": f"{type(exc).__name__}: {exc}"}
            if verbose:
                print("[V2 HPE BODY FOOTER TYPING WARNING]", result["footer_typing"])
        result["natural_review"] = hpe_natural_content_review(
            page=page,
            write_frame=write_frame,
            hpe_profile=profile,
            label="after_body_content_review",
            verbose=verbose,
        )
        # [V2 OPS STABLE] bottom summary retyping is deferred.
        result["bottom_summary_retype"] = None
        result["body_reading_break"] = hpe_body_reading_break(
            page=page,
            write_frame=write_frame,
            body_result=body_result,
            hpe_profile=profile,
            label="after_body_paste_reading",
            verbose=verbose,
        )
        result["mouse"] = hpe_small_mouse_drift(page, verbose=verbose, hpe_profile=profile)
        hpe_random_wait(page, before_btn_wait[0], before_btn_wait[1], "before_publish_button")
    return result


NAVER_POST_URL_RE = re.compile(
    r"https?://(?:(?:m\.)?blog\.naver\.com|blog\.naver\.com)/(?:PostView\.naver\?[^\s'\"<>]+|[A-Za-z0-9_.%-]+/\d+)",
    re.IGNORECASE,
)


def normalize_naver_post_url(url):
    """네이버 블로그 게시글 URL만 운영 추적용 표준 형태로 정리한다."""
    url = str(url or "").strip()
    if not url:
        return None

    match = NAVER_POST_URL_RE.search(url)
    if not match:
        return None

    post_url = match.group(0).replace("&amp;", "&")

    # PostView URL은 blogId/logNo가 함께 있어야 운영 추적에 쓸 수 있다.
    if "PostView.naver" in post_url:
        if "blogId=" not in post_url or "logNo=" not in post_url:
            return None

    return post_url




def normalize_image_url(url):
    """대표이미지/본문 이미지 URL 비교를 위해 최소 정규화한다."""
    url = str(url or "").strip().replace("&amp;", "&")
    if not url:
        return None
    if url.startswith("//"):
        url = "https:" + url
    return url


def classify_publish_image_url(url):
    """운영 검증용 이미지 유형을 URL 패턴만으로 보수적으로 분류한다."""
    normalized = normalize_image_url(url)
    lowered = str(normalized or "").lower()

    if not normalized:
        return {
            "url": None,
            "exists": False,
            "is_cafe24": False,
            "is_naver_land": False,
            "is_naver_blog": False,
            "risk_level": "missing",
            "risk_reasons": ["image_missing"],
        }

    risk_reasons = []
    is_cafe24 = "cafe24" in lowered or "cdn" in lowered and "cafe24" in lowered
    is_naver_land = "landthumb-phinf.pstatic.net" in lowered or "land.naver" in lowered
    is_naver_blog = "postfiles.pstatic.net" in lowered or "blogfiles.pstatic.net" in lowered or "blogthumb.pstatic.net" in lowered

    # 명백히 대표이미지로 부적합할 가능성이 큰 패턴만 위험으로 잡는다.
    risky_keywords = [
        "realtor", "profile", "logo", "office", "agent", "broker",
        "map", "staticmap", "roadview", "streetview",
        "floorplan", "floor_plan", "floor-plan", "plan_", "pyeongmyeon",
    ]
    for keyword in risky_keywords:
        if keyword in lowered:
            risk_reasons.append(f"keyword:{keyword}")

    if risk_reasons:
        risk_level = "warning"
    elif is_cafe24 or is_naver_land or is_naver_blog:
        risk_level = "ok"
    else:
        risk_level = "unknown"

    return {
        "url": normalized,
        "exists": True,
        "is_cafe24": bool(is_cafe24),
        "is_naver_land": bool(is_naver_land),
        "is_naver_blog": bool(is_naver_blog),
        "risk_level": risk_level,
        "risk_reasons": risk_reasons,
    }


def count_cafe24_images_in_html(html):
    """초안/미리보기 HTML 안의 Cafe24 이미지 URL 개수를 보수적으로 센다."""
    html = str(html or "")
    if not html:
        return 0
    urls = re.findall(r"https?://[^\s'\"<>]+", html, flags=re.IGNORECASE)
    return sum(1 for url in urls if classify_publish_image_url(url).get("is_cafe24"))


def extract_publish_image_urls_from_html(html, only_cafe24=False):
    """HTML 문자열에서 publish 검증 대상 이미지 URL을 순서 유지 + 중복 제거로 뽑는다."""
    html = str(html or "")
    urls = re.findall(r"https?://[^\s'\"<>),]+", html, flags=re.IGNORECASE)
    result = []
    seen = set()
    for url in urls:
        normalized = normalize_image_url(url)
        if not normalized or normalized in seen:
            continue
        info = classify_publish_image_url(normalized)
        if only_cafe24 and not info.get("is_cafe24"):
            continue
        seen.add(normalized)
        result.append(normalized)
    return result


def diff_ordered_url_lists(source_urls, editor_urls):
    source_urls = [normalize_image_url(u) for u in (source_urls or []) if normalize_image_url(u)]
    editor_urls = [normalize_image_url(u) for u in (editor_urls or []) if normalize_image_url(u)]
    editor_set = set(editor_urls)
    source_set = set(source_urls)
    missing = [u for u in source_urls if u not in editor_set]
    extra = [u for u in editor_urls if u not in source_set]
    return {
        "source_count": len(source_urls),
        "editor_count": len(editor_urls),
        "missing": missing,
        "extra": extra,
    }


def start_editor_mutation_observer(write_frame):
    """네이버 에디터 DOM에서 이미지 추가/삭제/src 변경 흔적을 임시로 수집한다."""
    try:
        write_frame.evaluate(
            """
            () => {
                window.__v2Cafe24MutationEvents = [];
                if (window.__v2Cafe24MutationObserver) {
                    try { window.__v2Cafe24MutationObserver.disconnect(); } catch (e) {}
                }
                const startedAt = Date.now();
                const pickUrl = (node) => {
                    try {
                        if (!node || node.nodeType !== 1) return '';
                        const el = node;
                        if (el.tagName && el.tagName.toLowerCase() === 'img') {
                            return el.currentSrc || el.src || el.getAttribute('src') || el.getAttribute('data-src') || '';
                        }
                        const img = el.querySelector ? el.querySelector('img') : null;
                        if (img) return img.currentSrc || img.src || img.getAttribute('src') || img.getAttribute('data-src') || '';
                    } catch (e) {}
                    return '';
                };
                const log = (event) => {
                    try {
                        event.t_ms = Date.now() - startedAt;
                        window.__v2Cafe24MutationEvents.push(event);
                        if (window.__v2Cafe24MutationEvents.length > 300) {
                            window.__v2Cafe24MutationEvents.shift();
                        }
                    } catch (e) {}
                };
                const observer = new MutationObserver((mutations) => {
                    mutations.forEach((m) => {
                        if (m.type === 'childList') {
                            m.addedNodes && m.addedNodes.forEach((n) => log({
                                type: 'node_added',
                                tag: n.tagName || '',
                                url: pickUrl(n),
                                text: (n.innerText || '').slice(0, 80)
                            }));
                            m.removedNodes && m.removedNodes.forEach((n) => log({
                                type: 'node_removed',
                                tag: n.tagName || '',
                                url: pickUrl(n),
                                text: (n.innerText || '').slice(0, 80)
                            }));
                        } else if (m.type === 'attributes') {
                            log({
                                type: 'attribute_changed',
                                attr: m.attributeName,
                                tag: m.target && m.target.tagName || '',
                                url: pickUrl(m.target)
                            });
                        }
                    });
                });
                observer.observe(document.body, {
                    childList: true,
                    subtree: true,
                    attributes: true,
                    attributeFilter: ['src', 'srcset', 'data-src', 'data-original', 'data-lazy-src', 'style', 'class']
                });
                window.__v2Cafe24MutationObserver = observer;
                return true;
            }
            """
        )
        return {"ok": True}
    except Exception as exc:
        return {"ok": False, "error": f"{type(exc).__name__}: {exc}"}


def stop_and_read_editor_mutation_observer(write_frame):
    try:
        events = write_frame.evaluate(
            """
            () => {
                const events = window.__v2Cafe24MutationEvents || [];
                if (window.__v2Cafe24MutationObserver) {
                    try { window.__v2Cafe24MutationObserver.disconnect(); } catch (e) {}
                }
                window.__v2Cafe24MutationObserver = null;
                return events.slice(-120);
            }
            """
        )
        return {"ok": True, "events": events or []}
    except Exception as exc:
        return {"ok": False, "events": [], "error": f"{type(exc).__name__}: {exc}"}


def save_editor_html_snapshot(write_frame, queue_id=None, label="snapshot"):
    """에디터 DOM 스냅샷을 Temp/blog_v2_debug에 저장한다."""
    result = {"ok": False, "path": None, "label": label, "error": None}
    try:
        html = write_frame.evaluate("() => document.documentElement.outerHTML")
        debug_dir = Path(os.environ.get("TEMP") or os.environ.get("TMP") or str(ROOT / "tmp")) / "blog_v2_debug"
        debug_dir.mkdir(parents=True, exist_ok=True)
        safe_queue = str(queue_id or "unknown")
        stamp = time.strftime("%Y%m%d_%H%M%S")
        path = debug_dir / f"v2_q{safe_queue}_{label}_{stamp}.html"
        path.write_text(str(html or ""), encoding="utf-8")
        result.update({"ok": True, "path": str(path)})
    except Exception as exc:
        result["error"] = f"{type(exc).__name__}: {exc}"
    return result


def collect_cafe24_paste_diagnostics(queue, body_result=None, write_frame=None, wait_ms=3200, verbose=True, effective_html=None):
    """
    STEP105-25: Cafe24 이미지가 Preview→Editor 사이에서 사라지는 원인 추적용 계측.
    발행 로직에는 개입하지 않고 URL 목록, missing URL, DOM mutation, HTML snapshot만 남긴다.
    """
    html = str(effective_html if effective_html is not None else get_publish_html(queue))
    source_cafe24_urls = extract_publish_image_urls_from_html(html, only_cafe24=True)
    diagnostics = {
        "checked": True,
        "source_cafe24_urls": source_cafe24_urls,
        "source_cafe24_count": len(source_cafe24_urls),
        "mutation_start": None,
        "snapshot_after_paste": None,
        "snapshot_after_stable": None,
        "editor_cafe24_urls_after_paste": [],
        "editor_cafe24_urls_after_stable": [],
        "diff_after_paste": None,
        "diff_after_stable": None,
        "mutation_events": [],
        "html_block_compare": None,
        "error": None,
    }

    def log(label, value=None):
        if not verbose:
            return
        if value is None:
            print(label)
        else:
            print(label, value)

    if write_frame is None:
        diagnostics["error"] = "write_frame_missing"
        return diagnostics

    try:
        log("[V2 CAFE24 SOURCE URLS]", {"count": len(source_cafe24_urls), "urls": source_cafe24_urls})
        diagnostics["mutation_start"] = start_editor_mutation_observer(write_frame)
        diagnostics["snapshot_after_paste"] = save_editor_html_snapshot(write_frame, queue_id=queue.get("id"), label="after_paste")
        stats_after_paste = get_editor_image_cdn_stats(write_frame)
        editor_after_paste = stats_after_paste.get("all_cafe24_urls") or []
        diagnostics["editor_cafe24_urls_after_paste"] = editor_after_paste
        diagnostics["diff_after_paste"] = diff_ordered_url_lists(source_cafe24_urls, editor_after_paste)

        log("[V2 CAFE24 EDITOR URLS AFTER PASTE]", {
            "count": len(editor_after_paste),
            "urls": editor_after_paste,
        })
        log("[V2 CAFE24 DIFF AFTER PASTE]", diagnostics["diff_after_paste"])

        try:
            write_frame.page.wait_for_timeout(int(wait_ms))
        except Exception:
            pass

        diagnostics["snapshot_after_stable"] = save_editor_html_snapshot(write_frame, queue_id=queue.get("id"), label="after_stable")
        stats_after_stable = get_editor_image_cdn_stats(write_frame)
        editor_after_stable = stats_after_stable.get("all_cafe24_urls") or []
        diagnostics["editor_cafe24_urls_after_stable"] = editor_after_stable
        diagnostics["diff_after_stable"] = diff_ordered_url_lists(source_cafe24_urls, editor_after_stable)
        diagnostics["html_block_compare"] = compare_cafe24_html_blocks(
            html=html,
            source_urls=source_cafe24_urls,
            missing_urls=(diagnostics["diff_after_stable"] or {}).get("missing") or [],
            survived_urls=editor_after_stable,
            queue_id=queue.get("id"),
        )
        log("[V2 CAFE24 HTML BLOCK COMPARE]", diagnostics["html_block_compare"])
        mutation_result = stop_and_read_editor_mutation_observer(write_frame)
        diagnostics["mutation_events"] = mutation_result.get("events") or []
        if not mutation_result.get("ok"):
            diagnostics["mutation_error"] = mutation_result.get("error")

        log("[V2 CAFE24 EDITOR URLS AFTER STABLE]", {
            "count": len(editor_after_stable),
            "urls": editor_after_stable,
        })
        log("[V2 CAFE24 DIFF AFTER STABLE]", diagnostics["diff_after_stable"])
        log("[V2 CAFE24 MUTATION EVENTS]", {
            "count": len(diagnostics["mutation_events"]),
            "sample": diagnostics["mutation_events"][:25],
        })
        log("[V2 EDITOR SNAPSHOT]", {
            "after_paste": diagnostics["snapshot_after_paste"],
            "after_stable": diagnostics["snapshot_after_stable"],
        })
    except Exception as exc:
        diagnostics["error"] = f"{type(exc).__name__}: {exc}"
        try:
            stop_and_read_editor_mutation_observer(write_frame)
        except Exception:
            pass

    return diagnostics


def html_context_for_url(html, url, radius=1800):
    """HTML 안에서 특정 URL 주변 블록을 추출한다. figure/div/table 단위가 잡히면 그 블록을 우선 반환."""
    html = str(html or "")
    url = str(url or "")
    if not html or not url:
        return {"found": False, "url": url, "context": "", "start": -1, "end": -1, "hints": {}}

    idx = html.find(url)
    if idx < 0:
        # HTML escape/encoding 차이를 고려해 파일명으로 한 번 더 찾는다.
        filename = url.split("/")[-1].split("?")[0]
        if filename:
            idx = html.find(filename)
    if idx < 0:
        return {"found": False, "url": url, "context": "", "start": -1, "end": -1, "hints": {}}

    start = max(0, idx - int(radius))
    end = min(len(html), idx + len(url) + int(radius))

    # 가능하면 가장 가까운 이미지 컨테이너 경계를 넓게 잡는다.
    for tag in ["figure", "table", "div", "p"]:
        open_pat = f"<{tag}"
        close_pat = f"</{tag}>"
        s = html.rfind(open_pat, 0, idx)
        e = html.find(close_pat, idx)
        if s >= 0 and e >= 0:
            e = e + len(close_pat)
            if (e - s) <= max(9000, radius * 4):
                start, end = s, e
                break

    context = html[start:end]
    lower = context.lower()
    hints = {
        "len": len(context),
        "has_img": "<img" in lower,
        "img_count": len(re.findall(r"<img\b", context, flags=re.IGNORECASE)),
        "has_figure": "<figure" in lower,
        "has_table": "<table" in lower,
        "has_link": "<a " in lower,
        "has_style_display_none": "display:none" in lower or "display: none" in lower,
        "has_zero_size": bool(re.search(r"(width|height)\s*[:=]\s*['\"]?0(?:px)?", context, flags=re.IGNORECASE)),
        "has_map_keyword": any(k in lower for k in ["map", "지도", "naver_map", "realtor_naver_map"]),
        "has_data_uri": "data:image" in lower,
    }
    return {"found": True, "url": url, "context": context, "start": start, "end": end, "hints": hints}


def compare_cafe24_html_blocks(html, source_urls, missing_urls=None, survived_urls=None, save_dir=None, queue_id=None):
    """살아남은 Cafe24 이미지와 누락된 Cafe24 이미지의 HTML 블록 구조를 비교/저장한다."""
    html = str(html or "")
    source_urls = list(source_urls or [])
    missing_urls = list(missing_urls or [])
    survived_urls = list(survived_urls or [])

    if not survived_urls:
        missing_set = set(missing_urls)
        survived_urls = [u for u in source_urls if u not in missing_set]

    result = {
        "checked": True,
        "missing_count": len(missing_urls),
        "survived_count": len(survived_urls),
        "missing_blocks": [],
        "survived_blocks": [],
        "saved_files": [],
        "suspicions": [],
    }

    debug_dir = None
    try:
        if save_dir:
            debug_dir = Path(save_dir)
        else:
            debug_dir = Path(os.environ.get("TEMP") or os.environ.get("TMP") or str(ROOT / "tmp")) / "blog_v2_debug"
        debug_dir.mkdir(parents=True, exist_ok=True)
    except Exception:
        debug_dir = None

    for label, urls, key in [
        ("missing", missing_urls, "missing_blocks"),
        ("survived", survived_urls[:3], "survived_blocks"),
    ]:
        for i, url in enumerate(urls):
            block = html_context_for_url(html, url)
            compact = {
                "url": url,
                "found": block.get("found"),
                "hints": block.get("hints") or {},
                "context_sample": (block.get("context") or "")[:700],
            }
            result[key].append(compact)

            if debug_dir and block.get("context"):
                try:
                    safe_queue = str(queue_id or "unknown")
                    filename_hint = url.split("/")[-1].split("?")[0][:80]
                    out = debug_dir / f"v2_q{safe_queue}_html_block_{label}_{i}_{filename_hint}.html"
                    out.write_text(block.get("context") or "", encoding="utf-8")
                    result["saved_files"].append(str(out))
                except Exception:
                    pass

    # 간단한 원인 추정
    for block in result["missing_blocks"]:
        hints = block.get("hints") or {}
        url = block.get("url") or ""
        lower_url = url.lower()
        if "realtor_naver_map" in lower_url or hints.get("has_map_keyword"):
            result["suspicions"].append("missing_image_is_realtor_map")
        if hints.get("has_style_display_none"):
            result["suspicions"].append("missing_block_display_none")
        if hints.get("has_zero_size"):
            result["suspicions"].append("missing_block_zero_size")
        if not hints.get("has_img"):
            result["suspicions"].append("missing_block_without_img_tag")

    # 중복 제거
    seen = set()
    dedup = []
    for item in result["suspicions"]:
        if item not in seen:
            seen.add(item)
            dedup.append(item)
    result["suspicions"] = dedup
    return result


def _walk_json_values_for_paths(obj, found=None, key_hint=""):
    """source_json 안의 로컬 파일 경로 후보를 재귀적으로 수집한다."""
    if found is None:
        found = []
    try:
        if isinstance(obj, dict):
            for k, v in obj.items():
                _walk_json_values_for_paths(v, found, str(k or ""))
        elif isinstance(obj, list):
            for v in obj:
                _walk_json_values_for_paths(v, found, key_hint)
        elif isinstance(obj, str):
            raw = obj.strip()
            low_key = str(key_hint or "").lower()
            low_raw = raw.lower()
            if (
                raw
                and (
                    "path" in low_key
                    or "file" in low_key
                    or "image" in low_key
                    or re.search(r"^[a-zA-Z]:[\\/]", raw)
                    or raw.startswith("/")
                )
                and low_raw.endswith((".png", ".jpg", ".jpeg", ".webp"))
            ):
                found.append(raw)
    except Exception:
        pass
    return found


def extract_header_cafe24_url_from_html(html):
    """본문 HTML에서 대표/header Cafe24 URL을 우선순위로 추출한다."""
    html = str(html or "")
    urls = extract_publish_image_urls_from_html(html, only_cafe24=True)
    for url in urls:
        lowered = (url or "").lower()
        if "header_" in lowered or "price_band" in lowered or "representative" in lowered:
            return url
    return urls[0] if urls else ""


def derive_local_paths_from_cafe24_url(cafe24_url):
    """
    Cafe24 URL을 기준으로 서버 로컬 파일 후보 경로를 만든다.
    예:
      https://.../real_auto/header_images/2026/06/2633630442/header_x.png
    후보:
      ROOT/mybox/generated/real_auto/header_images/2026/06/2633630442/header_x.png
      ROOT/storage/real_auto/header_images/...
      ROOT/public/real_auto/header_images/...
    """
    cafe24_url = str(cafe24_url or "").strip()
    if not cafe24_url:
        return []

    try:
        from urllib.parse import urlparse, unquote
        parsed = urlparse(cafe24_url)
        path = unquote(parsed.path or "").lstrip("/")
    except Exception:
        path = cafe24_url.split("://")[-1].split("/", 1)[-1].split("?")[0].lstrip("/")

    if not path:
        return []

    # CDN 경로에서 real_auto 이하를 유지한다.
    if "real_auto/" in path:
        rel = path[path.find("real_auto/"):]
    else:
        rel = path

    candidates = []
    base_dirs = [
        ROOT / "mybox" / "generated",
        ROOT / "mybox",
        ROOT / "storage",
        ROOT / "public",
        ROOT / "uploads",
        ROOT,
    ]

    for base in base_dirs:
        candidates.append(str((base / rel).resolve()))

    # real_auto가 중복되거나 빠진 생성 구조도 대비한다.
    if rel.startswith("real_auto/"):
        rel_without = rel[len("real_auto/"):]
        for base in [
            ROOT / "mybox" / "generated" / "real_auto",
            ROOT / "mybox" / "generated",
            ROOT / "storage" / "real_auto",
            ROOT / "public" / "real_auto",
        ]:
            candidates.append(str((base / rel_without).resolve()))

    # 중복 제거
    dedup = []
    seen = set()
    for p in candidates:
        pn = str(p).replace("\\", "/")
        if pn not in seen:
            seen.add(pn)
            dedup.append(pn)
    return dedup


def find_existing_representative_path_by_filename(filename, max_hits=1):
    """
    마지막 fallback: 대표이미지 파일명으로 제한된 주요 폴더만 검색한다.
    전체 드라이브 검색은 하지 않는다.
    """
    filename = str(filename or "").strip()
    if not filename:
        return []

    roots = [
        ROOT / "mybox",
        ROOT / "storage",
        ROOT / "public",
        ROOT / "uploads",
    ]

    found = []
    for root in roots:
        try:
            if not root.exists():
                continue
            for path in root.rglob(filename):
                if path.is_file():
                    found.append(str(path.resolve()).replace("\\", "/"))
                    if len(found) >= int(max_hits or 1):
                        return found
        except Exception:
            continue
    return found


def extract_representative_image_path_from_queue(queue):
    """
    STEP105-31:
    V1은 source_json.header_image.image_path가 있을 때 대표이미지를 직접 업로드한다.
    V2에서는 일부 큐에서 해당 값이 비어 있으므로, 아래 순서로 경로를 확장 탐색한다.

    1) source_json.header_image.* 명시 경로
    2) source_json 전체의 path/file/image 계열 로컬 경로
    3) clipboard_html/draft_html의 Cafe24 header URL → 로컬 후보 경로 역추정
    4) 대표이미지 파일명으로 주요 생성 폴더 제한 검색
    """
    try:
        source = json.loads(queue.get("source_json") or "{}")
    except Exception:
        source = {}

    header_image = source.get("header_image") or {}
    html = str(queue.get("clipboard_html") or queue.get("draft_html") or "")

    candidates = [
        header_image.get("image_path"),
        header_image.get("local_path"),
        header_image.get("original_image_path"),
        header_image.get("path"),
        header_image.get("file_path"),
        header_image.get("filename"),
        source.get("header_image_path"),
        source.get("representative_image_path"),
    ]

    candidates.extend(_walk_json_values_for_paths(source))

    header_url = extract_header_cafe24_url_from_html(html)
    url_candidates = derive_local_paths_from_cafe24_url(header_url)
    candidates.extend(url_candidates)

    try:
        filename = ""
        if header_url:
            filename = header_url.split("/")[-1].split("?")[0]
        if filename:
            candidates.extend(find_existing_representative_path_by_filename(filename, max_hits=2))
    except Exception:
        pass

    checked = []
    seen = set()
    for path in candidates:
        raw = str(path or "").strip().strip('"').strip("'")
        if not raw:
            continue
        normalized = raw.replace("\\", "/")
        # 상대경로면 ROOT 기준도 확인한다.
        possible = [normalized]
        if not re.search(r"^[a-zA-Z]:/", normalized) and not normalized.startswith("/"):
            possible.append(str((ROOT / normalized).resolve()).replace("\\", "/"))

        for p in possible:
            if p in seen:
                continue
            seen.add(p)
            checked.append(p)
            if p and os.path.exists(p):
                return p, {
                    "found": True,
                    "path": p,
                    "checked": checked[:80],
                    "source": "expanded_source_json_or_cafe24_url",
                    "header_url": header_url,
                }

    return "", {
        "found": False,
        "path": "",
        "checked": checked[:80],
        "source": "expanded_source_json_or_cafe24_url",
        "header_url": header_url,
    }


def strip_representative_image_from_html(html):
    """
    V1 이식:
    대표이미지를 네이버 에디터에 직접 업로드한 경우, 본문 HTML의 CDN 대표이미지 블록은 제거한다.
    이렇게 해야 같은 대표이미지가 본문에 2번 나오지 않고, 업로드된 이미지가 대표 체크 대상이 된다.
    """
    html = str(html or "")
    if not html:
        return html, {"changed": False, "removed_count": 0, "reason": "empty_html"}

    patterns = [
        r'<div[^>]*class=["\'][^"\']*realestate-representative-image[^"\']*["\'][^>]*>[\s\S]*?</div>\s*',
        r'<table[^>]*class=["\'][^"\']*realestate-representative-image-table[^"\']*["\'][^>]*>[\s\S]*?</table>\s*',
        r'<[^>]+[^>]*realestate-representative-image[^>]*>[\s\S]*?</[^>]+>\s*',
    ]

    cleaned = html
    total_removed = 0
    for pattern in patterns:
        cleaned2, count = re.subn(pattern, "", cleaned, count=1, flags=re.IGNORECASE)
        if count > 0:
            cleaned = cleaned2
            total_removed += count
            print(f"[V2 REP HTML STRIP] representative image block removed count={count}")
            break

    return cleaned.strip(), {
        "changed": total_removed > 0,
        "removed_count": total_removed,
        "reason": "representative_uploaded_strip_cdn_block" if total_removed else "representative_block_not_found",
    }


def try_set_representative_checkbox(page, write_frame, verbose=True):
    """
    V1 이식:
    네이버 에디터에서 대표이미지 체크를 안정적으로 시도한다.
    """
    if verbose:
        print("[V2 REP CHECK TRY]")

    candidates = [
        ("frame", write_frame, "label:has-text('대표')"),
        ("frame", write_frame, "button:has-text('대표')"),
        ("frame", write_frame, "span:has-text('대표')"),
        ("frame", write_frame, "div[role='button']:has-text('대표')"),
        ("page", page, "label:has-text('대표')"),
        ("page", page, "button:has-text('대표')"),
        ("page", page, "span:has-text('대표')"),
        ("page", page, "div[role='button']:has-text('대표')"),
    ]

    for wait_i in range(8):
        page.wait_for_timeout(1000)

        for root_name, root, selector in candidates:
            if root is None:
                continue
            try:
                loc = root.locator(selector)
                count = loc.count()
                if count <= 0:
                    continue

                print(f"[V2 REP BUTTON FOUND] wait={wait_i + 1}, root={root_name}, selector={selector}, count={count}")

                for idx in range(min(count, 5)):
                    try:
                        target = loc.nth(idx)
                        box = target.bounding_box()
                        if box and (box.get("width", 0) > 420 or box.get("height", 0) > 170):
                            continue

                        target.click(force=True, timeout=3000)
                        page.wait_for_timeout(1000)
                        print(f"[V2 REP BUTTON CLICKED] root={root_name}, selector={selector}, index={idx}")

                        try:
                            checked_count = root.locator("input[type='checkbox']:checked").count()
                            if checked_count > 0:
                                print(f"[V2 REP THUMBNAIL CONFIRMED] checked_count={checked_count}")
                                return {"ok": True, "method": "text_button", "root": root_name, "selector": selector, "index": idx}
                        except Exception:
                            pass

                        print("[V2 REP THUMBNAIL CONFIRMED] representative click attempted")
                        return {"ok": True, "method": "text_button_attempted", "root": root_name, "selector": selector, "index": idx}

                    except Exception as exc:
                        print(f"[V2 REP BUTTON CLICK ERROR] root={root_name}, selector={selector}, index={idx}, error={exc}")
            except Exception as exc:
                print(f"[V2 REP BUTTON SCAN ERROR] root={root_name}, selector={selector}, error={exc}")

        for root_name, root in [("frame", write_frame), ("page", page)]:
            if root is None:
                continue
            try:
                checks = root.locator("input[type='checkbox']")
                count = checks.count()
                if count <= 0:
                    continue

                print(f"[V2 REP CHECKBOX FOUND] wait={wait_i + 1}, root={root_name}, count={count}")
                for idx in range(min(count, 5)):
                    try:
                        cb = checks.nth(idx)
                        try:
                            if cb.is_checked(timeout=1000):
                                print(f"[V2 REP THUMBNAIL CONFIRMED] already checked root={root_name}, index={idx}")
                                return {"ok": True, "method": "checkbox_already_checked", "root": root_name, "index": idx}
                        except Exception:
                            pass

                        cb.click(force=True, timeout=3000)
                        page.wait_for_timeout(800)
                        print(f"[V2 REP BUTTON CLICKED] checkbox root={root_name}, index={idx}")

                        try:
                            if cb.is_checked(timeout=1000):
                                print(f"[V2 REP THUMBNAIL CONFIRMED] checkbox checked root={root_name}, index={idx}")
                                return {"ok": True, "method": "checkbox_checked", "root": root_name, "index": idx}
                        except Exception:
                            print("[V2 REP THUMBNAIL CONFIRMED] checkbox click attempted")
                            return {"ok": True, "method": "checkbox_attempted", "root": root_name, "index": idx}
                    except Exception as exc:
                        print(f"[V2 REP CHECKBOX CLICK ERROR] root={root_name}, index={idx}, error={exc}")
            except Exception as exc:
                print(f"[V2 REP CHECKBOX SCAN ERROR] root={root_name}, error={exc}")

    print("[V2 REP CHECK NOT FOUND]")
    return {"ok": False, "method": None, "error": "representative checkbox/button not found"}


def upload_representative_image_to_editor(page, write_frame, image_path, verbose=True):
    """
    V1 이식:
    대표이미지만 로컬 파일로 네이버 에디터에 직접 업로드한다.
    직접 업로드된 이미지는 네이버 대표이미지 체크 대상이 된다.
    """
    image_path = str(image_path or "").strip().replace("\\", "/")
    result = {
        "attempted": bool(image_path),
        "ok": False,
        "path": image_path,
        "method": None,
        "checkbox": None,
        "error": None,
    }

    if not image_path or not os.path.exists(image_path):
        result["error"] = "representative image path missing"
        print("[V2 REP UPLOAD SKIP]", result)
        return result

    print("[V2 REP UPLOAD START]", image_path)

    try:
        page.mouse.click(520, 430)
        page.wait_for_timeout(800)
    except Exception:
        pass

    image_button_selectors = [
        'button:has-text("사진")',
        'button:has-text("이미지")',
        'button:has-text("사진 첨부")',
        'button:has-text("첨부")',
        'a:has-text("사진")',
        'a:has-text("이미지")',
        'span:has-text("사진")',
        'span:has-text("이미지")',
        '[aria-label*="사진"]',
        '[aria-label*="이미지"]',
        '[title*="사진"]',
        '[title*="이미지"]',
        '.se-toolbar-item-image button',
        '.se-toolbar-item-image',
        '.se-image-toolbar-button',
    ]

    last_error = ""

    # 1) 이미 존재하는 file input 직접 처리
    for root_name, root in [("frame", write_frame), ("page", page)]:
        if root is None:
            continue
        try:
            inputs = root.locator("input[type='file']")
            count = inputs.count()
            print(f"[V2 REP FILE INPUT COUNT] root={root_name}, count={count}")
            for i in range(count):
                try:
                    inputs.nth(i).set_input_files(image_path, timeout=5000)
                    page.wait_for_timeout(8000)
                    print(f"[V2 REP UPLOAD OK] direct input root={root_name}, index={i}")
                    checkbox = try_set_representative_checkbox(page, write_frame, verbose=verbose)
                    result.update({"ok": True, "method": f"direct_input:{root_name}:{i}", "checkbox": checkbox})
                    return result
                except Exception as exc:
                    last_error = f"direct input root={root_name}, index={i}, error={exc}"
                    print("[V2 REP FILE INPUT ERROR]", last_error)
        except Exception as exc:
            last_error = f"input scan root={root_name}, error={exc}"
            print("[V2 REP FILE INPUT SCAN ERROR]", last_error)

    # 2) 사진/이미지 버튼 클릭 후 file chooser 처리
    for root_name, root in [("frame", write_frame), ("page", page)]:
        if root is None:
            continue
        for selector in image_button_selectors:
            try:
                loc = root.locator(selector).first
                if loc.count() <= 0:
                    continue
                print(f"[V2 REP IMAGE BUTTON TRY] root={root_name}, selector={selector}")

                with page.expect_file_chooser(timeout=7000) as fc_info:
                    loc.click(force=True, timeout=5000)

                chooser = fc_info.value
                chooser.set_files(image_path)
                page.wait_for_timeout(10000)

                print(f"[V2 REP UPLOAD OK] file chooser root={root_name}, selector={selector}")
                checkbox = try_set_representative_checkbox(page, write_frame, verbose=verbose)
                result.update({"ok": True, "method": f"file_chooser:{root_name}:{selector}", "checkbox": checkbox})
                return result
            except Exception as exc:
                last_error = f"root={root_name}, selector={selector}, error={exc}"
                print("[V2 REP IMAGE BUTTON ERROR]", last_error)

    # 3) 버튼 클릭 후 생성된 input[type=file] 처리
    for root_name, root in [("frame", write_frame), ("page", page)]:
        if root is None:
            continue
        for selector in image_button_selectors:
            try:
                loc = root.locator(selector).first
                if loc.count() <= 0:
                    continue
                loc.click(force=True, timeout=5000)
                page.wait_for_timeout(1500)

                inputs = root.locator("input[type='file']")
                count = inputs.count()
                for i in range(count):
                    try:
                        inputs.nth(i).set_input_files(image_path, timeout=5000)
                        page.wait_for_timeout(10000)
                        print(f"[V2 REP UPLOAD OK] post-click input root={root_name}, selector={selector}, index={i}")
                        checkbox = try_set_representative_checkbox(page, write_frame, verbose=verbose)
                        result.update({"ok": True, "method": f"post_click_input:{root_name}:{selector}:{i}", "checkbox": checkbox})
                        return result
                    except Exception as exc:
                        last_error = f"post-click root={root_name}, selector={selector}, input={i}, error={exc}"
                        print("[V2 REP POST CLICK INPUT ERROR]", last_error)
            except Exception as exc:
                last_error = f"post-click root={root_name}, selector={selector}, error={exc}"
                print("[V2 REP POST CLICK ERROR]", last_error)

    result["error"] = last_error or "representative upload failed"
    print("[V2 REP UPLOAD FAIL]", result)
    return result


def move_cursor_after_representative_image(page, write_frame):
    """대표이미지 직접 업로드 후 본문 붙여넣기 위치를 이미지 아래로 옮긴다."""
    try:
        print("[V2 CURSOR AFTER REP MOVE START]")
        page.keyboard.press("Escape")
        page.wait_for_timeout(500)

        try:
            page.mouse.wheel(0, 700)
            page.wait_for_timeout(500)
        except Exception:
            pass

        for x, y in [(520, 760), (520, 820), (620, 760)]:
            try:
                page.mouse.click(x, y)
                page.wait_for_timeout(400)
                break
            except Exception:
                continue

        for _ in range(2):
            try:
                page.keyboard.press("Enter")
                page.wait_for_timeout(250)
            except Exception:
                pass

        print("[V2 CURSOR AFTER REP MOVE END]")
        return {"ok": True}
    except Exception as exc:
        print("[V2 CURSOR AFTER REP MOVE ERROR]", str(exc))
        return {"ok": False, "error": str(exc)}


def hpe_human_wait_before_publish(page, min_seconds=300, max_seconds=600, logger=None, session=None, hpe_profile=None):
    """
    V1 STEP103 이식:
    글쓰기 진입 후 최종 발행까지 수초만에 끝나는 패턴을 피하기 위한 발행 직전 검토 대기.
    실행 모드에서만 길게 적용하고, check 모드에서는 호출하지 않는다.
    """
    profile = hpe_profile or get_v2_hpe_profile()
    review_range = profile.get("review_wait_seconds") or (min_seconds, max_seconds)
    try:
        min_seconds = int(os.environ.get("V2_HPE_REVIEW_MIN_SECONDS") or review_range[0] or min_seconds or 300)
        max_seconds = int(os.environ.get("V2_HPE_REVIEW_MAX_SECONDS") or review_range[1] or max_seconds or 600)
    except Exception:
        min_seconds = int(review_range[0] or 300)
        max_seconds = int(review_range[1] or 600)

    if min_seconds < 0:
        min_seconds = 0
    if max_seconds < min_seconds:
        max_seconds = min_seconds

    wait_seconds = random.randint(min_seconds, max_seconds)

    if wait_seconds <= 0:
        print("[V2 HUMAN WAIT BEFORE PUBLISH SKIP] wait_seconds=0")
        return {"waited": False, "wait_seconds": 0}

    print(
        "[V2 HUMAN WAIT BEFORE PUBLISH START]",
        f"wait_seconds={wait_seconds}",
        f"range={min_seconds}~{max_seconds}",
    )

    elapsed = 0
    while elapsed < wait_seconds:
        try:
            if random.random() < 0.55:
                page.mouse.wheel(0, random.randint(-450, 750))
            else:
                page.mouse.move(
                    random.randint(420, 1180),
                    random.randint(180, 760),
                    steps=random.randint(8, 22),
                )
        except Exception as exc:
            print("[V2 HUMAN WAIT ACTION WARN]", str(exc))

        chunk = random.randint(25, 70)
        chunk = min(chunk, wait_seconds - elapsed)
        time.sleep(chunk)
        elapsed += chunk

        print(
            "[V2 HUMAN WAIT BEFORE PUBLISH PROGRESS]",
            f"elapsed={elapsed}",
            f"remain={max(0, wait_seconds - elapsed)}",
        )

    print("[V2 HUMAN WAIT BEFORE PUBLISH DONE]", f"elapsed={elapsed}")
    return {"waited": True, "wait_seconds": wait_seconds, "elapsed": elapsed}


def normalize_representative_image_block_for_naver_paste(html):
    """
    STEP105-27:
    네이버 SmartEditor paste 과정에서 본문 최상단 단독 div+img 대표이미지가 누락되는 현상 대응.
    DB 원본은 변경하지 않고 V2 입력용 HTML만 아래처럼 변환한다.

    Before:
      <div class="realestate-representative-image"> <img ...> </div>

    After:
      <table role="presentation"><tr><td align="center"><img ...></td></tr></table>

    다른 이미지/본문/테이블은 건드리지 않는다.
    """
    html = str(html or "")
    result = {
        "changed": False,
        "reason": None,
        "before_block_found": False,
        "converted_count": 0,
    }

    pattern = re.compile(
        r'<div\b([^>]*class=["\'][^"\']*realestate-representative-image[^"\']*["\'][^>]*)>\s*(<img\b[^>]*>)\s*</div>',
        flags=re.IGNORECASE | re.DOTALL,
    )

    def repl(match):
        result["before_block_found"] = True
        div_attrs = match.group(1) or ""
        img_tag = match.group(2) or ""

        # 대표이미지 img는 최대한 단순하고 명확하게 유지한다.
        fixed_img = img_tag
        fixed_img = re.sub(r"\sloading=(['\"])[^'\"]*\1", "", fixed_img, flags=re.IGNORECASE)
        fixed_img = re.sub(r"\sdecoding=(['\"])[^'\"]*\1", "", fixed_img, flags=re.IGNORECASE)
        fixed_img = re.sub(r"\sdata-[a-zA-Z0-9_-]+=(['\"])[^'\"]*\1", "", fixed_img, flags=re.IGNORECASE)

        if re.search(r"\salt=", fixed_img, flags=re.IGNORECASE):
            fixed_img = re.sub(r"\salt=(['\"])[^'\"]*\1", ' alt="대표이미지"', fixed_img, flags=re.IGNORECASE)
        else:
            fixed_img = fixed_img.replace("<img", '<img alt="대표이미지"', 1)

        style_add = "width:100%;max-width:800px;height:auto;border-radius:18px;display:block;margin:0 auto;border:0;"
        if re.search(r"\sstyle=", fixed_img, flags=re.IGNORECASE):
            fixed_img = re.sub(
                r"\sstyle=(['\"])(.*?)\1",
                lambda m: f' style="{m.group(2).rstrip(";")};{style_add}"',
                fixed_img,
                count=1,
                flags=re.IGNORECASE,
            )
        else:
            fixed_img = fixed_img.replace("<img", f'<img style="{style_add}"', 1)

        result["converted_count"] += 1
        return (
            '<table class="realestate-representative-image-table" role="presentation" '
            'style="width:100%;border-collapse:collapse;margin:0 0 32px 0;">'
            '<tbody><tr><td align="center" style="padding:0;text-align:center;">'
            f'{fixed_img}'
            '</td></tr></tbody></table>'
        )

    html2 = pattern.sub(repl, html, count=1)
    if html2 != html:
        result["changed"] = True
        result["reason"] = "representative_image_div_img_converted_to_table"
    elif result["before_block_found"]:
        result["reason"] = "representative_image_block_found_but_unchanged"
    else:
        result["reason"] = "representative_image_block_not_found"

    return html2, result



def find_first_fin_land_map_url_in_text(value):
    text = str(value or "")
    urls = []
    for m in re.finditer(r"https?://fin\.land\.naver\.com/map[^\"'\s<>\\]+", text, flags=re.IGNORECASE):
        url = m.group(0).strip()
        url = url.replace("&amp;", "&")
        if url and url not in urls:
            urls.append(url)
    return urls[0] if urls else ""


def find_first_fin_land_map_url_deep(value, depth=0):
    """
    STEP107-42:
    middle_banner 링크 URL이 HTML에는 없고 source_json 내부에만 남아 있는 경우가 있다.
    queue/draft/source_json 전체를 재귀 탐색해 fin.land 전체매물 URL을 찾아낸다.
    """
    if depth > 8:
        return ""

    if value is None:
        return ""

    if isinstance(value, str):
        direct = find_first_fin_land_map_url_in_text(value)
        if direct:
            return direct

        stripped = value.strip()
        if stripped and stripped[0:1] in ("{", "["):
            try:
                parsed = json.loads(stripped)
                return find_first_fin_land_map_url_deep(parsed, depth + 1)
            except Exception:
                return ""

        return ""

    if isinstance(value, dict):
        # URL일 가능성이 높은 키부터 우선 탐색
        preferred_keys = [
            "realtor_naver_map",
            "realtor_naver_map_url",
            "naver_map_url",
            "map_url",
            "land_map_url",
            "fin_land_url",
            "fin_land_map_url",
            "realtor_url",
            "office_url",
            "all_article_url",
            "all_articles_url",
            "full_listing_url",
            "full_list_url",
            "url",
            "href",
            "link",
        ]

        lowered_map = {str(k).lower(): k for k in value.keys()}

        for key in preferred_keys:
            real_key = lowered_map.get(key.lower())
            if real_key is not None:
                found = find_first_fin_land_map_url_deep(value.get(real_key), depth + 1)
                if found:
                    return found

        for k, v in value.items():
            found = find_first_fin_land_map_url_deep(v, depth + 1)
            if found:
                return found

        return ""

    if isinstance(value, (list, tuple, set)):
        for item in value:
            found = find_first_fin_land_map_url_deep(item, depth + 1)
            if found:
                return found

        return ""

    return ""


def infer_middle_banner_fin_land_url_from_queue(queue):
    queue = queue or {}

    # queue row 전체를 재귀 탐색
    found = find_first_fin_land_map_url_deep(queue)
    if found:
        return found, "queue_deep"

    # 명시 필드 fallback
    for key in ["draft_source_json", "source_json", "clipboard_html", "draft_html"]:
        found = find_first_fin_land_map_url_deep(queue.get(key))
        if found:
            return found, key

    return "", ""

def normalize_remaining_cafe24_image_blocks_for_naver_paste(html, fallback_fin_land_url=''):
    """
    STEP107-41:
    middle_banner를 V1 방식처럼 '중개사 전체매물 fin.land 링크가 걸린 이미지 배너'로 유지한다.

    핵심:
    - 단순 <img>만 남기면 안 된다.
    - <a href="https://fin.land.naver.com/map?..."><img middle_banner ...></a> 구조가 살아야 한다.
    - 원본 HTML에 이미 a[href*=fin.land]가 있으면 그 href를 보존한다.
    - img만 남아 있더라도 같은 HTML 안의 fin.land map URL을 찾아 배너 링크로 복원한다.
    """
    html = str(html or "")
    changed_count = 0
    converted_urls = []
    injected_count = 0
    linked_count = 0
    detected_link_urls = []

    def normalize_href(href):
        href = str(href or "").strip()
        href = href.replace("&amp;", "&")
        if href.startswith("https://fin.land.naver.com/map"):
            return href
        return ""

    def first_fin_land_url(source):
        urls = []
        for m in re.finditer(r"https?://fin\.land\.naver\.com/map[^\"'\s<>]+", str(source or ""), flags=re.IGNORECASE):
            url = normalize_href(m.group(0))
            if url and url not in urls:
                urls.append(url)
        return urls[0] if urls else ""

    global_fin_href = first_fin_land_url(html)
    fallback_fin_land_url = normalize_href(fallback_fin_land_url)

    if not global_fin_href and fallback_fin_land_url:
        global_fin_href = fallback_fin_land_url

    if global_fin_href:
        detected_link_urls.append(global_fin_href)

    def make_block(src, href="", alt="네이버 등록 매물 안내"):
        src = normalize_image_url(src)
        href = normalize_href(href) or global_fin_href

        fixed_img = (
            f'<img src="{src}" alt="{alt}" '
            'style="width:100%;max-width:800px;height:auto;display:block;margin:0 auto;border:0;">'
        )

        if href:
            inner = (
                f'<a href="{href}" target="_blank" rel="noopener noreferrer" '
                'style="display:block;text-decoration:none;border:0;">'
                f'{fixed_img}'
                '</a>'
            )
        else:
            inner = fixed_img

        return (
            '<table class="v2-cafe24-middle-banner-block" role="presentation" '
            'style="width:100%;border-collapse:collapse;margin:24px 0;">'
            '<tbody><tr><td align="center" style="padding:0;text-align:center;">'
            f'{inner}'
            '</td></tr></tbody></table>'
        )

    # 1) 원본이 <a href="fin.land..."><img middle_banner ...></a> 구조인 경우
    #    a 전체를 table 안의 a+img로 변환해 링크를 보존한다.
    anchor_img_pattern = re.compile(
        r"<a\b(?P<a_attrs>[^>]*href=(?P<q>['\"])(?P<href>https?://fin\.land\.naver\.com/map.*?)(?P=q)[^>]*)>"
        r"(?P<body>.*?)"
        r"</a>",
        flags=re.IGNORECASE | re.DOTALL,
    )

    def repl_anchor(match):
        nonlocal changed_count, linked_count
        body = match.group("body") or ""
        if "middle_banner" not in body:
            return match.group(0)

        img_match = re.search(r"<img\b[^>]*middle_banner[^>]*>", body, flags=re.IGNORECASE | re.DOTALL)
        if not img_match:
            return match.group(0)

        img_tag = img_match.group(0)
        src_match = re.search(r"src=(['\"])(.*?)\1", img_tag, flags=re.IGNORECASE | re.DOTALL)
        src = normalize_image_url(src_match.group(2) if src_match else "")
        if not src:
            url_match = re.search(r"https?://[^\"'\s<>]+middle_banner[^\"'\s<>]+", img_tag, flags=re.IGNORECASE)
            src = normalize_image_url(url_match.group(0) if url_match else "")

        href = normalize_href(match.group("href"))
        if href and href not in detected_link_urls:
            detected_link_urls.append(href)

        if not src:
            return match.group(0)

        changed_count += 1
        linked_count += 1
        converted_urls.append(src)
        return make_block(src, href=href)

    html2 = anchor_img_pattern.sub(repl_anchor, html)

    # 2) 아직 남아 있는 middle_banner img 태그는 global fin.land URL이 있으면 링크를 걸어 변환한다.
    img_pattern = re.compile(r"<img\b[^>]*middle_banner[^>]*>", flags=re.IGNORECASE | re.DOTALL)

    def repl_middle_img(match):
        nonlocal changed_count, linked_count
        tag = match.group(0)
        url_match = re.search(r"https?://[^\"'\s<>]+middle_banner[^\"'\s<>]+", tag, flags=re.IGNORECASE)
        if not url_match:
            src_match = re.search(r"src=(['\"])(.*?)\1", tag, flags=re.IGNORECASE | re.DOTALL)
            url = src_match.group(2) if src_match else ""
        else:
            url = url_match.group(0)

        url = normalize_image_url(url)
        if not url:
            return tag

        changed_count += 1
        converted_urls.append(url)

        if global_fin_href:
            linked_count += 1

        return make_block(url, href=global_fin_href)

    html2 = img_pattern.sub(repl_middle_img, html2)

    # 3) middle_banner URL은 있는데 img 태그 치환이 안 된 경우를 대비해 standalone block을 주입한다.
    #    단, 이미 v2-cafe24-middle-banner-block으로 변환되어 있으면 중복 주입하지 않는다.
    middle_urls = []
    for m in re.finditer(r"https?://[^\"'\s<>]+middle_banner[^\"'\s<>]+", html2, flags=re.IGNORECASE):
        url = normalize_image_url(m.group(0))
        if url and url not in middle_urls:
            middle_urls.append(url)

    if middle_urls and "v2-cafe24-middle-banner-block" not in html2:
        block = "\n".join(make_block(url, href=global_fin_href) for url in middle_urls)
        insert_patterns = [
            r"(<h[1-6][^>]*>\s*매물\s*핵심\s*정보\s*</h[1-6]>)",
            r"(매물\s*핵심\s*정보)",
            r"(<table\b)",
        ]
        inserted = False
        for pat in insert_patterns:
            m = re.search(pat, html2, flags=re.IGNORECASE)
            if m:
                html2 = html2[:m.start()] + block + "\n" + html2[m.start():]
                injected_count += len(middle_urls)
                if global_fin_href:
                    linked_count += len(middle_urls)
                inserted = True
                break
        if not inserted:
            html2 = block + "\n" + html2
            injected_count += len(middle_urls)
            if global_fin_href:
                linked_count += len(middle_urls)

    return html2, {
        "changed": html2 != html,
        "converted_count": changed_count,
        "injected_count": injected_count,
        "linked_count": linked_count,
        "converted_urls": converted_urls,
        "middle_urls_detected": middle_urls,
        "fin_land_urls_detected": detected_link_urls,
        "reason": (
            "middle_banner_converted_or_injected_to_linked_table"
            if html2 != html and linked_count > 0
            else "middle_banner_converted_or_injected_to_simple_table_no_fin_link"
            if html2 != html
            else "no_middle_banner_conversion_needed"
        ),
    }


def normalize_cafe24_html_for_naver_paste(html):
    """
    STEP105-26 보수적 보정:
    네이버 에디터가 realtor_naver_map 이미지를 paste 단계에서 누락시키는 현상이 확인됨.
    해당 이미지는 지도/중개사 위치 보조 이미지 성격이 강하므로, paste 안정성을 위해
    V2 본문 입력 직전에 data-v2-role을 단순화하고 inline block/img 스타일을 명시한다.

    원본 DB HTML은 변경하지 않고, V2 입력용 HTML 문자열만 보정한다.
    """
    html = str(html or "")
    if "realtor_naver_map" not in html:
        return html, {"changed": False, "reason": "no_realtor_naver_map"}

    changed = False

    def fix_img(match):
        nonlocal changed
        tag = match.group(0)
        if "realtor_naver_map" not in tag:
            return tag
        new_tag = tag

        # lazy 관련 속성/role이 있으면 네이버 paste 파서가 걸러낼 가능성을 낮춘다.
        new_tag = re.sub(r"\sloading=(['\"])[^'\"]*\1", "", new_tag, flags=re.IGNORECASE)
        new_tag = re.sub(r"\sdecoding=(['\"])[^'\"]*\1", "", new_tag, flags=re.IGNORECASE)
        new_tag = re.sub(r"\sdata-[a-zA-Z0-9_-]+=(['\"])[^'\"]*\1", "", new_tag, flags=re.IGNORECASE)

        if re.search(r"\salt=", new_tag, flags=re.IGNORECASE):
            new_tag = re.sub(r"\salt=(['\"])[^'\"]*\1", ' alt="중개사 위치 지도"', new_tag, flags=re.IGNORECASE)
        else:
            new_tag = new_tag.replace("<img", '<img alt="중개사 위치 지도"', 1)

        style_add = "max-width:100%;height:auto;display:block;margin:18px auto;border:0;"
        if re.search(r"\sstyle=", new_tag, flags=re.IGNORECASE):
            new_tag = re.sub(
                r"\sstyle=(['\"])(.*?)\1",
                lambda m: f' style="{m.group(2).rstrip(";")};{style_add}"',
                new_tag,
                count=1,
                flags=re.IGNORECASE,
            )
        else:
            new_tag = new_tag.replace("<img", f'<img style="{style_add}"', 1)

        if new_tag != tag:
            changed = True
        return new_tag

    html2 = re.sub(r"<img\b[^>]*>", fix_img, html, flags=re.IGNORECASE)

    return html2, {
        "changed": bool(changed),
        "reason": "realtor_naver_map_img_normalized_for_paste",
    }


def get_editor_image_cdn_stats(write_frame):
    """네이버 에디터에 붙여넣어진 이미지 CDN 상태를 확인한다.

    STEP105-24/25:
    img[src]만 보면 네이버 에디터의 lazy 이미지/placeholder(data:image) 때문에
    실제 원본 CDN URL을 놓칠 수 있다. currentSrc/src/srcset/data-* 및 style background까지
    한 번에 수집해 Cafe24 누락 여부를 더 정확히 판단한다.
    """
    stats = {
        "checked": False,
        "image_count": 0,
        "cafe24_image_count": 0,
        "naver_land_image_count": 0,
        "naver_blog_image_count": 0,
        "sample_urls": [],
        "all_cafe24_urls": [],
        "raw_url_count": 0,
        "error": None,
    }
    try:
        raw_urls = write_frame.evaluate(
            """
            () => {
                const urls = [];
                const push = (v) => {
                    if (!v) return;
                    String(v).split(/[,\\s]+/).forEach(part => {
                        const s = String(part || '').trim();
                        if (!s) return;
                        if (s.startsWith('http') || s.startsWith('data:image')) urls.push(s);
                    });
                };
                document.querySelectorAll('img').forEach(img => {
                    push(img.currentSrc);
                    push(img.src);
                    push(img.getAttribute('src'));
                    push(img.getAttribute('data-src'));
                    push(img.getAttribute('data-original'));
                    push(img.getAttribute('data-lazy-src'));
                    push(img.getAttribute('srcset'));
                    push(img.getAttribute('data-srcset'));
                    push(img.getAttribute('data-original-src'));
                });
                document.querySelectorAll('[style]').forEach(el => {
                    const style = el.getAttribute('style') || '';
                    const matches = style.match(/url\\(["']?([^"')]+)["']?\\)/g) || [];
                    matches.forEach(m => {
                        const mm = m.match(/url\\(["']?([^"')]+)["']?\\)/);
                        if (mm && mm[1]) push(mm[1]);
                    });
                });
                return urls.slice(0, 1000);
            }
            """
        )
        urls = [normalize_image_url(url) for url in (raw_urls or [])]
        urls = [url for url in urls if url]
        unique_urls = []
        seen = set()
        for url in urls:
            if url in seen:
                continue
            seen.add(url)
            unique_urls.append(url)

        stats["checked"] = True
        stats["raw_url_count"] = len(raw_urls or [])
        stats["image_count"] = len([url for url in unique_urls if url.startswith("http") or url.startswith("data:image")])
        stats["sample_urls"] = unique_urls[:7]
        for url in unique_urls:
            info = classify_publish_image_url(url)
            if info.get("is_cafe24"):
                stats["cafe24_image_count"] += 1
                stats["all_cafe24_urls"].append(url)
            if info.get("is_naver_land"):
                stats["naver_land_image_count"] += 1
            if info.get("is_naver_blog"):
                stats["naver_blog_image_count"] += 1
    except Exception as exc:
        stats["checked"] = True
        stats["error"] = f"{type(exc).__name__}: {exc}"
    return stats


def verify_cafe24_cdn_flow(queue, body_result=None, write_frame=None, representative_image_result=None, logger=None, session=None, verbose=True, effective_html=None):
    """
    STEP105-19: Cafe24 CDN 이미지가 V2 발행 흐름에서 누락되는지 추적한다.

    기준:
    - before: 초안 HTML 안의 Cafe24 이미지 수
    - preview: Rendered Preview Copy 단계의 Cafe24 이미지 수
    - editor: 네이버 에디터 붙여넣기 후 img src 기준 Cafe24 이미지 수
    - published: 발행 후 게시글 이미지 중 Cafe24 이미지 수

    주의:
    네이버가 발행 후 이미지를 자체 CDN으로 변환할 수 있으므로 published 단계는 경고 지표로만 사용하고,
    발행 성공 자체를 막지는 않는다.
    """
    body_result = body_result or {}
    representative_image_result = representative_image_result or {}
    html = str(effective_html if effective_html is not None else get_publish_html(queue))
    copy_stats = ((body_result.get("copy") or {}).get("stats") or {})
    editor_stats = get_editor_image_cdn_stats(write_frame) if write_frame is not None else {
        "checked": False,
        "image_count": 0,
        "cafe24_image_count": None,
        "naver_land_image_count": None,
        "naver_blog_image_count": None,
        "sample_urls": [],
        "error": None,
    }

    result = {
        "checked": True,
        "ok": True,
        "before_cafe24_count": count_cafe24_images_in_html(html),
        "preview_cafe24_count": int(copy_stats.get("cafe24_image_count") or 0),
        "preview_image_count": int(copy_stats.get("image_count") or 0),
        "editor": editor_stats,
        "published_checked": bool(representative_image_result),
        "published_cafe24_count": representative_image_result.get("cafe24_image_count") if representative_image_result else None,
        "published_image_count": representative_image_result.get("image_count") if representative_image_result else None,
        "warnings": [],
        "blocking_errors": [],
        "gate_ok": True,
    }

    before_count = int(result["before_cafe24_count"] or 0)
    preview_count = int(result["preview_cafe24_count"] or 0)
    editor_count = editor_stats.get("cafe24_image_count")
    published_count = result.get("published_cafe24_count")

    # STEP107-47:
    # V2 direct clipboard 방식에서는 preview iframe/preview page를 사용하지 않는다.
    # 따라서 preview_cafe24_count=0 이더라도 발행 차단 조건으로 보지 않는다.
    #
    # 실제 발행 전 안전 기준은 Editor DOM / HTML Block Compare 결과다.
    if before_count > 0 and preview_count < before_count:
        result["warnings"].append("preview_cafe24_count_less_than_source_ignored_for_v2_direct_clipboard")

    # Editor DOM은 네이버 SmartEditor가 일부 이미지를 lazy/component 처리하면서 실제 발행 결과와 다를 수 있다.
    # 따라서 1개 이상 살아 있으면 차단하지 않고 경고만 둔다. 0개면 차단한다.
    if before_count > 0 and editor_count is not None:
        editor_count_int = int(editor_count or 0)
        if editor_count_int < 1:
            result["blocking_errors"].append("editor_cafe24_image_missing")
        elif editor_count_int < before_count:
            result["warnings"].append("editor_cafe24_count_less_than_source_reference_only")

    # 발행 후 게시글에서는 네이버가 자체 CDN으로 변환할 수 있으므로, published 카운트는 품질 참고용이다.
    if before_count > 0 and published_count is not None and int(published_count or 0) < 1:
        result["warnings"].append("published_cafe24_image_not_detected_reference_only")
    if editor_stats.get("error"):
        result["warnings"].append("editor_image_stats_error")

    result["gate_ok"] = len(result["blocking_errors"]) == 0
    result["ok"] = bool(result["gate_ok"])

    def log(message, value=None):
        if not verbose:
            return
        if value is None:
            print(message)
        else:
            print(message, value)

    step_ctx = session.step(
        "v2_verify_cafe24_cdn",
        logger=logger,
        meta={
            "before_cafe24_count": result["before_cafe24_count"],
            "preview_cafe24_count": result["preview_cafe24_count"],
            "editor_cafe24_count": editor_count,
            "published_cafe24_count": published_count,
        },
    ) if session else _nullcontext()

    with step_ctx:
        log("[V2 CAFE24 VERIFY BEFORE]", {"count": result["before_cafe24_count"]})
        log("[V2 CAFE24 VERIFY PREVIEW]", {"count": result["preview_cafe24_count"], "image_count": result["preview_image_count"]})
        log("[V2 CAFE24 VERIFY EDITOR]", editor_stats)
        if result["published_checked"]:
            log("[V2 CAFE24 VERIFY PUBLISHED]", {"count": result["published_cafe24_count"], "image_count": result["published_image_count"]})
        log("[V2 CAFE24 VERIFY RESULT]", {"ok": result["ok"], "gate_ok": result.get("gate_ok"), "blocking_errors": result.get("blocking_errors"), "warnings": result["warnings"]})
        return result


def verify_representative_image_after_publish(page, context, post_url=None, logger=None, session=None, timeout_ms=12000, verbose=True, representative_upload_result=None):
    """
    STEP105-17: 발행 완료 후 대표이미지 후보를 검증한다.

    검증 기준:
    - og:image 또는 twitter:image 존재 여부
    - 게시글 본문/완료 페이지의 첫 이미지 후보
    - Cafe24 CDN / Naver Land CDN / Naver Blog 변환 이미지 여부
    - 로고/지도/평면도/중개사 프로필 가능성 URL 패턴 경고

    실제 네이버 대표이미지 선택은 서버 측 처리 지연이 있을 수 있으므로,
    이 함수는 발행 성공을 막지 않고 검증 결과만 반환한다.
    """
    result = {
        "checked": False,
        "ok": False,
        "post_url": post_url,
        "og_image": None,
        "twitter_image": None,
        "first_image": None,
        "representative_image": None,
        "image_count": 0,
        "cafe24_image_count": 0,
        "naver_land_image_count": 0,
        "naver_blog_image_count": 0,
        "risk_level": "not_checked",
        "risk_reasons": [],
        "source": None,
        "representative_upload_ok": bool((representative_upload_result or {}).get("ok")),
        "representative_upload_path": (representative_upload_result or {}).get("path"),
        "representative_upload_method": (representative_upload_result or {}).get("method"),
        "error": None,
    }

    def log(message, value=None):
        if not verbose:
            return
        if value is None:
            print(message)
        else:
            print(message, value)

    def get_meta_image(target, selector):
        try:
            value = target.locator(selector).first().get_attribute("content", timeout=2000)
            return normalize_image_url(value)
        except Exception:
            return None

    def get_images(target):
        try:
            return target.locator("img[src]").evaluate_all(
                "els => els.map(img => img.currentSrc || img.src).filter(Boolean).slice(0, 500)"
            )
        except Exception:
            return []

    step_ctx = session.step(
        "v2_verify_representative_image",
        logger=logger,
        meta={"post_url": post_url, "timeout_ms": timeout_ms},
    ) if session else _nullcontext()

    with step_ctx:
        try:
            candidates = []
            try:
                candidates = list(context.pages or [])
            except Exception:
                candidates = [page]

            # post_url이 현재 탭에 없으면, 검증용 새 탭으로 실제 게시글을 열어본다.
            verification_page = None
            if post_url:
                for candidate in candidates:
                    try:
                        if normalize_naver_post_url(candidate.url) == normalize_naver_post_url(post_url):
                            verification_page = candidate
                            break
                    except Exception:
                        pass
                if verification_page is None:
                    try:
                        verification_page = context.new_page()
                        verification_page.goto(post_url, wait_until="domcontentloaded", timeout=timeout_ms)
                        verification_page.wait_for_timeout(1500)
                        candidates.append(verification_page)
                    except Exception as exc:
                        result["risk_reasons"].append(f"post_url_open_failed:{type(exc).__name__}")

            if verification_page is None:
                verification_page = page

            target_pages = [verification_page] + [p for p in candidates if p is not verification_page]

            for target in target_pages:
                og_image = get_meta_image(target, "meta[property='og:image']")
                twitter_image = get_meta_image(target, "meta[name='twitter:image']")
                images = [normalize_image_url(x) for x in get_images(target)]
                images = [x for x in images if x]

                if og_image or twitter_image or images:
                    result["source"] = "post_page" if target is verification_page else "context_page"
                    result["og_image"] = og_image
                    result["twitter_image"] = twitter_image
                    result["first_image"] = images[0] if images else None
                    result["image_count"] = len(images)
                    break

            all_urls = []
            if result.get("og_image"):
                all_urls.append(result["og_image"])
            if result.get("twitter_image"):
                all_urls.append(result["twitter_image"])
            if result.get("first_image"):
                all_urls.append(result["first_image"])

            representative = result.get("og_image") or result.get("twitter_image") or result.get("first_image")
            rep_info = classify_publish_image_url(representative)
            result["representative_image"] = rep_info

            # 본문 이미지 전체는 대표 검증 보조 지표로만 카운트한다.
            counted_images = []
            try:
                counted_images = [normalize_image_url(x) for x in get_images(verification_page)]
                counted_images = [x for x in counted_images if x]
            except Exception:
                counted_images = []

            for img_url in counted_images:
                info = classify_publish_image_url(img_url)
                if info.get("is_cafe24"):
                    result["cafe24_image_count"] += 1
                if info.get("is_naver_land"):
                    result["naver_land_image_count"] += 1
                if info.get("is_naver_blog"):
                    result["naver_blog_image_count"] += 1

            risk_reasons = list(result.get("risk_reasons") or [])

            upload_ok = bool((representative_upload_result or {}).get("ok"))
            upload_checkbox_ok = bool(((representative_upload_result or {}).get("checkbox") or {}).get("ok"))

            if not rep_info.get("exists"):
                if upload_ok and upload_checkbox_ok:
                    # 네이버 게시글/프롤로그 HTML은 발행 직후 iframe/비동기 로딩 때문에 og:image/img를 못 읽는 경우가 있다.
                    # 실제 목록 썸네일은 대표 업로드+대표 버튼 클릭 결과를 따르므로, 이 경우는 검증 미검출로만 기록한다.
                    result["source"] = result.get("source") or "direct_upload_representative_assumed"
                    result["representative_image"] = {
                        "url": None,
                        "exists": True,
                        "is_cafe24": False,
                        "is_naver_land": False,
                        "is_naver_blog": True,
                        "risk_level": "ok",
                        "risk_reasons": [],
                        "assumed_from_direct_upload": True,
                    }
                    rep_info = result["representative_image"]
                    risk_reasons.append("representative_image_page_probe_unreadable_reference_only")
                else:
                    risk_reasons.append("representative_image_missing")
            risk_reasons.extend(rep_info.get("risk_reasons") or [])

            # reference_only 경고만 있는 경우 운영 품질 실패로 보지 않는다.
            blocking_risks = [
                r for r in risk_reasons
                if not str(r).endswith("_reference_only")
            ]

            result["checked"] = True
            result["ok"] = bool(rep_info.get("exists") and rep_info.get("risk_level") in ["ok", "unknown"] and not blocking_risks)
            result["risk_reasons"] = risk_reasons
            result["risk_level"] = "warning" if blocking_risks else (rep_info.get("risk_level") or "unknown")

            if result["ok"]:
                log("[V2 REPRESENTATIVE IMAGE OK]", result.get("representative_image"))
            else:
                log("[V2 REPRESENTATIVE IMAGE WARNING]", {"risk_level": result["risk_level"], "risk_reasons": risk_reasons, "image": representative})

            return result

        except Exception as exc:
            result["checked"] = True
            result["ok"] = False
            result["risk_level"] = "error"
            result["error"] = f"{type(exc).__name__}: {exc}"
            result["risk_reasons"] = ["representative_image_verify_error"]
            log("[V2 REPRESENTATIVE IMAGE ERROR]", result["error"])
            return result

def build_pre_execute_gate_result(
    final_publish_execute=False,
    cafe24_cdn_result=None,
    publish_dialog_result=None,
    hpe_title_result=None,
    hpe_body_result=None,
):
    """
    STEP105-24/25: 실제 Final Publish 클릭 직전 안전 게이트.

    check 모드에서는 진단만 하고, execute 모드에서는 아래 항목이 불안정하면 클릭을 막는다.
    - 최종 발행 버튼 확인
    - Cafe24 CDN 에디터 유지
    - HPE 제목 후 행동
    - HPE 본문 후 스크롤/하단 타이핑 행동
    """
    cafe24_cdn_result = cafe24_cdn_result or {}
    publish_dialog_result = publish_dialog_result or {}
    hpe_title_result = hpe_title_result or {}
    hpe_body_result = hpe_body_result or {}

    errors = []
    warnings = []

    final_button = (publish_dialog_result.get("final_button") or {})
    if not final_button.get("found"):
        errors.append("final_publish_button_not_found")

    cafe24_checked = bool(cafe24_cdn_result.get("checked"))
    cafe24_blocking_errors = cafe24_cdn_result.get("blocking_errors") or []
    cafe24_gate_ok = cafe24_cdn_result.get("gate_ok")
    if cafe24_gate_ok is None:
        cafe24_gate_ok = cafe24_cdn_result.get("ok")

    if not cafe24_checked:
        errors.append("cafe24_cdn_not_checked")
    elif not cafe24_gate_ok:
        if cafe24_blocking_errors:
            for err in cafe24_blocking_errors:
                errors.append(f"cafe24:{err}")
        else:
            errors.append("cafe24_cdn_not_gate_ok")

    for warning in cafe24_cdn_result.get("warnings") or []:
        warnings.append(f"cafe24:{warning}")

    if not hpe_title_result.get("checked"):
        errors.append("hpe_title_action_missing")

    if not hpe_body_result.get("checked"):
        errors.append("hpe_body_action_missing")
    else:
        footer = hpe_body_result.get("footer_typing") or {}
        if not footer.get("attempted") or not footer.get("ok"):
            errors.append("hpe_body_footer_typing_failed")

        scroll = hpe_body_result.get("scroll") or {}
        scroll_events = scroll.get("events") or []
        if not scroll_events:
            errors.append("hpe_body_scroll_missing")
        if any(event.get("error") for event in scroll_events if isinstance(event, dict)):
            errors.append("hpe_body_scroll_error")

    allowed = len(errors) == 0

    return {
        "checked": True,
        "execute": bool(final_publish_execute),
        "allowed": allowed,
        "errors": errors,
        "warnings": warnings,
    }


def build_post_publish_quality_summary(queue, post_url_result=None, representative_image_result=None, cafe24_cdn_result=None, representative_upload_result=None, effective_html=None):
    """
    STEP105-28:
    실제 발행 후 운영 판단에 필요한 품질 결과를 한 곳에 요약한다.

    목적:
    - post_url 수집 성공 여부
    - 대표이미지 후보가 존재하는지
    - 대표이미지가 위험 후보(지도/중개사/평면도/로고)인지
    - Cafe24 CDN 이미지가 에디터/게시글 검증에서 유지되는지
    - 운영 DB history meta_json에 그대로 남길 수 있는 compact summary 제공
    """
    post_url_result = post_url_result or {}
    representative_image_result = representative_image_result or {}
    cafe24_cdn_result = cafe24_cdn_result or {}
    representative_upload_result = representative_upload_result or {}

    original_html = get_publish_html(queue)
    html = str(effective_html if effective_html is not None else original_html)
    source_cafe24_urls = extract_publish_image_urls_from_html(html, only_cafe24=True)
    original_cafe24_urls = extract_publish_image_urls_from_html(original_html, only_cafe24=True)
    expected_header_url = None
    for url in original_cafe24_urls:
        lowered = (url or "").lower()
        if "header_" in lowered or "representative" in lowered or "price_band" in lowered:
            expected_header_url = url
            break
    if expected_header_url is None:
        for url in source_cafe24_urls:
            lowered = (url or "").lower()
            if "header_" in lowered or "representative" in lowered or "price_band" in lowered:
                expected_header_url = url
                break
    if expected_header_url is None and source_cafe24_urls:
        expected_header_url = source_cafe24_urls[0]

    editor = cafe24_cdn_result.get("editor") or {}
    representative = representative_image_result.get("representative_image") or {}
    representative_url = (
        representative.get("url")
        or representative_image_result.get("og_image")
        or representative_image_result.get("twitter_image")
        or representative_image_result.get("first_image")
    )
    representative_url = normalize_image_url(representative_url)
    expected_header_url = normalize_image_url(expected_header_url)

    warnings = []
    errors = []

    post_url = post_url_result.get("post_url")
    if not post_url:
        errors.append("post_url_missing")

    direct_rep_ok = bool(representative_upload_result.get("ok")) and bool((representative_upload_result.get("checkbox") or {}).get("ok"))

    if not representative_image_result.get("checked"):
        if direct_rep_ok:
            warnings.append("representative_post_page_probe_skipped_reference_only")
        else:
            warnings.append("representative_not_checked")
    elif not representative_image_result.get("ok"):
        if direct_rep_ok:
            warnings.append("representative_post_page_probe_unreadable_reference_only")
        else:
            warnings.append("representative_not_ok")

    if representative_image_result.get("risk_reasons"):
        for reason in representative_image_result.get("risk_reasons") or []:
            reason = str(reason)
            if direct_rep_ok and (reason.endswith("_reference_only") or reason in ["representative_image_missing", "image_missing"]):
                warnings.append(f"representative:{reason}_ignored_direct_upload")
            else:
                warnings.append(f"representative:{reason}")

    if expected_header_url:
        if not representative_url:
            if direct_rep_ok:
                warnings.append("representative_url_missing_reference_only")
            else:
                warnings.append("representative_url_missing")
        elif expected_header_url == representative_url:
            pass
        elif expected_header_url.split("?")[0] == representative_url.split("?")[0]:
            pass
        else:
            # 네이버가 og:image를 자체 CDN으로 변환할 수 있으므로 error는 아니고 warning으로만 둔다.
            warnings.append("representative_not_expected_header_url")

    if not cafe24_cdn_result.get("checked"):
        warnings.append("cafe24_not_checked")
    elif cafe24_cdn_result.get("blocking_errors"):
        for err in cafe24_cdn_result.get("blocking_errors") or []:
            warnings.append(f"cafe24_blocking:{err}")
    elif cafe24_cdn_result.get("gate_ok") is False:
        warnings.append("cafe24_gate_not_ok")

    for warning in cafe24_cdn_result.get("warnings") or []:
        warnings.append(f"cafe24:{warning}")

    editor_cafe24_count = editor.get("cafe24_image_count")
    before_cafe24_count = cafe24_cdn_result.get("before_cafe24_count")
    if before_cafe24_count and editor_cafe24_count is not None and int(editor_cafe24_count or 0) < int(before_cafe24_count or 0):
        warnings.append("editor_cafe24_less_than_source")

    blocking_warnings = [
        w for w in warnings
        if not str(w).endswith("_reference_only")
        and "_reference_only_" not in str(w)
        and not str(w).endswith("_ignored_direct_upload")
        and "published_cafe24_image_not_detected_reference_only" not in str(w)
    ]

    quality_ok = len(errors) == 0 and len(blocking_warnings) == 0

    return {
        "checked": True,
        "quality_ok": quality_ok,
        "post_url": post_url,
        "post_url_source": post_url_result.get("source"),
        "expected_header_url": expected_header_url,
        "representative_url": representative_url,
        "representative_risk_level": representative_image_result.get("risk_level"),
        "source_cafe24_count": len(source_cafe24_urls),
        "original_cafe24_count": len(original_cafe24_urls),
        "representative_direct_upload_ok": direct_rep_ok,
        "blocking_warnings": blocking_warnings,
        "editor_cafe24_count": editor_cafe24_count,
        "published_cafe24_count": cafe24_cdn_result.get("published_cafe24_count"),
        "published_image_count": cafe24_cdn_result.get("published_image_count"),
        "errors": errors,
        "warnings": warnings,
    }


def collect_post_url_after_publish(page, context, logger=None, session=None, timeout_ms=15000, verbose=True):
    """
    Final Publish 실행 이후 네이버 블로그 실제 게시글 URL을 수집한다.

    수집 우선순위:
    1) 현재 page.url / context.pages URL
    2) 완료 화면의 a[href]
    3) 새 탭 또는 지연 이동 URL

    DB 업데이트는 다음 STEP에서 처리하고, 이번 단계는 수집 결과만 반환한다.
    """
    started_at = None
    try:
        started_at = page.evaluate("Date.now()")
    except Exception:
        started_at = None

    result = {
        "post_url": None,
        "success_like": False,
        "source": None,
        "current_url": None,
        "pages": [],
        "error": None,
    }

    def log(message, value=None):
        if not verbose:
            return
        if value is None:
            print(message)
        else:
            print(message, value)

    def inspect_page_urls(source_prefix):
        try:
            pages = list(context.pages or [])
        except Exception:
            pages = [page]

        for idx, target in enumerate(pages):
            try:
                url = target.url
            except Exception:
                url = ""
            result["pages"].append(url)
            post_url = normalize_naver_post_url(url)
            if post_url:
                return post_url, f"{source_prefix}:page_url:{idx}"
        return None, None

    def inspect_page_links(target, source_prefix):
        try:
            hrefs = target.locator("a[href]").evaluate_all(
                "els => els.map(a => a.href).filter(Boolean).slice(0, 300)"
            )
        except Exception:
            hrefs = []

        for idx, href in enumerate(hrefs or []):
            post_url = normalize_naver_post_url(href)
            if post_url:
                return post_url, f"{source_prefix}:link:{idx}"
        return None, None

    try:
        with session.step("v2_collect_post_url", logger=logger, meta={"timeout_ms": timeout_ms}) if session else _nullcontext():
            # Final Publish 직후 네이버가 완료 화면/게시글로 이동하는 시간을 준다.
            deadline_ms = int(timeout_ms or 15000)
            poll_ms = 500
            elapsed = 0

            while elapsed <= deadline_ms:
                try:
                    result["current_url"] = page.url
                except Exception:
                    result["current_url"] = None

                post_url, source = inspect_page_urls("poll")
                if post_url:
                    result.update({"post_url": post_url, "success_like": True, "source": source})
                    log("[V2 POST URL DETECTED]", post_url)
                    return result

                try:
                    pages = list(context.pages or [])
                except Exception:
                    pages = [page]

                for page_idx, target in enumerate(pages):
                    post_url, source = inspect_page_links(target, f"poll:page:{page_idx}")
                    if post_url:
                        result.update({"post_url": post_url, "success_like": True, "source": source})
                        log("[V2 POST URL DETECTED]", post_url)
                        return result

                page.wait_for_timeout(poll_ms)
                elapsed += poll_ms

            # URL을 못 찾았더라도 완료/성공 징후가 있는지 최소 확인한다.
            success_text_patterns = [
                "발행되었습니다",
                "게시되었습니다",
                "등록되었습니다",
                "내 블로그",
                "글 보기",
                "확인",
            ]
            success_like = False
            for target in list(context.pages or [page]):
                for text in success_text_patterns:
                    try:
                        if target.get_by_text(text, exact=False).count() > 0:
                            success_like = True
                            break
                    except Exception:
                        pass
                if success_like:
                    break

            result["success_like"] = bool(success_like)
            result["source"] = "success_text_only" if success_like else "not_found"
            result["error"] = "post_url_not_found"
            log("[V2 POST URL NOT FOUND]", {"success_like": result["success_like"], "current_url": result["current_url"]})
            return result

    except Exception as exc:
        result["error"] = f"{type(exc).__name__}: {exc}"
        log("[V2 POST URL COLLECT ERROR]", result["error"])
        return result


class _nullcontext:
    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc, tb):
        return False


def get_table_columns(conn, table_name):
    """현재 DB의 테이블 컬럼 목록을 조회한다. 컬럼명이 다소 달라도 안전하게 증분 업데이트하기 위함이다."""
    try:
        with conn.cursor() as cur:
            cur.execute(f"SHOW COLUMNS FROM {table_name}")
            rows = cur.fetchall()
    except Exception as exc:
        print(f"[V2 DB SCHEMA SKIP] table={table_name} error={type(exc).__name__}: {exc}")
        return set()

    columns = set()
    for row in rows or []:
        if isinstance(row, dict):
            name = row.get("Field")
        else:
            name = row[0] if row else None
        if name:
            columns.add(str(name))
    return columns


def build_update_sql(table_name, key_column, key_value, values, columns):
    """존재하는 컬럼만 UPDATE SQL로 만든다."""
    usable = []
    params = []
    for col, value in values.items():
        if col in columns:
            if isinstance(value, _SqlNow):
                usable.append(f"{col} = NOW()")
            else:
                usable.append(f"{col} = %s")
                params.append(value)

    if not usable or key_column not in columns:
        return None, []

    sql = f"UPDATE {table_name} SET " + ", ".join(usable) + f" WHERE {key_column} = %s"
    params.append(key_value)
    return sql, params


def build_insert_sql(table_name, values, columns):
    """존재하는 컬럼만 INSERT SQL로 만든다."""
    usable_cols = []
    value_exprs = []
    params = []
    for col, value in values.items():
        if col in columns:
            usable_cols.append(col)
            if isinstance(value, _SqlNow):
                value_exprs.append("NOW()")
            else:
                value_exprs.append("%s")
                params.append(value)

    if not usable_cols:
        return None, []

    sql = f"INSERT INTO {table_name} (" + ", ".join(usable_cols) + ") VALUES (" + ", ".join(value_exprs) + ")"
    return sql, params


def update_v2_publish_success_db(conn, queue, result, session=None, logger=None):
    """
    STEP105-16: V2 실제 발행 성공 후 운영 DB를 반영한다.

    안전 원칙:
    - V1 publish_worker.py는 수정하지 않는다.
    - post_url이 확인된 경우에만 published 계열 상태로 업데이트한다.
    - 테이블/컬럼은 DB마다 차이가 있을 수 있으므로 SHOW COLUMNS 기준으로 존재 컬럼만 업데이트한다.
    - history 테이블이 없거나 컬럼명이 다르면 queue/draft 업데이트만 성공시키고 경고 로그로 남긴다.
    """
    result = result or {}
    post_url = result.get("post_url") or ((result.get("post_url_result") or {}).get("post_url"))
    post_url_result = result.get("post_url_result") or {}

    if not post_url:
        print("[V2 DB UPDATE SKIP] post_url not found; queue/draft/history update skipped to avoid unsafe duplicate state")
        return {
            "updated": False,
            "reason": "post_url_missing",
            "post_url": None,
        }

    queue_id = queue.get("id")
    draft_id = queue.get("draft_id")
    realtor_id = queue.get("realtor_id")
    article_no = queue.get("article_no") or queue.get("queue_article_no") or queue.get("draft_article_no")
    publish_title = get_publish_title(queue)
    session_id = getattr(session, "session_id", None) if session else None

    def parse_blog_url_parts(url):
        url = str(url or "")
        m = re.search(r"blog\.naver\.com/([^/?#]+)/([0-9]+)", url)
        if m:
            return m.group(1), m.group(2)
        m = re.search(r"blogId=([^&]+).*?(?:logNo|postNo)=([0-9]+)", url)
        if m:
            return m.group(1), m.group(2)
        return None, None

    blog_id, blog_post_no = parse_blog_url_parts(post_url)

    db_result = {
        "updated": True,
        "post_url": post_url,
        "queue_updated": False,
        "draft_updated": False,
        "history_inserted": False,
        "warnings": [],
    }

    step_ctx = session.step("v2_db_publish_success_update", logger=logger, meta={"queue_id": queue_id, "draft_id": draft_id, "post_url": post_url}) if session else _nullcontext()

    with step_ctx:
        try:
            queue_columns = get_table_columns(conn, "blog_publish_queue")
            queue_values = {
                "queue_status": "published",
                "worker_status": "success",
                "status": "published",
                "publish_status": "published",
                "post_url": post_url,
                "blog_url": post_url,
                "published_url": post_url,
                "naver_post_url": post_url,
                "published_at": _sql_now(),
                "finished_at": _sql_now(),
                "completed_at": _sql_now(),
                "last_error_message": None,
                "error_message": None,
                "success_like": 1,
                "updated_at": _sql_now(),
            }
            sql, params = build_update_sql("blog_publish_queue", "id", queue_id, queue_values, queue_columns)
            if sql:
                with conn.cursor() as cur:
                    cur.execute(sql, params)
                db_result["queue_updated"] = True
                print("[V2 DB QUEUE UPDATED]", {"queue_id": queue_id, "post_url": post_url})
            else:
                db_result["warnings"].append("blog_publish_queue_update_sql_empty")
                print("[V2 DB QUEUE UPDATE SKIP] no matching columns")

            draft_columns = get_table_columns(conn, "blog_article_drafts")
            draft_values = {
                "status": "published",
                "draft_status": "published",
                "review_status": "published",
                "publish_status": "published",
                "post_url": post_url,
                "blog_url": post_url,
                "published_url": post_url,
                "naver_post_url": post_url,
                "published_at": _sql_now(),
                "updated_at": _sql_now(),
            }
            sql, params = build_update_sql("blog_article_drafts", "id", draft_id, draft_values, draft_columns)
            if sql:
                with conn.cursor() as cur:
                    cur.execute(sql, params)
                db_result["draft_updated"] = True
                print("[V2 DB DRAFT UPDATED]", {"draft_id": draft_id, "post_url": post_url})
            else:
                db_result["warnings"].append("blog_article_drafts_update_sql_empty")
                print("[V2 DB DRAFT UPDATE SKIP] no matching columns")

            history_table = "blog_v2_publish_history"
            history_columns = get_table_columns(conn, history_table)
            if not history_columns:
                history_table = "blog_publish_history"
                history_columns = get_table_columns(conn, history_table)
            history_meta = {
                "source": post_url_result.get("source"),
                "post_url": post_url,
                "blog_id": blog_id,
                "blog_post_no": blog_post_no,
                "session_id": session_id,
                "post_url_result": post_url_result,
                "representative_image_result": result.get("representative_image_result"),
                "representative_upload_result": result.get("representative_upload_result"),
                "representative_strip_result": result.get("representative_strip_result"),
                "hpe_title_result": result.get("hpe_title_result"),
                "hpe_body_result": result.get("hpe_body_result"),
                "hpe_review_wait_result": result.get("hpe_review_wait_result"),
                "post_publish_quality_result": result.get("post_publish_quality_result"),
                "cafe24_cdn_result": result.get("cafe24_cdn_result"),
                "cafe24_paste_diagnostics": result.get("cafe24_paste_diagnostics"),
                "pre_execute_gate_result": result.get("pre_execute_gate_result"),
            }
            history_json = json.dumps(history_meta, ensure_ascii=False, default=str)

            history_values = {
                # 현재 운영 테이블 컬럼명 우선 매핑
                "pipeline_id": session_id,
                "pipeline_version": "v2",
                "source": "v2",
                "realtor_id": realtor_id,
                "article_no": article_no,
                "draft_id": draft_id,
                "publish_queue_id": queue_id,
                "blog_id": blog_id,
                "blog_post_no": blog_post_no,
                "blog_url": post_url,
                "publish_title": publish_title,
                "published_at": _sql_now(),
                "publish_status": "success",
                "publish_error_message": None,
                "search_keyword": publish_title,
                "search_status": "pending",
                "is_exposed": 0,
                "check_count": 0,
                "error_count": 0,
                "is_removed_article": 0,
                "is_private_synced": 0,
                "private_sync_status": "pending",
                "publish_hour": datetime.now().hour,
                "publish_weekday": datetime.now().weekday(),
                "experiment_group": "v2_publish",
                "meta_text": history_json,

                # 혹시 다른 환경에 범용 컬럼이 있으면 같이 기록
                "queue_id": queue_id,
                "publish_type": "naver_blog",
                "status": "success",
                "result_status": "success",
                "queue_status": "published",
                "worker_status": "success",
                "title": publish_title,
                "post_url": post_url,
                "published_url": post_url,
                "naver_post_url": post_url,
                "success_like": 1,
                "session_id": session_id,
                "result_json": json.dumps(result, ensure_ascii=False, default=str),
                "meta_json": history_json,
                "created_at": _sql_now(),
                "updated_at": _sql_now(),
            }
            sql, params = build_insert_sql(history_table, history_values, history_columns)
            if sql:
                with conn.cursor() as cur:
                    cur.execute(sql, params)
                db_result["history_inserted"] = True
                print("[V2 DB HISTORY INSERTED]", {"table": history_table, "queue_id": queue_id, "draft_id": draft_id, "post_url": post_url})
            else:
                db_result["warnings"].append("blog_publish_history_insert_sql_empty_or_table_missing")
                print("[V2 DB HISTORY INSERT SKIP] no matching table/columns", {"table": history_table})

            conn.commit()
            print("[V2 DB UPDATE COMMIT]", db_result)
            return db_result

        except Exception as exc:
            try:
                conn.rollback()
            except Exception:
                pass
            db_result["updated"] = False
            db_result["error"] = f"{type(exc).__name__}: {exc}"
            print("[V2 DB UPDATE ERROR]", db_result["error"])
            return db_result



def v2_queue_article_exists_in_latest_snapshot(conn, queue):
    """
    STEP107-37:
    MariaDB 호환성을 위해 Queue 선택 SQL이 아니라 Python 단계에서
    최신 SUCCESS Snapshot 포함 여부를 검사한다.
    """
    queue = queue or {}
    realtor_id = queue.get("realtor_id")
    article_no = queue.get("article_no") or queue.get("queue_article_no") or queue.get("draft_article_no")

    if not realtor_id or not article_no:
        return {"ok": False, "exists": False, "reason": "missing_realtor_or_article_no"}

    try:
        with conn.cursor() as cur:
            cur.execute("SHOW TABLES LIKE 'blog_realtor_article_snapshot_runs'")
            if not cur.fetchone():
                return {"ok": True, "exists": True, "reason": "snapshot_runs_missing_skip_guard"}

            cur.execute("SHOW TABLES LIKE 'blog_realtor_article_snapshot_items'")
            if not cur.fetchone():
                return {"ok": True, "exists": True, "reason": "snapshot_items_missing_skip_guard"}

            cur.execute("""
                SELECT id
                FROM blog_realtor_article_snapshot_runs
                WHERE realtor_id=%s
                  AND status='success'
                ORDER BY snapshot_date DESC, id DESC
                LIMIT 1
            """, (int(realtor_id),))
            run = cur.fetchone()

            if not run:
                return {"ok": False, "exists": False, "reason": "latest_success_snapshot_missing"}

            run_id = run.get("id")

            cur.execute("""
                SELECT 1
                FROM blog_realtor_article_snapshot_items
                WHERE run_id=%s
                  AND CAST(article_no AS UNSIGNED)=CAST(%s AS UNSIGNED)
                LIMIT 1
            """, (int(run_id), str(article_no)))
            exists = cur.fetchone() is not None

        return {
            "ok": True,
            "exists": bool(exists),
            "reason": "" if exists else "article_not_in_latest_success_snapshot",
            "run_id": run_id,
            "article_no": str(article_no),
            "realtor_id": int(realtor_id),
        }

    except Exception as exc:
        return {
            "ok": False,
            "exists": False,
            "reason": f"snapshot_guard_error: {type(exc).__name__}: {exc}",
            "article_no": str(article_no),
            "realtor_id": int(realtor_id),
        }


def record_v2_publish_success_via_router(queue, flow_result, db_update=None):
    """
    STEP107-36:
    v2_publish_worker 발행 성공 결과를 Publish Queue Router 방식으로도 확정 기록한다.
    서버 발행/클라이언트 발행이 같은 완료 기록 함수를 쓰게 하기 위한 연결부다.
    """
    if record_publish_success is None:
        return {"ok": False, "skipped": True, "reason": "router_import_failed"}

    queue = queue or {}
    flow_result = flow_result or {}
    db_update = db_update or {}

    post_url = (
        flow_result.get("post_url")
        or (flow_result.get("post_url_result") or {}).get("post_url")
        or db_update.get("post_url")
    )

    if not post_url:
        return {"ok": False, "skipped": True, "reason": "post_url_missing"}

    try:
        result = record_publish_success(
            queue_id=queue.get("id"),
            claim_token=queue.get("claim_token") or "",
            article_no=queue.get("article_no") or queue.get("draft_article_no"),
            realtor_id=queue.get("realtor_id"),
            publish_result_url=post_url,
            blog_id=(db_update or {}).get("blog_id") or "",
            blog_post_no=(db_update or {}).get("blog_post_no") or "",
            worker_id="server:v2_publish_worker",
        )
        print("[V2 ROUTER RECORD SUCCESS]", result)
        return result
    except Exception as exc:
        result = {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
        print("[V2 ROUTER RECORD SUCCESS ERROR]", result)
        return result



def is_v2_login_required_error(error_message):
    msg = str(error_message or "")
    return (
        "nidlogin.login" in msg
        or "로그인 페이지 감지" in msg
        or "login_required" in msg
        or "session_expired" in msg
    )


def mark_v2_queue_session_required(conn, queue, error_message):
    """
    STEP107-38:
    네이버 세션 만료/로그인 필요 에러는 발행 실패가 아니라 세션 보류 상태로 남긴다.
    """
    queue = queue or {}
    queue_id_value = queue.get("id")
    realtor_id_value = queue.get("realtor_id")

    if not queue_id_value:
        return {"ok": False, "reason": "queue_id_missing"}

    try:
        with conn.cursor() as cur:
            cur.execute("""
                UPDATE blog_publish_queue
                SET queue_status='pending',
                    worker_status='login_required',
                    last_error_type='login_required',
                    error_message=%s,
                    heartbeat_at=NULL,
                    claimed_by=NULL,
                    claim_token=NULL,
                    claimed_at=NULL,
                    updated_at=NOW()
                WHERE id=%s
            """, (str(error_message or "")[:2000], int(queue_id_value)))
            queue_affected = cur.rowcount

        try:
            with conn.cursor() as cur:
                cur.execute("SHOW TABLES LIKE 'blog_naver_sessions'")
                if cur.fetchone() and realtor_id_value:
                    cur.execute("""
                        UPDATE blog_naver_sessions
                        SET status='session_expired',
                            last_error_message=%s,
                            last_checked_at=NOW()
                        WHERE realtor_id=%s
                    """, (str(error_message or "")[:1000], int(realtor_id_value)))
        except Exception:
            pass

        conn.commit()
        result = {
            "ok": True,
            "queue_id": int(queue_id_value),
            "realtor_id": realtor_id_value,
            "queue_affected": int(queue_affected or 0),
            "worker_status": "login_required",
        }
        print("[V2 SESSION REQUIRED MARK]", result)
        return result

    except Exception as exc:
        result = {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
        print("[V2 SESSION REQUIRED MARK ERROR]", result)
        return result


def record_v2_publish_failure_via_router(queue, error_message):
    """
    STEP107-36:
    v2_publish_worker 발행 실패를 Publish Queue Router 방식으로도 기록한다.
    """
    if record_publish_failure is None:
        return {"ok": False, "skipped": True, "reason": "router_import_failed"}

    queue = queue or {}
    try:
        result = record_publish_failure(
            queue_id=queue.get("id"),
            claim_token=queue.get("claim_token") or "",
            article_no=queue.get("article_no") or queue.get("draft_article_no"),
            realtor_id=queue.get("realtor_id"),
            error_message=error_message,
        )
        print("[V2 ROUTER RECORD FAILURE]", result)
        return result
    except Exception as exc:
        result = {"ok": False, "error": f"{type(exc).__name__}: {exc}"}
        print("[V2 ROUTER RECORD FAILURE ERROR]", result)
        return result



class _SqlNow:
    pass


def _sql_now():
    """PyMySQL 계열에서 NOW()를 값처럼 넣기 어려워 SQL 빌더에서 치환 가능한 표식으로 쓴다."""
    return _SqlNow()



def claim_v2_publish_queue_processing(conn, queue, session=None, logger=None):
    """
    STEP105-20: 실제 --execute 실행 전에 Queue를 processing으로 선점한다.

    목적:
    - 야간 운영/수동 실행이 겹칠 때 같은 pending queue가 중복 발행되는 것을 막는다.
    - Dry check 계열은 Queue 상태를 바꾸지 않는다.

    안전 원칙:
    - V1 publish_worker.py는 수정하지 않는다.
    - 존재하는 컬럼만 업데이트한다.
    - queue_status 컬럼이 있으면 pending인 건만 processing으로 바꾼다.
    - 이미 다른 워커가 선점했으면 claimed=False를 반환한다.
    """
    queue_id = queue.get("id") if queue else None
    if not queue_id:
        return {"claimed": False, "reason": "queue_id_missing"}

    result = {
        "claimed": False,
        "queue_id": queue_id,
        "warnings": [],
    }

    step_ctx = session.step("v2_queue_claim_processing", logger=logger, meta={"queue_id": queue_id}) if session else _nullcontext()

    with step_ctx:
        try:
            columns = get_table_columns(conn, "blog_publish_queue")
            if "id" not in columns:
                result["reason"] = "blog_publish_queue_id_column_missing"
                print("[V2 QUEUE CLAIM SKIP] id column missing")
                return result

            sets = []
            params = []
            if "queue_status" in columns:
                sets.append("queue_status = %s")
                params.append("processing")
            if "worker_status" in columns:
                sets.append("worker_status = %s")
                params.append("processing")
            if "started_at" in columns:
                sets.append("started_at = NOW()")
            if "last_error_message" in columns:
                sets.append("last_error_message = NULL")
            if "error_message" in columns:
                sets.append("error_message = NULL")
            if "updated_at" in columns:
                sets.append("updated_at = NOW()")

            if not sets:
                result["reason"] = "no_claim_columns"
                print("[V2 QUEUE CLAIM SKIP] no matching columns")
                return result

            sql = "UPDATE blog_publish_queue SET " + ", ".join(sets) + " WHERE id = %s"
            params.append(queue_id)
            if "queue_status" in columns:
                sql += " AND queue_status = %s"
                params.append("pending")

            with conn.cursor() as cur:
                cur.execute(sql, params)
                affected = int(getattr(cur, "rowcount", 0) or 0)

            conn.commit()
            result["claimed"] = affected == 1
            result["affected"] = affected
            if result["claimed"]:
                print("[V2 QUEUE CLAIMED]", {"queue_id": queue_id, "status": "processing"})
            else:
                result["reason"] = "queue_not_pending_or_already_claimed"
                print("[V2 QUEUE CLAIM FAILED]", result)
            return result

        except Exception as exc:
            try:
                conn.rollback()
            except Exception:
                pass
            result["error"] = f"{type(exc).__name__}: {exc}"
            print("[V2 QUEUE CLAIM ERROR]", result["error"])
            return result



def mark_v2_queue_needs_review(conn, queue, reason, verify_result=None, session=None, logger=None):
    """
    STEP115-01:
    실제 발행은 되었지만 경량 운영 검증에서 확인이 필요한 경우,
    중복 발행을 막기 위해 queue_status는 published를 유지하고 worker_status/error_message만 needs_review로 표시한다.
    """
    queue_id = queue.get("id") if queue else None
    if not queue_id:
        return {"updated": False, "reason": "queue_id_missing"}

    reason = str(reason or "post_publish_verify_needs_review")[:1000]
    result = {"updated": False, "queue_id": queue_id, "reason": reason, "warnings": []}

    step_ctx = session.step("v2_db_needs_review_update", logger=logger, meta={"queue_id": queue_id, "reason": reason}) if session else _nullcontext()

    with step_ctx:
        try:
            columns = get_table_columns(conn, "blog_publish_queue")
            if "id" not in columns:
                result["reason"] = "blog_publish_queue_id_column_missing"
                return result

            values = {
                # queue_status는 published를 유지한다. 실제 발행 후 재시도 중복 발행 방지가 우선이다.
                "worker_status": "needs_review",
                "last_error_type": "post_publish_verify",
                "last_error_message": reason,
                "error_message": reason,
                "updated_at": _sql_now(),
            }
            sql, params = build_update_sql("blog_publish_queue", "id", queue_id, values, columns)
            if not sql:
                result["reason"] = "no_needs_review_update_columns"
                print("[V2 DB NEEDS REVIEW UPDATE SKIP] no matching columns")
                return result

            with conn.cursor() as cur:
                cur.execute(sql, params)
            conn.commit()
            result["updated"] = True
            print("[V2 DB NEEDS REVIEW UPDATED]", {"queue_id": queue_id, "reason": reason})
            return result

        except Exception as exc:
            try:
                conn.rollback()
            except Exception:
                pass
            result["error"] = f"{type(exc).__name__}: {exc}"
            print("[V2 DB NEEDS REVIEW UPDATE ERROR]", result["error"])
            return result


def lightweight_post_publish_operational_verify(conn, queue, flow_result=None, db_update=None, session=None, logger=None):
    """
    STEP115-01:
    발행 속도를 거의 늦추지 않는 경량 운영 검증.

    확인 범위:
    - post_url 수집 여부
    - DB update 결과(queue/draft/history)
    - blog_publish_queue published 반영 여부
    - blog_article_drafts post_url 반영 여부
    - 대표이미지 direct upload 결과
    - post_publish_quality_result의 blocking_warnings/errors
    """
    flow_result = flow_result or {}
    db_update = db_update or {}
    queue_id = queue.get("id") if queue else None
    draft_id = queue.get("draft_id") if queue else None
    post_url = flow_result.get("post_url") or ((flow_result.get("post_url_result") or {}).get("post_url")) or db_update.get("post_url")

    verify = {
        "checked": True,
        "ok": False,
        "queue_id": queue_id,
        "draft_id": draft_id,
        "post_url": post_url,
        "errors": [],
        "warnings": [],
        "db": {},
        "quality": {},
        "needs_review": False,
    }

    step_ctx = session.step("v2_lightweight_post_publish_verify", logger=logger, meta={"queue_id": queue_id, "draft_id": draft_id, "post_url": post_url}) if session else _nullcontext()

    with step_ctx:
        try:
            if not post_url:
                verify["errors"].append("post_url_missing")

            if not db_update.get("queue_updated"):
                verify["errors"].append("queue_update_not_confirmed")
            if not db_update.get("draft_updated"):
                verify["errors"].append("draft_update_not_confirmed")
            if not db_update.get("history_inserted"):
                verify["warnings"].append("history_insert_not_confirmed")

            quality = flow_result.get("post_publish_quality_result") or {}
            q_errors = list(quality.get("errors") or [])
            q_blocking = list(quality.get("blocking_warnings") or [])
            verify["quality"] = {
                "quality_ok": quality.get("quality_ok"),
                "errors": q_errors,
                "blocking_warnings": q_blocking,
                "representative_direct_upload_ok": quality.get("representative_direct_upload_ok"),
            }
            for err in q_errors:
                verify["errors"].append(f"quality:{err}")
            for warning in q_blocking:
                verify["errors"].append(f"quality_blocking:{warning}")

            rep_upload = flow_result.get("representative_upload_result") or {}
            if rep_upload.get("attempted") and not rep_upload.get("ok"):
                verify["errors"].append("representative_direct_upload_failed")

            # DB 재조회는 2회 SELECT만 수행한다. 게시글 재접속/이미지 DOM 분석은 하지 않는다.
            if queue_id:
                queue_columns = get_table_columns(conn, "blog_publish_queue")
                select_cols = [c for c in ["id", "queue_status", "worker_status", "post_url", "blog_url", "published_url", "naver_post_url", "publish_result_url", "finished_at"] if c in queue_columns]
                if select_cols:
                    with conn.cursor() as cur:
                        cur.execute("SELECT " + ", ".join(select_cols) + " FROM blog_publish_queue WHERE id=%s LIMIT 1", (queue_id,))
                        row = cur.fetchone()
                    verify["db"]["queue"] = row
                    if not row:
                        verify["errors"].append("queue_row_missing_after_update")
                    else:
                        q_status = str(row.get("queue_status") or "")
                        if "queue_status" in row and q_status != "published":
                            verify["errors"].append(f"queue_status_not_published:{q_status}")
                        url_values = [str(row.get(c) or "") for c in ["post_url", "blog_url", "published_url", "naver_post_url", "publish_result_url"] if c in row]
                        if post_url and url_values and post_url not in url_values:
                            verify["warnings"].append("queue_post_url_column_not_matching_reference_only")

            if draft_id:
                draft_columns = get_table_columns(conn, "blog_article_drafts")
                select_cols = [c for c in ["id", "status", "draft_status", "review_status", "publish_status", "post_url", "blog_url", "published_url", "naver_post_url", "published_at"] if c in draft_columns]
                if select_cols:
                    with conn.cursor() as cur:
                        cur.execute("SELECT " + ", ".join(select_cols) + " FROM blog_article_drafts WHERE id=%s LIMIT 1", (draft_id,))
                        row = cur.fetchone()
                    verify["db"]["draft"] = row
                    if not row:
                        verify["errors"].append("draft_row_missing_after_update")
                    else:
                        url_values = [str(row.get(c) or "") for c in ["post_url", "blog_url", "published_url", "naver_post_url"] if c in row]
                        if post_url and url_values and post_url not in url_values:
                            verify["errors"].append("draft_post_url_not_matching")

            verify["ok"] = len(verify["errors"]) == 0
            verify["needs_review"] = not verify["ok"]
            print("[V2 LIGHT POST PUBLISH VERIFY]", verify)
            return verify

        except Exception as exc:
            verify["errors"].append(f"verify_exception:{type(exc).__name__}: {exc}")
            verify["ok"] = False
            verify["needs_review"] = True
            print("[V2 LIGHT POST PUBLISH VERIFY ERROR]", verify)
            return verify

def update_v2_publish_failure_db(conn, queue, reason, result=None, session=None, logger=None):
    """
    STEP105-20: --execute 중 실패/불명확 상태를 pending으로 방치하지 않기 위한 안전 업데이트.

    특히 Final Publish 이후 post_url을 못 찾은 경우, 실제 발행은 되었을 수 있으므로
    pending 재시도로 남겨두면 중복 발행 위험이 있다. 따라서 failed/needs_review 계열로 보내
    관리자 확인 대상으로 만든다.
    """
    queue_id = queue.get("id") if queue else None
    if not queue_id:
        return {"updated": False, "reason": "queue_id_missing"}

    reason = str(reason or "v2_publish_failed")[:1000]
    result = result or {}
    update_result = {
        "updated": False,
        "queue_id": queue_id,
        "reason": reason,
        "warnings": [],
    }

    step_ctx = session.step("v2_db_publish_failure_update", logger=logger, meta={"queue_id": queue_id, "reason": reason}) if session else _nullcontext()

    with step_ctx:
        try:
            columns = get_table_columns(conn, "blog_publish_queue")
            if "id" not in columns:
                update_result["reason"] = "blog_publish_queue_id_column_missing"
                return update_result

            values = {
                "queue_status": "failed",
                "worker_status": "failed",
                "publish_status": "failed",
                "status": "failed",
                "last_error_message": reason,
                "error_message": reason,
                "finished_at": _sql_now(),
                "updated_at": _sql_now(),
            }
            sql, params = build_update_sql("blog_publish_queue", "id", queue_id, values, columns)
            if not sql:
                update_result["reason"] = "no_failure_update_columns"
                print("[V2 DB FAILURE UPDATE SKIP] no matching columns")
                return update_result

            with conn.cursor() as cur:
                cur.execute(sql, params)
            conn.commit()
            update_result["updated"] = True
            print("[V2 DB FAILURE UPDATED]", {"queue_id": queue_id, "reason": reason})
            return update_result

        except Exception as exc:
            try:
                conn.rollback()
            except Exception:
                pass
            update_result["error"] = f"{type(exc).__name__}: {exc}"
            print("[V2 DB FAILURE UPDATE ERROR]", update_result["error"])
            return update_result

def run_v2_flow_check(
    queue,
    session,
    logger,
    check_browser=False,
    check_write=False,
    check_recovery=False,
    check_editor=False,
    check_title=False,
    check_body=False,
    check_publish_button=False,
    check_publish_click=False,
    check_publish_dialog=False,
    check_final_publish=False,
    final_publish_execute=False,
    enable_long_hpe_wait=False,
    hpe_mode=None,
    headless=False,
):
    realtor_id = int(queue.get("realtor_id"))
    session_file = get_session_file(realtor_id)

    print("[V2 SESSION FILE]", session_file)
    hpe_profile = get_v2_hpe_profile(
        hpe_mode=hpe_mode,
        execute=bool(final_publish_execute),
        enable_long_hpe_wait=enable_long_hpe_wait,
    )
    print_v2_hpe_profile(hpe_profile, queue=queue, execute=bool(final_publish_execute))

    with session.step("v2_session_file_check", logger=logger, meta={"session_file": session_file, "hpe_mode": hpe_profile.get("mode")}):
        if not os.path.exists(session_file):
            raise FileNotFoundError(f"세션 파일 없음: {session_file}")

    browser = None

    try:
        with sync_playwright() as p:
            with session.step("v2_browser_launch", logger=logger, meta={"headless": headless}):
                browser = p.chromium.launch(
                    headless=bool(headless),
                    args=[
                        "--disable-blink-features=AutomationControlled",
                        "--no-sandbox",
                    ],
                )

            with session.step("v2_context_create", logger=logger):
                context = browser.new_context(
                    storage_state=session_file,
                    locale="ko-KR",
                    viewport={"width": 1400, "height": 1000},
                    permissions=["clipboard-read", "clipboard-write"],
                )

            page = context.new_page()
            nav = V2PublishNavigation(verbose=True)

            home_result = nav.go_blog_home(page, logger=logger, session=session)

            with session.step("v2_login_status_check", logger=logger, meta={"url": page.url}):
                if is_login_page_url(page.url):
                    recovered, recover_message = recover_login_page_once_v2(
                        page=page,
                        context=context,
                        realtor_id=realtor_id,
                        session_file=session_file,
                        reason=f"블로그 홈 접속 중 로그인 페이지 감지: {page.url}",
                        target_url="https://blog.naver.com",
                    )
                    print("[V2 AUTO RELOGIN HOME RESULT]", recovered, recover_message)
                    if not recovered:
                        raise RuntimeError(f"로그인 페이지 감지 / 자동복구 실패: {recover_message}")

            if check_browser and not any([
                check_write,
                check_recovery,
                check_editor,
                check_title,
                check_body,
                check_publish_button,
                check_publish_click,
                check_publish_dialog,
                check_final_publish,
            ]):
                return {
                    "status": "success",
                    "message": "V2 browser session check completed",
                    "home": home_result,
                    "url": page.url,
                }

            write_result = nav.go_write(page, logger=logger, session=session)

            with session.step("v2_write_url_check", logger=logger, meta={"url": page.url}):
                if is_login_page_url(page.url):
                    recovered, recover_message = recover_login_page_once_v2(
                        page=page,
                        context=context,
                        realtor_id=realtor_id,
                        session_file=session_file,
                        reason=f"글쓰기 이동 후 로그인 페이지 감지: {page.url}",
                        target_url="https://blog.naver.com/GoBlogWrite.naver",
                    )
                    print("[V2 AUTO RELOGIN WRITE RESULT]", recovered, recover_message)
                    if not recovered:
                        raise RuntimeError(f"글쓰기 이동 후 로그인 페이지 감지 / 자동복구 실패: {recover_message}")

                    # 자동 재로그인 후 목적지가 블로그 홈 등에 머물 수 있어 글쓰기 이동을 한 번 더 보장한다.
                    if is_login_page_url(page.url) or "GoBlogWrite" not in str(page.url):
                        write_result = nav.go_write(page, logger=logger, session=session)
                        page.wait_for_timeout(1500)

                    if is_login_page_url(page.url):
                        raise RuntimeError(f"자동복구 후에도 글쓰기 로그인 페이지 감지: {page.url}")

            if check_write and not any([
                check_recovery,
                check_editor,
                check_title,
                check_body,
                check_publish_button,
                check_publish_click,
                check_publish_dialog,
                check_final_publish,
            ]):
                return {
                    "status": "success",
                    "message": "V2 write navigation check completed",
                    "home": home_result,
                    "write": write_result,
                    "url": page.url,
                }

            editor_result = wait_editor_ready(
                page,
                logger=logger,
                session=session,
                timeout_seconds=30,
                verbose=True,
            )

            if not editor_result.get("ready"):
                raise RuntimeError("Editor Ready 실패")

            write_frame = editor_result.get("frame")

            with session.step(
                "editor.recovery",
                logger=logger,
                meta={
                    "target_type": editor_result.get("target_type"),
                    "frame_name": editor_result.get("frame_name"),
                    "frame_url": editor_result.get("frame_url"),
                },
            ):
                recovery_result = recover_editor(
                    page=page,
                    write_frame=write_frame,
                    max_wait_seconds=10,
                    verbose=True,
                )

            title_result = None
            body_result = None
            publish_button_result = None
            publish_click_result = None
            publish_dialog_result = None
            hpe_publish_approach_result = None
            hpe_final_dialog_review_result = None
            final_publish_result = None
            post_url_result = None
            representative_image_result = None
            post_publish_quality_result = None
            cafe24_cdn_result = None
            cafe24_paste_diagnostics = None
            cafe24_html_normalize_result = None
            remaining_cafe24_normalize_result = None
            representative_html_normalize_result = None
            representative_upload_result = None
            representative_strip_result = None
            representative_cursor_result = None
            hpe_review_wait_result = None
            cafe24_html_block_compare = None
            hpe_title_result = None
            hpe_body_result = None
            pre_execute_gate_result = None

            if any([
                check_title,
                check_body,
                check_publish_button,
                check_publish_click,
                check_publish_dialog,
                check_final_publish,
            ]):
                title_result = input_title(
                    page=page,
                    write_frame=write_frame,
                    title=get_publish_title(queue),
                    logger=logger,
                    session=session,
                    verbose=True,
                )
                hpe_title_result = hpe_after_title_input(
                    page=page,
                    write_frame=write_frame,
                    logger=logger,
                    session=session,
                    verbose=True,
                    hpe_profile=hpe_profile,
                )

            if any([
                check_body,
                check_publish_button,
                check_publish_click,
                check_publish_dialog,
                check_final_publish,
            ]):
                publish_html_for_input = get_publish_html(queue)
                cafe24_html_normalize_result = None
                representative_html_normalize_result = None
                representative_upload_result = None
                representative_strip_result = None
                representative_cursor_result = None

                with session.step("v2_representative_direct_upload", logger=logger, meta={"queue_id": queue.get("id")}) if session else NullContext():
                    representative_image_path, representative_path_result = extract_representative_image_path_from_queue(queue)
                    print("[V2 REP PATH]", representative_path_result)
                    if representative_image_path:
                        representative_upload_result = upload_representative_image_to_editor(
                            page=page,
                            write_frame=write_frame,
                            image_path=representative_image_path,
                            verbose=True,
                        )
                        print("[V2 REP UPLOAD RESULT]", representative_upload_result)
                        if representative_upload_result.get("ok"):
                            publish_html_for_input, representative_strip_result = strip_representative_image_from_html(publish_html_for_input)
                            print("[V2 REP HTML STRIP RESULT]", representative_strip_result)
                            representative_cursor_result = move_cursor_after_representative_image(page, write_frame)
                            print("[V2 REP CURSOR RESULT]", representative_cursor_result)
                        else:
                            print("[V2 REP UPLOAD WARN] 대표이미지 직접 업로드 실패. CDN 대표이미지 본문 삽입 방식을 유지합니다.")
                    else:
                        representative_upload_result = {"attempted": False, "ok": False, "path": "", "path_result": representative_path_result}
                        print("[V2 REP UPLOAD SKIP] source_json.header_image.image_path 없음")

                # 대표이미지 직접 업로드 성공 시에는 본문 CDN 대표 블록을 제거했으므로 table 변환을 건너뛴다.
                # 업로드 실패/경로 없음일 때만 기존 Cafe24 대표이미지 블록을 SmartEditor 친화 table 구조로 보정한다.
                with session.step("v2_representative_html_normalize", logger=logger, meta={"queue_id": queue.get("id")}) if session else NullContext():
                    if representative_upload_result and representative_upload_result.get("ok"):
                        representative_html_normalize_result = {"changed": False, "reason": "direct_representative_upload_used"}
                    else:
                        publish_html_for_input, representative_html_normalize_result = normalize_representative_image_block_for_naver_paste(publish_html_for_input)
                    print("[V2 REPRESENTATIVE HTML NORMALIZE]", representative_html_normalize_result)

                with session.step("v2_cafe24_html_normalize", logger=logger, meta={"queue_id": queue.get("id")}) if session else NullContext():
                    publish_html_for_input, cafe24_html_normalize_result = normalize_cafe24_html_for_naver_paste(publish_html_for_input)
                    print("[V2 CAFE24 HTML NORMALIZE]", cafe24_html_normalize_result)

                remaining_cafe24_normalize_result = None
                middle_banner_direct_paste = v2_html_has_middle_banner(publish_html_for_input)

                with session.step("v2_remaining_cafe24_html_normalize", logger=logger, meta={"queue_id": queue.get("id")}) if session else NullContext():
                    if middle_banner_direct_paste:
                        # V1과 동일한 원칙: 중간배너가 있으면 preview 변환/재가공 없이 원본 HTML을 그대로 붙여넣는다.
                        # 여기서 <a href=fin.land...>가 있다면 그대로 살아야 하며, 없으면 진단 로그로 확인한다.
                        remaining_cafe24_normalize_result = {
                            "changed": False,
                            "skipped": True,
                            "reason": "middle_banner_direct_clipboard_v1_style_preserve_original_html",
                            "html_has_fin_land": "fin.land.naver.com/map" in str(publish_html_for_input or ""),
                            "html_has_middle_banner": True,
                        }
                        print("[V2 REMAINING CAFE24 HTML NORMALIZE]", remaining_cafe24_normalize_result)
                    else:
                        fallback_fin_land_url, fallback_fin_land_source = infer_middle_banner_fin_land_url_from_queue(queue)
                        print("[V2 MIDDLE BANNER FIN URL INFER]", {
                            "found": bool(fallback_fin_land_url),
                            "source": fallback_fin_land_source,
                            "url": fallback_fin_land_url,
                        })
                        publish_html_for_input, remaining_cafe24_normalize_result = normalize_remaining_cafe24_image_blocks_for_naver_paste(
                            publish_html_for_input,
                            fallback_fin_land_url=fallback_fin_land_url,
                        )
                        if isinstance(remaining_cafe24_normalize_result, dict):
                            remaining_cafe24_normalize_result["fallback_fin_land_url"] = fallback_fin_land_url
                            remaining_cafe24_normalize_result["fallback_fin_land_source"] = fallback_fin_land_source
                        print("[V2 REMAINING CAFE24 HTML NORMALIZE]", remaining_cafe24_normalize_result)

                # STEP107-46: 발행 worker는 HTML 내용을 만들거나 보강하지 않고, 초안 생성 결과를 붙여넣기만 한다.
                v2_log_publish_html_required_blocks_before_paste(publish_html_for_input)

                if middle_banner_direct_paste:
                    body_result = input_body_direct_clipboard_html_v1_style(
                        page=page,
                        write_frame=write_frame,
                        html=publish_html_for_input,
                        fallback_text=get_fallback_text(queue),
                        logger=logger,
                        session=session,
                        verbose=True,
                    )
                else:
                    body_result = input_body(
                        page=page,
                        write_frame=write_frame,
                        html=publish_html_for_input,
                        fallback_text=get_fallback_text(queue),
                        logger=logger,
                        session=session,
                        verbose=True,
                    )
                preview_cleanup_result = cleanup_v2_unintended_pages_and_refocus(
                    context=context,
                    editor_page=page,
                    label="after_body_input",
                )
                hpe_body_result = hpe_after_body_input(
                    page=page,
                    write_frame=write_frame,
                    logger=logger,
                    session=session,
                    verbose=True,
                    hpe_profile=hpe_profile,
                    queue=queue,
                    body_result=body_result,
                )
                with session.step("v2_cafe24_paste_diagnostics", logger=logger, meta={"queue_id": queue.get("id")}) if session else NullContext():
                    cafe24_paste_diagnostics = collect_cafe24_paste_diagnostics(
                        queue=queue,
                        body_result=body_result,
                        write_frame=write_frame,
                        wait_ms=3200,
                        verbose=True,
                        effective_html=publish_html_for_input,
                    )
                cafe24_cdn_result = verify_cafe24_cdn_flow(
                    queue=queue,
                    body_result=body_result,
                    write_frame=write_frame,
                    representative_image_result=None,
                    logger=logger,
                    session=session,
                    verbose=True,
                effective_html=publish_html_for_input,
                                )

            if any([
                check_publish_button,
                check_publish_click,
                check_publish_dialog,
                check_final_publish,
            ]):
                page.wait_for_timeout(1500)
                pre_publish_cleanup_result = cleanup_v2_unintended_pages_and_refocus(
                    context=context,
                    editor_page=page,
                    label="before_publish_button_discovery",
                )
                publish_button_result = discover_publish_button(
                    page=page,
                    write_frame=write_frame,
                    logger=logger,
                    session=session,
                    verbose=True,
                )

                if not publish_button_result.get("found"):
                    raise RuntimeError("발행 버튼 탐색 실패")

                hpe_publish_approach_result = hpe_publish_button_approach(
                    page=page,
                    write_frame=write_frame,
                    publish_target=publish_button_result.get("best"),
                    hpe_profile=hpe_profile,
                    label="before_publish_button_click_probe",
                    verbose=True,
                )

            if check_publish_click or check_publish_dialog or check_final_publish:
                publish_click_result = probe_publish_click(
                    page=page,
                    write_frame=write_frame,
                    discovery_result=publish_button_result,
                    logger=logger,
                    session=session,
                    verbose=True,
                )

            if check_publish_dialog or check_final_publish:
                # probe 단계는 안전 닫기를 수행하므로, 최종 발행/다이얼로그 분석 전 다시 연다.
                page.wait_for_timeout(800)

                print("[V2 PUBLISH DIALOG OPEN FOR INSPECT]")

                confirm_count = 0
                try:
                    confirm_count = write_frame.locator("button[class*='confirm_btn']").count()
                except Exception:
                    confirm_count = 0

                if confirm_count > 0:
                    print("[V2 PUBLISH DIALOG ALREADY OPEN] confirm_btn detected")
                else:
                    print("[V2 PUBLISH DIALOG REOPEN REQUIRED] confirm_btn not found")

                    publish_button_result = discover_publish_button(
                        page=page,
                        write_frame=write_frame,
                        logger=logger,
                        session=session,
                        verbose=True,
                    )

                    best = publish_button_result.get("best") or {}
                    selector = best.get("selector") or "button:has-text('발행')"
                    index = int(best.get("index") or 0)

                    write_frame.locator(selector).nth(index).click(force=True, timeout=5000)
                    page.wait_for_timeout(1500)

                publish_dialog_result = inspect_publish_dialog(
                    page=page,
                    write_frame=write_frame,
                    logger=logger,
                    session=session,
                    verbose=True,
                )

                if publish_dialog_result.get("found"):
                    hpe_final_dialog_review_result = hpe_final_dialog_review(
                        page=page,
                        write_frame=write_frame,
                        hpe_profile=hpe_profile,
                        label="after_publish_dialog_open",
                        verbose=True,
                    )

                if check_final_publish:
                    with session.step(
                        "v2_pre_execute_gate",
                        logger=logger,
                        meta={"execute": bool(final_publish_execute)},
                    ) if session else NullContext():
                        pre_execute_gate_result = build_pre_execute_gate_result(
                            final_publish_execute=bool(final_publish_execute),
                            cafe24_cdn_result=cafe24_cdn_result,
                            publish_dialog_result=publish_dialog_result,
                            hpe_title_result=hpe_title_result,
                            hpe_body_result=hpe_body_result,
                        )
                        print("[V2 PRE EXECUTE GATE]", pre_execute_gate_result)
                        if final_publish_execute and not pre_execute_gate_result.get("allowed"):
                            raise RuntimeError(
                                "V2 pre-execute gate blocked final publish: "
                                + ",".join(pre_execute_gate_result.get("errors") or [])
                            )

                    with session.step("hpe.before_final_publish_review", logger=logger, meta={"execute": bool(final_publish_execute), "hpe_mode": hpe_profile.get("mode")}) if session else NullContext():
                        hpe_random_scroll(page, write_frame=write_frame, label="before_final_publish", verbose=True, hpe_profile=hpe_profile)
                        final_wait = hpe_profile.get("before_final_publish_ms") or (700 if final_publish_execute else 350, 1800 if final_publish_execute else 900)
                        if not final_publish_execute:
                            final_wait = (350, 900)
                        hpe_random_wait(page, final_wait[0], final_wait[1], "before_final_publish")
                        if final_publish_execute and hpe_profile.get("review_wait_enabled"):
                            hpe_review_wait_result = hpe_human_wait_before_publish(
                                page=page,
                                min_seconds=(hpe_profile.get("review_wait_seconds") or (180, 420))[0],
                                max_seconds=(hpe_profile.get("review_wait_seconds") or (180, 420))[1],
                                logger=logger,
                                session=session,
                                hpe_profile=hpe_profile,
                            )
                            print("[V2 HPE REVIEW WAIT RESULT]", hpe_review_wait_result)
                        elif final_publish_execute:
                            hpe_review_wait_result = {
                                "waited": False,
                                "wait_seconds": 0,
                                "reason": "hpe_mode_test_or_review_wait_disabled",
                                "hpe_mode": hpe_profile.get("mode"),
                            }
                            print("[V2 HPE REVIEW WAIT SKIP]", hpe_review_wait_result)

                    final_publish_result = final_publish(
                        page=page,
                        write_frame=write_frame,
                        dialog_result=publish_dialog_result,
                        execute=bool(final_publish_execute),
                        logger=logger,
                        session=session,
                        verbose=True,
                    )

                    if final_publish_execute:
                        # STEP105-18: Final Publish 이후 후처리 구간도 HPE 세션 안에 명확히 묶는다.
                        # post_url 수집/대표이미지 검증은 각각 세부 step을 갖지만,
                        # 운영 로그에서 후반부 전체 성공/실패 구간을 한눈에 보기 위해 outer step을 둔다.
                        with session.step(
                            "v2_after_final_publish",
                            logger=logger,
                            meta={
                                "queue_id": queue.get("id"),
                                "draft_id": queue.get("draft_id"),
                                "article_no": queue.get("article_no") or queue.get("draft_article_no"),
                                "final_publish_status": (final_publish_result or {}).get("status"),
                            },
                        ):
                            page.wait_for_timeout(1500)
                            post_url_result = collect_post_url_after_publish(
                                page=page,
                                context=context,
                                logger=logger,
                                session=session,
                                timeout_ms=15000,
                                verbose=True,
                            )

                            if post_url_result and post_url_result.get("post_url"):
                                representative_image_result = verify_representative_image_after_publish(
                                    page=page,
                                    context=context,
                                    post_url=post_url_result.get("post_url"),
                                    logger=logger,
                                    session=session,
                                    timeout_ms=12000,
                                    verbose=True,
                                    representative_upload_result=representative_upload_result,
                                )
                                cafe24_cdn_result = verify_cafe24_cdn_flow(
                                    queue=queue,
                                    body_result=body_result,
                                    write_frame=write_frame,
                                    representative_image_result=representative_image_result,
                                    logger=logger,
                                    session=session,
                                    verbose=True,
                                effective_html=publish_html_for_input,
                                )
                                with session.step("v2_post_publish_quality_summary", logger=logger, meta={"post_url": post_url_result.get("post_url")}) if session else NullContext():
                                    post_publish_quality_result = build_post_publish_quality_summary(
                                        queue=queue,
                                        post_url_result=post_url_result,
                                        representative_image_result=representative_image_result,
                                        cafe24_cdn_result=cafe24_cdn_result,
                                        representative_upload_result=representative_upload_result,
                                        effective_html=publish_html_for_input,
                                    )
                                    print("[V2 POST PUBLISH QUALITY]", post_publish_quality_result)
                            else:
                                post_publish_quality_result = build_post_publish_quality_summary(
                                    queue=queue,
                                    post_url_result=post_url_result,
                                    representative_image_result=representative_image_result,
                                    cafe24_cdn_result=cafe24_cdn_result,
                                    representative_upload_result=representative_upload_result,
                                    effective_html=publish_html_for_input,
                                )
                                print("[V2 AFTER FINAL PUBLISH WARNING] post_url missing; representative image check skipped")
                                print("[V2 POST PUBLISH QUALITY]", post_publish_quality_result)

            return {
                "status": "success",
                "message": (
                    "V2 final publish executed"
                    if check_final_publish and final_publish_execute
                    else "V2 final publish ready check completed"
                    if check_final_publish
                    else "V2 publish dialog inspect completed"
                    if check_publish_dialog
                    else "V2 publish click probe completed"
                    if check_publish_click
                    else "V2 publish button discovery completed"
                    if check_publish_button
                    else "V2 body check completed"
                    if check_body
                    else "V2 title check completed"
                    if check_title
                    else "V2 editor ready/recovery check completed"
                ),
                "home": home_result,
                "write": write_result,
                "editor": {
                    "ready": editor_result.get("ready"),
                    "target_type": editor_result.get("target_type"),
                    "frame_name": editor_result.get("frame_name"),
                    "frame_url": editor_result.get("frame_url"),
                    "frames_count": len(editor_result.get("frames") or []),
                },
                "recovery": recovery_result,
                "title": title_result,
                "body": body_result,
                "publish_button": publish_button_result,
                "hpe_publish_approach_result": hpe_publish_approach_result,
                "publish_click": publish_click_result,
                "publish_dialog": publish_dialog_result,
                "hpe_final_dialog_review_result": hpe_final_dialog_review_result,
                "final_publish": final_publish_result,
                "post_url": (post_url_result or {}).get("post_url") if post_url_result else None,
                "post_url_result": post_url_result,
                "representative_image_result": representative_image_result,
                "post_publish_quality_result": post_publish_quality_result,
                "cafe24_cdn_result": cafe24_cdn_result,
                "representative_html_normalize_result": representative_html_normalize_result,
                "representative_upload_result": representative_upload_result,
                "representative_strip_result": representative_strip_result,
                "representative_cursor_result": representative_cursor_result,
                "hpe_review_wait_result": hpe_review_wait_result,
                "cafe24_html_normalize_result": cafe24_html_normalize_result,
                "remaining_cafe24_normalize_result": remaining_cafe24_normalize_result,
                "cafe24_paste_diagnostics": cafe24_paste_diagnostics,
                "hpe_title_result": hpe_title_result,
                "hpe_body_result": hpe_body_result,
                "pre_execute_gate_result": pre_execute_gate_result,
                "url": page.url,
            }

    finally:
        if browser:
            try:
                browser.close()
            except Exception:
                pass


def run_v2_publish_once(
    realtor_id=None,
    queue_id=None,
    exclude_queue_ids=None,
    execute=False,
    pipeline_id=None,
    enable_long_hpe_wait=False,
    hpe_mode=None,
    check_browser=False,
    check_write=False,
    check_recovery=False,
    check_editor=False,
    check_title=False,
    check_body=False,
    check_publish_button=False,
    check_publish_click=False,
    check_publish_dialog=False,
    check_final_publish=False,
    headless=False,
):
    conn = get_conn()
    try:
        queue = get_v2_pending_queue(
            conn,
            realtor_id=realtor_id,
            queue_id=queue_id,
            exclude_queue_ids=exclude_queue_ids,
        )
        if not queue:
            print("[V2 PUBLISH TARGET NONE]", f"realtor_id={realtor_id or 'ALL'}", f"queue_id={queue_id or 'AUTO'}")
            return {"processed": 0, "success": 0, "failed": 0, "message": "pending queue none", "queue_id": queue_id}

        requested_queue_id = queue_id
        queue_id = queue.get("id")
        article_no = queue.get("article_no") or queue.get("queue_article_no") or queue.get("draft_article_no")
        publish_title = get_publish_title(queue)

        snapshot_guard = v2_queue_article_exists_in_latest_snapshot(conn, queue)
        print("[V2 SNAPSHOT GUARD]", snapshot_guard)
        if not snapshot_guard.get("exists"):
            message = snapshot_guard.get("reason") or "article_not_in_latest_success_snapshot"
            if record_publish_failure is not None:
                try:
                    record_publish_failure(
                        queue_id=queue.get("id"),
                        article_no=article_no,
                        realtor_id=queue.get("realtor_id"),
                        error_message=message,
                    )
                except Exception as exc:
                    print("[V2 SNAPSHOT GUARD FAILURE RECORD WARN]", f"{type(exc).__name__}: {exc}")
            return {
                "processed": 1,
                "success": 0,
                "failed": 1,
                "queue_id": queue_id,
                "article_no": article_no,
                "error": message,
                "snapshot_guard": snapshot_guard,
            }

        print("=" * 80)
        print("[V2 PUBLISH TARGET SELECTED]")
        print("queue_id:", queue_id)
        print("queue_id_arg:", requested_queue_id if requested_queue_id else "AUTO")
        print("realtor_id:", queue.get("realtor_id"))
        print("draft_id:", queue.get("draft_id"))
        print("article_no:", article_no)
        print("publish_title:", publish_title)
        print("execute:", execute)
        print("check_browser:", check_browser)
        print("check_write:", check_write)
        print("check_recovery:", check_recovery)
        print("check_editor:", check_editor)
        print("check_title:", check_title)
        print("check_body:", check_body)
        print("check_publish_button:", check_publish_button)
        print("check_publish_click:", check_publish_click)
        print("check_publish_dialog:", check_publish_dialog)
        print("check_final_publish:", check_final_publish)
        print("clipboard_len:", queue.get("clipboard_len"))
        print("draft_len:", queue.get("draft_len"))
        print("plain_len:", queue.get("plain_len"))
        print("=" * 80)

        session = PublishSession(
            pipeline_id=pipeline_id or queue.get("pipeline_id") or "V2-PUBLISH-MANUAL",
            realtor_id=queue.get("realtor_id"),
            publish_queue_id=queue_id,
            article_no=article_no,
            draft_id=queue.get("draft_id"),
            actor_name="v2_default",
            profile_name="v2_default",
        )

        logger = BehaviorLogger(conn=conn, verbose=True)
        engine = HumanPublishEngine(logger=logger)

        pre_claim_result = None

        def handler(hpe_session):
            with hpe_session.step(
                "v2_publish_prepare",
                logger=logger,
                meta={
                    "queue_id": queue_id,
                    "article_no": article_no,
                    "publish_title": publish_title,
                    "clipboard_len": queue.get("clipboard_len"),
                    "draft_len": queue.get("draft_len"),
                    "plain_len": queue.get("plain_len"),
                },
            ):
                pass

            nonlocal pre_claim_result

            if execute:
                pre_claim_result = claim_v2_publish_queue_processing(
                    conn=conn,
                    queue=queue,
                    session=hpe_session,
                    logger=logger,
                )
                if not pre_claim_result.get("claimed"):
                    return {
                        "status": "skipped",
                        "message": "V2 execute skipped because queue was not claimable",
                        "queue_claim": pre_claim_result,
                    }

            if any([
                check_browser,
                check_write,
                check_recovery,
                check_editor,
                check_title,
                check_body,
                check_publish_button,
                check_publish_click,
                check_publish_dialog,
                check_final_publish,
                execute,
            ]):
                flow_result = run_v2_flow_check(
                    queue=queue,
                    session=hpe_session,
                    logger=logger,
                    check_browser=check_browser,
                    check_write=check_write,
                    check_recovery=check_recovery,
                    check_editor=check_editor,
                    check_title=check_title,
                    check_body=check_body,
                    check_publish_button=check_publish_button,
                    check_publish_click=check_publish_click,
                    check_publish_dialog=check_publish_dialog,
                    check_final_publish=(check_final_publish or execute),
                    final_publish_execute=bool(execute),
                    enable_long_hpe_wait=enable_long_hpe_wait,
                    hpe_mode=hpe_mode,
                    headless=headless,
                )

                # STEP105-18: DB 성공 반영도 HPE publish_session 내부에서 수행한다.
                # 그래야 발행 후반부(post_url/대표이미지/DB) 전체가 하나의 세션 로그로 추적된다.
                if execute and isinstance(flow_result, dict):
                    flow_result["queue_claim"] = pre_claim_result

                    if flow_result.get("status") == "success" and flow_result.get("post_url"):
                        flow_result["db_update"] = update_v2_publish_success_db(
                            conn=conn,
                            queue=queue,
                            result=flow_result,
                            session=hpe_session,
                            logger=logger,
                        )
                        flow_result["router_record_success"] = record_v2_publish_success_via_router(
                            queue=queue,
                            flow_result=flow_result,
                            db_update=flow_result.get("db_update"),
                        )
                        flow_result["operational_verify_result"] = lightweight_post_publish_operational_verify(
                            conn=conn,
                            queue=queue,
                            flow_result=flow_result,
                            db_update=flow_result.get("db_update"),
                            session=hpe_session,
                            logger=logger,
                        )
                        if not (flow_result.get("operational_verify_result") or {}).get("ok"):
                            reason = "post_publish_light_verify_failed: " + ", ".join((flow_result.get("operational_verify_result") or {}).get("errors") or [])[:900]
                            flow_result["status"] = "needs_review"
                            flow_result["message"] = "V2 final publish executed but lightweight verification needs review"
                            flow_result["needs_review_update"] = mark_v2_queue_needs_review(
                                conn=conn,
                                queue=queue,
                                reason=reason,
                                verify_result=flow_result.get("operational_verify_result"),
                                session=hpe_session,
                                logger=logger,
                            )
                    elif flow_result.get("status") == "success" and not flow_result.get("post_url"):
                        # 실제 Final Publish를 눌렀는데 URL 수집이 실패하면 pending으로 남기지 않는다.
                        # 발행 성공 여부가 불명확하므로 관리자 확인 대상으로 보낸다.
                        flow_result["status"] = "needs_review"
                        flow_result["message"] = "V2 final publish executed but post_url was not collected; manual review required"
                        flow_result["db_update"] = update_v2_publish_failure_db(
                            conn=conn,
                            queue=queue,
                            reason="post_url_missing_after_execute",
                            result=flow_result,
                            session=hpe_session,
                            logger=logger,
                        )

                return flow_result

            return {"status": "dry_run", "message": "V2 publish worker dry-run"}

        try:
            result = engine.run(session, handler=handler)
        except Exception as exc:
            error_message = f"{type(exc).__name__}: {exc}"
            print("[V2 PUBLISH ERROR CAUGHT]", error_message)

            if is_v2_login_required_error(error_message):
                session_mark = mark_v2_queue_session_required(
                    conn=conn,
                    queue=queue,
                    error_message=error_message,
                )
                result = {
                    "status": "login_required",
                    "session_required": True,
                    "error": error_message,
                    "queue_id": queue.get("id"),
                    "article_no": article_no,
                    "session_mark": session_mark,
                }
                session.success = False
            else:
                if record_publish_failure is not None:
                    try:
                        record_publish_failure(
                            queue_id=queue.get("id"),
                            article_no=article_no,
                            realtor_id=queue.get("realtor_id"),
                            error_message=error_message,
                        )
                    except Exception as record_exc:
                        print("[V2 ROUTER RECORD FAILURE WARN]", f"{type(record_exc).__name__}: {record_exc}")
                result = {
                    "status": "failed",
                    "error": error_message,
                    "queue_id": queue.get("id"),
                    "article_no": article_no,
                }
                session.success = False

        db_update_result = None
        if isinstance(result, dict):
            db_update_result = result.get("db_update")

        print("=" * 80)
        print("[V2 PUBLISH WORKER DONE]")
        print("session_id:", session.session_id)
        print("success:", session.success)
        print("result:", result)
        print("=" * 80)

        result_status = result.get("status") if isinstance(result, dict) else None
        success_count = 1 if (session.success and result_status not in ["skipped", "failed", "needs_review"]) else 0

        return {
            "processed": 1,
            "success": success_count,
            "failed": 0 if success_count else 1,
            "selected_queue_id": queue_id,
            "session_id": session.session_id,
            "queue_claim": pre_claim_result,
            "db_update": db_update_result,
            "result": result,
        }

    finally:
        conn.close()



def run_v2_publish_batch(
    realtor_id=None,
    queue_id=None,
    limit=1,
    execute=False,
    pipeline_id=None,
    enable_long_hpe_wait=False,
    hpe_mode=None,
    check_browser=False,
    check_write=False,
    check_recovery=False,
    check_editor=False,
    check_title=False,
    check_body=False,
    check_publish_button=False,
    check_publish_click=False,
    check_publish_dialog=False,
    check_final_publish=False,
    headless=False,
):
    """
    STEP108-01:
    --limit 실행용 batch wrapper.

    주의:
    - queue_id가 지정되면 limit과 무관하게 해당 queue 1건만 처리한다.
    - check 모드에서는 DB 상태가 바뀌지 않으므로 exclude_queue_ids로 같은 queue 반복을 방지한다.
    """
    try:
        limit = int(limit or 1)
    except Exception:
        limit = 1

    if limit < 1:
        limit = 1

    if queue_id:
        limit = 1

    summary = {
        "processed": 0,
        "success": 0,
        "failed": 0,
        "results": [],
    }

    processed_queue_ids = []

    for idx in range(limit):
        print("=" * 80)
        print("[V2 PUBLISH BATCH ITEM]", f"{idx + 1}/{limit}")
        print("=" * 80)

        result = run_v2_publish_once(
            realtor_id=realtor_id,
            queue_id=queue_id,
            exclude_queue_ids=processed_queue_ids,
            execute=execute,
            pipeline_id=pipeline_id,
            enable_long_hpe_wait=enable_long_hpe_wait,
            hpe_mode=hpe_mode,
            check_browser=check_browser,
            check_write=check_write,
            check_recovery=check_recovery,
            check_editor=check_editor,
            check_title=check_title,
            check_body=check_body,
            check_publish_button=check_publish_button,
            check_publish_click=check_publish_click,
            check_publish_dialog=check_publish_dialog,
            check_final_publish=check_final_publish,
            headless=headless,
        )

        summary["results"].append(result)

        processed = int(result.get("processed") or 0) if isinstance(result, dict) else 0
        success = int(result.get("success") or 0) if isinstance(result, dict) else 0
        failed = int(result.get("failed") or 0) if isinstance(result, dict) else 0

        summary["processed"] += processed
        summary["success"] += success
        summary["failed"] += failed

        result_queue_id = None
        if isinstance(result, dict):
            qclaim = result.get("queue_claim") or {}
            if isinstance(qclaim, dict):
                result_queue_id = qclaim.get("queue_id")
            if not result_queue_id:
                inner = result.get("result") or {}
                if isinstance(inner, dict):
                    result_queue_id = inner.get("queue_id")

        # queue_claim이 없는 check 모드에서는 selected queue를 result에 직접 넣지 않으므로
        # 로그 반복 방지를 위해 마지막 결과에서 session queue를 추적하기 어렵다.
        # 대신 run_v2_publish_once가 처리한 queue_id를 반환하도록 아래에서 보강된다.
        if isinstance(result, dict) and result.get("selected_queue_id"):
            result_queue_id = result.get("selected_queue_id")

        if result_queue_id:
            processed_queue_ids.append(int(result_queue_id))

        if processed == 0:
            break

    print("=" * 80)
    print("[V2 PUBLISH BATCH DONE]", summary)
    print("=" * 80)
    return summary


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--realtor-id", type=int, default=None)
    parser.add_argument("--queue-id", type=int, default=None, help="특정 blog_publish_queue.id 1건만 직접 선택")
    parser.add_argument("--limit", type=int, default=1, help="처리할 pending queue 수. 기본 1")
    parser.add_argument("--pipeline-id", default=None)
    parser.add_argument("--execute", action="store_true")
    parser.add_argument("--enable-long-hpe-wait", action="store_true", help="하위 호환 옵션. 지정 시 --hpe-mode operation과 동일하게 운영 검토 대기를 켠다.")
    parser.add_argument("--hpe-mode", default=None, choices=["test", "conservative", "operation"], help="HPE 행동 모드. test=빠른 검증, conservative=중간, operation=운영 발행")
    parser.add_argument("--check-browser", action="store_true")
    parser.add_argument("--check-write", action="store_true")
    parser.add_argument("--check-recovery", action="store_true")
    parser.add_argument("--check-editor", action="store_true")
    parser.add_argument("--check-title", action="store_true")
    parser.add_argument("--check-body", action="store_true")
    parser.add_argument("--check-publish-button", action="store_true")
    parser.add_argument("--check-publish-click", action="store_true")
    parser.add_argument("--check-publish-dialog", action="store_true")
    parser.add_argument("--check-final-publish", action="store_true")
    parser.add_argument("--headless", action="store_true")
    args = parser.parse_args()

    result = run_v2_publish_batch(
        realtor_id=args.realtor_id,
        queue_id=args.queue_id,
        limit=args.limit,
        execute=args.execute,
        pipeline_id=args.pipeline_id,
        enable_long_hpe_wait=args.enable_long_hpe_wait,
        hpe_mode=args.hpe_mode,
        check_browser=args.check_browser,
        check_write=args.check_write,
        check_recovery=args.check_recovery,
        check_editor=args.check_editor,
        check_title=args.check_title,
        check_body=args.check_body,
        check_publish_button=args.check_publish_button,
        check_publish_click=args.check_publish_click,
        check_publish_dialog=args.check_publish_dialog,
        check_final_publish=args.check_final_publish,
        headless=args.headless,
    )
    print("[RESULT]", result)


if __name__ == "__main__":
    main()
