diff --git a/.claude/skills/catalog-intake/SKILL.md b/.claude/skills/catalog-intake/SKILL.md new file mode 100644 index 0000000..8ab64da --- /dev/null +++ b/.claude/skills/catalog-intake/SKILL.md @@ -0,0 +1,175 @@ +--- +name: catalog-intake +description: >- + Google Forms(식탁보 사이트 추가/수정/삭제 요청) 응답 CSV를 읽어 docs/Catalog.xml 등록을 + 돕는다. 헤더를 정규화하고, 브라우저(Edge)로 위장해 보안 플러그인 URL 생존/파일명을 확인하고, + 공식 로고를 투명 정사각 PNG로 만들고, catalogutil.cs 검증을 거쳐 사람이 검토할 PR 후보를 + 만든다. "제보 CSV 처리", "폼 응답 등록", "catalog intake" 요청 시 사용. +--- + +# 카탈로그 제보 인테이크 (catalog-intake) + +Google Forms 제보(비-GitHub 사용자용)를 `docs/Catalog.xml` 변경으로 옮기는 반자동 절차. +`docs/SITE_HEALTH_WORKFLOW.md`와 같은 철학을 따른다: **기계는 정규화·검증·후보 생성까지, +최종 판정과 반영(merge)은 사람.** + +## ⚠️ 안전 경계선 (먼저 읽기) + +- 이 카탈로그의 ``는 **사일런트 스위치로 무인 설치되는 보안 프로그램**이다. + 제보는 익명은 아니지만(폼 로그인 필수) **미검증 외부 입력**이다. +- 따라서 이 스킬은 **PR 후보까지만** 만든다. **자동 commit/merge 금지.** 사람이 검토 후 반영. +- `check_urls.py`·`fetch_logo.py`는 URL을 **읽기만** 한다. 설치 파일을 실행하지 않는다. + +## 입력 + +- 필수: Google Forms 응답을 내보낸 **CSV 경로** (Sheets → 파일 → 다운로드 → CSV). +- 참고 문서: [references/field-mapping.md](references/field-mapping.md) — 필드 매핑·Category· + gap 정의·설치기/스위치 시드표. 처리 규칙의 근거는 전부 여기에 있다. + +## 파이프라인 + +```text +[0] 최신화 (필수) main pull → 작업 브랜치 생성 ← 스킬 시작 전 반드시 + │ +[1] normalize (오프라인) CSV → 정규화 JSON + gap 플래그 + │ +[2] 게이트 확인 consent/dedup 누락 건은 보류 표시 + │ +[3] 항목별 처리 빈칸 4종 채우기(아래 규칙) — URL확인/로고/스위치/분류 + │ +[4] 편집 Catalog.xml + 이미지 배치 (변경 유형별 commit 분리) + │ +[5] 검증 catalogutil.cs 스키마 검증 + (선택) checksites.cs probe + │ +[6] PR + 미완 이슈 완전반영은 PR diff / 미완은 추적 이슈 생성 후 PR에 링크. merge는 사람. +``` + +### 0. 최신화 — 스킬 실행 전 반드시 + +제보를 처리하기 전에 **로컬 main을 원격 최신으로 맞추고 새 작업 브랜치에서 시작**한다. +stale한 베이스 위에서 편집하면 이미 반영된 항목을 중복 추가하거나 충돌 검사가 어긋난다. + +```bash +git switch main +git pull --ff-only origin main # 최신 카탈로그 확보 (fast-forward만) +git switch -c catalog-intake/<날짜-또는-배치명> # 예: catalog-intake/2026-07-forms +``` + +- `--ff-only`가 실패(로컬에 이탈 커밋 존재)하면 사람에게 알리고 멈춘다 — 임의 merge/rebase 금지. +- 이 최신 main 기준으로 [1] normalize의 `--catalog docs/Catalog.xml` 충돌 검사가 정확해진다. + +### 1. normalize — CSV 정규화 (오프라인) + +```bash +uv run .claude/skills/catalog-intake/scripts/normalize_csv.py \ + --catalog docs/Catalog.xml --out intake.json --pretty +``` + +각 레코드에 `change_type`, `display_name_ko/en`, `url`, `category`, `id_suggested`, +`id_collision`, `package_urls[]`, `gaps[]`가 채워진다. **요약의 "매칭 안 된 CSV 열"과 +`gaps`를 먼저 확인**하고 처리 계획을 세운다. + +### 2. 게이트 확인 + +`consent-missing` 또는 `dedup-missing`이 있으면 스팸/오제출 의심 → 그 건은 **처리 보류**로 +표시하고 사람에게 확인을 요청한다. 임의로 진행하지 않는다. + +### 3. 항목별 처리 — 빈칸 4종 채우기 + +[references/field-mapping.md](references/field-mapping.md) 3~5절을 근거로: + +- **`arguments-missing` + `package-name-derive`** — 각 `package_urls`를 확인: + ```bash + uv run .claude/skills/catalog-intake/scripts/check_urls.py [ ...] --json + ``` + 살아있는지·최종 URL·파일명을 얻는다. 파일명을 시드표(4절)에 대조해 **Package Name**과 + **Arguments**를 정한다. 시드표에 없으면 `src/ussf.cs` 또는 설치기 `/?`로 스위치를 조사하고, + 넘겨짚지 않는다(특히 TouchEn=`/silence`, IPInside=`/nodlg`). + - `check_urls`가 403/차단이면 JS 게이트일 수 있음 → 무거운 케이스이므로 + `dotnet run --file src/checksites.cs -- probe ...`(Playwright Edge)로 폴백. + +- **로고 → 투명 정사각 PNG** — CSV의 `icon_ref`는 인증이 필요한 Drive 링크이므로 보통 + 사이트에서 직접 만든다: + ```bash + uv run .claude/skills/catalog-intake/scripts/fetch_logo.py \ + <대표 URL> docs/images//.png --size 256 --bg auto + # 이미 로컬로 받은 아이콘이 있으면: + uv run .claude/skills/catalog-intake/scripts/fetch_logo.py \ + --from-image docs/images//.png --bg auto + ``` + 플랫 로고가 아니거나 배경이 복잡하면 `--bg rembg`(무거움: `uv run --with rembg …`). + 결과가 어색하면 사람에게 공식 로고 교체를 권한다. + +- **`category-needs-decision`(증권 등)** — 자동 결정 금지. Other 잠정값 그대로 두고 + 사람에게 "Other로 둘지 / XSD에 새 enum 추가할지" 결정을 요청. + +- **`id-collision`** — 이름을 조정하거나, 사실상 기존 항목 수정이면 `change_type`을 + modify로 재해석. 새 Id를 임의로 만들지 말고 사람과 합의. + +### 4. 편집 (변경 유형별 commit 분리) + +`references/field-mapping.md` 1절 매핑대로 `docs/Catalog.xml`(또는 `docs/sites.xml`)을 +편집하고 이미지를 배치한다. commit은 의미 단위로 분리: +`(1) 신규 추가 (2) 수정 (3) 삭제`. + +### 5. 검증 + +```bash +dotnet run --file src/catalogutil.cs -- ./docs/ ./outputs/ # 스키마/리소스 검증 +dotnet run --file src/checkimages.cs -- ./docs/Catalog.xml ./docs/images # 이미지 존재 검증 +# 새로 추가/수정한 Id만 실제 접속 재검증(선택): +dotnet run --file src/checksites.cs -- probe ./docs/ ./health-report/ --only +``` +Error/Warning이 남지 않을 때까지 정리한다. + +### 6. PR 후보 제출 + +**모든 제보 항목은 아래 3가지 중 하나로만 분류한다. "수동 확인 필요"를 이유로 조용히 +드롭하지 않는다 — 드롭하면 그 제보는 유실된다.** + +| 결과 | 처리 | PR에서 | +|---|---|---| +| **완전 반영** | 검증까지 끝나 Catalog.xml에 반영 | diff에 포함 | +| **미완(수동 검토 필요)** | 자동으로 확정 못 한 항목 | **제외 금지.** PR 본문에 남기고 + **추적 이슈를 반드시 생성해 링크** | +| **중복/무효로 제외** | 기존 항목과 중복이거나 정크인 것 | 본문에 사유만 기록(진짜 제외) | + +- 미완과 제외는 **다르다**: "확정 못 함"(미완)을 "제외"로 처리하면 안 된다. 제외는 중복·정크에만. + +**미완 항목마다 추적 이슈를 생성한다**(유실 방지의 핵심 — PR 본문만으론 merge 후 사라진다): + +```bash +gh issue create \ + --title "[제보 미완] <이름> (<도메인>) — <막힌 지점 요약>" \ + --body-file <이슈본문.md> \ + --label "help wanted" --label "enhancement" +# 이슈 본문에 담을 것: 제보 요약 / 자동으로 확인한 것 / 막힌 지점 / 사람이 해야 할 일 / 관련 PR # +``` + +- 이슈 본문은 **자기완결**로(site-health `issue` 단계와 동일 철학) — 후속 처리에 필요한 컨텍스트를 모두 담아 PR/대화 없이도 처리 가능하게. +- 생성한 이슈 번호를 PR 본문의 해당 미완 항목 옆에 `추적: #`으로 링크한다. +- PR 본문 구성: 변경 요약 · 완전 반영 목록 · **미완(수동 검토 필요) + 추적 이슈 링크** · 중복 제외 목록 · 검증 결과. +- 완전 반영 항목이 기존 제보 이슈를 닫아야 하면 `Closes #`로 연결. +- **여기서 멈춘다.** merge는 사람이 한다. + +## 자동 vs 사람 승인 경계 (site-health와 동일) + +| 작업 | 자동 | 사람 승인 | +|---|:---:|:---:| +| CSV 정규화 / URL 생존 확인 / 로고 생성 | ✓ | | +| 같은 도메인 내 URL 한 줄 갱신 | ✓ | | +| 새 `Service` 추가 · Id 신설 | | ✓ | +| `Service` 삭제 | | ✓ | +| 증권 등 Category 결정 · XSD 변경 | | ✓ | +| 사일런트 스위치가 시드표에 없어 조사한 값 | | ✓ | +| 게이트(consent/dedup) 누락 건 반영 | | ✓ | +| commit / merge | | ✓ | + +## 스크립트 요약 + +| 스크립트 | 런타임 | 역할 | 네트워크 | +|---|---|---|:---:| +| `normalize_csv.py` | uv(무의존) | CSV→정규화 JSON·gap 플래그·Id 파생·충돌검사 | ✗ | +| `check_urls.py` | uv(httpx) | Edge 위장 URL 생존/파일명 확인 | ✓ | +| `fetch_logo.py` | uv(pillow/numpy/bs4) | 로고 수집→배경 투명화→정사각 PNG | ✓* | + +`*` `--from-image`는 네트워크 불필요. UA 프로파일은 `src/checksites.cs`의 Edge 131과 동일. diff --git a/.claude/skills/catalog-intake/references/field-mapping.md b/.claude/skills/catalog-intake/references/field-mapping.md new file mode 100644 index 0000000..461df89 --- /dev/null +++ b/.claude/skills/catalog-intake/references/field-mapping.md @@ -0,0 +1,87 @@ +# 제보 폼 → 카탈로그 스키마 매핑 참조 + +이 문서는 Google Forms **"식탁보 사이트 추가/수정/삭제 요청"** 응답을 `docs/Catalog.xml` +스키마로 옮길 때 쓰는 매핑·규칙·시드 데이터를 담는다. `catalog-intake` 스킬과 +`scripts/normalize_csv.py` 가 이 표를 기준으로 동작한다. + +## 1. 필드 매핑 + +| 폼 질문(키워드) | 논리 필드 | 카탈로그 대상 | 비고 | +|---|---|---|---| +| 타임스탬프 | `timestamp` | (이슈 메타) | Google 자동 | +| 이메일 주소 | `reporter_email` | (이슈 메타) | 로그인 필수 → verified | +| 어떤 유형의 변경 사항 | `change_type` | 처리 분기 | 추가/수정/삭제 → add/modify/delete | +| 웹 사이트의 한국어 이름 | `display_name_ko` | `Service@DisplayName` | | +| 웹 사이트의 영어 이름 | `display_name_en` | `Service@en-US-DisplayName` | Id 파생 소스 | +| 웹 사이트의 대표 주소 | `url` | `Service@Url` | | +| 웹 사이트의 아이콘 | `icon_ref` | `docs/images//.png` | CSV엔 Drive 링크(인증 필요) | +| 웹 사이트의 종류 | `category_ko` → `category` | `Service@Category` | 아래 2절 | +| 보안 플러그인 …URL(한 줄씩) | `package_urls[]` | `` | Name·Arguments는 미수집 | +| …Windows Sandbox 창이 닫히나요 | `compat_notes` | `CompatNotes` | AST 관련은 생략 가능 | +| 플러그인 사용 메뉴/위치 | `usage_notes` | (판단용 메타) | 개별 사이트 vs 다른 형태 판단 | +| 개인 정보 수집 동의 | `consent` | 게이트 | 비면 `consent-missing` | +| 없거나 반영되지…먼저 확인 | `dedup_ack` | 게이트 | 비면 `dedup-missing` | + +> 헤더는 **부분 문자열 키워드**로 매칭하므로 폼 문구가 조금 바뀌어도 견딘다. +> 매칭 안 된 열은 normalize 요약의 "매칭 안 된 CSV 열"에 출력된다 — 무시하지 말 것. + +## 2. Category 매핑 (한국어 종류 → XSD enum) + +XSD `CatalogInternetServiceCategory` enum: +`Other, Banking, Financing, Security, Insurance, CreditCard, Government, Education` + +| 폼 선택지 | enum | 상태 | +|---|---|---| +| 은행 | `Banking` | 1:1 | +| 카드 | `CreditCard` | 1:1 | +| 공공기관 | `Government` | 1:1 | +| 금융 | `Financing` | 1:1 | +| 보험 | `Insurance` | 1:1 | +| 교육 | `Education` | 1:1 | +| **증권** | `Financing`(잠정) | ⚠️ 대응 enum 없음. **운영자 결정(2026-07): 잠정적으로 `Financing`**. 향후 XSD에 `Securities` 추가 시 재검토 | +| 기타 | `Other` | 1:1 | + +> enum에는 있지만 폼엔 없는 `Security`(보안)는 제보로 들어올 일이 거의 없다. +> 증권은 이제 결정된 매핑이므로 `category-needs-decision` 플래그를 남기지 않는다. + +## 3. 기계가 못 채우는 빈칸(gaps) — 스킬이 반드시 개입 + +| gap 플래그 | 의미 | 해결 방법 | +|---|---|---| +| `arguments-missing` | 폼이 사일런트 스위치를 안 받음 | 4절 시드표 → 없으면 `src/ussf.cs` 또는 설치기 `/?`·`/help` | +| `package-name-derive` | Package Name 미수집(URL만) | 4절 시드표 + `check_urls.py` 파일명 | +| `category-needs-decision` | 증권 등 enum 없는 종류 | 사람 결정 | +| `id-collision` | 기존 Service/Companion Id와 충돌 | 이름 조정 or 기존 항목 수정으로 처리 | +| `consent-missing` / `dedup-missing` | 게이트 미동의 | 스팸/오제출 의심 → 처리 보류 후 확인 | + +## 4. 설치 프로그램 시드표 (Catalog.xml 실측 빈도 기반) + +URL 파일명 → **Package Name** 및 **Arguments(사일런트 스위치)** 추론용. 신규 URL이 +아래 파일명 패턴과 맞으면 그대로 쓰고, 아니면 조사한다. (스위치는 벤더마다 다르니 +`/silent`로 넘겨짚지 말 것 — 특히 TouchEn 계열은 `/silence`, IPInside는 `/nodlg`.) + +| 파일명 패턴(예) | Package Name | Arguments | +|---|---|---| +| `veraport-g3*`, `veraport*` | `Veraport` | `/silent` | +| `nos_setup*` | `nProtect Online Security` | `/silent` | +| `astx_setup*`, `astxdn*` | `AhnLabSafeTx` | `/silent` | +| `delfino-g3*`, `delfino*` | `Delfino G3` | `/silent` | +| `AnySign_Installer*`, `AnySign*` | `AnySign` | `/S` | +| `TouchEn_nxKey*32*` | `TouchEnKey32` | `/silence` | +| `TouchEn_nxKey*64*` | `TouchEnKey64` | `/silence` | +| `INIS_EX*`, `INISAFE*CrossWeb*` | `INISAFECrossWeb` | `/S` | +| `I3GSvcManager*`, `IPinside*` | `IPInside` | `/nodlg` | +| `ePageSafer*`, `ePageSAFER*` | `ePageSafer` | `/silent` | +| `UniSign*` | `UniSign` | `/silent` | +| `iSASService*` | `iSASService` | `/silent` | +| `MagicLine4NX*` | `MagicLine4NX` | `/silent` | +| `SecuKit*` | `SecuKitNXS` | `/silent` | + +> 이 표는 힌트다. 최종 확인은 실제 설치기 동작(무인 설치 테스트) 기준. +> 새 벤더/버전이면 `src/ussf.cs`로 스위치를 탐색하고 표를 갱신하는 것을 권장. + +## 5. Id 파생 규칙 + +영어 이름 → PascalCase. 이미 대문자 2자 이상인 토큰(약어: KB, KEB, NH)은 원형 보존. +예: `Woori Bank`→`WooriBank`, `KB Kookmin Bank`→`KBKookminBank`. +파생 후 **반드시** 기존 `Service/Companion Id`와 충돌 검사(`normalize_csv.py --catalog`). diff --git a/.claude/skills/catalog-intake/scripts/check_urls.py b/.claude/skills/catalog-intake/scripts/check_urls.py new file mode 100644 index 0000000..a848a61 --- /dev/null +++ b/.claude/skills/catalog-intake/scripts/check_urls.py @@ -0,0 +1,156 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["httpx>=0.27"] +# /// +""" +check_urls.py — 보안 플러그인(설치 파일) URL을 '브라우저인 척'(Windows Edge) 요청 헤더로 +접속해 생존/최종 URL/파일명/콘텐츠 형식을 확인한다. + +왜 스푸핑이 필요한가 + 국내 공공/금융 사이트의 다운로드 엔드포인트는 curl/봇 User-Agent 를 403 으로 막는 경우가 + 많다. 식탁보가 실제로 도는 Edge IE Mode 환경과 헤더를 일치시켜야 정상 응답을 받는다. + (프로파일은 src/checksites.cs 의 Edge 131 UA + client-hints 와 동일하게 유지.) + +한계 + 이 스크립트는 정적 파일 서버/직링크 확인용(httpx, JS 렌더링 없음)이다. + JS 게이트가 걸린 '사이트 페이지'까지 검증해야 하면 무거운 케이스이므로 + src/checksites.cs(Playwright Edge) 로 폴백한다. + +보안 + - http/https 만 허용, 사설/루프백 대역 차단(SSRF 방지). + - 설치 파일 본문은 실행하지 않는다. 가능하면 HEAD, 아니면 1바이트 Range GET 으로 헤더만 본다. + +사용법 + uv run check_urls.py [ ...] [--json] [--timeout 30] +""" +from __future__ import annotations + +import argparse +import ipaddress +import json +import re +import socket +import sys +from urllib.parse import urlparse, unquote + +import httpx + +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8") # type: ignore[union-attr] + except Exception: # noqa: BLE001 + pass + +# src/checksites.cs 와 동일 프로파일 (일관성 유지) +EDGE_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0") +EDGE_HEADERS = { + "User-Agent": EDGE_UA, + "sec-ch-ua": '"Microsoft Edge";v="131", "Chromium";v="131", "Not_A Brand";v="24"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "sec-ch-ua-platform-version": '"15.0.0"', + "sec-ch-ua-arch": '"x86"', + "sec-ch-ua-bitness": '"64"', + "Accept": ("text/html,application/xhtml+xml,application/xml;q=0.9," + "application/octet-stream;q=0.9,*/*;q=0.8"), + "Accept-Language": "ko-KR,ko;q=0.9,en;q=0.8", + "Upgrade-Insecure-Requests": "1", +} + + +def is_public_url(url: str) -> tuple[bool, str]: + """http/https + 공개 IP 만 허용.""" + try: + p = urlparse(url) + except Exception as exc: # noqa: BLE001 + return False, f"parse-error: {exc}" + if p.scheme not in ("http", "https"): + return False, f"scheme-not-allowed: {p.scheme or '(none)'}" + host = p.hostname + if not host: + return False, "no-host" + try: + infos = socket.getaddrinfo(host, None) + except socket.gaierror as exc: + return False, f"dns-failed: {exc}" + for info in infos: + ip = ipaddress.ip_address(info[4][0]) + if (ip.is_private or ip.is_loopback or ip.is_link_local + or ip.is_reserved or ip.is_multicast): + return False, f"blocked-ip: {ip}" + return True, "ok" + + +def filename_from(url: str, content_disposition: str | None) -> str: + if content_disposition: + m = re.search(r"filename\*?=(?:UTF-8'')?\"?([^\";]+)", content_disposition) + if m: + return unquote(m.group(1)).strip() + path = urlparse(url).path + name = path.rsplit("/", 1)[-1] + return unquote(name) + + +def check_one(client: httpx.Client, url: str) -> dict: + result: dict = {"url": url, "ok": False} + allowed, reason = is_public_url(url) + if not allowed: + result["error"] = f"skipped ({reason})" + return result + try: + # HEAD 우선(가벼움). 일부 서버는 HEAD 미지원 → Range GET 으로 폴백. + resp = client.head(url, follow_redirects=True) + if resp.status_code >= 400 or resp.status_code == 405: + resp = client.get(url, follow_redirects=True, + headers={"Range": "bytes=0-0"}) + except httpx.HTTPError as exc: + result["error"] = f"{type(exc).__name__}: {exc}" + return result + + cd = resp.headers.get("content-disposition") + result.update({ + "ok": resp.status_code < 400, + "status": resp.status_code, + "final_url": str(resp.url), + "redirected": str(resp.url) != url, + "content_type": resp.headers.get("content-type", ""), + "content_length": resp.headers.get("content-length", ""), + "filename": filename_from(str(resp.url), cd), + }) + return result + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description="보안 플러그인 URL 생존/파일명 확인 (Edge 스푸핑)") + ap.add_argument("urls", nargs="+", help="확인할 URL(들)") + ap.add_argument("--json", action="store_true", help="JSON 출력") + ap.add_argument("--timeout", type=float, default=30.0, help="타임아웃 초(기본 30)") + args = ap.parse_args(argv) + + results: list[dict] = [] + with httpx.Client(headers=EDGE_HEADERS, timeout=args.timeout, + verify=True) as client: + for url in args.urls: + results.append(check_one(client, url)) + + if args.json: + print(json.dumps(results, ensure_ascii=False, indent=2)) + else: + for r in results: + if r["ok"]: + mark = "✅" + detail = (f"{r['status']} · {r['filename'] or '-'} · " + f"{r['content_type'] or '-'}" + + (" ↪redirect" if r.get("redirected") else "")) + else: + mark = "❌" + detail = r.get("error") or f"status {r.get('status')}" + print(f"{mark} {r['url']}") + print(f" {detail}") + + return 0 if all(r["ok"] for r in results) else 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/.claude/skills/catalog-intake/scripts/fetch_logo.py b/.claude/skills/catalog-intake/scripts/fetch_logo.py new file mode 100644 index 0000000..34c652e --- /dev/null +++ b/.claude/skills/catalog-intake/scripts/fetch_logo.py @@ -0,0 +1,264 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["httpx>=0.27", "pillow>=10.3", "beautifulsoup4>=4.12", "numpy>=1.26"] +# /// +""" +fetch_logo.py — 기관/서비스 공식 로고를 확보해 '투명 배경 정사각 PNG'로 만든다. +(카탈로그 규격: docs/images//.png) + +두 가지 입력 경로 + 1) 사이트에서 자동 수집: 대표 URL을 브라우저(Edge)로 위장해 fetch → favicon/ + apple-touch-icon/og:image/웹매니페스트/관용 경로에서 로고 후보를 모아 가장 큰 것 선택. + (src/fetchfavicon.cs 의 탐색 전략을 Python으로 포팅.) + 2) 로컬 이미지 지정: --from-image 로 이미 받은 아이콘 파일을 그대로 가공. + (폼 업로드 아이콘은 Drive 링크라 인증이 필요하므로, 로컬로 내려받았다면 이 경로를 쓴다.) + +fetchfavicon.cs 와의 차이(= 이 스크립트가 존재하는 이유) + - C# 도구는 정사각 패딩/업스케일까지만 하고 '배경 제거'를 못 한다. + - 여기서는 흰/단색 배경을 투명으로 키잉(corner-key)하거나, 필요 시 rembg(ML)로 매팅한다. + +배경 제거 모드(--bg) + auto : 네 모서리가 균일한 단색이면 그 색을 투명 처리(플랫 로고에 안전, 기본값) + corner : auto 와 동일하되 균일성 검사 없이 강제 키잉 + rembg : ML 매팅(사진/복잡한 배경용). `uv run --with rembg fetch_logo.py ...` 로 실행해야 함 + none : 배경 유지(알파만 보장) + +사용법 + uv run fetch_logo.py [--size 256] [--bg auto|corner|rembg|none] + uv run fetch_logo.py --from-image logo.jpg [--size 256] [--bg auto] +""" +from __future__ import annotations + +import argparse +import io +import json +import re +import sys +from urllib.parse import urljoin, urlparse + +import httpx +import numpy as np +from bs4 import BeautifulSoup +from PIL import Image + +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8") # type: ignore[union-attr] + except Exception: # noqa: BLE001 + pass + +EDGE_UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0") +EDGE_HEADERS = { + "User-Agent": EDGE_UA, + "sec-ch-ua": '"Microsoft Edge";v="131", "Chromium";v="131", "Not_A Brand";v="24"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Windows"', + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/*;q=0.9,*/*;q=0.8", + "Accept-Language": "ko-KR,ko;q=0.9,en;q=0.8", +} + +COMMON_PATHS = [ + "/apple-touch-icon.png", "/apple-touch-icon-180x180.png", + "/android-chrome-512x512.png", "/android-chrome-192x192.png", + "/icon-512x512.png", "/logo.png", "/favicon-32x32.png", + "/favicon.png", "/favicon.ico", +] + + +def fetch_bytes(client: httpx.Client, url: str) -> bytes | None: + try: + r = client.get(url, follow_redirects=True) + if r.status_code < 400 and r.content: + return r.content + except httpx.HTTPError: + return None + return None + + +def discover_candidates(client: httpx.Client, site_url: str) -> list[str]: + """페이지 HTML + 관용 경로 + 파비콘 서비스에서 로고 후보 URL 목록.""" + candidates: list[str] = [] + seen: set[str] = set() + + def add(u: str | None) -> None: + if not u: + return + if u.lower().endswith(".svg"): + return # Pillow가 SVG를 못 여니 제외(필요 시 별도 래스터화) + if u not in seen: + seen.add(u) + candidates.append(u) + + html = None + try: + r = client.get(site_url, follow_redirects=True) + if r.status_code < 400: + html = r.text + base = str(r.url) + except httpx.HTTPError: + base = site_url + + if html: + soup = BeautifulSoup(html, "html.parser") + for link in soup.find_all("link"): + rel = " ".join(link.get("rel", [])).lower() + if "icon" in rel: + add(urljoin(base, link.get("href", ""))) + og = soup.find("meta", property="og:image") + if og and og.get("content"): + add(urljoin(base, og["content"])) + for man in soup.find_all("link", rel="manifest"): + man_url = urljoin(base, man.get("href", "")) + data = fetch_bytes(client, man_url) + if data: + try: + icons = json.loads(data).get("icons", []) + for ic in icons: + if ic.get("src"): + add(urljoin(man_url, ic["src"])) + except (json.JSONDecodeError, AttributeError): + pass + + parsed = urlparse(site_url) + root = f"{parsed.scheme}://{parsed.netloc}" + for p in COMMON_PATHS: + add(root + p) + + # 최후의 폴백: 파비콘 서비스(작으니 우선순위 낮게 = 맨 뒤) + domain = parsed.netloc + add(f"https://www.google.com/s2/favicons?domain={domain}&sz=128") + add(f"https://icons.duckduckgo.com/ip3/{domain}.ico") + return candidates + + +def load_largest(data: bytes) -> Image.Image | None: + """바이트 → PIL 이미지. ICO는 내장된 가장 큰 프레임을 고른다.""" + try: + im = Image.open(io.BytesIO(data)) + except Exception: # noqa: BLE001 + return None + if getattr(im, "format", "") == "ICO": + try: + sizes = im.ico.sizes() # {(w,h), ...} + best = max(sizes, key=lambda s: s[0] * s[1]) + im = im.ico.getimage(best) + except Exception: # noqa: BLE001 + pass + return im.convert("RGBA") + + +def pick_best(client: httpx.Client, candidates: list[str]) -> tuple[Image.Image, str] | None: + best: Image.Image | None = None + best_url = "" + best_area = -1 + for url in candidates: + data = fetch_bytes(client, url) + if not data: + continue + im = load_largest(data) + if im is None: + continue + area = im.width * im.height + # 파비콘 서비스는 폴백이므로 동점일 때 밀리도록 약간 감점 + if "s2/favicons" in url or "duckduckgo" in url: + area = int(area * 0.5) + if area > best_area: + if best is not None: + best.close() + best, best_url, best_area = im, url, area + else: + im.close() + if best is None: + return None + return best, best_url + + +def key_out_background(im: Image.Image, mode: str, + tol: int = 32, uniform_tol: int = 25) -> Image.Image: + """모서리 단색 배경을 투명 처리.""" + arr = np.array(im.convert("RGBA")).astype(np.int16) + h, w = arr.shape[:2] + corners = np.array([arr[0, 0, :3], arr[0, w - 1, :3], + arr[h - 1, 0, :3], arr[h - 1, w - 1, :3]], dtype=np.int16) + bg = corners.mean(axis=0) + spread = np.max(np.linalg.norm(corners - bg, axis=1)) + if mode == "auto" and spread > uniform_tol: + # 모서리가 균일하지 않음 → 이미 투명이거나 배경이 단색이 아님. 손대지 않음. + return im.convert("RGBA") + dist = np.linalg.norm(arr[:, :, :3] - bg, axis=2) + arr[dist < tol, 3] = 0 + return Image.fromarray(arr.astype(np.uint8), "RGBA") + + +def key_out_rembg(im: Image.Image) -> Image.Image: + try: + from rembg import remove # 지연 임포트(무거움) + except ImportError: + print("[error] rembg 미설치. `uv run --with rembg fetch_logo.py ...` 로 실행하세요.", + file=sys.stderr) + raise SystemExit(3) + return remove(im.convert("RGBA")) + + +def trim_and_square(im: Image.Image, size: int) -> Image.Image: + """알파 경계로 트림 → 정사각 캔버스 중앙 배치 → 목표 크기로 리샘플.""" + im = im.convert("RGBA") + alpha = np.array(im)[:, :, 3] + ys, xs = np.where(alpha > 0) + if len(xs) and len(ys): + im = im.crop((int(xs.min()), int(ys.min()), + int(xs.max()) + 1, int(ys.max()) + 1)) + side = max(im.width, im.height) + canvas = Image.new("RGBA", (side, side), (0, 0, 0, 0)) + canvas.paste(im, ((side - im.width) // 2, (side - im.height) // 2), im) + if side != size: + canvas = canvas.resize((size, size), Image.LANCZOS) + return canvas + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description="공식 로고 → 투명 정사각 PNG") + ap.add_argument("site_url", nargs="?", help="대표 사이트 URL(자동 수집)") + ap.add_argument("output", type=str, help="출력 PNG 경로") + ap.add_argument("--from-image", type=str, default=None, + help="사이트 대신 로컬 이미지 파일을 가공") + ap.add_argument("--size", type=int, default=256, help="정사각 한 변 픽셀(기본 256)") + ap.add_argument("--bg", choices=["auto", "corner", "rembg", "none"], + default="auto", help="배경 제거 모드") + ap.add_argument("--timeout", type=float, default=30.0) + args = ap.parse_args(argv) + + if args.from_image: + im = Image.open(args.from_image).convert("RGBA") + source = args.from_image + else: + if not args.site_url: + print("[error] site_url 또는 --from-image 중 하나는 필요합니다.", file=sys.stderr) + return 2 + with httpx.Client(headers=EDGE_HEADERS, timeout=args.timeout) as client: + candidates = discover_candidates(client, args.site_url) + print(f"[info] 로고 후보 {len(candidates)}개 발견", file=sys.stderr) + picked = pick_best(client, candidates) + if picked is None: + print("[error] 로고 후보를 하나도 불러오지 못했습니다.", file=sys.stderr) + return 1 + im, source = picked + + print(f"[info] 원본: {source} ({im.width}x{im.height})", file=sys.stderr) + + if args.bg in ("auto", "corner"): + im = key_out_background(im, mode=args.bg) + elif args.bg == "rembg": + im = key_out_rembg(im) + + out = trim_and_square(im, args.size) + out.save(args.output, format="PNG", optimize=True) + print(f"[ok] 저장: {args.output} ({args.size}x{args.size}, bg={args.bg})", file=sys.stderr) + print("[note] 생성 이미지는 임시본일 수 있습니다. 가능하면 공식 로고로 교체하세요.", + file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/.claude/skills/catalog-intake/scripts/normalize_csv.py b/.claude/skills/catalog-intake/scripts/normalize_csv.py new file mode 100644 index 0000000..d24ec5e --- /dev/null +++ b/.claude/skills/catalog-intake/scripts/normalize_csv.py @@ -0,0 +1,286 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [] +# /// +""" +normalize_csv.py — Google Forms(식탁보 사이트 추가/수정/삭제 요청) 응답 CSV를 +카탈로그 스키마에 가까운 정규화 JSON으로 변환한다. (네트워크 접근 없음, 순수 로직) + +역할 + - CSV 헤더를 '질문 제목 키워드'로 관대하게 매칭한다(헤더 문구가 조금 바뀌어도 견딤). + - 한국어 종류(은행/카드/…) → Catalog.xsd Category enum으로 매핑. + - 영어 이름 → Service Id 후보(PascalCase) 파생. + - 보안 플러그인 URL(한 줄에 하나) → 리스트로 분해. + - 기계가 못 채우는 빈칸(gaps)을 명시적으로 표시한다: + * arguments-missing : 폼이 사일런트 스위치를 안 받음 → 조사 필요 + * package-name-derive : Package Name은 URL에서 추론/확정 필요 + * category-needs-decision: '증권' 등 enum에 직접 대응 없는 종류 + * id-collision : (--catalog 지정 시) 기존 Id와 충돌 + * consent-missing/dedup-missing : 게이트 미동의(스팸/오제출 의심) + +사용법 + uv run normalize_csv.py [--catalog ../../docs/Catalog.xml] [--out out.json] [--pretty] + +출력 + - stdout: 사람용 요약(행별 한 눈에 보이는 표 형태) + - --out 지정 시 정규화 레코드 JSON 배열을 파일로 저장 +""" +from __future__ import annotations + +import argparse +import csv +import io +import json +import re +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +# Windows 콘솔 기본 인코딩(cp949)에서 한글/이모지 출력이 깨지거나 죽는 것을 방지 +for _stream in (sys.stdout, sys.stderr): + try: + _stream.reconfigure(encoding="utf-8") # type: ignore[union-attr] + except Exception: # noqa: BLE001 + pass + +# XSD CatalogInternetServiceCategory enum (docs/Catalog.xsd 기준) +VALID_CATEGORIES = { + "Other", "Banking", "Financing", "Security", + "Insurance", "CreditCard", "Government", "Education", +} + +# 폼 라디오(한국어 종류) → enum. +CATEGORY_MAP = { + "은행": "Banking", + "카드": "CreditCard", + "공공기관": "Government", + "금융": "Financing", + "보험": "Insurance", + "교육": "Education", + # '증권'은 XSD에 대응 enum이 없음. 운영자 결정(2026-07): 잠정적으로 Financing 매핑. + # 향후 XSD에 Securities 를 추가하면 여기와 field-mapping.md 를 함께 갱신할 것. + "증권": "Financing", + "기타": "Other", +} +# 대응 enum이 없어 사람 판단이 필요한 종류. 현재는 모두 매핑 결정되어 비어 있다. +CATEGORY_NEEDS_DECISION: set[str] = set() + +CHANGE_TYPE_MAP = {"추가": "add", "수정": "modify", "삭제": "delete"} + +# 헤더 → 필드. (부분 문자열 키워드 리스트 중 하나라도 포함되면 매칭) +# 순서 중요: 더 구체적인 규칙을 앞에 둔다. +HEADER_RULES: list[tuple[str, list[str]]] = [ + ("timestamp", ["타임스탬프", "timestamp"]), + ("reporter_email", ["이메일", "사용자"]), # "이메일 주소" 또는 "사용자 이름"(identity) 열 + ("change_type", ["유형의 변경", "변경 사항"]), + ("display_name_ko", ["한국어 이름"]), + ("display_name_en", ["영어 이름"]), + ("url", ["대표 주소"]), + ("icon_ref", ["아이콘"]), + ("category_ko", ["종류"]), + ("package_urls", ["보안 플러그인", "플러그인 프로그램"]), # "…URL을 한 줄씩…" + ("compat_notes", ["windows sandbox", "sandbox"]), + ("usage_notes", ["메뉴", "위치"]), + ("consent", ["개인 정보 수집", "개인정보 수집"]), + ("dedup_ack", ["없거나 반영되지", "먼저 확인"]), +] + + +def build_header_index(fieldnames: list[str]) -> dict[str, str]: + """CSV 실제 헤더 → 논리 필드명. 각 논리 필드는 최초 매칭 헤더 하나만 취한다.""" + mapping: dict[str, str] = {} + used_headers: set[str] = set() + for field, keywords in HEADER_RULES: + if field in mapping.values(): + continue + for header in fieldnames: + if header in used_headers: + continue + low = header.lower() + if any(kw.lower() in low for kw in keywords): + mapping[header] = field + used_headers.add(header) + break + return mapping + + +def derive_id(english_name: str) -> str: + """영어 이름 → PascalCase Id 후보. 이미 대문자/내부대문자 토큰은 보존(KEB, NH, KB).""" + if not english_name: + return "" + tokens = re.split(r"[^0-9A-Za-z]+", english_name.strip()) + parts: list[str] = [] + for tok in tokens: + if not tok: + continue + # 이미 두 글자 이상 대문자를 포함하면 약어로 보고 원형 유지 + if sum(1 for c in tok if c.isupper()) >= 2: + parts.append(tok) + else: + parts.append(tok[:1].upper() + tok[1:]) + return "".join(parts) + + +def split_package_urls(raw: str) -> list[str]: + """줄바꿈/공백/쉼표로 구분된 URL 텍스트에서 http(s) URL만 추출.""" + if not raw: + return [] + candidates = re.split(r"[\s,]+", raw.strip()) + urls: list[str] = [] + for c in candidates: + c = c.strip().strip("<>\"'") + if re.match(r"^https?://", c, re.IGNORECASE): + urls.append(c) + # 중복 제거(순서 보존) + seen: set[str] = set() + out: list[str] = [] + for u in urls: + if u not in seen: + seen.add(u) + out.append(u) + return out + + +def load_existing_ids(catalog_path: Path) -> set[str]: + ids: set[str] = set() + try: + tree = ET.parse(catalog_path) + except Exception as exc: # noqa: BLE001 + print(f"[warn] 카탈로그를 읽지 못해 Id 충돌 검사를 건너뜁니다: {exc}", file=sys.stderr) + return ids + for el in tree.iter(): + tag = el.tag.split("}")[-1] + if tag in ("Service", "Companion"): + sid = el.get("Id") + if sid: + ids.add(sid.strip()) + return ids + + +def yes_like(value: str) -> bool: + """라디오 단일 옵션(동의/확인)이 채워졌으면 True.""" + return bool(value and value.strip()) + + +def normalize_row(row: dict[str, str], header_index: dict[str, str], + existing_ids: set[str]) -> dict: + def get(field: str) -> str: + for header, logical in header_index.items(): + if logical == field: + return (row.get(header) or "").strip() + return "" + + display_name_en = get("display_name_en") + category_ko = get("category_ko") + category = CATEGORY_MAP.get(category_ko, "Other") + package_urls = split_package_urls(get("package_urls")) + id_suggested = derive_id(display_name_en) + + gaps: list[str] = [] + # 폼은 사일런트 스위치를 받지 않으므로 항상 조사 필요 + if package_urls: + gaps.append("arguments-missing") + gaps.append("package-name-derive") + if category_ko in CATEGORY_NEEDS_DECISION: + gaps.append("category-needs-decision") + id_collision = bool(id_suggested and id_suggested in existing_ids) + if id_collision: + gaps.append("id-collision") + if not yes_like(get("consent")): + gaps.append("consent-missing") + if not yes_like(get("dedup_ack")): + gaps.append("dedup-missing") + + rec = { + "timestamp": get("timestamp"), + "reporter_email": get("reporter_email"), + "change_type": CHANGE_TYPE_MAP.get(get("change_type"), get("change_type")), + "display_name_ko": get("display_name_ko"), + "display_name_en": display_name_en, + "url": get("url"), + "category_ko": category_ko, + "category": category, + "id_suggested": id_suggested, + "id_collision": id_collision, + "package_urls": package_urls, + "compat_notes": get("compat_notes"), + "usage_notes": get("usage_notes"), + "icon_ref": get("icon_ref"), + "gaps": gaps, + } + return rec + + +def read_csv_rows(path: Path) -> tuple[list[str], list[dict[str, str]]]: + # Google/Excel 내보내기는 utf-8-sig(BOM) 인 경우가 많다. + text = path.read_text(encoding="utf-8-sig") + reader = csv.DictReader(io.StringIO(text)) + fieldnames = reader.fieldnames or [] + rows = [dict(r) for r in reader] + return fieldnames, rows + + +def print_summary(records: list[dict], header_index: dict[str, str], + fieldnames: list[str]) -> None: + unmatched = [h for h in fieldnames if h not in header_index] + print(f"# 정규화 결과: {len(records)}건") + if unmatched: + print(f"# 매칭 안 된 CSV 열({len(unmatched)}): " + ", ".join(unmatched)) + print() + for i, r in enumerate(records, start=1): + print(f"[{i}] {r['change_type'] or '?'} · {r['display_name_ko'] or '(이름없음)'} " + f"({r['display_name_en'] or '-'})") + print(f" Id후보 : {r['id_suggested'] or '-'}" + + (" ⚠️충돌" if r['id_collision'] else "")) + print(f" 분류 : {r['category_ko'] or '-'} → {r['category']}") + print(f" URL : {r['url'] or '-'}") + print(f" 패키지 : {len(r['package_urls'])}개") + for u in r["package_urls"]: + print(f" - {u}") + if r["gaps"]: + print(f" 빈칸 : {', '.join(r['gaps'])}") + print() + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description="Google Forms 제보 CSV → 정규화 JSON") + ap.add_argument("csv", type=Path, help="응답 CSV 경로") + ap.add_argument("--catalog", type=Path, default=None, + help="docs/Catalog.xml 경로(지정 시 Id 충돌 검사)") + ap.add_argument("--out", type=Path, default=None, help="정규화 JSON 저장 경로") + ap.add_argument("--pretty", action="store_true", help="JSON 들여쓰기") + args = ap.parse_args(argv) + + if not args.csv.exists(): + print(f"[error] CSV 없음: {args.csv}", file=sys.stderr) + return 2 + + fieldnames, rows = read_csv_rows(args.csv) + header_index = build_header_index(fieldnames) + + matched_fields = set(header_index.values()) + required = {"display_name_ko", "display_name_en", "url", "category_ko", "package_urls"} + missing = required - matched_fields + if missing: + print(f"[warn] 필수 열 매칭 실패: {sorted(missing)} — 헤더를 확인하세요.", + file=sys.stderr) + + existing_ids = load_existing_ids(args.catalog) if args.catalog else set() + + records = [normalize_row(r, header_index, existing_ids) for r in rows] + + print_summary(records, header_index, fieldnames) + + if args.out: + args.out.write_text( + json.dumps(records, ensure_ascii=False, + indent=2 if args.pretty else None), + encoding="utf-8", + ) + print(f"# JSON 저장: {args.out}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/README.md b/README.md index be4d6ce..eb13762 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,25 @@ dotnet run .\src\checksites.cs issue .\health-report\\ 자동 검출과 사람 판정의 분리, GitHub 이슈 자동 등록, AI 어시스트(Claude Code 등)를 활용한 후속 카탈로그 갱신 방법은 [docs/SITE_HEALTH_WORKFLOW.md](docs/SITE_HEALTH_WORKFLOW.md)를 참고하세요. +#### 제보 인테이크 스킬 (catalog-intake) + +GitHub에 익숙하지 않은 사용자를 위한 [Google Forms 제보](https://forms.gle/Pw6pBKhqF1e5Nesw6)를, **검토부터 카탈로그 수용까지 반자동으로 처리**하는 Claude Code 스킬입니다. [.claude/skills/catalog-intake/](.claude/skills/catalog-intake/)에 있으며, 그동안 수작업으로 처리하던 제보 반영을 자동화합니다. + +Google Forms 응답을 내보낸 CSV를 입력으로 받아 다음을 수행합니다. + +1. **정규화**: CSV → 카탈로그 스키마 필드 매핑, Service Id 파생, 기존 Id 충돌 검사, 폼이 받지 않는 항목(사일런트 스위치 등)을 gap으로 플래그. +2. **URL 검증**: 보안 플러그인 설치 URL을 Windows Edge로 위장(User-Agent + Client Hints)해 접속 — 국내 공공/금융 사이트의 봇 차단을 통과하고, 설치 *페이지*와 직접 설치 *파일*을 구분합니다. +3. **로고 생성**: 기관 공식 로고를 수집해 배경을 투명 처리한 정사각 PNG(`docs/images//.png`)로 변환. +4. **검증·제출**: `catalogutil.cs` 스키마 검증을 거쳐 사람이 검토할 PR 후보를 생성. + +처리 결과는 세 갈래로 정리하며, **검출·정규화는 자동, 최종 판정과 병합(merge)은 사람**이 합니다(사이트 헬스 워크플로와 동일 철학). + +- **완전 반영**: 검증까지 끝난 항목 → PR diff에 포함 +- **미완(수동 검토 필요)**: 자동으로 확정하지 못한 항목 → 유실 방지를 위해 추적 이슈를 생성하고 PR에 링크 +- **중복/무효**: 기존 항목과 중복이거나 부적합 → 사유를 기록하고 제외 + +실행에는 [uv](https://docs.astral.sh/uv/)(Python)가 필요합니다. 스크립트는 PEP 723 자체완결 형식이라 `uv run