#!/bin/sh
# Claude Code Skill 설치 스크립트
# 생성: 2026-08-13 · Skill 1개 · 파일 2개
#
#   ./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/biblio-analysis"
cat > "$DEST/biblio-analysis/SKILL.md" <<'SKILL_PAYLOAD_EOF'
---
name: biblio-analysis
description: Web of Science·PubMed·KoreaMed 등 여러 서지 소스를 하나로 통합하고, CrossRef/PubMed API로 교차검증하며, 계량서지학 분석(저널·키워드·기관 네트워크·국제공동연구·연도 추세)과 보고서를 생성한다. GPT 정규화 결과 캐싱, 기관명 병합 검증 포함. TRIGGER - "계량서지", "bibliometric", "논문 목록 통합", "WoS/PubMed/KoreaMed 합치기", "기관 분석", "공동연구 네트워크", "논문 데이터 교차검증", 서지 데이터로 분석 보고서를 만들 때.
---

# 계량서지 분석

## 언제 쓰나

여러 데이터베이스에서 뽑은 논문 목록을 합쳐 "30년간 한국 AML/MDS 연구 동향" 같은 분석 보고서를 낼 때. 논문 **수집**은 `~/.claude/skills/`의 `paper-collect`·`bibliography-fetcher` 계열이 하고, 이 skill은 **통합·검증·분석**을 맡는다.

## 정본 코드

```
~/Documents/IMOK/AML260130_Korea AML MDS 30y/
  consolidate_bibliometrics.py     749줄  ★ 3소스 통합 (정본)
  journal_analysis.py / keyword_analysis.py / trend_analysis.py
  scripts/verify_koremed.py, verify_pubmed.py, validate_data.py
  scripts/institutional_network.py, international_collaboration.py, bibliometric_laws.py
  scripts/extract_institutions_gpt.py, gpt_verify_*.py
  .claude/agents/     6종: data-validator, pubmed-verifier, koremed-verifier,
                          report-verifier, biblio-analyst, feedback-processor
  report_template.html, generate_pdf_report.py
~/Documents/IMOK/BTC_KOL_PoC/       collect_pubmed/openalex/koreamed.py (다중 소스 수집)
```

**`.claude/agents/` 6종을 새 서지 프로젝트에 그대로 복사해 쓸 수 있다.** 다만 기대 건수가 하드코딩돼 있으니 프로젝트 숫자로 바꿔야 한다.

## 바로 쓰는 코드

```bash
python3 scripts/crossref_verify.py --title "논문 제목" --year 2020 --mailto you@example.org
python3 scripts/crossref_verify.py --excel papers.xlsx --title-col Title
```

`scripts/crossref_verify.py` — `verify_koremed.py` 의 3단계 검증을 추출. DOI 조회 → 제목 검색 → KoreaMed 수동확인 URL 생성. `--mailto` 를 주면 polite pool(~50 req/sec).

## 소스 통합 순서가 중요하다

```
WoS 기준으로 시작 (1,679건)
  → PubMed 병합 (merge_wos_pubmed): 겹치는 건 정보 보강, PubMed-only 234건 추가
  → KoreaMed 추가 (add_koremed): 기존에 없는 670건만
  → 최종 2,583건, Source 컬럼으로 출처 표시
```

**가장 정보가 풍부한 소스를 기준(base)으로 잡고 나머지를 병합한다.** WoS는 저자 소속(C1)·교신저자(RP) 필드가 구조화돼 있어 기준으로 적합하다. 순서를 바꾸면 소속 정보가 빈 레코드가 늘어난다.

소스별로 파서가 따로 필요하다 — `parse_wos_c1`(소속), `parse_wos_rp`(교신저자), `parse_koremed_authors`, `parse_koremed_affiliation`. **KoreaMed는 저자와 소속이 분리돼 있지 않아 저자 목록을 참조해 소속을 매핑해야 한다.**

Excel 출력 시 소스별 행 색상을 다르게 준다(WoS 흰색 / PubMed-only 연녹색 / KoreaMed 연주황). 검수자가 한눈에 출처를 본다.

## 교차검증: API가 없는 DB 대응

PubMed는 PMID로 efetch하면 끝이지만, **KoreaMed는 공식 API가 없다.** 3단계 전략:

```
1) DOI 있음  → CrossRef REST API로 메타데이터 조회. 제목 유사도 70%↑ + 연도 일치
2) DOI 없음  → CrossRef query.bibliographic 로 제목+연도 검색
               부가효과: 없던 DOI를 찾아낸다
3) 그래도 없음 → KoreaMed SearchBasic.php URL 생성 → 수동 확인
```

