#!/bin/sh
# Claude Code Skill 설치 스크립트
# 생성: 2026-08-13 · Skill 1개 · 파일 1개
#
#   ./install-skills.sh            ~/.claude/skills/ 에 설치 (모든 프로젝트에서 사용)
#   ./install-skills.sh ./myrepo   ./myrepo/.claude/skills/ 에 설치 (그 저장소 전용)
set -e

DEST="${1:+$1/.claude/skills}"
DEST="${DEST:-$HOME/.claude/skills}"
mkdir -p "$DEST"
echo "설치 위치: $DEST"


mkdir -p "$DEST/wos-selector-debug"
cat > "$DEST/wos-selector-debug/SKILL.md" <<'SKILL_PAYLOAD_EOF'
---
name: wos-selector-debug
description: Web of Science Export 다이얼로그 Selenium 자동화의 selector fail 을 진단·수정합니다. Angular Material 동적 ID 함정 (#radio3 → radio7 등) 과 Smart vs Advanced Search 다이얼로그 wrapper 차이에 대응하는 텍스트 기반 selector 패턴을 제공합니다.
---

# WoS Selector 디버그 스킬

Web of Science (`webofscience.com`) Export 다이얼로그를 Selenium 으로 조작할 때 selector 가 fail 하는 패턴을 진단하고 수정합니다.

## 언제 사용

- WoS 자동화 (Excel export, Full record 선택) 가 fail 또는 timeout
- "Record Content 드롭다운 버튼을 찾지 못함" / `[RADIO] 모든 N회 시도 실패` 로그
- nightly Selenium 잡이 매일 동일 패턴으로 실패 (4일 이상 연속)
- WoS UI 가 업데이트되어 기존 selector 가 안 통함

**대상 코드**: `scripts/wos-collector/helper.py` (Conv_home 프로젝트) 또는 동일 패턴을 쓰는 다른 WoS Selenium 자동화.

## 핵심 함정

### 1. Angular Material 동적 ID

WoS 가 export 다이얼로그를 `mat-mdc-radio-button`, `mat-mdc-select` 컴포넌트로 렌더링.
Angular Material 은 컴포넌트마다 페이지 전역 카운터로 ID 를 할당 (`#radio0`, `#radio1`, ...).
**다이얼로그를 닫고 다시 열면 카운터가 증가** → `#radio3` 이 두 번째 다이얼로그에서 `#radio7` 등으로 바뀜.

**증상**: 첫 batch 는 통과, batch 2 (다이얼로그 재오픈) 에서 fail.

**fix**: 텍스트 기반 selector 사용.
```python
# bad
(By.CSS_SELECTOR, "#radio3")

# good
(By.XPATH, "//mat-radio-button[contains(., 'Records from')]")
```

### 2. Smart vs Advanced Search 다이얼로그 wrapper 차이

같은 export 다이얼로그처럼 보이지만 wrapper element 가 페이지마다 다름:
- Advanced Search 결과 페이지: `mat-mdc-dialog-container` 또는 `role='dialog'` wrapper
- Smart Search 결과 페이지: **표준 Material wrapper 없음** (cdk-overlay-pane 만, 표준 dialog 클래스 없음)

dialog scope (`//*[@role='dialog']//`) 로 selector 좁히면 Smart Search 에서 fail.

**fix**: dialog scope 좁히지 말고 텍스트 노드 직후의 첫 트리거 element 매치.
```python
# good (텍스트-트리거 거리가 짧으니 외부 element 끼어들기 어려움)
(By.XPATH, "//*[contains(normalize-space(.), 'Record Content')]/following::button[@aria-haspopup='listbox' or @aria-haspopup='menu' or @role='combobox'][1]")
```

### 3. selector timeout 누적

`EC.element_to_be_clickable` 의 default 10초 × selector 7개 = 70초 wait. 모두 fail 시 wrapper 의 5분 timeout 도달 → SIGKILL → 다이얼로그 dirty 상태로 retry.

**fix**: per-selector timeout 3초 상한.
```python
def _find_dropdown_button(driver, timeout=10):
    per_timeout = min(timeout, 3)  # 각 selector 최대 3초
    for i, (sel_type, sel) in enumerate(_DROPDOWN_BUTTON_SELECTORS):
        try:
            btn = WebDriverWait(driver, per_timeout).until(EC.element_to_be_clickable((sel_type, sel)))
            if btn: return btn
        except Exception:
            continue
    return None
```

## 진단 패턴: visible 후보 dump

selector 가 모두 fail 했을 때, 페이지의 visible listbox/select 후보를 JS 로 dump 해서 정확한 selector 작성에 활용:

```python
candidates = driver.execute_script(
    """
    const seen = [];
    document.querySelectorAll('button[aria-haspopup], mat-select, [role=\\'combobox\\']').forEach(el => {
        const rect = el.getBoundingClientRect();
        if (rect.width === 0 || rect.height === 0) return;  // invisible 제외
        seen.push({
            tag: el.tagName.toLowerCase(),
            id: el.id || '',
            cls: (el.className || '').toString().slice(0, 80),
            text: (el.innerText || '').slice(0, 40),
            aria: el.getAttribute('aria-haspopup') || '',
            x: Math.round(rect.x), y: Math.round(rect.y),
        });
    });
    return seen.slice(0, 8);
    """
) or []
for c in candidates:
    print(f"  <{c['tag']}> id={c['id']!r} aria={c['aria']!r} pos=({c['x']},{c['y']}) text={c['text']!r}")
```

후보의 좌표(x, y) 로 다이얼로그 내부 element 식별 가능 (다이얼로그는 보통 중앙, 외부 element 는 사이드바).

## 라디오 버튼 클릭 5가지 방법

Angular Material radio 가 click 안 먹는 경우를 위한 fallback chain:

```python
def click_radio_robust(driver, radio_element, max_retries=5):
    """텍스트 기반으로 찾은 mat-radio-button element 에 클릭 시도."""
    for attempt in range(max_retries):
        # 방법1: 내부 <label> 클릭 (표준 클릭 영역)
        label = radio_element.find_element(By.XPATH, ".//label")
        ActionChains(driver).move_to_element(label).pause(0.2).click().perform()
        if is_selected(radio_element): return True

        # 방법2: .mdc-radio 클릭
        # 방법3: <input> 에 focus 후 Space 키
        # 방법4: JS native input.click() — input style 임시로 visible 처리 후 click()
        # 방법5: JS 강제 상태 변경 + Angular change event dispatch
        # ... (각 방법 후 is_selected 확인, fail 시 다음 방법)
```

**핵심**: `mat-radio-button` element 를 텍스트 기반으로 매번 새로 찾기 (DOM 재렌더링 대응).

## Dry-run 검증 패턴

WoS 큐 처리 같은 시스템은 DB 갱신 없는 dry-run 모드를 제공해야 함:
```bash
# DB 갱신 없이 selector 작동만 검증
process-wos-lookup-queue.ts --dry-run --limit 7
```

DB update 단계만 `if (!dryRun) await prisma.xxx.update(...)` 로 감싸면 됨.

## chrome 좀비 cleanup

WoS 자동화는 `.chrome-profile-wos-meta` 같은 user_data_dir 을 공유. SIGKILL 또는 비정상 종료 시 chrome 프로세스 + Singleton 잠금 잔존 → 다음 실행 fail.

```bash
# 표준 cleanup 패턴
pkill -f "chrome-profile-wos-meta" 2>/dev/null && sleep 2
rm -f scripts/wos-collector/.chrome-profile-wos-meta/Singleton{Lock,Socket,Cookie}
```

## 참조

- 실제 사고: 2026-05-13 Conv_home `#radio3` 하드코딩 4일 연속 fail. daily-uus 800건 + 큐 7건 모두 영향. 텍스트 기반 selector 로 fix (commit `ce1a23f` + `1cb2aaa`).
- 운영 문서: `docs/wos-lookup-queue.md` (트러블슈팅 섹션)
- 관련 스킬: `author-extractor-wos` (저자 추출 시 WOS 데이터 활용)
SKILL_PAYLOAD_EOF

echo ""
echo "완료 — Skill 1개를 설치했습니다."
echo "Claude Code를 다시 시작하면 인식됩니다."