CrossRef는 무료이고 API 키가 필요 없다. **`mailto` 파라미터를 넣으면 polite pool로 들어가 ~50 req/sec까지 쓸 수 있다.** 안 넣으면 훨씬 느리다.

KoreaMed URL 검색 태그: `[TI]` 제목, `[AU]` 저자, `[DPY]` 출판연도.

**"API 없음 = 검증 불가"가 아니다.** DOI를 중간 다리로 삼으면 대부분 검증된다. 남은 것만 사람이 본다.

## GPT 정규화 결과는 반드시 캐싱한다

기관명 정규화("Asan Medical Center" = "울산대학교 의과대학 서울아산병원"), 키워드 추출, AML/MDS 분류 등에 GPT를 쓴다. 프로젝트에 캐시 파일이 여럿 있다:

```
institution_cache.json / institution_cache_corrections.json
keyword_cache.json / keyword_cache_v2.json
aml_mds_gpt_cache.json / gpt_kr_foreign_cache.json
```

수천 건을 분석 스크립트 돌릴 때마다 다시 물으면 비용과 시간이 폭발하고, **무엇보다 결과가 매번 조금씩 달라져 분석이 재현되지 않는다.**

`_corrections.json`을 캐시와 **분리**한 게 중요하다 — 사람이 고친 내용을 별도 파일에 두면 캐시를 새로 만들어도 수정이 살아남는다.

`gpt_verify_*.py` 계열은 GPT 결과를 다시 GPT로 검증한다(기관 병합, 국내/해외 판정, 공동연구 쌍). 자동 정규화는 반드시 틀리므로 검증 단계를 따로 둔다.

## 보고서는 검증 대상이다

`report-verifier` agent가 **생성된 보고서의 모든 수치를 원본 Excel과 대조**한다. `verify_chart21_final.py`, `verify_charts_collab.py`처럼 차트별 검증 스크립트도 있다.

LLM이 쓴 보고서 문장의 숫자는 틀린다. 보고서를 만들면 숫자 검증을 반드시 붙여라.

`feedback-processor` agent는 사람 피드백을 받아 스크립트 수정 → 보고서 재생성 → 재검증까지 돌린다.

## 분석 축

| 스크립트 | 내용 |
|---|---|
| `journal_analysis.py` | 저널 분류, 국내/국제 판별(`is_korean_journal`) |
| `keyword_analysis.py` | 키워드 빈도, 시기 구분(`get_period`) |
| `trend_analysis.py` | 연도별 추세 |
| `institutional_network.py` | 기관 공저 네트워크 |
| `international_collaboration.py` | 국제 공동연구 |
| `bibliometric_laws.py` | Lotka/Bradford 등 계량서지 법칙 |
| `generate_author_leadership.py` | 제1저자·교신저자 기반 리더십 |

시기 구분(`get_period`)을 여러 스크립트가 공유한다 — **한 군데서 정의해라.** 스크립트마다 다르게 나누면 표끼리 안 맞는다.

## 연관 skill

`paper-collect`·`bibliography-fetcher`·`batch-author-extractor`(수집·저자추출), `[[lit-relevance-classify]]`, `[[excel-case-validator]]`
SKILL_PAYLOAD_EOF
mkdir -p "$DEST/biblio-analysis/scripts"
cat > "$DEST/biblio-analysis/scripts/crossref_verify.py" <<'SKILL_PAYLOAD_EOF'
#!/usr/bin/env python3
"""CrossRef로 논문 서지를 교차검증한다. API 키 불필요.

출처: AML260130_Korea AML MDS 30y/scripts/verify_koremed.py 에서 재사용 부분만 추출.
KoreaMed처럼 공식 API가 없는 DB도 DOI와 CrossRef를 다리 삼으면 대부분 검증된다.

    python crossref_verify.py --doi 10.3346/jkms.2020.35.e1
    python crossref_verify.py --title "Acute myeloid leukemia in Korea" --year 2020
    python crossref_verify.py --excel papers.xlsx --title-col Title --year-col Year --doi-col DOI
    python crossref_verify.py --self-check

메일 주소를 주면 CrossRef polite pool(~50 req/sec)로 들어간다. 안 주면 훨씬 느리다.
    --mailto you@example.org  또는  환경변수 CROSSREF_MAILTO
"""
import argparse
import json
import os
import re
import sys
import time
import urllib.parse

CROSSREF_BASE = "https://api.crossref.org"
DOI_API_BASE = "https://doi.org/api/handles"
KOREAMED_SEARCH_URL = "https://www.koreamed.org/SearchBasic.php"

CROSSREF_DELAY = 0.1   # polite pool 기준
MAX_RETRIES = 3
MATCH_THRESHOLD = 70.0 # 제목 유사도 채택 기준(%)

MAILTO = os.environ.get("CROSSREF_MAILTO", "")


def _headers():
    return {"User-Agent": f"BibVerifier/1.0 (mailto:{MAILTO})" if MAILTO else "BibVerifier/1.0"}


def _params(extra=None):
    p = dict(extra or {})
    if MAILTO:
        p["mailto"] = MAILTO
    return p


def _get(url, params):
    import requests
    for attempt in range(MAX_RETRIES):
        try:
            resp = requests.get(url, params=params, headers=_headers(), timeout=30)
            if resp.status_code == 404:
                return None
            resp.raise_for_status()
            return resp.json()
        except requests.exceptions.RequestException:
            if attempt < MAX_RETRIES - 1:
                time.sleep(2 ** attempt)
    return None


# ── CrossRef ────────────────────────────────────────────────────
def parse_crossref_work(item):
    """CrossRef work 아이템 → 평평한 딕셔너리."""
    authors = []
    for a in item.get("author", []):
        name = f"{a.get('family', '')} {a.get('given', '')}".strip()
        if name:
            authors.append(name)

    published = item.get("published-print") or item.get("published-online") or {}
    date_parts = published.get("date-parts", [[None]])[0]
    year = str(date_parts[0]) if date_parts and date_parts[0] else ""

    titles = item.get("title", [])
    journals = item.get("container-title", [])

    return {
        "doi": item.get("DOI", ""),
        "title": titles[0] if titles else "",
        "authors": authors,
        "first_author": authors[0] if authors else "",
        "journal": journals[0] if journals else "",
        "year": year,
        "volume": item.get("volume", ""),
        "issue": item.get("issue", ""),
        "page": item.get("page", ""),
        "publisher": item.get("publisher", ""),
        "score": item.get("score", 0),
    }


def crossref_lookup_doi(doi):
    """1단계 — DOI로 메타데이터 조회."""
    data = _get(f"{CROSSREF_BASE}/works/{doi}", _params())
    return parse_crossref_work(data["message"]) if data else None


def crossref_search_title(title, year="", rows=3):
    """2단계 — 제목+연도로 검색. DOI가 없던 논문의 DOI를 찾아내는 효과도 있다."""
    p = _params({"query.bibliographic": title, "rows": rows})
    if year:
        p["filter"] = f"from-pub-date:{year},until-pub-date:{year}"
    data = _get(f"{CROSSREF_BASE}/works", p)
    return [parse_crossref_work(i) for i in data["message"]["items"]] if data else []


def check_doi_exists(doi):
    """DOI가 실제로 resolve 되는지."""
    import requests
    try:
        resp = requests.get(f"{DOI_API_BASE}/{doi}", timeout=15)
        if resp.status_code == 200:
            return resp.json().get("responseCode") == 1
    except Exception:
        pass
    return False


def build_koreamed_search_url(title, year=""):
    """3단계 — CrossRef에서 못 찾은 건의 수동 확인용 URL.
    검색 태그: [TI] 제목 · [AU] 저자 · [DPY] 출판연도"""
    clean_title = re.sub(r"[^\w\s]", "", str(title)[:60]).strip()
    parts = [f'"{clean_title}"[TI]']
    if year:
        parts.append(f'"{year}"[DPY]')
    q = {"QY": " ".join(parts), "DisplayCount": 20, "DT": 1}
    return f"{KOREAMED_SEARCH_URL}?{urllib.parse.urlencode(q)}"


# ── 비교 ────────────────────────────────────────────────────────
def normalize_text(s):
    if s is None or s != s:  # None 과 NaN 을 함께 걸러낸다
        return ""
    s = re.sub(r"[^\w\s]", "", str(s).strip().lower())
    return re.sub(r"\s+", " ", s)


def title_similarity(t1, t2):
    """제목 유사도 0~100. 단어 집합 Jaccard."""
    n1, n2 = normalize_text(t1), normalize_text(t2)
    if not n1 or not n2:
        return 0.0
    if n1 == n2:
        return 100.0
    w1, w2 = set(n1.split()), set(n2.split())
    return len(w1 & w2) / len(w1 | w2) * 100


def verify(title, year="", doi=""):
    """3단계 검증. → dict(stage, matched, similarity, crossref, koreamed_url)"""
    if doi:
        cr = crossref_lookup_doi(doi)
        time.sleep(CROSSREF_DELAY)
        if cr:
            sim = title_similarity(title, cr["title"])
            return {"stage": "doi", "matched": sim >= MATCH_THRESHOLD,
                    "similarity": round(sim, 1), "crossref": cr, "koreamed_url": ""}

    for cand in crossref_search_title(title, year):
        time.sleep(CROSSREF_DELAY)
        sim = title_similarity(title, cand["title"])
        if sim >= MATCH_THRESHOLD:
            return {"stage": "title", "matched": True, "similarity": round(sim, 1),
                    "crossref": cand, "koreamed_url": ""}

    return {"stage": "manual", "matched": False, "similarity": 0.0,
            "crossref": None, "koreamed_url": build_koreamed_search_url(title, year)}


# ── 자체 점검 ───────────────────────────────────────────────────
def _self_check():
    assert normalize_text(None) == ""
    assert normalize_text(float("nan")) == ""
    assert normalize_text("  A, B!  c  ") == "a b c"

    assert title_similarity("Acute myeloid leukemia", "acute, myeloid leukemia!") == 100.0
    assert title_similarity("a b c d", "a b c d") == 100.0
    assert title_similarity("", "x") == 0.0
    # 2/4 겹침 → 교집합2 / 합집합4 = 50%
    assert abs(title_similarity("a b c", "a b d e") - 40.0) < 0.01, title_similarity("a b c", "a b d e")

    w = parse_crossref_work({
        "DOI": "10.1/x", "title": ["T"], "container-title": ["J"],
        "author": [{"family": "Kim", "given": "S"}, {"family": "Lee", "given": "H"}],
        "published-print": {"date-parts": [[2020, 3]]}, "volume": "5"})
    assert w["year"] == "2020" and w["first_author"] == "Kim S" and w["journal"] == "J"
    assert len(w["authors"]) == 2

    # 저자·연도·저널이 없어도 죽지 않아야 한다
    empty = parse_crossref_work({})
    assert empty["year"] == "" and empty["first_author"] == "" and empty["title"] == ""

    u = build_koreamed_search_url("Acute myeloid leukemia: a study", "2020")
    assert "%5BTI%5D" in u and "2020" in u and u.startswith(KOREAMED_SEARCH_URL)
    print("self-check OK")


def main():
    global MAILTO
    p = argparse.ArgumentParser()
    p.add_argument("--doi"); p.add_argument("--title"); p.add_argument("--year", default="")
    p.add_argument("--mailto", help="CrossRef polite pool 용 메일 주소")
    p.add_argument("--excel", help="일괄 검증할 엑셀/CSV")
    p.add_argument("--title-col", default="Title"); p.add_argument("--year-col", default="Year")
    p.add_argument("--doi-col", default="DOI")
    p.add_argument("--self-check", action="store_true")
    a = p.parse_args()

    if a.self_check:
        _self_check(); return 0
    if a.mailto:
        MAILTO = a.mailto
    if not MAILTO:
        print("경고: --mailto 없이 호출 — polite pool 밖이라 느립니다.", file=sys.stderr)

    if a.excel:
        import pandas as pd
        df = pd.read_csv(a.excel) if a.excel.lower().endswith(".csv") else pd.read_excel(a.excel)
        rows, ok = [], 0
        for _, r in df.iterrows():
            res = verify(r.get(a.title_col, ""), str(r.get(a.year_col, "") or ""),
                         str(r.get(a.doi_col, "") or ""))
            ok += bool(res["matched"])
            rows.append({"title": r.get(a.title_col, ""), **{k: v for k, v in res.items() if k != "crossref"},
                         "found_doi": (res["crossref"] or {}).get("doi", "")})
        print(json.dumps(rows, ensure_ascii=False, indent=2))
        print(f"\n검증 {ok}/{len(rows)}건 일치", file=sys.stderr)
        return 0 if ok == len(rows) else 1

    if not (a.doi or a.title):
        p.error("--doi 또는 --title 필요")
    print(json.dumps(verify(a.title or "", a.year, a.doi or ""), ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    sys.exit(main())
SKILL_PAYLOAD_EOF
chmod +x "$DEST/biblio-analysis/scripts/crossref_verify.py"

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