# 🗺️ 크롤링 가이드 — 데이터·코드 지도 (정본)

> **이 문서의 정체**: 모음전이 소싱처에서 **재고·가격을 어떻게 긁고, 어디에 저장하고, 어떻게 계산해, 어디에 표시**하는지를 한 곳에 적은 단일 진실 원천(SSOT).
>
> **읽는 법 (2명의 독자)**
> - 🟢 **사용자** — 각 절의 `🟢 쉬운 설명`만 읽어도 전체 그림이 잡힙니다.
> - 🔧💻 **Claude Code** — 방향을 잃으면 **이 문서를 위에서 아래로 그대로 읽고** 복귀하세요. `🔧 상세 로직` + `💻 핵심 코드(파일:라인)`까지 읽으면 코드 위치가 바로 잡힙니다.
>
> **갱신 규칙**: 코드가 바뀌면 이 문서의 해당 `💻` 블록도 같이 고칩니다. 파일:라인은 표류할 수 있으니 인용 시 실제 코드와 대조하세요. (작성 기준 2026-06-22)
>
> 🛑 **신규 소싱처 추가·크롤/저장 코드 수정 전 → §5 에러 이력 카탈로그부터 확인** — 과거 발생한 재고·가격·저장 에러가 같은 원인으로 재발합니다. 작업 전 §5의 **재발방지 체크**를 대조하세요.

---

## §0. 전체 흐름 지도

🟢 **쉬운 설명** — 소싱처 상품 하나의 재고·가격이 화면에 뜨기까지 **4단계**를 거칩니다.

```
[소싱처 사이트]
     │  ① 크롤러가 페이지에서 값을 긁음 (재고·표면노출가·혜택)
     ▼
[② 저장]  SourceProduct / SourceOption 테이블 + dynamic_benefits_json
     │
     ▼
[③ 계산]  표면노출가 − 혜택 = 최종매입가
     │
     ▼
[④ 표시]  매트릭스 셀 · 계산식(영수증) 팝업 · 크롤 로그
```

🔧 **상세 로직 + 💻 4개 층의 파일 위치**

| 층 | 하는 일 | 핵심 파일 |
|---|---|---|
| ① 크롤러(파싱) | 페이지 → 재고·가격·혜택 값 추출 | `lemouton/sourcing/crawlers/<소싱처>.py` |
| ② 저장(영속) | 옵션 dict → DB 컬럼 + `dynamic_benefits_json` | `lemouton/sources/service.py` (`OPTION_DYNAMIC_KEYS` :606 → `PRODUCT_DYNAMIC_KEYS` :645 파생) |
| ③ 계산(매입가) | 표면가 − 혜택 누적 차감 | `webapp/routes/api_benefits.py:496` `compute_breakdown` + `lemouton/pricing/unified.py:75` `compute_market_price` |
| ④ 표시(매트릭스) | 셀·로그·영수증 렌더 | `webapp/routes/api_pricing.py` `_option_matrix_data`(한 건) / `_option_matrix_data_many`(여러 건 — 주문 표처럼 상품이 수백 종일 때. 속은 같은 함수 하나) + `webapp/templates/bundles/_matrix_v3.html` |

> 🔧 **우리 옵션 ↔ 소싱처 표기 잇기(「축 맞추기」)** — 소싱처는 `BLACK`, 우리는 「검정」이라
> 부른다. 이 둘을 잇는 표가 `source_axis_aliases` 이고 **조합마다가 아니라 축마다** 한 번만
> 맞춘다(6색×10사이즈=60번 → 색 6 + 사이즈 10 = 16번). 저장 단위는 **소싱처**라 무신사에서
> 맞춘 것이 롯데온에 새지 않는다. **1:1** 이 깨지면 그 소싱처 옵션의 재고가 두 배로 잡혀
> 초과 판매가 난다 — 그래서 잠금이 걸려 있다.
> 💻 표·잠금 `lemouton/sourcing/axis_alias.py` · 판정 `lemouton/sourcing/axis_match.py:49 match_one`
> · 화면 창구 `webapp/routes/bundles.py:2334 api_axis_mapping_preview` / `:2415 api_axis_mapping_set`
> · 화면 JS `webapp/static/option_url_modal.js:1260 axisSet`
>
> 🔴 **어느 축이 「색상」이고 어느 축이 「사이즈」인지는 `lemouton/sourcing/axis_slot.py` 한 곳에서
> 정한다 (2026-08-12).** 예전엔 **「몇 번째 축인가」(위치)** 로 정했는데, 노션 확정대로 축을
> **모델·색상·사이즈** 로 짜면 그 순간 옵션 「색상」 칸에 **모델명**이 저장되고 소싱처의 색 표기가
> 모델 축에 붙었다(경고 없이 값이 틀어지는 부류).
> · `storage_slots` = 옛 칸(`color_code`/`size_code`) 채우기 — 이름을 아는 축이 먼저 자리를 잡고
>   나머지는 남은 자리를 쓴다(자리를 비우면 격자가 한 칸으로 뭉개진다)
> · `semantic_slots` = 소싱처 짝 찾기 — **모델 축은 소싱처가 회수하지 않아 언제나 짝 없음**.
>   화면도 「아직 안 한다」가 아니라 「모델 축은 소싱처가 알려주지 않습니다」로 말이 갈린다
>   (`bundles.py:2267 _AXIS_MODEL_UNAVAILABLE`)
> · 이름을 못 알아보는 옛 매트릭스(「단계1·단계2」)는 **오늘 그대로 위치로** 정해 동작이 안 바뀐다
> · 쓰는 곳 = 저장 `lemouton/sourcing/option_service.py`(생성·이름바꾸기 **둘 다**) ·
>   축맞추기 화면 `bundles.py` · **매트릭스 읽기** `lemouton/sourcing/axis_match_audit.py:109 _axis_names`
>   (이 값이 `api_pricing.py:1225 match_source_option` 으로 흘러가 **어느 소싱처 가격·재고를
>   우리 옵션에 붙일지**를 정한다 — §5 O32)
> 라이브 전수 대조: 지금 있는 축 조합(색상·사이즈 / 단계1·단계2 / 축설계없음)에서 **달라지는 것 0건**.

> ⚠️ **라이브 크롤은 크롬 확장(navGrab) 경로** — `extension/moum-crawler/background.js` → 서버 `/api/sources/parse`(파싱) → `/api/sources/crawl-result`(저장). ~~이 라이브 경로가 ②의 일부(혜택 저장)를 우회한다~~ 는 **낡은 기록** — 2026-07-23 T10(확장 v0.7.56 `BENEFIT_PASSTHROUGH`)으로 해소, §2 끝 저장 배선 표 참조. 현대H몰은 전용 `fetchHmallAdapter` 를 탄다(범용 어댑터에 코드를 넣으면 조용히 안 돈다 → §5 P25).
>
> 🔧 **옵션 재고 영속·매칭** — 옵션 재고는 `(color_text, size_text)`당 `SourceOption` **1행**에 영속(`_persist_option_stocks`). 매트릭스 **읽기**(`_match_option_so`)와 크롤 **쓰기**(`_persist`)가 **같은 행**을 봐야 정합. 같은 옵션이 색 포맷 다르게 **중복 INSERT**되면(`upsert`가 `(spid,color,size)` 정확일치만 갱신) 읽기/쓰기가 다른 행 → `null` 중복행 읽고 상품 `last_stock` 폴백으로 stale 둔갑 → §5 에러이력 S3(롯데온 단품 999).

---

## §1. 재고편 — 소싱처별 재고 수집

🟢 **쉬운 설명** — 소싱처마다 재고를 알아내는 방법이 다릅니다. 어떤 곳은 페이지 코드 안에 "남은 수량"이 숫자로 들어있고(SSG), 어떤 곳은 "품절" 표시만 있고(롯데온), 어떤 곳은 별도 API를 부릅니다(무신사). 공통으로 **0=품절 / 실수량 / 999=수량 미상("재고있음")** 으로 정규화하고, 화면엔 **충분 · 소량(3개↓) · 품절 · 크롤실패 · 미크롤** 로 표기합니다.

🔧 **왜 소싱처마다 방식이 다른가 (성능 우선순위)** — ① 전용 재고 API 공개 여부(무신사·롯데온) ② 내장 구조데이터(`__NEXT_DATA__`·`option_stock_data`·`uitemObj`·`itemInvQtyInfo`) 존재 여부(현대H몰·르무통·SSG·롯데아이몰) ③ WAF/봇차단(롯데아이몰·네이버) ④ 로그인은 대개 **혜택만** 필요(재고·표면가는 무로그인). → "API > 내장 JSON > HTML > DOM" 순으로 가능한 가장 높은 방식 채택.

🪟 **크롤이 창을 여는 3가지 방식 (2026-07-14)** — 값을 어디서 얻느냐로 갈린다. `fetch`는 원래 동시에 여러 개를 쏠 수 있어, 탭 1개(또는 0개) 안에서 '동시 상한'만큼 동시에 긁으면 창 없이도 빨라진다.
- **창 0개 · SW fetch** — 확장 서비스워커가 탭 없이 직접 원문 fetch. **르무통·SSF·현대H몰** (`FAST_FETCH_SOURCES`).
- **창 1개 · same-origin fetch** — WAF(Sec-Fetch 차단)로 밖에서 못 부름 → 그 **도메인 탭 1개**에서 same-origin fetch(렌더 없음). **SSG·롯데아이몰** (`SAMEORIGIN_FETCH_SOURCES`). URL 여러 개여도 탭 1개서 동시 fetch. ⚠️예전 문서·지도가 이를 "SW fetch 창없이"로 **오기 → 정정**(SSG 창 1개가 뜨는 이유).
- **창 N개 · 렌더** — 값이 JS로 그려져야 생김(로그인 혜택 등) → 페이지를 실제 렌더. **무신사·롯데온**. fetch형도 실패 시 이 렌더로 폴백(안전망).
- **'동시 상한'(자동화 설정) = 한 번에 동시에 긁는 개수**. fetch형(창0·창1)은 창은 그대로·동시 fetch만 늘고, 렌더형(창N)은 창 개수가 늘어난다. 💻 `extension/moum-crawler/background.js` `runSource`(레인=효율적 병렬)

> **로그인·위치는 소싱처가 아니라 데이터 종류별로 갈린다**: 재고·표면가 = 대부분 무로그인·서버 / 혜택 = 로그인·확장. (SSG·SSF는 재고·표면가·혜택 모두 무로그인 서버 curl_cffi.)

🔧 **상세 — 재고 센티넬(약속된 숫자)**
- `0` = 품절 / `999` = 수량 미상(충분, 소싱처가 in-stock 명시 확인) / `-1` = 불명(⚠️확인필요 — 크롤은 됐으나 신뢰할 신호를 못 읽음, 수량 0 취급·판매제외) / `None` = 미크롤(이번 크롤 미포함) / 무신사 `STOCK_CAP` = 충분
- **999는 소싱처가 in-stock 을 명시 확인했을 때만 부여. 신호를 못 읽으면 반드시 -1(불명) 사용.**
- 종착지 `_resolve_stock`에서 `stock_label / stock_qty / stock_out`로 해석 — 💻 `webapp/routes/api_pricing.py:142`

### 소싱처별 재고 비교표

| 소싱처 | 수집 방식 | 재고 판정 규칙 (실코드) | 데이터 위치 | 파일 |
|---|---|---|---|---|
| **무신사** | API(POST) | `MusinsaCrawler.fetch` → `POST goods-detail.musinsa.com/api2/goods/{goodsNo}/options/v2/prioritized-inventories` · `outOfStock`→0 / "N개 남음"→실수량 / 없음→999 | `last_stock` | 서버 `crawlers/musinsa.py:492` / **라이브=확장 `background.js` `musinsaExtractor`** |
| **SSG** | SSR `<script>` JS | `SsgCrawler.crawl` → `_parse_uitem_options` · 정규식 `usablInvQty`: `'0'`→품절, else 실수량 | `last_stock` | `crawlers/ssg.py:608` |
| **SSF** | HTML(curl_cffi) | `SsfCrawler.fetch` · `#optionDiv1 li a[optcd]@statcd`: `SLDOUT`→품절 / `품절임박 (N)`→실수량 | `current_stock` | `crawlers/ssf.py` |
| **롯데온** | API 우선→DOM 폴백 | API `pbf.lotteon.com/.../option/mapping/{spd}/{sitm}` 우선=실수량 → DOM `div.layer_option li.soldout`→0·else 999 | `last_stock` | 서버 `crawlers/lotteon.py:482,1293` / **라이브=확장 `background.js` `lotteonExtractor`** |
| **스마트스토어** | 확장 per-SKU(로그인) | `/n/v2/channels/{cu}/products/{channelProductNo}`(=`A.id`) → `sku_stock`(색·사이즈별 0=품절·N=실수량) → `_persist_option_stocks` 영속 | `current_stock` | `ss_lemouton.py` + `api_pricing.py` |
| **르무통 공홈** | HTML(Cafe24) | `LemoutonCrawler` · `option_stock_data` 파싱 → 실조합·실재고만 | `current_stock` | `crawlers/lemouton.py` |
| **현대H몰** | 단품=내장 `__NEXT_DATA__` / 모음전(2축)=`item-stockcount` API(서버사이드) | 단품: `itemPtc.stockList[]` → `sellGbcd`(품절판정)·`stockCount`(실수량) / 모음전: 색별 `item-stockcount` 프로브 → `sellGbcd`("00"=판매·그 외=품절, `stockCount=1`은 품절센티넬) | `last_stock`·`current_stock` | `crawlers/hmall.py`(`fetch_combo_persize_options`) |
| **롯데아이몰** | 내장 JSON+DOM | `itemInvQtyInfo[].inv_qty` → **사이트 자체 JS 기준**(`_lotteimall_disp_qty`): `<=0`=품절0 / **`<5`=한정(실수량 N개 남음)** / `>=5`=충분999 (라이브 JS: `<=0`품절·`<5`'N개 남음'·`>500`판매중). ⚠구'30상한→50'은 폐기(1회관찰 오일반화, 충분10을 '10개남음'으로 오표기했음) · WAF 403→curl_cffi 위장 | `last_stock` | `crawlers/lotteon.py`(도메인 라우팅) |

### 재고 3상태 판정 + 특이사항

🟢 **쉬운 설명** — 모든 소싱처의 재고 신호는 **3가지 상태** 중 하나로 정규화됩니다.

| 상태 | 저장값 | 판정 기준 |
|---|---|---|
| **품절(없음)** | `0` | 소싱처가 "품절", "재입고 알림", `statcd=SLDOUT` 등 명시적 품절 신호를 줌 |
| **한정(N개)** | `N` (실수량) | "N개 남음", "마지막 N개", "품절임박 (N)" 등 수량이 수치로 드러남 |
| **충분(표식 없음)** | `999` (수량 미상 센티넬) | 소싱처가 in-stock 을 명시 확인했으나 수량을 주지 않음. **"신호를 못 읽음"과 엄격 구분 — 애매하면 999 금지, 불명(-1) 사용** |
| **불명(못 읽음)** | `-1` | 크롤은 됐으나 신뢰할 신호를 못 읽음(API 미매칭·파싱 실패·호출 실패). 화면 ⚠️확인필요·수량0 취급·판매제외. 999(충분)와 엄격 구분 |

🔧 **소싱처별 3상태 신호 요약**

- **무신사**: `outOfStock=true` → 0 / `"잔여 N개"·"N개 남음"·"마지막 N개"` 정규식 → 실수량 N / 그 외 → 999 (충분·**추론**: 양의 수량 없음) / **재고 API(prioritized-inventories) 전체 실패(inv 0건) → -1(불명)** — 999 둔갑=오버셀 금지 [2026-07-08 `_musinsa_option_stock`]
- **SSF**: `statcd=SLDOUT` → 0 / `품절임박 (N)` 정규식(괄호 내 공백 포함) → 실수량 N / 정상 statcd → 999(충분·**상태값**) · 옵션 목록 통째 파싱 실패의 단품폴백(999)은 정상 단품과 구분 불가 → 상품 status 게이트
- **SSG**: `usablInvQty=0` → 0 / 양수 → 실수량(SSR 페이지 직접 파싱·**실수량**) / **필드 부재(정규식 미스=파싱 실패) → -1(불명)** — 0(품절)·999(재고있음) 둘 다 둔갑 금지 [2026-07-08 `ssg.py` `_STOCK_UNKNOWN`]
- **롯데온**: 재고 API `pbf.lotteon.com`(실수량) → DOM 폴백 `li.soldout`→0·else 999 / **수량을 구조적으로 안 줄 수 있음 → `unknown`(가짜 충분 금지)**. ⚠️ **표면가 `.final span.num` 은 렌더 DOM 전용 — SSR 원문엔 가격 없음(JS 렌더로만) → 창 필요(서버 raw fetch·SW fetch 창없이로는 못 얻음). 실측 확인 2026-07-08(502 해소·URL LO2158462914 실페이지). P17(SPA 렌더 전 1원 저장)과 동일 원인.** 재고 API 는 창없이 가능(파라미터 spd/sitm 필요).
- **스마트스토어**: `/n/v2/channels/{cu}/products/{channelProductNo}` → 전 SKU 정확 수량 (`A.id` 사용 필수; productNo 쓰면 204 → 999 둔갑)
- **르무통 공홈**: `option_stock_data` 파싱 → 실조합·실재고만 수집
- **현대H몰**: 단품=`__NEXT_DATA__ itemPtc.stockList[]` → `sellGbcd` 품절판정 + `stockCount` 실수량 / 모음전(색×사이즈)=색별 `item-stockcount` API 프로브 → `sellGbcd`("00"=판매중·그 외="11"=품절). ⚠️품절 사이즈도 `stockCount=1` 센티넬을 주므로 **단품·모음전 모두 반드시 `sellGbcd` 우선**(stockCount 단독 판정=거짓 '1개 있음'=금전손실). 둘 다 `_size_stock_from_row` 공유. 공개 API라 서버사이드 수집(확장 불요)

🔧 **특이사항 — URL에 재고를 매핑했으나 정상 결과가 안 뜨는 케이스**

1. **옵션없음** — URL에 접속했으나 소싱처 페이지에서 **옵션 데이터 자체를 파싱하지 못한 경우**(옵션 `<li>` 없음 = 옵션 목록 전체가 안 나옴). 옛 재고값을 끌어쓰지 않고 "옵션없음"으로 표기한다. (아래 2와 구분: 여긴 목록 자체가 없음 / 2는 목록은 있는데 특정 조합만 없음)
2. **미판매/소멸 → 품절** [2026-07-08 (다)] — 소싱처가 옵션 목록을 **성공(ok) 크롤**했는데(목록은 나옴) 이 색×사이즈가 그 목록에 없음(`match_failed`) = 소싱처가 안 파는 조합/소멸 → **품절**(`not_sold`→0). 판매 제외(기회손실 방향, 오버셀 아님). ⚠️ 단 크롤 실패·미크롤 상태면 목록이 최신이 아니므로 **품절 둔갑 금지** → '확인 불가'. (이름 불일치로 매칭을 놓친 경우도 품절로 표기될 수 있음 = 기회손실 방향이라 허용, 매칭 정규화로 최소화.)
3. **확인 불가** — 매칭은 됐으나(`stock_uncollected`) 이 셀 per-size 재고를 못 읽음, 또는 match_failed인데 크롤 실패 상태. '재고있음' 둔갑 금지 → '확인 불가'(수량0·판매 제외·재크롤).
4. **크롤실패** — API·DOM 두 경로가 모두 실패하거나(딜 페이지 파싱 실패, WAF 차단, 네트워크 오류 등) 응답이 가이드 로직과 맞지 않아 크롤러가 성공 판정을 내릴 수 없는 경우. 이 역시 옛값 절대 금지, "크롤실패"로 표기한다.
   - 💻 `_effective_stock_status`(match_failed+ok→`not_sold`→품절 / 그 외→`uncollected`→확인불가) · `_resolve_stock` · `_stock_state` — `webapp/routes/api_pricing.py`

> 두 케이스 모두 §4 철칙 ①·②(폴백가 금지·크롤 실패 = 표기)의 재고판 적용. 화면·로그에 반드시 드러내야 하며 조용한 성공 위장(silent pass) 금지.

⚠️ **재고편 함정**
- 무신사 "마지막 N개" 정규식 빠지면 한정재고가 '재고있음'으로 둔갑.
- SSF 정규식 괄호 공백 `품절임박 ( N )` 처리 필수.
- 네이버(스마트스토어)는 `A.id`(channelProductNo) 사용(productNo 쓰면 204→999 둔갑).
- **확장이 per-SKU 재고를 잘 긁어도 `current_stock`에 저장 안 하면 매트릭스가 전부 999 둔갑.** 옵션단위 `current_stock`은 ① 서버사이드 `_ingest`(무로그인) ② 확장 `crawl-result`의 `_persist_option_stocks`(로그인 전용) **둘 다**에서 채워져야 함. 확장 전용(스스·SSF·SSG)은 ②가 단일 경로. (`[[project_smartstore_stock_not_persisted_extension_path]]`)
- 크롤 실패 시 **옛 재고 절대 금지**, "크롤 실패" 표기 → §4.
- **⓪ 수집 성공 게이트(저장)**: 확장 `crawl-result` 상품 `last_stock`은 `status=='ok'`일 때만 기록 — 실패(error)면 옛값(하드리셋 NULL=미크롤) 보존, 실패가 재고합계를 '재고있음'으로 덮어쓰기 금지 [2026-07-08 `api_pricing.py`]. 옵션 레벨 `_persist_option_stocks`도 동일하게 ok 에서만.
- **재고 판정 우선순위(게이트)** = ⓪수집성공(실패→-1 확인불가) → ①옵션존재(없으면 품절·999 금지) → ②품절신호 → ③한정 N → ④제한신호 없음→999. 앞에서 걸리면 멈춤.
- **'애매하면 999' 금지** — 재고 신호를 못 읽으면 999가 아니라 -1(불명). 999는 명시 in-stock 증명 필요.
- 무신사: 인벤토리 API 옵션 미매칭(`invMap[it.no]` 미스) 또는 호출 실패(재시도 후) 시 999 둔갑 금지 → 불명(-1).
- 롯데온: DOM 폴백에서 수량·품절 신호 못 읽으면 999 아니라 불명(-1). (대체상품 가드는 별도 — 숫자형 URL 포함 spdNo 숫자 비교)
- **현대H몰 모음전(2축)**: 색 인덱스 `uitmSeq`는 **비순차 내부 ID**(블랙1·…·올리브6·크림핑크18·아이보리21·스카이블루22) — 페이지·색목록 어디에도 진짜 값 없음(전부 0). `1..N` 순회는 7번째+ 색을 통째로 놓치고 중간 `seq`는 MIX(여러색×한사이즈) 쓰레기를 줌 → **프로브**(단일색 응답만 채택+색 dedup)로 9색 전부 수집. 품절판정은 `sellGbcd`(`stockCount=1`은 품절 센티넬 → '1개 있음' 거짓재고=금전손실). 서버사이드(`fetch_combo_persize_options`)라 확장 재로드 불필요. (S19·S20)
- **롯데온·스마트스토어 실수량 (라이브 검증 2026-06-25)**: 둘 다 **라이브에서 한정수량(실수량)을 정확히 잡는다** — 롯데온 124셀·스마트스토어 98셀 실수량 확인(URL별 상이값으로 진위 교차확인). 롯데온은 라이브 확장이 옵션 API `option/mapping`의 `stkQty`로, 스마트스토어는 로그인 확장 per-SKU API → `_persist_option_stocks` 영속(2026-06-22). ⚠️ 롯데온 충분도 `stkQty` **실수량(20·30·41)**으로 옴 — `999`는 롯데온 표현이 아니다. 화면 `999`/품절 둔갑의 **주원인 = `SourceOption` 중복행 stale → 상품 `last_stock`=999 폴백**(아이보리·스카이블루, 2026-06-25 dedup 해결 → §5 에러이력 S3). 대체상품 `999`(품절 슬롯에 끼는 다른 상품)는 별개·드문 케이스(확장 spdNo 가드). **주의**: origin/main **서버 크롤러**(`lotteon.py` DOM 폴백 `else 999`·`ss_lemouton.py` 상품단위)만 보면 "못 잡음"으로 오해 가능 — 라이브 크롤은 확장 경로라 실수량을 잡음(저장소 확장 ≠ 라이브 설치 확장).

---

## §2. 가격편 — 소싱처별 가격·혜택 수집

🟢 **쉬운 설명** — 모든 소싱처의 매입가는 한 공식입니다.

> **표면 노출가 − 혜택(적립·할인·쿠폰·카드) = 최종 매입가**

> 로그인은 대개 **혜택만** 필요하다(표면가는 무로그인). SSG·SSF는 혜택도 무로그인 서버에서 raw HTML 정규식으로 읽는다. ~~"라이브 확장크롤은 혜택을 저장하지 않는다(기지 결함)"~~ 는 **낡은 기록** — 확장 v0.7.56(2026-07-23 T10)부터 라이브 확장 크롤도 혜택을 저장한다(§2 끝 저장 배선 표).

%혜택은 **항상 베이스금액 기준**(직전 잔액에 곱함). 혜택 4분류: **선반영(preapplied)** · **후반영(deduct/accrue)** · **결제(payment, 택1)** · **캐시백(cashback)**.

💻 **계산 엔진**: `api_benefits.py:496 compute_breakdown` → `final_price.py compute_final_price` → `unified.py:75 compute_market_price`

### 소싱처별 계산 로직 (베이스금액① = 그 소싱처가 노출하는 기준가)

> ⚠️ **라인 번호는 힌트일 뿐, 권위 있는 원천은 함수명·분기 마커다.** 코드가 리팩터되며
> 줄 번호는 쉽게 밀린다(무신사 항목이 실제로 그랬다 — `:727~752`로 적혀 있었지만
> 2026-07-20 확인 시 실제 블록은 `api_benefits.py` `compute_breakdown` 함수의
> `if str(source_id) == '3':` 분기, `:970~1009` 부근이었다). 코드에서 못 찾으면
> 함수명/분기 조건으로 검색해 실제 위치를 다시 확인할 것 — 여기 적힌 숫자를 맹신 금지.

- **SSG** (`_site_for=='ssg'` 분기 — `api_benefits.py:835~` 힌트): 최적가(bestAmt, 즉시할인 선반영) → −SSG MONEY 적립(①무조건=항상 / ②충전결제=rate≥3%만) → −카드혜택가 → −상품쿠폰(**라벨에 「제휴」가 있으면 = 경유 축**. `channel='naver_via'` + `enabled=True` 로 올려 캐시백과 **택1**·큰 쪽 자동 채택 / 제휴가 아닌 일반 상품쿠폰은 종전대로 수동 토글 — `api_benefits.py:907` `_pc_is_affiliate`) → −리뷰적립(텍스트) 50원(시드) − OK캐시백 2%(시드, `base_ratio` **1.0** 전액 예외 — 카드와 동시 차감) / 현대카드 2.73% 청구할인 fallback(네이버페이 제외)
- **무신사** (`compute_breakdown` 의 `if str(source_id) == '3':` 분기 — `api_benefits.py:1133~` 힌트): **표면 노출가**(surface_price = `goodsPrice.salePrice`, 사이트 노출가. **회원가 아님** — 회원가는 로그인 전용 별도 필드 `member_price` 이고 값이 다르다, 예: 표면가 119,900 ≠ 회원가 101,510) → 차감은 정액(원) 먼저·정률(%) 나중(`final_price.py:_benefit_priority`, 2026-07-19부터). 정액 그룹 안에서는 삽입 순서 그대로: −후기적립(500원 고정, 템플릿 혜택이라 가장 먼저 병합됨) → −상품쿠폰 → −등급할인 → −등급적립 → −무신사머니 결제적립 (라이브 실측 순서, 2026-07-20) → 결제 택1(정률): **무신사머니 금액 vs 현대카드 2.73% 중 차감이 큰 쪽 자동 선택** (2026-07-22 Task 5, 스펙 §3-3 — 머니>0 이면 머니 행 `pay_method='mus_money'` 태깅 + 플로어 `HYUNDAI_FLOOR_KEY` 선태깅으로 tagged 경로 실측 비교. 종전 "머니가 잡히면 무조건 머니(현대카드 하드 비활성)"는 폐기 — `api_benefits.py` `_card_floor` musinsa 분기). ⚠️ 무신사머니는 **금액**이지 %가 아니다 — 코드 어디에도 "3%" 리터럴은 없다(잘못된 옛 기술이었음).
- **롯데온** (`compute_breakdown` 의 `_lo_max_mode` 블록 — `api_benefits.py:1235~` 힌트, 2026-07-23 Task 8 · 스펙 §3-5): **최대혜택가 베이스** — 크롤 `lotteon_max_price`(「최대 할인혜택 적용하기」 가격, 확장 v0.7.55 pbf API `favorBox`·`qtyChangeFavorInfoList`)가 있으면 ①카드-프리 베이스 = 최대혜택가 + 사이트 선반영 최적카드(amount 최대) 가산 ②결제 경로를 엔진 tagged 열거로 택1 — 보유 카드별 즉시할인(그 카드가 현대카드면 2.73% 병행) vs 현대카드 무-즉시할인(2.73% + N페이 1%), 실제 최종가 낮은 쪽 ③**보유카드 가드** — `PurchaseCard` 마스터 라벨과 보수적 매칭(`card_candidates.match_owned_card_label`, 애매하면 미보유 = 가산 유지 = 매입가 과대 = 안전 방향) ④기존 미태깅 결제성 행은 소등+경고 로그. `lotteon_max_price` 없으면(구데이터) 종전 경로: 판매가 베이스 → −롯데오너스 회원할인 → −스토어찜 쿠폰(토글) → 현대카드 2.73% fallback. 시드: OK캐시백 1.1%(`base_ratio` 0.9 공급가)·L.POINT 적립 0.05%·리뷰적립(텍스트) 50원 — 캐시백·적립 축은 카드 경로와 무관하게 동시 차감. (~~"나의 혜택가 베이스"~~ 서술은 폐기 — 나의 혜택가는 롯데오너스 포함가라 이중차감 함정, `background.js` 2026-07-03 fix Ⓑ)
- **SSF** (`_site_for=='ssf'` 분기 — `api_benefits.py:813~` 힌트): 판매가(시즌 할인 선반영) → −기프트포인트 10%(멤버십, 즉시할인) → −멤버십포인트 적립(0.5~5%) / 결제 택1: 토스페이 5% / 시드: 리뷰적립(텍스트) 200원·네이버페이 적립 1%(카드와 동시) + 현대카드 2.73% 플로어
- **롯데아이몰** (`_site_for=='lotteimall'` 분기 — `api_benefits.py:1124~` 힌트): 표면노출가(카드 미적용 할인가) → −L.POINT 적립 → −{카드} 청구할인(크롤 정액) / 무이자 할부 / **엔진 주입(카탈로그)**: OK캐시백 2.5%(`base_ratio` 0.9)·리뷰적립(텍스트) 100원 + 현대카드 2.73% 플로어
  - **다운로드 쿠폰 = 「플러스 할인쿠폰」 칸** (2026-07-23 **사장님 주문서 실측**으로 확정): PDP 「쿠폰받기」 레이어(`div.layer_down_coupon .coupon_list li` → `.name`·`.price`/`.per`)에 있고, **확장이 이미 받는 원본 SSR HTML(403KB) 안**이라 **추가 API 호출 0**(`crawlers/lotteon.py::_parse_download_coupons`).
  - 주문서 실측 검산(goods_no=2559138690): 총 149,000 − **할인쿠폰 6장 29,100**(= 표면가 119,900) − **플러스 할인쿠폰 6,000** = **113,900**. 즉 표면가에 반영된 할인쿠폰과 **동시 적용**이고, 기준은 정가가 아니라 **표면가**(119,900×5%=5,995, 화면 6,000 — 단수는 내림으로 두고 백원 버림에서 흡수).
  - 🔴 **택1은 플러스 칸 안에서** — 쿠폰함 공식 문구 「**플러스/즉시적립할인은 1개만 적용**」. 경유 「네이버 N%플러스할인쿠폰」이 **같은 칸**이라 큰 쪽 1장만 쓴다(`resolve_download_coupon_saving(rival_saving=…)` + 엔진 `_plus_slot_taken` 가드). 경유가 **선반영형**이면 칸을 안 쓰므로 경쟁 아님.
  - 💻 **칸을 코드로 판별하는 법** (2026-07-23 실측): 주문서 hidden 값이 쿠폰을 두 버킷으로 나눠 든다 — `cpn_crd_dc_amt_12`(=**할인쿠폰**, 구분코드 `VAR_ADTN_COST_DTL_SCT_CD_COUPON`=12) / `cpn_crd_dc_amt_dup_30`(=**중복할인**, `VAR_ADTN_COST_DTL_SCT_CD_DUP_DISC`=**30** ← 플러스쿠폰이 여기). 주문 단위 플래그 `dup_cpn_enable`(Y/N)·`dup_cpn_cnt`·`lump_sum_cpn_enable`. 실측 이 상품: 12→29,100 · **dup_30→6,000**.
  - 🔴 **PDP 에는 그 코드가 없다** — 쿠폰 `<li>` 에 data 속성 0개, 스크립트에 `cpnKndCd`·`couponType`·`DUP_DISC` 류 키 **전부 0건**. `goDownloadCoupon('P', selIdx)` 의 `'P'` 는 쿠폰 종류가 아니라 **다운로드 방식**(개별 / `'A'`=전체)이다. → **크롤이 보는 PDP 만으로는 칸을 판별할 수 없다.**
  - ✅ **대신 쿠폰함으로 확인된다**: 받으면 `searchCouponList.lotte?coupon_type=P`(**플러스쿠폰**) 목록에 적재된다 — 실측으로 매수가 1,557→**1,558**로 늘고 「[르무통] 5% 다운로드 쿠폰」이 **P 목록에서 확인**됨(일반 쿠폰 21매 쪽 아님). 쿠폰함 공식 문구도 「**선택할인에 더하여 추가할인이 가능한 쿠폰**」.
  - 🔒 **사장님 확정 규칙(2026-07-23)**: 「플러스쿠폰은 **일반 쿠폰과 중복은 되지만** 플러스쿠폰을 **여러 개 쓰는 건 안 된다**.」 = 현행 구현(할인쿠폰과 동시 차감 + 플러스 칸 1장·경유 쿠폰과 택1)과 일치.
  - 💰 **L.POINT 적립 기준 = 쿠폰 차감 후 금액**(2026-07-23 주문서 실측 570원): 사이트가 노출하는 정액(599P)은 **표면가 기준**이라, 그대로 정액으로 넣으면 「정액 먼저」 규칙 때문에 쿠폰보다 앞에서 빠지고 금액도 쿠폰 반영 전으로 고정된다 → **정률로 환산**해 주입(`rate = 정액 ÷ 표면가`, `api_benefits.py` `_lp_rate`). ⚠️요율을 0.05% 단위로 **스냅하지 말 것** — 올려 잡히면(0.5415%→0.55%) 적립을 과다 차감해 매입가 과소가 된다. 카드 청구할인이 있는 상품은 적립 기준이 실제(카드 전)보다 낮아져 **덜 깎인다 = 안전 방향**(순차 차감 모델의 구조적 오차, 1~2원대).
  - 📐 **수집·반영 분리 원칙(사장님 지시)**: 「**정보 수집에는 전부** 하고, **로직에 플러스쿠폰이면 한 개만 반영**」 — 레이어의 쿠폰을 미리 걸러 버리지 않고 전량 저장(근거 보존·진단), **선택은 계산 단계에서** 한다. 플러스 칸 후보 풀 = {다운로드 쿠폰 전량} ∪ {경유 네이버 플러스쿠폰} → **가장 큰 1장만** 반영(핀: `tests/pricing/test_lotteimall_download_coupon_engine.py::test_only_one_across_download_and_naver_pool`).
  - ⚠️ 「쿠폰받기로 받은 것은 **항상** 플러스인가」는 **실측 1건 근거**다(단정 금지). 반례가 의심되면 쿠폰함 P 목록에 그 쿠폰명이 있는지로 판별한다.
  - ⚠️ **처음엔 「할인쿠폰 칸 택1」로 오판했다**(PDP 만 보고 추론) — 칸 판정은 **주문서에서 확인**해야 한다. §5 P31.
- **스마트스토어·르무통 공홈**: 표면 노출가 → 시드 혜택(리뷰적립(포토) 5,000원·네이버페이 적립 1% — `source_benefit_seed.py`) + 현대카드 2.73% 플로어 → 그 외는 소싱처 템플릿값
- **현대H몰** (`compute_breakdown` 의 `_hm_card_mode` 블록 — `api_benefits.py:1112` 부근, 2026-07-23 2차 T1·T3 · 스펙 §11-3): 표면가 `bbprc`(깜짝할인 선반영, 무로그인 내장. **카드 즉시할인은 bbprc 에 미포함** → 가산 없음) → H.Point 적립(상시·정액) → **카드 즉시할인 = 결제 택1 후보**(`hmall_card_discounts[]`, 확장 v0.7.60~) — 보유 카드만 주입(`card_candidates.match_owned_card_label`, 애매하면 미보유=안 깎음=안전) · `min_order` 미달 제외 · 현대카드 2.73% 플로어와 택1(차감 큰 쪽) · **카드 주입 시에만 플로어 선태깅**(`_hm_card_mode` — 페어링 없으면 이중차감). / **엔진 주입(카탈로그)**: OK캐시백 2.7%(`base_ratio` 0.9)·리뷰적립(텍스트) 100원·네이버페이 적립 1%
  - 💻 수집(창 0개 유지): `GET /api/hf/dp/v1/item-ptc/item-prmo-lst?slitmCd=` → `crdImdtDcPrmoList[]`. 🔴 **쿠키 `uh2oxid` 를 헤더로도 재전송해야 200**(쿠키만 보내면 401 「만료된 uh2oxid」) — 확장이 `chrome.cookies` 로 읽어 주입. SSR HTML·`__NEXT_DATA__` 엔 정말 없어 "렌더 창 필요"로 오판하기 쉽다.
  - 필드: `crdcNm`(카드명)·`famtFxrtGbcd`(1=정률/2=정액)·`famtFxrtVal`·`strtVal`(최소결제)·`aplyStrtDtm~aplyEndDtm`·`crdDcExpsYn`·`pcAplyYn`. **유효기간이 당일 00:00~23:59 = 일자별 로테이션**이라 크롤 당일 값만 쓴다(사장님 확정). `prmoNm "(전관)"` = 상품 공통. 덤으로 `stlmWayPrmoList`(결제수단 프로모션) 동시 수집.
  - ⚠️ 보유 매칭 실측: 삼성카드(사이트) ↔ 삼성셀렉트(마스터) = **미보유가 정답** / 현대카드 ↔ 넥슨현대카드 = 보유. 라이브 실증(2026-07-23): 8개 상품 전부 112,500 → **109,900**(현대 5% 채택·삼성 제외).

**공통 — 혜택엔진 확정 규칙 (2026-07-22~23, 스펙 §3 확정표)**
- **현대카드 2.73% 플로어 = 전 소싱처** (`api_benefits.py` `_card_floor` 3분기: 롯데온·SSG / 무신사 / 잔여 5곳 `lemouton·ss_lemouton·ssf·hmall·lotteimall` — 2026-07-23 T11b 확장). 다른 카드 결제혜택(아이몰 크롤 청구할인·H몰 카드 즉시할인 토글 등)이 활성이면 결제 택1에서 차감 큰 쪽이 이긴다 = "청구할인 없을 시 현대카드" fallback 의미 유지. 핀: `tests/pricing/test_hyundai_floor_all_sources.py`.
- **캐시백·네이버페이는 결제 택1 밖 — 카드와 동시 차감** (`final_price.py` `_is_cashback` 제외·`_is_payment` 의 '네이버' 예외. legacy 경로도 동일 — `_compute_legacy` 가 택1 후보에서 캐시백 제외, 스펙 §4-1). 캐시백 차감 = `int(잔액 × base_ratio × 적립율)` — `base_ratio` 0.9(공급가 기준) / SSG 1.0(전액 예외). 예(핀: `tests/pricing/test_catalog_source_benefits.py`): hmall 표면 100,000원 → 리뷰적립 100원 차감 후 잔액 99,900 → 캐시백 `int(99,900×0.9×0.027)=2,427`.
- **hmall·롯데아이몰 = 카탈로그 소싱처**(문자열 source_id `key:...`)라 `SourceBenefitTemplate`(정수 source_id) 행을 못 만든다 → 고정 혜택을 `compute_breakdown` 이 직접 주입(`supports_benefit_templates` 가드 — 템플릿 지원이 생기면 자동 중단해 DB행과 이중차감 방지). 값 변경 = 코드 수정.
- **시드 정본** `lemouton/sourcing/source_benefit_seed.py`: 리뷰적립 6곳(르무통·스스 포토 5,000 / 무신사 후기 500 / SSF 200 / 롯데온·SSG 50) · N페이 1% 3곳(르무통·스스·SSF) · L.POINT 0.05%(롯데온) · OK캐시백(SSG 2%×1.0·롯데온 1.1%×0.9). 전부 "그 계열 행이 하나라도 있으면 통째 skip" 멱등 가드.

💻 **핵심 — SSG MONEY 적립 2분기** (`api_benefits.py:840`)
```python
_is_charge = ('충전' in _smt)                       # ② 충전결제 전용 판정
_ssgm_enabled = (not _is_charge) or (_rate >= 0.03)  # ①항상 / ②3%↑만
```
저장 키 화이트리스트: `service.py:606` `OPTION_DYNAMIC_KEYS` (`ssg_money_rate`·`ssg_money_already_applied`·`ssg_money_text` 등).

### 💾 저장 배선 — 혜택이 어디서 `dynamic_benefits_json`에 박히나 (복구 핵심)

| 경로 | 저장 함수 | 혜택(`dynamic_benefits_json`) |
|---|---|---|
| **Python 서버 크롤** (fetch_one_source/배치) | `upsert_source_product` — 키 화이트리스트 단일 원천 `OPTION_DYNAMIC_KEYS`/`PRODUCT_DYNAMIC_KEYS` (`lemouton/sources/service.py:606~` 힌트, 롯데온·현대H몰·경유 키 포함) | ✅ **저장됨** |
| **라이브 확장 크롤 — BG_JS 소싱처** (무신사·롯데온) | `save_crawl_result`(api_pricing.py) — 무신사 = `benefit_lines` 콘텐츠 판정 블록(`has_musinsa_member_signal`), 롯데온 = `lotteon_max_price`·`lotteon_card_discounts`·`lotteon_store_discount` 명시 필드(확장 v0.7.55 `toItemBG` 화이트리스트) | ✅ **저장됨** |
| **라이브 확장 크롤 — 현대H몰**(전용 어댑터) | `fetchHmallAdapter` 가 `item-prmo-lst`(카드)·`item-ptc`(경유 `tcDcInf`) 를 붙여 `hmall_card_discounts`·`hmall_pay_promos`·`naver_via_*` 를 crawl-result 로 전송(확장 v0.7.60~61) | ✅ **저장됨** |
| **라이브 확장 크롤 — parse 소싱처** (르무통·SSF·SSG·스스·현대H몰·롯데아이몰) | ① `/api/sources/parse` 서버측 영속(`api_sources_parse.py` — parse 시점에 `dynamic_benefits_json` 직접 저장) ② 확장 v0.7.56 `BENEFIT_PASSTHROUGH` **22키**를 crawl-result 에 실어 `save_crawl_result` 의 `PRODUCT_DYNAMIC_KEYS` 스캔이 상품 레벨 저장 — **이중화** | ✅ **저장됨** (2026-07-23 T10) |

→ ~~"SSG/SSF 혜택은 라이브 확장 크롤로 영원히 갱신 안 됨"~~ 은 **낡은 기록** (T10 해소). crawl-result 전달이 실제로 메꾸는 갭 = ①**신규 URL 첫 크롤**(parse 시점엔 `SourceProduct` 가 아직 없어 상품 레벨 저장이 스킵되던 G1 갭) ②hmall per-size 교체로 옵션 혜택 행이 prune 되던 갭. 확장은 미수집 표식(null/0/''/false/빈배열)을 버리고 절대 채워 보내지 않는다 — 키 부재 시 서버는 기존 parse 영속값 보존(무스톰프 핀: `tests/pricing/test_parse_path_benefit_no_stomp.py`).

🔴 **불리언 플래그는 그 규칙의 예외다** — `naver_via_preapplied` 처럼 `False` 자체가 뜻을 갖는 키는 병합 필터가 버리면 **stale `True` 가 영영 안 덮여** 그 소싱처가 경유 혜택을 못 깎는다. `api_pricing.py` `save_crawl_result` 의 `_BOOL_KEYS` 예외에 **반드시 등록**할 것(P26). 키 화이트리스트 정본은 `lemouton/sources/service.py` `OPTION_DYNAMIC_KEYS`(→`PRODUCT_DYNAMIC_KEYS` 파생) — 2026-07-23 추가분: `hmall_card_discounts`·`hmall_pay_promos`·`naver_via_rate`·`naver_via_amount`·`naver_via_preapplied`·`naver_via_label`.

### 🔀 경유(N쇼핑) 판별 · 쿠폰 요율 (2026-07-23 혜택엔진 2차)

🔒 **사장님 확정 규칙 (2026-07-23, 원문)** — 「같이 깎이면 안 돼. 일반적으로 OK캐시백 있는 경우, **경유는 N쇼핑 or OK캐시백 中 택1**이야. **할인율 높은 걸로** 하는 게 맞아. **중복은 안 돼.**」
→ 경유(유입) 축 = `{일반 / N쇼핑(`channel='naver_via'`) / OK캐시백(`apply_mode='cashback'`)}` **택1**, 엔진이 경로 열거로 큰 쪽을 자동 채택한다(제약② — `final_price.py:154 _is_tagged` · `:320 has_naver_via` · `:341`).
🔴 **신규 경유성 혜택은 반드시 `channel='naver_via'` 를 줘야 한다.** 안 주면 일반 혜택 축이라 캐시백과 **동시 차감** → 매입가 과소 = 마진 착시(실제 사고: SSG 제휴쿠폰, P30).

| 소싱처 | 판별 원천 | 표시가 반영 | 처리 |
|---|---|---|---|
| **현대H몰** | `item-ptc` 의 `tcDcInf`(`tcCdNm`='네이버가격비교'·`dcRate` 8·`tcDcAmt` 14,730) | **선반영** | `naver_via_preapplied=true` → 주입 안 함 |
| **롯데온** | `favorBox` `discountGroups[].discountApplyPromotionList[]` 의 「제휴할인」 | **선반영** | 동일 |
| **SSG** | 「쿠폰보기」 **레이어**의 다운로드 쿠폰 라벨 「[제휴할인] …」 (크롤러가 `ckwhere=ssg_naver` 로 요청해 노출시킴 — `ssg.py NAVER_COUPON_PARAMS`). 🔴**상품쿠폰 블록(`dl.cdtl_cpn_wrap`)이 아니다** — 2026-07-23 실측 페이지엔 그 블록이 0건이었고 제휴쿠폰은 레이어에만 있었다(P33). 수집 = `ssg.py::_parse_download_coupons`(`#store_modal_view_coupon_detail` → 제목이 「다운로드 쿠폰」인 `div.dialog_group` → `div.dialog_coupon`) | 미반영 | `channel='naver_via'` 로 차감(택1) |
| **롯데아이몰** | 쿠폰함 「네이버 N%플러스할인쿠폰」 | 미반영(발급형) | 카테고리 **확실 매칭만** 차감 |

- **선반영 판별 게이트** — 소싱처가 이미 경유 할인을 붙여 노출하면 `naver_via_preapplied=True` 로 표시하고 **주입하지 않는다**(재차감=이중차감 방지).
  - 🔴 현대H몰 판별은 **raw HTML 로는 못 한다**(할인내역이 JS 렌더·12KB 스켈레톤) — HTML 문자열 검색 구현은 실패한다. 반드시 `item-ptc` API 의 `tcDcInf` 노드를 본다.
  - ⚠️ **SSG 경유는 이미 수집 중이었다** — 새로 긁는 코드를 또 만들지 말 것(중복 구현 금지).
- **롯데아이몰 플러스쿠폰**(`lotteimall_coupons.py`) — 쿠폰명에서 요율%+카테고리 접미사를 파싱해 **확실한 매핑만** 채택(애매하면 안 깎음). 쿠폰함 규칙상 **1장만 적용** → 매칭 중 최대 요율 1장.
- **경유 쿠폰 실적용 요율 우선순위**(`purchase_feedback.py`) — ①실구매 기록(최신 우선·월별 변동 반영) → ②카테고리 추정 → ③없으면 **안 깎음**. 요율은 `0<r<=100` 검증(클램프 금지 — 잘못된 값은 거부).

⚠️ 매칭/크롤 실패 시 **대표가(평균·최저) 폴백 절대 금지** → §5.

---

## §2-b. 카테고리편 — 소싱처별 상품 카테고리 경로 수집

> 2026-07-23 신설. 마켓 카테고리 맵핑의 입력값(`category_path`)이다. **못 뽑으면 빈 문자열 = 「카테고리 확인불가」** —
> 숫자 분류코드를 이름인 척 넣거나 상품명으로 추측하지 않는다(§4 무결성). 조각 정리는 공통 `build_category_path` 로 일원화.

| 소싱처 | 원천 | 실코드 | 실측 예 |
|---|---|---|---|
| **무신사** | `goods-detail.musinsa.com/api2/goods/{id}` 의 `category.categoryDepth{1..4}Name` — **표면가를 이미 부르는 그 응답**이라 추가 호출 0 | 확장 `background.js`(추출이 서버 아님) | 4046672·4800825 → `신발>스니커즈>라이프스타일화` |
| **롯데온** | JSON-LD `Product.category` 1순위 + DOM `ol.locationList li a` 폴백(Vue 렌더 타이밍 대비) | 확장 `background.js` | LO2158462485 → `여성패션>신발>운동화/스니커즈>스니커즈` |
| **롯데아이몰** | `div.location` 의 **직계** `a.home` + `div.his > a.one` | `crawlers/lotteon.py::_parse_category_path` | goods_no=2559329941 실화면 `홈 › 패션슈즈 › 스니커즈/운동화 › 런닝화/워킹화` |
| **스마트스토어(르무통)** | inline `__PRELOADED_STATE__` 의 `simpleProductForDetailPage.A.category.wholeCategoryName` (셀러가 고른 완성 경로) | `crawlers/ss_lemouton.py::_parse_category_path` | `패션잡화>여성신발>스니커즈/운동화>워킹화` |
| **르무통 공홈** | Cafe24 「현재 위치」 빵부스러기 `div.xans-product-headcategory > ol li a` (빈 `displaynone` 칸은 자동 탈락) | `crawlers/lemouton.py::_parse_category_path` | `홈>Men>클래식` → `Men>클래식` |
| **SSF** | JSON-LD `BreadcrumbList` 1순위(`position` 명시·리뉴얼에 덜 흔들림) + DOM `div.breadcrumb li a` 폴백 | `crawlers/ssf.py::_parse_category_path` | `백＆슈즈>여성 슈즈>운동화/스니커즈` |
| **SSG** | `div.cate_location` 의 단계별 `div.lo_depth_01` **직계** `a` | `crawlers/ssg.py::_parse_category_path` | 라이브 캡처 fixture 로 회귀 고정 |
| **현대H몰** | ❌ **없음(확인불가 고정)** — SSR `__NEXT_DATA__`·실브라우저 DOM 둘 다 빵부스러기/JSON-LD/메타 **0건**. 있는 건 숫자코드 `itemPtc.itemDScfCd` 뿐이고 코드→이름 공개 경로 없음(item-ctg 404·item-ptc 401) | `crawlers/hmall.py`(모듈 docstring 사유 + `category_path=""` + SSR fixture 회귀핀) | — |

**함정**
- 🔴 **드롭다운을 경로로 착각** — 롯데아이몰 `div.his` 안 `div.hislayer`, SSG `div.lo_depth_02` 는 그 단계의 **형제 카테고리 목록**(여성브랜드의류·패션잡화…)이라 경로가 아니다. 두 곳 다 **직계 자식만**(`recursive=False`) 읽는다. fixture 로 못 박음.
- ⚠️ **첫 칸이 카테고리가 아닐 수 있다** — SSG 는 `href="/"` 인 「SSG.COM」(사이트 루트)이 1단계라 '홈' 더미와 같이 제외한다(`base.build_category_path`).
- ⚠️ **르무통 공홈은 URL 의존** — Cafe24 빵부스러기는 `cate_no` 로 결정되므로 `cate_no` 없는 URL 은 '홈'만 남아 **빈 문자열**이 된다(= 확인불가. 지어내지 않는다).
- 🔴 **반쪽 경로 금지** — 스스에서 leaf(`categoryName`)만 남는 경로는 쓰지 않는다. 깊이가 어긋난 경로는 맵핑 키를 오염시킨다 → 못 뽑으면 빈 문자열.
- ⚠️ 무신사 `baseCategoryFullPath` 는 1단계가 영문(`Shoes > …`)이라 **안 쓴다**.
- ⚠️ 무신사·롯데온은 추출이 **확장**에 있다 → 서버 배포만으론 안 바뀐다(확장 재로드 필요).
  (이미지·상세도 같은 이유로 §2-c 의 그 두 줄만 확장 몫이다 — v0.7.63.)

---

## §2-c. 이미지·상세편 — 소싱처별 상품 이미지 URL · 상세페이지 수집

> 2026-07-23 신설(M4-4: 서버 파서 6곳) → 확장 경로 2곳 추가(M4-5, 확장 v0.7.63) = **소싱처 8곳 전부 수집**.
> 마켓 등록의 필수 입력값이다 — **6마켓 전부 대표 이미지 필수**,
> 옥션·G마켓·11번가·롯데온 4마켓은 **상세 HTML 도 필수**(`registration/compile_more.py`).
> 저장처 = `source_products.images_json`(JSON 배열) · `source_products.detail_html`.
> 빈 값이면 **기존값 보존(무스톰프)** — 못 뽑은 크롤이 이미 확보한 이미지를 지우면 등록이 막힌다.
>
> ⚖️ **지식재산권** — 이미지는 브랜드 저작물이다. 현재 단계는 **URL 수집·저장까지**만이고
> 파일을 내려받거나 마켓에 올리지 않는다. 실제 업로드는 브랜드별 지재권 제외 정책을 통과한
> 건에 대해서만 이후 단계에서 한다.
>
> 🔴 **핫링크도 '게시'다** — 소싱처 URL 을 그대로 `<img src>` 로 걸어 두는 것(핫링크)은
> "우리가 안 올렸다"가 아니다. **우리 마켓 페이지에 그 이미지를 게시한 것**이고, 덤으로
> ①소싱처 트래픽 무단 사용 ②소싱처가 파일을 바꾸면 우리 상품 사진이 같이 바뀐다
> ③소싱처가 이미지를 내리면 상품이 사진 없는 페이지가 된다. 지재권 판단 대상은
> **다운로드 여부가 아니라 게시 여부**다 — 핫링크·재업로드 똑같이 제외 정책을 통과해야 한다.
>
> 🅽 **스스만 다르다** — 스마트스토어는 원본 URL 을 못 쓰고 **네이버 CDN 업로드가 필수**
> (`registration/image_prep.py::prepare_cdn_images` → `cdn_images_json`). 나머지 5마켓은 공개 URL 그대로.

| 소싱처 | 이미지 원천 | 상세 원천 | 실코드 |
|---|---|---|---|
| **르무통 공홈** | `meta[og:image]`(대표) + `.xans-product-addimage img` 중 `extra/` 만(추가 4) = **5장** ※ addimage 첫 장은 대표의 small 렌디션(같은 사진)이라 뺀다 | `#proDetail div.inner div.cont` (img 18장) | `crawlers/lemouton.py::_parse_image_urls`·`dedupe_cafe24_main_renditions`·`_parse_detail_html` (+ `lemouton_playwright.py` 동일 셀렉터) |
| **SSF** | JSON-LD `Product.image` **4장**(`LB_750x1000`) · 폴백 `#godImgThumb .thumb-item img` | `#godsTabView div.gods-detail-img` (img 18장) — ⚠️ **raw HTML 엔 없다**(AJAX). 확장 navGrab(창 렌더) 경로에서만 잡힘 | `crawlers/ssf.py::_parse_image_urls`·`_parse_detail_html` |
| **SSG** | JSON-LD `Product.image` **8장**(`_i{1..8}_1200.jpg`) · 폴백 `og:image`(250px) | `itemdesc.ssg.com` **교차출처 iframe** 을 크롤 경로에서 **한 번 더 GET** → `div#descContents`(img 5장). 주소는 페이지의 `iframe#_ifr_html@src` 에서 **읽는다**(`ts` 는 조립 불가) | `crawlers/ssg.py::_parse_image_urls` · `extract_detail_iframe_url`·`fetch_detail_html`·`parse_detail_iframe_html` (배선 = `SsgCrawler.fetch` + `api_sources_parse`) |
| **롯데아이몰** | 대표 = `div.thumb_product img`(`_L`) + 추가 = `div.list_thumb img`(`_S{n}`)를 **`_L{n}` 으로 올려** dedup = 사진 N장 | `div#speedycat_container_root`(폴백 `div.box_statem`→`div.area_statem`), img 46장 | `crawlers/lotteon.py::_parse_image_urls`·`_lotteimall_thumb_to_large`·`_parse_detail_html` |
| **현대H몰** | ❗페이지에 사진이 **한 장도 없다**(13KB 스켈레톤·`<img>` 0개·`og:image` 없음). `__NEXT_DATA__` 의 `orglImgNm` + **CDN 버킷 규칙**으로 조립 | `item-dtl` API 의 `respData.itemPtc.htmlItstCntnList[].htmlItstCntn`(img 18장) — 공개 API | `crawlers/hmall.py::_parse_image_urls`·`_hmall_static_bucket`·`fetch_detail_html`·`detail_html_from_item_dtl` (배선 = `HmallCrawler.fetch` + `api_pricing` hmall 서버보강 블록) |
| **스스르무통** | `__PRELOADED_STATE__` 의 `simpleProductForDetailPage.A.representativeImageUrl`(대표) + `optionalImageUrls`(추가 4) = **5장** | `div.se-main-container`(SE ONE) — ⚠️ **raw HTML 엔 없다**. 확장 navGrab(창 렌더) 경로에서만 잡힘. 본문 API 는 비브라우저 **429 WAF** | `crawlers/ss_lemouton.py::_parse_image_urls`·`_parse_detail_html` |
| **무신사** | 이미 부르고 있는 `goods-detail.musinsa.com/api2/goods/{id}` 응답의 `thumbnailImageUrl`(대표) + `goodsImages[].imageUrl`(추가컷) 에 `https://image.msscdn.net` 접두 = **3장**. **추가 HTTP 호출 0**(표면가·카테고리와 같은 응답) | 같은 응답의 `goodsContents`(HTML 원문·절대 URL, img 18장) | 확장 `background.js::musinsaImageUrlsBG`·`musinsaDetailHtmlBG` (원천 읽기 = `musinsaExtractor` 의 `musinsa_goods` · 창없이 = `fetchMusinsaAdapter`) |
| **롯데온** | JSON-LD `Product.image`(절대 URL) **1순위** · 폴백 = 이미 부르는 base API `imgInfo.imageList[]` 의 `imgRteNm`+`imgFileNm` 에 `https://contents.lotteon.com/itemimage` 접두. **추가 HTTP 호출 0** | base API `descInfo.epnJsn` 의 `DSCRP` 항목 → `https://contents.lotteon.com/itemdetail` + `dtlFileRteNm` + `dtlFileNm` **파일 1회 GET**(⚠️ CORS 헤더가 없어 **서비스워커**가 받는다) | 확장 `background.js::lotteonImageUrlsBG`·`lotteonDetailUrlBG`·`fetchDetailFileBG` (원천 읽기 = `lotteonExtractor` 의 `lotteon_ld_images`·`lotteon_base`) |

**함정**
- 🔴 **이미지 요청을 abort 하면 URL 이 오염된다** — `block_heavy_resources` 가 image 를 `route.abort()` 하면 Cafe24 인라인 `onerror="this.src='…'"` 가 돌아 상품 사진 src 가 전부 `img.echosting.cafe24.com/thumb/img_product_big.gif` 회색 플레이스홀더로 바뀐다(실측: 6장 전부). → **abort 대신 1×1 투명 GIF 로 fulfill**(전송량은 여전히 0). SSG 썸네일도 같은 onerror 패턴이다.
- 🔴 **깨진 `og:image` 를 대표로 쓰지 말 것** — SSF `meta[og:image]` 실측값은 `https://img.ssfshop.com`(경로 없는 호스트만)이다. 썼으면 전 상품이 같은 잘못된 URL 로 등록됐다.
- 🔴 **스킴 없는 이미지 주소** — SSG JSON-LD 는 `sitem.ssgcdn.com/…` 처럼 스킴도 `//` 도 없이 호스트로 시작한다. urljoin 하면 `https://www.ssg.com/sitem.ssgcdn.com/…` 라는 없는 주소가 된다 → `base._looks_like_bare_host`.
- ⚠️ **해상도 치환(작은→큰) 금지**(추측 금지) — 르무통 실측: `extra/small` 과 `extra/big` 은 **같은 파일**이고, 대표 썸네일 `small`→`big` 은 **404**. 치환했으면 마켓에 깨진 이미지가 올라갔다.
  - **예외는 실측했을 때뿐** — 롯데아이몰은 `_S{n}→_L{n}` 을 한다. 근거: 썸네일이 있는 번호는 `_L{n}` 이 **17/17 전부 200**, 없는 번호는 200 이 아니라 **307**(상품 5건 HEAD 실측, 2026-07-23). 크기 `_S` 6,845B < `_H` 27,331B < `_L` 67,446B → 마켓용은 `_L`. **실측 없이 같은 짓 금지.**
- ⚠️ **주소를 조립해야만 하는 소싱처도 있다(현대H몰)** — PDP 원문에 `<img>` 가 0개라 읽을 게 없다. `image.hmall.com/static/{cd[-3]}/{cd[-4]}/{cd[-6:-4]}/{cd[-8:-6]}/{orglImgNm}` 규칙은 **상품 31건 (코드,실버킷) 전수 대조 31/31 일치 + 조립주소 HEAD 200 3건**(없는 번호는 404)로 확정한 것이다. 안전장치 = **상품코드가 정확히 10자리**(실측 31건이 전부 10자리 — 8·9·11자리는 미검증이라 조립 금지, 리뷰지적 I1) **+ 파일명이 `{상품코드}_{번호}.{이미지확장자}` 표준 꼴**일 때만 조립(쿼리스트링·`.php` 는 배제, 리뷰지적 M5). 그 외는 빈 값.
- ⚠️ **대표는 `og:image` 말고 화면 큰 이미지로** — 아이몰 `og:image` 는 같은 사진의 `_H` 판이라, 추가(`_L`)와 섞이면 **같은 사진이 렌디션만 달리해 두 번** 실린다(르무통 I6 과 같은 함정).
- ⚠️ **`onerror` 회색판이 src 로 들어오기도 한다** — 아이몰 `image2.lotteimall.com/goods/common/no_550.gif`. 공통 `noimage` 규칙엔 안 걸리므로 소싱처 파서에서 배제한다.
- ⚠️ **상세는 '상품 상세'만 좁혀 담는다** — 르무통 `#proDetail` 통째 = 쇼핑몰 시즌 이벤트 배너(남의 몰 홍보), SSF `#godsDetailInfoTab` 통째 = 추천상품 36장 + SSF 내부 상품번호, 아이몰 `div.detail` 통째 = 롯데 「오늘의 방송」 배너(`tdy_snd_banner`), 스스 상세 옆 = **스토어 공지사항 프레임**(`goodsinfo_frame_basic_wrap`, 타 상품 홍보 배너 3장) + 네이버 동영상 플레이어. 좁힌 컨테이너만 쓴다.
- 🔴 **상세를 화면 DOM 에서 긁으면 지연로딩에 당한다(현대H몰)** — 라이브 DOM 의 상세 이미지 46장 중 **45장의 `src` 가 `no_image_600x600.jpg` 회색 판**이고 실주소는 `data-src="//ca2.hyundaihmall.com/S/…"` 에 숨어 있다. **API 원문(`item-dtl`)에는 실주소가 그대로** 있다 → 원천을 DOM 이 아니라 API 로 잡는다.
- 🔴 **셀러가 상세 안에 1×1 비콘을 심어 두기도 한다(스스 실측)** — `<img width="1" height="1" data-src="proxy-smartstore.naver.net/img/Yml0Lmx5LzNFQU1kY3E=">` (base64 = `bit.ly/3EAMdcq`, 받아 보면 **0 bytes text/plain**). 공통 관문의 1×1 규칙이 걷어낸다(16장 → 14장).
- ⚠️ **스마트스토어 레이아웃 클래스는 배포마다 바뀌는 해시**(`boBK8xvewr`·`tnjZqbG4MS`) — 절대 기대면 안 된다. `se-main-container` 같은 **에디터 표준 이름**만 쓴다.
- ⚠️ **네이버는 헤드리스를 막는다** — 스스 상세 fixture 는 `playwright.chromium.launch(headless=False, channel='chrome')` 로만 받힌다(헤드리스는 429 에러 페이지 22KB). 아이몰은 서버 curl_cffi 가 WAF **403** 이라 역시 실 Chrome 필요.
- ⚠️ **지연로딩 placeholder** — Cafe24 edibot 은 `src` 에 1px base64 를 넣고 실주소를 `ec-data-src` 에 둔다. 안 살리면 마켓 상세가 백지(`base.sanitize_detail_html` 이 처리).
- 🔴 **상세 안 `<a href>` 는 절대화가 아니라 폐기다** — 마켓 상세에 **타 쇼핑몰 링크**를 심으면 판매금지·계정 제재 사유다. 상대 href 를 절대화하면 없던 링크를 '작동하는 링크'로 만드는 짓이다(2026-07-23 실측 사고). `a` 는 **unwrap**(글·사진만 남기고 주소 폐기).
- 🔴 **상세 안 추적픽셀** — `<img src="//log.ssfshop.com/px.gif?pid=…" width="1" height="1">` 같은 1×1 비콘이 그대로 실리면 **우리 마켓 상세가 열릴 때마다 소싱처로 방문 신호**가 간다. 상세 img 에도 수집기와 같은 hint/host 필터 + 1×1 크기힌트를 건다.
- ⚠️ **`logo`/`banner` 를 부분문자열로 거르면 진짜 상품을 버린다** — 패션에서 '로고 티셔츠/후디'는 흔한 상품이다(실측 오탐 DROP: `logo_tee_front.jpg`·`BIG_LOGO_HOODIE_1.jpg`·`BANNER_ITEM_1.jpg`). **디렉터리명이 그 낱말이거나 파일명 몸통이 그 낱말(+숫자)** 일 때만 버린다. + 후보가 있었는데 0장이면 warning 1줄(조용한 실패 금지).
- ⚠️ **대표사진이 갤러리에 두 번 실린다(Cafe24)** — 대표 1장을 `/web/product/{big|medium|small|tiny}/` 로 복제 저장하는데 **렌디션마다 해시가 다르다** → 문자열 dedup 이 못 잡는다. 추가이미지는 항상 `/web/product/extra/…`. 실측 4건(219·140·233·235) 전부 addimage[0] 이 대표와 **바이트 크기까지 동일** → `extra/` 없는 렌디션은 1장만 남긴다.
- 🔴 **확장 경로(무신사·롯데온)는 「보냈다」와 「저장됐다」가 다르다** — 확장이 만든 값은 `toItemBG` 가 실어 보내는 키만 `/api/sources/crawl-result` 에 도달한다. `image_urls`·`detail_html` 두 줄이 빠지면 확장은 수집하고 서버는 버린다(조용한 실패). 대신 `BENEFIT_PASSTHROUGH`(혜택 화이트리스트)에는 **넣지 않는다** — 넣으면 `dynamic_benefits_json` 에 중복 저장된다(전용 컬럼이 진실 원천). 핀 = `tests/sources/test_ext_images_detail.py`.
- 🔴 **「롯데온」과 「롯데아이몰」은 다른 소싱처다 — 셀렉터를 돌려쓰지 말 것** — 서버 파싱 6곳은 `lemouton·ssf·ssg·ss_lemouton·hmall·**lotteimall**`(`api_sources_parse.py::_PARSE_SOURCES`)이고, **롯데온(lotteon)은 무신사와 같이 `BG_JS_SOURCES`** 라 원본 HTML 을 서버로 아예 안 보낸다. 그래서 `crawlers/lotteon.py` 의 `_parse_image_urls`·`_parse_detail_html`(`div.thumb_product`·`div.list_thumb`·`#speedycat_container_root`)은 **전부 롯데아이몰 DOM 전용**이다 — 파일 이름이 `lotteon.py` 라고 롯데온용이 아니다. 롯데온 원천은 따로 실측한 JSON-LD·pbf base API·`contents.lotteon.com` 이다(위 표).
- 🔴 **카테고리와 사진은 같은 `ld+json` Product 블록에서 뽑는다(롯데온)** — PDP 가 Product 블록을 여러 개 낼 수 있다(본상품 + 「함께 본 상품」). 원천을 따로 고르면 「본상품 카테고리 + **추천상품 대표사진**」 이라는 다른 상품이 섞인 한 행이 만들어진다. 수신부 `status=="ok"` 게이트는 「빈 값」만 막지 이 **「틀린 값」은 못 막는다** → 대표사진 오등록(금전·계정 위험). **첫 Product 블록에서 끊고**, 사진이 없으면 없는 대로 두고 base API 폴백에 맡긴다. 핀 = `tests/sources/fixtures/lotteon_product_2blocks.html`(2블록 재현 fixture).
- ⚠️ **상세 파일은 성공(ok) 크롤에서만 받는다** — `lotteonExtractor` 는 **품절이면 `ok:false`** 인데 그때도 base 응답은 차 있다. 게이트가 없으면 품절 상품마다 `contents.lotteon.com` 에 GET 1회(최대 8초)를 날리고 서버는 수신 게이트에서 통째로 버린다 = 순수 낭비. 서버쪽 현대H몰 상세 보강이 쓰는 규칙과 같다 — 실패는 보통 WAF·차단인데 요청을 더 얹으면 더 조인다.
- 🔴 **롯데온 상세 파일은 페이지에서 못 받는다(CORS)** — `contents.lotteon.com` 응답에 `Access-Control-Allow-Origin` 이 없다(2026-07-23 헤더 실측). 페이지(MAIN world) fetch 는 브라우저가 막으므로 **manifest `host_permissions` 를 가진 서비스워커**(`fetchDetailFileBG`)가 받는다. 추출기는 **주소만** 넘긴다.
- ⚠️ **`descInfo.epnJsn` 에는 A/S 안내도 같이 온다** — `pdEpnTypCd=="DSCRP"` 만 상품설명이다. `AS_CNTS` 를 상세로 올리면 오등록. 상세 파일 호스트는 후보 6개 HEAD 실측에서 `/itemdesc`·`/desc`·`/pdDesc` 등이 전부 **403** 이고 **`/itemdetail` 만 200 HTML**(서로 다른 상품 2건)이었다 — 그 하나만 쓴다.
- ⚠️ **무신사 렌디션 `_500` 을 큰 판으로 바꾸지 말 것** — `_500` 을 떼거나 `_1200` 으로 바꾸면 **404**(2026-07-23 HEAD 실측). 호스트 `image.msscdn.net` 은 지어낸 게 아니라 **PDP `og:image` 가 `호스트+thumbnailImageUrl` 과 문자열까지 일치**함을 확인한 값이다.
- ⚠️ **확장 추출기는 규칙을 갖지 않는다** — `musinsaExtractor`·`lotteonExtractor` 는 `chrome.scripting.executeScript` 로 페이지에 통째 주입돼 **바깥 스코프를 못 쓴다**. 그래서 조립 규칙을 그 안에 복사하면 규칙이 두 벌이 된다(모순). 추출기는 **원문 조각만** 결과에 담고, 조립은 `background.js` 의 `M4IMG-HELPERS` 블록 한 곳이 한다. 테스트도 그 블록을 **node 로 그대로 실행**한다(로직 복제 금지).
- ⚠️ **길이 상한(200,000자)은 태그 경계에서 자른다** — 그냥 자르면 꼬리가 `…alt="상세이미지` 처럼 태그 중간이라 **깨진 HTML** 이 4마켓 상세로 나간다. 마지막 `>` 까지만 남기고, 경계가 없으면 빈 문자열 + 경고.

- ✅ **타 마켓 브랜딩 그림은 남는다 — 사장님 결정 완료(2026-07-23 「나」안: 자동 삭제 ❌, 등록 전 보여주고 고른다)** — `a` unwrap 이 **링크 주소는** 버리지만 **배너 그림은 보존**한다(실측: SSG 상세 안 `ssg_banner.jpg` + `department.ssg.com` 기획전 링크). 그 상세가 옥션·G마켓·11번가·롯데온 본문으로 올라가면 **경쟁 마켓 브랜딩이 우리 리스팅에** 실린다 = 판매금지·상품삭제 사유가 될 수 있다. 파일명 자동판정은 오탐(상품 사진 삭제)이 나므로 **차단이 아니라 표면화**로 뒀다 — 판정의 단일 진실 원천은 `crawlers/foreign_assets.py::detect_foreign_market_assets`(마켓 토큰 + **토큰 경계** 판정이라 `ssgcdn` 은 제외 + 소싱처 상품사진 CDN 화이트리스트)이고, `sanitize_detail_html` 은 그걸 불러 **경고 한 줄만** 남기고 **지우지 않는다**. 실제로 빼는 건 등록 전 점검 화면에서 사장님이 고른 주소뿐이다. **이 동작이 곧 결정된 최종안이다** — 결정 기록 = `docs/사장님_판단대기.md` 「✅ 최근 해소된 것」. 자동 삭제로 바꾸지 말 것(오탐이 멀쩡한 상품 사진을 지우면 「조용히 안 팔리는」 손해가 더 오래 간다는 것이 채택 사유).

**정리 규칙(공통)** — `base.build_image_urls`(절대화·순서유지 중복제거·아이콘/로고/1px/스킨호스트 배제·확장자 확증·0장이면 경고·**상한 20장 초과분은 잘랐다고 경고**(M4)·**`data:` placeholder 도 후보로 계수**해 지연로딩 실패가 무음으로 지나가지 않게 함(I3)) · `base.pick_img_src`(**지연로딩 실주소 고르기 — 수집기·상세정리기 공통 규칙**: `src` 가 비었거나 `data:` 면 `ec-data-src`→`data-src`→`data-original`→`data-lazy-src`→`data-echo` 순) · `base.sanitize_detail_html`(script·iframe·media·svg 제거 / **`a`·`picture` unwrap** / on* 핸들러·HTML 주석 제거 / img 절대화·비상품 img 제거 / 알맹이(=주소 있는 img 또는 텍스트) 없으면 빈 문자열 / 태그 경계 컷).

**수신 경계(서버)** — 크롤 결과를 받는 두 지점에서 **① `status=='ok'` 게이트 ② 재정제**를 건다.
`webapp/routes/api_pricing.py::save_crawl_result`(확장 crawl-result) · `webapp/routes/api_sources_parse.py::_persist_images_and_detail`(parse).
- ① 무스톰프는 "빈 값"만 막고 **"틀린 값"은 못 막는다** — 에러 페이지·롯데온 대체상품 가드가 **다른 상품 사진**을 실어 오면 그대로 대표이미지가 갈린다. 같은 파일의 재고 영속이 2026-07-08 사고 이후 쓰는 게이트와 동일 규칙. parse 경로는 `CrawlResult` 에 `status` 가 없어 crawl-result 와 **같은 근거**(상품명 + 가격 잡힌 옵션)로 판정한다(`_crawl_status_ok`).
- ② 정제기는 서버에 있고 **멱등**이라 재실행 비용이 0 이다. 확장이 원시값을 실어 보내도 DB→마켓으로 새지 않는다.

**서버측 상세 보강 2곳(예외 — 왜 '크롤=로컬' 위반이 아닌가)** — 상세가 **페이지에 아예 없는** 두 소싱처는
서버가 공개 문서 하나를 더 받아 채운다. 로그인·세션·개인화가 없는 공개 GET 이고, 이미 같은 성격의
서버보강(`item-stockcount`)이 2026-06-29부터 라이브에 돌고 있다. 확장 재배포 없이 값이 채워진다.
- **SSG** `api_sources_parse.py` — 확장이 **방금 준 HTML** 에서 iframe 주소만 읽어 `itemdesc.ssg.com` 1회 GET.
  ⚠️ 이건 「공개 API 1회 GET」이라기보다 **impersonate 세션**이다(curl_cffi chrome120·홈 워밍업·sleep 1.2), 그리고 `DEFAULT_TIMEOUT=30` 이 **Flask 핫패스에서 동기**로 돈다.
- **현대H몰** `api_pricing.py::save_crawl_result` hmall 블록 — `item-dtl` 1회 GET(`item-stockcount` 바로 옆). **성공(`status == 'ok'`) 크롤에서만** 부른다(실패=대개 WAF·차단인데 요청을 더 얹으면 더 조인다 — 리뷰지적 M2).
- 🔴 **킬스위치 = `MOUM_SERVER_DETAIL_FETCH`** (기본 **ON**, `0` 이면 두 곳 다 접속 안 함 · 판정 = `lemouton/sourcing/server_crawl_gate.py::server_detail_fetch_enabled`). SSG·현대H몰이 서버 IP 를 조이면 **배포 없이 env 하나로** 끈다. 크롤 자체를 켜는 `MOUM_SERVER_CRAWL`(기본 OFF)과는 **다른 손잡이**다 — 저건 가격·재고 수집을 서버가 하느냐이고, 이건 '상세 한 장 더 받기'만 끈다. 회귀핀 `tests/pricing/test_server_detail_fetch_gate.py`.
- 둘 다 **실패하면 빈 문자열 + 경고 로그**. 상세 하나 때문에 가격·재고 저장이 죽지 않는다.

---

## §2-d. 등록 초안편 — 크롤한 상품 → 마켓 등록 초안(ProductDraft)

> 2026-07-23 신설. 여기까지 모은 재료(가격·재고·옵션·카테고리·이미지·상세)를
> **등록 초안 1건**으로 옮기는 다리다. 이 다리가 없던 동안 초안은 수기 폼 1곳에서만
> 생겼고(`source='manual'` 하드코딩), `source='crawl'`·`source_site`·`source_category_path`
> 는 컬럼만 있고 아무도 안 채웠다.

💻 **실코드** — `lemouton/registration/draft_from_crawl.py`(변환기, 순수) ·
`webapp/routes/bulk/draft_from_url.py`(`POST /bulk/api/drafts/from-url`) ·
화면 = 대량등록 「✍️ 수기 등록」 탭 **0 소싱처 URL 로 초안 만들기** 카드.
동일성 키 = **(`product_drafts.source_url` 정규화형, `source_site`)** —
같은 URL 이 소싱처 두 곳에 있을 수 있어 사이트까지 봐야 한다.
DB 방어 = 부분 유니크 인덱스 `uq_product_drafts_source_url_live`
(`WHERE deleted_at IS NULL`, `shared/db.py`) — gunicorn 워커 3개가 동시에 같은 URL 을
받아도 초안이 2벌 생기지 않는다. 걸리면 라우트가 되돌리고 한 번 다시 돌아 **갱신** 경로를 탄다.

**규칙 (전부 「지어내지 않는다」의 갈래)**
- 🔴 **매입가를 판매가로 쓰지 않는다** — `last_price`·`current_price` 는 **우리가 사는 값**이다.
  판매가를 안 주면 `SALE_PRICE_UNSET`(0) 으로 두고, 6마켓 컴파일러가 전부 「판매가가 0 이하」로
  막는다(= 사전 점검이 빨간불로 표면화). 컬럼이 `nullable=False` 라 NULL 을 못 넣어 쓰는 값이지
  「0원에 팔겠다」가 아니다.
- 🔴 **옵션 추가금도 0 — 단, 처음 만들 때만** — 옵션별 매입가 차이는 **판매 정책이 아니다**.
  대신 매입가가 갈리면 경고를 띄운다(그대로 두면 비싼 옵션이 싼 값에 팔린다 = 손해).
- 🔴🔴 **재크롤이 사람이 넣은 「추가금·품번」을 지우지 않는다** (2026-07-23 리뷰 C1)
  갱신은 `options_json` 을 통째로 덮지 않고 **(색상, 사이즈) 키로 머지**한다
  (`draft_from_crawl.merge_options`): 재고만 크롤 값으로, `extra_price`·`sku` 는 기존 값 유지,
  사라진 조합 제거·새 조합 추가. 예전에는 「260mm +30,000원」이 재크롤 한 번에 0 으로 돌아가
  **비싼 옵션이 기본가로 팔렸다**. ★ 크롤이 옵션을 **0개** 주면 기존 옵션을 지우지 않는다 —
  옵션 0개는 「없어졌다」가 아니라 대개 파싱 실패다(정합성 원칙 1).
- 🔴 **브랜드는 지어내지 않고 실데이터로만 채운다** (리뷰 C2)
  경로 = `lemouton/sources/crawl_change_stats.py:102 brands_of_source_product()`
  (`option_source_links` FK 경유 — URL 문자열 비교가 아니다). 브랜드가 하나로 정해지면 채우고,
  `UNSPECIFIED`(연결 없음)거나 여러 브랜드가 섞이면 **비운 채** 둔다.
  비면 활성 `BrandRestriction` 이 있는 동안 사전 점검이 `need_brand`, 등록 라우트가
  `BRAND_UNKNOWN` 으로 막는다 — 「브랜드 없음 = 무판정」으로 제한표가 무력해지던 구멍을 닫았다.
  `compile_eleven11` 의 **상품명 첫 토큰 fallback 은 제거**했다(「나이키 에어포스 1」→`나이키`).
- ⚠️ **사진이 바뀌면 `cdn_images_json` 을 비운다** (리뷰 I1) — `service.py` 는 CDN 값이 있으면
  업로드를 건너뛰고 그 값으로 컴파일한다. 안 비우면 **옛 사진이 스스에 나간다.**
- ⚠️ **갱신이 무엇을 덮었는지 전부 말한다** (리뷰 I3) — 응답 `changes`(= `warnings` 에도 포함):
  옵션 추가·제거·재고변경 N, 유지된 추가금·품번 N, 이미지 3→2장, 상세 교체, 분류 변경.
- ⚠️ **재고 3상태를 뭉개지 않는다** — `n>0` 판매가능 / `0` 품절 / `-1` 확인불가 / `None` 미크롤.
  `or 0` 로 합치면 「아직 안 긁었다」가 「품절」로 둔갑한다(`registration/options.py` 규약과 동일).
- ⚠️ **서버가 크롤을 돌리지 않는다** — 그 URL 의 `SourceProduct` 가 없으면 **404 「먼저 크롤이
  돌아야 합니다」**. 크롤 = 로컬 PC(§0·CLAUDE.md 정합성 원칙 3).
- ⚠️ **URL 은 `normalize_url` 로 조회** — 저장은 정규화형인데 사장님은 광고 추적 파라미터가
  붙은 원문을 붙여넣는다. 이걸 놓쳐 조인이 통째로 빗나간 이력이 있다(INV-2, 2026-06-13).
- ⚠️ **초안은 (소싱처, URL) 당 1벌** — 이미 있으면 새로 만들지 않고 **크롤 소유 칸만** 갱신한다
  (`CRAWL_OWNED_FIELDS` = 옵션·재고·이미지·CDN이미지·상세·카테고리경로 — 이 상수가 갱신 루프의
  단일 원천이다). 판매가·고시·A/S·배송비·매입가마진 6칸은 사람이 채운 값이라 손대지 않고,
  이름·브랜드는 **비어 있을 때만** 채운다. 지운 초안(`deleted_at`)은 되살리지 않고 새로 만든다.
- 🔴 **이미 마켓에 올라간 초안은 덮지 않는다 — 판정은 「상태 문자열」이 아니라 「사실」** (리뷰 C3)
  `draft.status` 로 보면 안 된다. `service.py` 는 **마켓 한 곳만** 실패해도
  `draft.status='failed'` 로 쓴다 → 스스 성공 + 쿠팡 실패면 `failed` 인데 스스에는 상품이 살아
  있다. 그래서 `ProductDraftMarket` 중 `status='ok'` 이거나 `market_product_id` 가 있는 행이
  하나라도 있으면 잠근다(`draft_from_crawl.registered_market_rows`).

**여전히 사람 몫(크롤이 원리상 못 주는 것)** — 판매가 · 상품고시정보 · A/S 전화·안내 ·
배송비·반품비 · 고시유형(기본 「의류」) · 마켓 카테고리 코드.
브랜드는 옵션 링크(`brands_of_source_product`)가 있으면 자동, 없으면 사람 몫이다.
고시 13~14칸은 「설정 > 고시정보 기본값」이 채워 두면 사전 점검이 자동으로 쓴다.

---

## §3. 매트릭스 보기 > 로그의 가격·재고 표기

🟢 **쉬운 설명** — 모음전 상세 > 옵션 셀에서 가격·재고를 보는 곳은 **3군데**(셀 · 매트릭스 보기 팝업 · 크롤 로그)이고, 값은 **두 종류**입니다.
- **표면 노출가** — 소싱처에 적힌 그대로(셀 큰 숫자, 크롤 로그 "표면"=surf 취소선)
- **최종 매입가** — 혜택 다 뺀 실제 사는 값(계산식 영수증·셀 fx뱃지, 크롤 로그 "매입"=buy)

🔧💻 **데이터 위치·공유·fx 계산**

| 표기 | 무엇(필드) | 데이터 출처 | 파일 |
|---|---|---|---|
| 표면 노출가(셀) | `crawled_price` | `SourceProduct.last_price` / `SourceOption.current_price` | `api_pricing.py:395` |
| 최종 매입가(영수증·fx뱃지) | `final_price` | `compute_breakdown` / `compute_market_price` | `api_benefits.py:496` · `/api/source-benefits/breakdown` |
| 재고(셀) | `stock_label·stock_qty·stock_out` | `_resolve_stock`(센티넬 해석) | `api_pricing.py:241` |
| 크롤 로그 surf/buy | 표면=surf, 매입=buy | 확장 → CustomEvent | `ext_bridge.js` → `crawl_log.js` |

- **공유**: 셀 + 매트릭스 보기는 **같은 `window.DATA`** 한 벌을 읽음(로드 시 1회 `/api/bundles/<code>/option-matrix` fetch 캐시). 셀 최종가 = `window.SM_BREAKDOWNS["{sku}|{srcId}"].final_price`. **크롤 로그만 별도 실시간 스트림**(background.js surf=out.price → ext_bridge.js → crawl_log.js).

### 🧩 셀 · 매트릭스 보기 · 로그 — 위치 / 형태 / 호출처

| 요소 | ① 위치 (파일·DOM) | ② 형태 | ③ 호출처 |
|---|---|---|---|
| **📍 셀** | `_matrix_v3.html ~5691`(`renderSiteCell`) · 옵션 탭 매트릭스의 소싱처 컬럼 | **큰 숫자 = 최종매입가**(`px-main`), 작게 "판매가"=표면노출가, + 재고 + **fx뱃지** + ★최저 / 왼쪽 소싱참고·사입 박스 | `loadMatrix` → `/option-matrix` 1회 fetch → **window.DATA**, fx=후보별 키 `SM_BREAKDOWNS["{sku}|{srcId}|{price}"]` (bulk `/breakdowns`) |
| **🔲 매트릭스 보기**(팝업) | `_matrix_v3.html render() 1269~1374` · '매트릭스 보기' 버튼 | 행=옵션 × 열=소싱처 격자 + **상태점**(충분·소량3개↓·품절·크롤실패·미크롤) | 버튼 → render()가 **같은 window.DATA**(셀과 공유, 추가 호출 없음) |
| **📊 로그**(위젯) | `crawl_log.js 7~31` + `ext_bridge.js 23~31` · 우측 플로팅 | 진행바 + 소싱처 카드 + 상세표(타입·URL·**표면노출가 취소선(surf)**·**최종매입가 fx(buy)**) | **별도 실시간** · `background.js`(surf) → postMessage → `ext_bridge` **moum-crawl-log** CustomEvent → `crawl_log.js` |

### ⭐ 완전한 B — 한 소싱처에 URL 여러 개일 때 (매우 중요, 절대 간과 금지)

🟢 **쉬운 설명** — 같은 소싱처(예: SSG)에 등록 URL이 2개 이상이면, **셀은 그중 최종매입가가 가장 낮은 URL(후보)을 골라 보여준다.** 표면가가 같아도(둘 다 119,900) 한 URL만 SSG MONEY 같은 혜택이 잡혀 더 쌀 수 있다(예: 110,796).

🔧💻 **철칙 — 셀과 계산식은 반드시 "같은 후보"를 써야 한다.**
- **셀**: bulk `/api/source-benefits/breakdowns` 호출 시 후보마다 `source_product_id`를 같이 보내 최저 후보 선택. `_matrix_v3.html` `smFetchBreakdowns`(~2974) · `_pickBestSrc`(~5621, 기준=`final_price`) · `renderSiteCell`(~5691, 셀에 `data-cell-spid` 기록).
- **계산식(fx 팝업)**: 단건 `/breakdown/{sku}/{src}` 호출에 **반드시 `&source_product_id=<셀이 고른 spid>`** 를 실어야 한다. 안 실으면 백엔드가 등록 OptionSourceUrl(다른 URL)로 폴백 → **셀(110,796) ≠ 계산식(116,627) 불일치 = §4 단일 진실 원천 위반.**
  - 💻 `_matrix_v3.html` fx 핸들러(~3393)·`smRefreshFxInPlace`(~4017)가 `td.dataset.cellSpid`를 URL에 첨부. 백엔드 `get_breakdown`(api_benefits.py:831)→`compute_breakdown` 경로3(`source_product_id` 직읽기, :523~535)이 그 후보의 `dynamic_benefits_json`을 읽음. (2026-06-22 수정·배포)
- ⚠️ **유령가 가드** — 최저 후보가 stale(옛 혜택)이면 셀이 실제보다 싼 값을 표시 → 폴백가 금지 위반. best 후보는 `last_status!='error'`·재고있음만 포함(`_pickBestSrc`/`getCellPrice`). 의심 시 재크롤로 후보 데이터 갱신.

---

## §4. 무결성 철칙 (방향 복귀 기준)

### 🔒 정합성 기준점 3원칙 (절대 규칙 · 2026-07-03 사용자 못 박음 · 예외 없음)

> **"무엇이 진실인가"의 기준점.** 아래 4 철칙은 이 기준점을 **지키는 방법**이다. 위반 = 금전 손실·계정 위험.

1. **재고 기준 = 소싱처 "실제 상품 브라우저 URL"의 재고.** 추정·폴백·평균 금지 — 그 URL 상품 페이지의 **실제 데이터로만** 확인·판정한다. 데이터로 확인 못 하면 **"확인 불가"로 표기**하지, 있다고 단정하지 않는다.
   - 💻 강제: `_persist_option_stocks`(per-URL 실수량 영속) · `_resolve_stock`(판정) · `stock_unknown` 신호등. ← 철칙 2·4가 뒷받침.
2. **가격 기준 = 위와 동일하게 실브라우저 소싱처 상품 URL이 기준.** 표면노출가·최종매입가 모두 그 URL 상품의 실제 값으로 확인. 매칭·크롤 실패 시 대표가(평균·최저) **폴백 금지** → "가격 없음 + 크롤 실패" 표면화.
   - 💻 강제: `is_crawl_valid` 게이트 · `_match_failed` · `_pick_cheapest_buyable`(폴백 금지) · `price_zero` 신호. ← 철칙 1·3이 뒷받침.
3. **크롤은 서버가 아닌 로컬 PC로 한다.** 크롤 = 로컬(크롬 확장 / Playwright), 업로드 = 서버. "서버 크롤 실패"는 설계상 정상이지 버그가 아니다. 서버 크롤 부활 제안 금지.
   - 💻 강제: 확장 경로 `EXTRACTORS`(background.js) · `ext_bridge`. 서버 크롤 실패 = 설계(정상).

### 지키는 방법 — 4 철칙

데이터 정확성 = 금전 손실 직결. 방향 잃으면 이 4개로 복귀.
1. **폴백가 금지** — 실패 시 평균·최저 대표가로 채우지 않는다. "가격없음 + 크롤실패"로 표면화. (기준점 2)
2. **크롤 실패 = 표기** — 옛 가격/재고 끌어쓰지 않고 "크롤 실패". (하드리셋: 시작 NULL·pending → finalize) (기준점 1·2)
3. **단일 진실 원천** — 표면가=`last_price`, 매입가=`compute_market_price` 한 곳에서만. (기준점 2)
4. **누락에 경고** — 후보가 조용히 탈락(None)하면 안 됨. 화면·로그에 드러나야 함. (기준점 1)

---

## §5. 에러 이력 — 신규 추가 전 필독 (재고·가격·기타)

> 🧭 **판정 기준 = 전체크롤(코드).** 재발방지가 실제 코드에 들어갔나가 진실, 원문·보기는 그 코드를 따라왔나(동기화/뒤처짐)만 본다. 동기화 대시보드(`/sourcing-guide/map` §5)가 ❌ 코드 미반영 / 🔄 문서 뒤처짐 / ✅ 완전 동기화로 분류.

> 📝 **기재 거버넌스 — 추가/수정/삭제 판정 (중복·모순·교차회귀 방지)**
> 1. **근본원인 먼저** — 증상 아닌 "왜"를 확정.
> 2. **추가 vs 수정** — `같은 소싱처+같은 근본+같은 증상`이 이미 있으면 **수정**(기존 행에 커밋·rev 보강), 새 행 금지. 같은 근본 다른 소싱처면 둘 다 유지(예: S1·S2·S8).
> 3. **모순 검사** — 재발방지가 §1~§4와 어긋나면 (a)§1~4 정정 / (b)코드 위반 중이면 "§X 위반 중" 명시 / (c)그 외 모순은 사용자 보고.
> 4. **교차 회귀 방지 ⭐** — 소싱처 전용 코드에서 고칠 수 있으면 거기서(타 소싱처 무영향). 공유 코드(`_match_option_so`·`persist_crawled_options`·`compute_breakdown` 등) 수정이 불가피하면 **같은 코드 쓰는 다른 소싱처 전부 재크롤 회귀 검증** + 회귀테스트 PASS + 각 항목 rev. (94466889 회귀 재발 차단)
> 5. **삭제** — 버그 아님·진짜 중복일 때만. 묶을 수 있으면 유지(무결성).
> 6. **ID·rev·양쪽 반영** — prefix(S/P/O)+다음 번호(재사용 금지). 추가/수정/삭제마다 rev(날짜·액션·내용·소싱처·커밋). 원문(.md)+보기(error_catalog.json) **둘 다** 갱신.

> 🛑 **신규 소싱처 추가·크롤/저장 코드 수정 전, 이 카탈로그부터 확인.** 과거 발생한 재고·가격·저장 에러가 다른 소싱처에서 **같은 원인으로 재발**한다. 작업 전 "내가 고치는 곳이 아래 **재발방지 체크**에 걸리지 않나"를 대조한다.

🟢 **분류 2축 + 상태** — 각 에러는 **증상유형**(📦재고 · 💰가격 · 🛠️기타) × **원인계층**(백엔드 코드 · 프론트엔드 코드(확장 추출기 포함) · 기타[외부사이트·데이터·정책·인프라·미구현]) 로 태깅. 상태 `✅수정` / `⏳미수정`. 재고·가격 크롤 에러가 아니면 전부 **기타**.

> 보기 데이터 원천 = `webapp/static/error_catalog.json`(대시보드 fetch). 원문 표는 이 JSON 과 동기화 유지 — 자동점검(`guide_sync`)이 누락 ID·중복 ID·심볼 미발견을 배너로 표면화.

🛡️ **공통 근본 패턴 — "조용한 실패(silent failure)"**: ① 실패를 삼키고 성공처럼 보임 ② delete-then-insert가 빈/부분 페이로드로 데이터 삭제 ③ 무제한 동시성이 인프라 한도 초과 ④ **stale 브랜치 머지가 함수·DB 컬럼을 말없이 삭제** → `import` 가 깨지는데 `try/except: pass` 가 `ImportError` 를 삼켜 기능이 inert(S14·S17 = 같은 머지 `94466889` 가 하드리셋·판매차단·재고색매칭·중복병합·무결성점검을 한꺼번에 떨굼. `_resolve_sourcing_cost` 도 같은 식으로 한 번 유실됐다 복원). → **재발 방지 4철칙: (a) 저장 실패는 항상 사용자에게 표면화 · (b) 삭제형 저장은 빈 페이로드 가드 · (c) 대량 저장은 동시성 제한 + 멱등 재시도 · (d) 큰 머지·브랜치 통합 직후 `import`+테스트 전수 실행 — 함수·컬럼 유실 즉시 탐지(`try/except: pass` 로 import 감싸지 말 것).** (§4 무결성 4철칙의 저장판)

### 🛡️ 철칙 ← 출처 에러 (§4 무결성 4철칙은 모두 실제 에러에서 나왔다)

> 철칙은 추상이 아니라 **사고의 응결**이다. 철칙을 어기면 아래 에러가 그대로 재발한다. (§4 ↔ 이 카탈로그 양방향 추적)

| §4 철칙 | 이 철칙을 낳은 에러 |
|---|---|
| **① 폴백가 금지** | P23(가짜 95000) · S1·S2·S8(상품합계 재고 폴백 둔갑) · [[feedback_no_fallback_price_on_match_fail]] |
| **② 크롤실패=표기** | S14(하드리셋·판매차단·None=미크롤, 복원·수정완료) · P7(일시실패 stale 정체) · P6(신규URL 드롭) |
| **③ 단일 진실원천** | P4(셀≠계산식) · O1(last_price 평균 vs 최저 분열) · P19(cross-source 오저장) · P21(중복 product) |
| **④ 누락에 경고** | P6(not_found 드롭) · P13·P15(무효SKU·허용목록밖 무보고 skip) · O7(저장실패 위장) · P10(HTTP500 누락) |
| **조용한 실패 3철칙** (저장판) | (a)표면화 ← O7·O9·O10·O5 / (b)빈 페이로드 가드 ← P11 / (c)동시성 제한 ← P10 |

### 📦 재고 에러 (22)

| # | 소싱처 | 계층 | 상태 | 증상 | 근본원인 | 재발방지 체크 | 커밋 |
|---|---|---|---|---|---|---|---|
| S1 | 르무통 | 백+프 | ✅ | 사이즈별 재고가 상품합계로 균일 둔갑(올리브그린 전사이즈 53, 품절 265도 '있음') | ⓐ `_persist_option_stocks`가 기존행만 갱신·생성 안 함→신규 URL 옵션행 0 ⓑ `ext_bridge`가 `crawl-result`에 `options[]` 미전송→매트릭스가 상품 `last_stock`(합계) 균일 폴백 | 옵션 영속을 단일 루틴(`persist_crawled_options`)으로 생성+갱신, `parse`·`save` 양쪽 호출 + ext_bridge `options[]` 전송 | d1fbe8fb 2d63a9e1 |
| S2 | 스마트스토어 | 백엔드 | ✅ | 전부 999 '있음' 둔갑(품절 SKU도) | `save_crawl_result`가 옵션 `current_stock` 미영속, 서버크롤만 채워 확장전용 경로 영원히 stale | `_persist_option_stocks` 영속(확장 경로도) | — |
| S3 | 롯데온 | 백엔드 | ✅ | 충분 재고가 999⚠️품절(특정 색만) | `SourceOption` 중복행(색 포맷 3종 INSERT)→매트릭스가 null 중복행 읽고 `last_stock`=999 폴백 | 색 호환 그룹만 dedup(멀티색 skip) | — |
| S4 | 롯데온 | 프론트(확장) | ✅ | 품절 사이즈가 '4개·있음' 둔갑 | 대체상품 가드 `_realSpd` 정규식이 LO접두 하드코딩→숫자형 URL서 무력화 | 범용 패턴 + 숫자만 비교 | — |
| S5 | SSG | 백엔드 | ✅ | 단일색 상품 재고 uniform 둔갑 | 파서가 사이즈를 `color_text`에 박고 `size_text` 비움→`_ingest` 조용히 skip→SO 미생성→상품레벨 폴백 | size-only 축 처리 | — |
| S6 | 무신사 | 백엔드 | ✅ | 단품 거짓 품절 | `_match_option_so`가 빈색(`color=''`)일 때 size-only 매칭 안 함 | 빈색 옵션 매칭 규칙 | — |
| S7 | 르무통 | 백+프 | ✅ | 비활성(OFF) 사이즈에 가격 누출 | 크롤러 미판매 사이즈 과다수집(데카르트곱 999) + 매트릭스가 `is_active` 무시 | 실조합만 수집 + is_active 존중 | — |
| S8 | 무신사 | 백+프 | ✅ | 한정수량·품절 사이즈가 '재고있음' 균일 | S1 동계열 — 확장 client추출 경로서 사이즈별 옵션행 미생성 | `persist_crawled_options` + ext_bridge `options[]` | 2d63a9e1 |
| S9 | 스마트스토어 | 프론트(확장) | ✅ | 재고 999 silent 둔갑 | `productNo` 먼저 써 204 빈응답→999. `channelProductNo`(A.id) 써야 정확 | A.id 사용 | — |
| S10 | SSF | 백엔드 | ✅ | 품절임박 N개 미식별 | 정규식이 `품절임박 ( N )` 괄호 안 공백 미처리 | 괄호 공백 허용 정규식 | — |
| S11 | 전 소싱처 | 백엔드 | ✅ | 수량0·전부있음(boolean만) | 실수량 안 긁고 in/out boolean만 출력→거짓 '재고있음' | 실수량 캡처(3상태) | — |
| S12 | 옵션조합 모달 | 프론트 | ✅ | 재고관리 매핑 입력·저장해도 새로고침하면 사라짐 | `autoSave()`가 재고매핑 저장단계 미호출(`invApplyMapping`이 삭제된 버튼에만 묶여 고아화) | 새 저장단계는 autoSave에 배선 + 로딩완료(invLoaded) 가드 | c948d4ec |
| S13 | SSG | 백엔드 | ✅ | 딜페이지(여러 단품 묶음) 전부 status=error·재고 누락 | 딜페이지엔 `uitemObjArr` 인라인JS가 없어 `_parse_uitem_options` 빈값→RuntimeError(일반 itemView만 성공) | 대표상품 itemView로 재크롤(`_resolve_deal_representative_url`). ⚠️재발·모델매핑 미배선 잔존 | — |
| S14 | 전 소싱처 | 기타(엔진) | ✅ (ⓐ·ⓑ) | 전체크롤 미완료 시 전 매트릭스가 가짜 '재고있음' + 유효가격 없는 옵션 판매차단 안 됨 | **추적**: ⓐ `_resolve_stock`이 `raw is None→'재고있음'`(가이드 §1 'None=미크롤'과 상충) ⓑ **판매차단 게이트 inert** — 하드리셋·finalize 함수·`crawl_blocked` 컬럼이 stale 브랜치 머지(94466889)에서 동반 유실, `service.py`의 `try/except: pass`가 ImportError 삼켜 no-op | **ⓑ 복원(8cc2faa4)**: `crawl_blocked` 컬럼+3함수 복원·하드리셋/finalize 재가동(통합테스트 PASS). **ⓐ 수정(2026-06-28)**: `_resolve_stock(site, raw, last_status)`이 None을 상태로 구분 — `error→크롤실패` / `ok→재고있음`(수량미상) / 그 외(`pending`·미시도·`no_crawler`)→`미크롤`. 프론트 3곳(셀·상세팝업2)이 `stock_label` 사용, 미크롤/크롤실패=회색. 재고 테스트 32 PASS. (※ 성공인데 None은 본래 999여야 — 그런 소싱처는 크롤러가 999 세팅해야) | 8cc2faa4 + 2026-06-28 |
| S15 | 무신사 | 백엔드 | ✅ | 단품 SP에 121행 누적→빈색 크롤이 엉뚱한 색 행에 써 재고 영구 불일치 | `_discover_color_variants`가 형제색을 색필터 없이 한 단품 SP에 병합 + size 정규화 부재(220/220MM 별행) + dedup 없음 | upsert 정규화 + 등록색 스코프 + dedup | — |
| S16 | 무신사 | 프론트(확장) | ✅ | 인벤토리 API 호출 통째 실패 시 전 옵션 999 '재고있음' 둔갑 | 옛 추출기가 `invMap` 빈 상태에서 `remainQuantity==null ? 999` (호출실패와 미매칭 미구분) | **라이브 확장 v0.6.7에 구현됨** — `invOk`(arr.length>0) 플래그로 API 실패 시 `stock=null`(불명), 999 둔갑 차단. ⏳였던 건 카탈로그가 stale repo(v0.4.3) 기준이라(2026-06-28 v0.6.7 감사 확인) | v0.6.7 |
| S17 | 전 소싱처 | 백엔드 | ✅ | 옵션 색이 다른 색에 **부분일치**로 오매칭→엉뚱한 재고/가격 끌어옴(예: '그레이' 조회가 '라이트그레이' 행에 매칭) | `_match_option_so`가 단일 패스 `oc in sc` 부분일치로 **첫 일치** 반환(정확 매칭 우선 없음). 2026-06-22 stale 머지(94466889)가 2단계 매칭(H1, `43714105`)을 되돌려 **재발** | 2단계 매칭 복원 — **정확 매칭 최우선** + 부분일치 후보 둘 이상이면 `None`(추측 금지, §4 무결성). 회귀 `test_stock_matching` 32 PASS | 8cc2faa4 |
| S18 | 업로드 변동감지 | 백엔드 | 📌주의 | 한정 1개 재고를 '사실상 품절'로 분류(확인=설계 의도) | `change_detection.py` `STOCK_AVAILABLE_THRESHOLD=2` → `stock<2`를 '사실상 품절' 그룹으로 | **버그 아님(확인 완료, 2026-06-28)** — 임계치는 PUT(마켓 푸시) **변동감지 그룹 판단 한정**. 마켓 노출은 `raw_stock` 그대로(1개=1개), `5→1` 전이도 감지(`stock_changed`). 의도된 설계 | — |
| S19 | 현대H몰 | 프론트(확장) | ✅ | 색×사이즈 상품 사이즈별 품절(다크네이비 260·265·275mm)이 '재고있음(1)'으로 둔갑 | 확장 `hmallPerSizeOptions`(색별 `item-stockcount` 드릴다운, b4c180f4)이 `stock=it.stockCount` 만 사용 → 품절 사이즈도 stockCount=1 로 와서 재고있음. **품절 신호 `sellGbcd`("11") 미사용** | stock 산정에 `sellGbcd` 적용 — `sellGbcd!="00"`이면 0(품절). 라이브: 다크네이비 260/265/275mm=0 확인(ext v0.7.3). **→ S20에서 서버사이드 이전·재로드 불필요화** | — |
| S20 | 현대H몰 | 백엔드 | ✅ | 색×사이즈 모음전에서 **7번째+ 색(크림핑크·아이보리·스카이블루)이 통째로 미수집** — 사이즈별 재고가 영영 안 뜸 | 색 인덱스 `uitmSeq`가 '색 위치'가 아니라 **비순차 내부 ID**(블랙1·…·올리브6·크림핑크18·아이보리21·스카이블루22). 페이지·색목록 모두 `uitmSeq=0`만 줌 → 확장 `1..15` 순회가 7번째+ 색을 놓치고 중간 seq는 MIX(여러색×한사이즈) 쓰레기 반환 | `item-stockcount`가 공개 API(무로그인)임을 확인 → **서버사이드 이전**. `fetch_combo_persize_options` 프로브(단일색 응답만 채택+색 dedup, `sellGbcd` 3상태)로 9색 전부 수집. `crawl-result`가 hmall이면 서버가 직접 교체(실패 시 확장값 유지), `/api/sources/hmall-persize-refresh`로 재크롤 없이 교정. **확장 재로드 영구 불필요.** 라이브: 9색 106옵션(uitmCnt=106 일치)·크림핑크 260=품절·245=10 | 17e5d9cb |
| S21 | 현대H몰 | 백엔드 | ✅ | **단품**(색 1개) 사이즈별 품절(다크네이비 260·275mm)이 매트릭스 '재고있음(1개)'으로 둔갑 — **같은 상품 색상모음전은 정상 품절**(S20 서버 경로) | 단품은 `fetch_combo_persize_options`가 `uitmCombYn!="Y"`라 None 리턴 → 확장 navGrab→`parse_html`의 SSR `stockList` 경로를 탐. 그 루프가 `stock=stockCount`만 사용(line 218) → 품절 사이즈도 `stockCount=1` 센티넬로 와서 '1개' 둔갑. **품절 신호 `sellGbcd`("11") 미사용**(S19·S20과 동일 메커니즘의 단품 경로 누락) | `parse_html` stockList 루프가 이미 있는 `_size_stock_from_row(row)`(sellGbcd 3상태) 재사용 — `sellGbcd!="00"`이면 0. 회귀테스트 `test_hmall_parse_dan_soldout_by_sellgbcd`(255=2·260/265/275=0). 라이브: 다크네이비 단품 260·275=품절 확인 대기 | — |
| S22 | 판매처 전송 | 백엔드 | ✅ | 센티넬 재고(999=수량미상)가 실수량인 양 판매처로 전송될 뻔 | _new_values_for_options 가 999 센티넬을 그대로 전송값에 포함 → 미상 재고가 실재고로 업로드될 위험(금전) | 전송경로에서 999 센티넬 제외(미상 재고는 전송 대상에서 배제) | `webapp/routes/sets_api.py::_new_values_for_options` |

### 💰 가격 에러 (34)

| # | 소싱처 | 계층 | 상태 | 증상 | 근본원인 | 재발방지 체크 | 커밋 |
|---|---|---|---|---|---|---|---|
| P1 | 무신사 | 프론트(확장) | ✅ | 표면가에 회원가 오긁음 | 회원가 잘못 읽어 표면가 오염 | API `goodsPrice.salePrice` 직읽기 + 검증게이트(폴백금지) | — |
| P2 | 무신사 | 백엔드 | ✅ | 자동크롤이 혜택 누락 | 백그라운드 엔진이 benefit 필드 전송 누락(+스마트행 1개가 전체 정지) | 자동크롤도 혜택 전송 + per-unit 타임아웃 | — |
| P3 | SSG·SSF | 백엔드 | ✅ | SSG MONEY 적립이 라이브크롤로 영원히 안 채워짐 | 옛 가정: `save_crawl_result`만 봄(price/stock만 저장) | **해소 확인(2026-06-28)** — SSG·SSF는 navGrab→`/api/sources/parse` 경로, 거기 `_save_navgrab_dynamic_benefits`(api_sources_parse.py:115)가 `ssg_money_rate`·`point_rate` 등을 `SourceProduct.dynamic_benefits_json`에 저장(2026-06-22). 카탈로그 ⏳는 stale 가정이었음 | 2026-06-22 |
| P4 | SSG·다중URL | 프론트 | ✅ | 셀 가격 ≠ 계산식(fx 팝업) | fx 단건 breakdown에 `source_product_id` 미전달→등록 URL 폴백→SSOT 위반 | 셀 best 후보 spid를 fx 단건에 첨부 | — |
| P5 | SSF | 백엔드 | ✅ | 저장 전체 실패(priced 0) | soft-delete 행 못 찾고 INSERT→UniqueViolation→그 옵션 저장 전체 롤백 | `deleted_at` 필터 제거 + revive | — |
| P6 | 신규 등록 URL | 백엔드 | ✅ | 위젯 '완료'인데 매트릭스 '크롤링 미실시' | `save_crawl_result`가 `SourceProduct` 없으면 not_found로 조용히 드롭(등록은 `BundleSourceUrl`만 생성) | 등록 URL이면 `upsert_source_product`로 행 생성 후 저장 | 21dc3ded |
| P7 | 무신사 등 | 기타(부하) | ✅ | 일시 실패→소싱처 0% 정체 | 고부하 타임아웃/빈그랩 + 하드리셋 price=null이면 `last_price` 안 덮어 stale에 status=error 정체 | 재시도 + 동시창 축소(폴백 유지) | — |
| P8 | 롯데온·SSG | 백엔드 | ✅ | 카드 청구할인 미반영 | 청구할인 없을 때 현대카드 2.73% fallback 미주입 | 결제 택1 pick-best 주입 | — |
| P9 | 무신사 등 | 백엔드 | ✅ | 혜택 금액 0으로 저장(부분 스냅샷이 템플릿 가림) | 부분 스냅샷 override가 템플릿 전체 가림은 이름 기준 병합으로 해소(P20과 동일 수정). 무신사 비로그인 금액0 clear는 의도(폴백 금지, 버그 아님) | 이름 기준 병합(`compute_breakdown` 의 `effective = []` 조립 루프 — `compute_breakdown` 의 `effective` 조립 루프) | 2026-06-22 |
| P10 | URL 저장 | 기타(DB풀)+프 | ✅ | URL 저장 시 매번 임의 ~7건이 HTTP 500으로 조용히 누락(option_ids 유실) | 48개를 `Promise.all` 동시 저장→Supabase 풀(15) 초과 | 대량 저장 동시성 제한(5) + PUT 일시 500 재시도 | 77127de8 |
| P11 | URL↔옵션 매핑 | 프론트(+백엔드 설계) | ✅ | 멀쩡한 URL↔옵션 매핑이 통째 삭제될 수 있음 | 옵션키 canonical_sku 해소 실패 시 빈 목록 전송, 백엔드는 빈 목록=전체삭제 | delete형 저장은 빈 페이로드 가드(빈 목록 omit) | c5406e92 |
| P12 | 옵션조합 모달 | 프론트 | ⏳ | 축 값 이름변경(240→245) 시 그 옵션 매핑이 조용히 유실 | rename 시 매핑 키 마이그레이션 없음(`keyOf`=표시값 JSON) | **부분 완화(2026-06-28)**: 유실 시 `console.warn`로 표면화(조용한 실패 차단). 완전 수정(old→new 키 마이그레이션)은 fragile 모달의 rename 흐름 변경이라 별도 | — |
| P13 | URL 저장 | 백엔드 | ✅ | 무효 SKU를 거부 건수 보고 없이 조용히 skip | `_sync_option_links`가 rejected 무보고(재고매핑은 보고) | `_sync_option_links`가 rejected 반환→두 호출자 응답 `rejected_skus`(재고매핑 패턴 거울) | 2026-06-28 |
| P14 | 매트릭스 | 프론트 | ⏳ | '재계산▶' 버튼이 저장 0인 stub | 버튼이 실제 재계산·저장 안 함(누르면 `loadMatrix` 새로고침만) | 실제 재계산 미구현(잔존) — 우선 라벨 '재계산(준비중)'으로 오인 차단(2026-06-28) | — |
| P15 | 가격설정 일괄저장 | 백엔드 | ✅ | 허용목록 밖 필드를 조용히 무시 | `bulk_set_price_config`가 허용목록 밖 필드를 무보고 skip | 응답에 `ignored_fields` 추가(저장 불변, 보고만) | 2026-06-28 |
| P16 | 무신사 | 프론트 | ✅ | 매트릭스 fx stale(셀≠계산식, 동적혜택 소싱처만) | 확장 백그라운드 크롤 종료 후 페이지가 `loadMatrix` 미호출→`SM_BREAKDOWNS` 캐시 stale(정적혜택은 값 불변이라 안 보임) | 전역 finish 리스너→디바운스 loadMatrix | — |
| P17 | 롯데온 | 프론트(확장) | ✅ | 상품가가 '1원'으로 저장 | 보조정규식 `나의 혜택가[^\d]*([\d,]+)`가 라벨 뒤 첫 숫자를 잡는데 가격은 라벨 앞·뒤엔 "1회 최대 20개"의 1 + 가격 하한 없음 + SPA 렌더 전 | 숫자+'원' 인접·자릿수≥4·1000원 하한·렌더 폴링 + 서버 price<100 거부 | — |
| P18 | 전 소싱처 | 백엔드 | ✅ | 39/40 성공인데 저장 0건(수집가 전량 소실→전 옵션 판매차단) | `/api/sources/crawl-result` for루프가 한 item 예외 시 commit 전 깨져 배치 전량 롤백(1건이 40건 죽임) | item별 `begin_nested` savepoint 격리 + item_errors 표면화 | — |
| P19 | SSF | 백엔드 | ✅ | 멤버십포인트 혜택이 계산식에 안 뜸 | 같은 URL이 lemouton·ssf 2상품에 걸려 url만으로 매칭하면 site=lemouton 상품에 저장→breakdown(site=ssf)이 못 읽음 | `source_key==site` 매칭 | — |
| P20 | 르무통 등 | 백엔드 | ✅ | 혜택 12개 중 1개만 남고 템플릿 전부 드롭→언더프라이싱 | `compute_breakdown` 게이트가 override 1건이라도 있으면 템플릿 전체 무시(매트릭스서 혜택 1개만 건드리면 발생) | 이름 기준 병합(override+템플릿) | — |
| P21 | SSF 등 | 백엔드 | ✅ | 재크롤해도 영영 안 갱신(pending 고정) | 정규화 도입 전 raw URL 행을 `upsert_source_product`가 못 찾아 매번 중복 SourceProduct 생성→매트릭스가 중복 중 pending행을 비결정적으로 픽 | 양쪽 정규화 비교 + dedupe 마이그레이션 | — |
| P22 | 전 소싱처 | 백엔드(성능) | ✅ | `/breakdowns`가 ~110초→새로고침 시 크롤현황 카드 사라짐 | 루프(876건)마다 `compute_breakdown` 내부 5쿼리 + `SourceProduct` 전체 `.all()` × Supabase RTT (N+1) | `_build_breakdown_cache` 1회 prefetch(110초→0.3초) | — |
| P23 | 단일옵션 | 백엔드 | ✅ | 레거시 단일옵션 price-calc가 크롤가 없으면 95000 가짜 매입가 표시 | `get_price_breakdown`(`api_pricing.py`)에 `or 95000` 폴백 잔존(메인 경로만 고침). 호출 프론트는 없음(dead 레거시) | `or 95000` 제거→없으면 `price_unavailable+crawl_failed` 표면화(§4) | 2026-06-28 |
| P24 | 무신사 등 | 백엔드 | ✅ | 모음전 1건·상품 7개에서 옵션 65%가 **남의 상품** 가격·쿠폰으로 계산(매입가 과소) | `compute_breakdown` 가드 `if source_product_id and not _dynamic_benefits` — 낡은 OptionSourceUrl 조회가 먼저 뭔가 찾으면(무신사는 항상) 매트릭스가 넘긴 정답 `source_product_id` 를 무시 | 가드를 `if source_product_id` 로 — 명시 지정이 낡은 조회를 무조건 이김(site·deleted_at 검증 후 동적혜택 교체). ⚠️**진단 시 주의**: `/breakdowns` 를 `source_product_id` 없이 호출하면 지금도 낡은 조회 폴백이 돌아 **같은 소싱처 다른 상품**의 동적혜택으로 계산된다 → "반영 안 됨"으로 오판하기 쉽다(화면 매트릭스는 후보별 spid 를 넘기므로 정상) | eb0aca57 |
| P25 | 현대H몰 | 프론트(확장) | ✅ | 확장 로드·재크롤 2회에도 카드 즉시할인이 계산에 안 들어옴(최종매입가 불변) — 오류 없이 조용한 무동작 | 카드 수집 호출을 범용 fetchRawParseAdapter 에 넣었는데 hmall 은 전용 fetchHmallAdapter 를 탄다 → 코드가 애초에 실행되지 않음. 진단이 늦어진 건 content_mou.js 의 MOUM_EXT_VERSION 이 0.7.54 로 굳어 페이지 data-moum-ext 가 구버전을 가리켜 '확장이 안 올라갔다'로 오판시켰기 때문 | 전용 어댑터가 있는 소싱처는 **그 어댑터 반환부**에 넣는다(범용에서 제거·오배치 주의 주석). 확장 버전 상수 3곳(manifest·background·content) 동기 → 로드버전 진단 신뢰 회복 | `extension/moum-crawler/background.js::fetchHmallAdapter` |
| P26 | 전 소싱처 | 백엔드 | ✅ | 경유(N쇼핑) 상태에서 한 번 선반영으로 판정되면 이후 해제돼도 **그 소싱처가 영영 경유 혜택을 못 깎음** | crawl-result 병합 필터가 False 를 '값 없음'으로 보고 버려서, naver_via_preapplied=True 가 한 번 박히면 이후 False 를 보내도 안 덮임(불리언 False 를 빈값 취급하는 고전 함정) | 플래그 키는 병합 필터 예외로 통과시켜 False 도 반영되게 한다. 불리언 필드에 '빈값=스킵' 규칙을 그대로 적용하지 말 것 | `webapp/routes/api_pricing.py::save_crawl_result` |
| P27 | 전 소싱처 | 백엔드 | ✅ | 여러 상품 일괄 계산 시 먼저 계산한 SKU 가 나중 SKU 의 혜택을 영구 삭제(순서 의존·단건 재현 불가) | `compute_breakdown` 이 공유 캐시(`_cache['tpl_by_src']`) 소유의 ORM 행을 `it.enabled=False` 로 직접 꺼서 flush 시 DB 까지 write 가능 | 지역 `_disabled_ids` 집합 + 읽기 전용 `_Disabled` 프록시(5지점) — ORM 행 불변 | dd80f599 |
| P28 | 현대H몰·롯데아이몰 | 백엔드 | ✅ | 그 두 소싱처 셀의 fx(계산식)를 누르면 「계산식 로드 실패 / not_found」 — 값은 정상인데 설명만 안 열림 | 단건 라우트가 `/breakdown/<sku>/<int:source_id>` 라 **정수 전용**인데 카탈로그 소싱처 id 는 문자 키(`key:hmall`·`key:lotteimall`) → 라우트에 아예 안 걸려 앱 404 핸들러 JSON 이 화면에 뜸. 일괄(`/breakdowns`)은 2026-07-20 에 같은 이유로 `_sid_key` 를 이미 넣었는데 **단건만 남아 있었다** | **같은 버그의 짝을 한쪽만 고치지 말 것** — 일괄·단건은 항상 함께 점검(카탈로그 소싱처는 `source_id` 가 문자열이다) | b0656bb5 |
| P29 | 현대H몰·롯데아이몰 | 백엔드 | ✅ | P28 을 고치자 이번엔 **500** — 2단 버그 | 문자 키를 `SourceBenefitTemplate.source_id`(Integer) 컬럼 `filter_by` 에 넣어 DB 가 거부. 일괄은 dict 조회라 안 터졌고 **단건만 DB 를 탔다** | 단건도 `_build_breakdown_cache` 를 만들어 `compute_breakdown(_cache=…)` 로 통일 — ①캐시는 dict 조회라 타입 오류 원천 차단 ②**셀 값과 fx 설명이 구조적으로 같은 계산**이 된다(경로가 갈리면 설명이 셀과 다른 값을 말하는 더 나쁜 사고) | 0900f7bd |
| P30 | SSG | 백엔드 | ✅ | 경유 쿠폰과 OK캐시백이 **둘 다** 깎일 수 있었음 → 매입가 과소 = 마진 착시 | 크롤러가 `ckwhere=ssg_naver` 로 요청해 노출된 「[제휴할인] N% 쿠폰」을 `product_coupon_rate`(일반 상품쿠폰)로 파싱 → **경유 축이 아니어서** 토글을 켜면 캐시백과 동시 차감. 사장님 확정 규칙(경유는 N쇼핑 or OK캐시백 택1·큰 쪽·중복 금지) 위반 | 쿠폰 라벨에 「제휴」가 있으면 `channel='naver_via'` 부여(+`enabled=True` 로 택1 후보 승격) → 엔진 제약②가 캐시백을 자동 배제. **신규 경유성 혜택 추가 시 체크 3종**: ①`channel='naver_via'` 줬나 ②선반영이면 `preapplied` 로 주입 자체를 막았나 ③캐시백과 동시에 안 뜨는지 테스트로 핀 했나(`tests/pricing/test_naver_via_axis.py`) | 82b6e3b7 |

| P31 | 롯데아이몰 | 백엔드 | ✅ | PDP 「쿠폰받기」의 다운로드 쿠폰(예: [르무통] 5%)이 계산식에 전혀 안 뜸 — 사장님이 화면 보고 지적 | 아이몰 크롤러가 표면가·L.POINT·자동 카드할인 3개만 뽑고 **쿠폰 파서가 아예 없었다**(파일 전체 쿠폰 문자열 0건). 값이 틀린 게 아니라 **입력이 안 들어간 것** | 원본 SSR HTML 에서 수집(`_parse_download_coupons` — 추가 API 0). ⚠️**어느 칸인지가 핵심** — 이 쿠폰은 「**플러스 할인쿠폰**」 칸이라 표면가에 반영된 「할인쿠폰」과 **동시 적용**되고(기준=표면가), 택1은 경유 「네이버 N%플러스할인쿠폰」과의 사이에서 일어난다(쿠폰함: 플러스/즉시적립 1개). 🔴**1차 구현 때 「할인쿠폰 칸 택1」로 오판**해 차감이 0 이 되게 만들었다 — PDP 만 보고 칸을 추론한 게 원인이고, **사장님 주문서 실측(149,000−29,100−6,000=113,900)으로 정정**. **칸(슬롯) 판정은 반드시 주문서에서 확인할 것** | `lemouton/sourcing/crawlers/lotteon.py::resolve_download_coupon_saving` |

| P32 | 롯데온·현대H몰·롯데아이몰 | 기타 | ✅ | 구현이 끝난 기능이 지도 신뢰 칸엔 「🔶 구현 예정」으로 남아, 다음 세션이 미구현으로 오판하고 다시 만들 뻔 | 확장 0.7.55(롯데온 최대혜택가·카드 항목별)와 카탈로그 소싱처 캐시백 상수 주입이 끝났는데 배지만 그대로였다. 🔴게다가 1차 대조 때 **DB 시드 표(`OK_CASHBACK_SEED`)에 없다**는 이유로 Hmall·아이몰 캐시백을 「미구현」이라 적었다 — 실제로는 엔진 상수 주입(`api_benefits.py:1088·1149`)으로 이미 구현·테스트까지 있었다 | 전수 대조 9건 → 구현완료 5건 ✅ 승격 / 미구현 3건 근거 명시 / 주문서 실측 1건 유지. **재발방지 2종**: ①구현 여부는 시드 표 한 곳만 보지 말고 **엔진 주입 경로**(`_DynBenefit` append)까지 본다 — 카탈로그 소싱처(`key:hmall`·`key:lotteimall`)는 문자열 source_id 라 템플릿을 못 붙여 상수 주입이 **정상 설계**다 ②수집 필드를 붙인 커밋에서 지도 배지를 같이 올린다 | `webapp/routes/api_benefits.py:1088,1149` |
| P33 | SSG | 백엔드 | ✅ | 네이버 경유로 뜨는 「[제휴할인] 백화점 8% 쿠폰」이 계산에 **한 건도** 안 들어감 → 매입가 과대 | 크롤러가 `ckwhere=ssg_naver` 로 쿠폰을 **노출까지 시켜 놓고**, 파서는 `dl.cdtl_cpn_wrap`(dt='상품쿠폰')만 봤다. 제휴쿠폰은 「쿠폰보기」 레이어에 있고 라벨에 '상품쿠폰' 글자가 없어 매칭 0건. 실제 저장 페이지엔 그 블록 자체가 **0건**이었다(정답지 D6 가 「근거 없음」으로 지적해 둔 항목) | 레이어 파서 신설 — `#store_modal_view_coupon_detail` → 제목 「다운로드 쿠폰」 그룹만 → `.dialog_coupon_detail_tit`(라벨)·`.dialog_coupon_detail_price em`(금액). **검산**: 즉시할인가 71,638 × 8% = 5,731 · × 5% = 3,581 로 사이트 표시와 원 단위 일치 → 기준금액 = **표면노출가** 확정, 그래서 정액이 아니라 **요율**로 싣는다. ⚠️보수 3종 ①여러 장이면 **큰 쪽 1장만**(동시 적용은 주문서에서만 확정 — P31 교훈) ②「적용 중인 쿠폰」 그룹은 선반영이라 **안 읽음** ③쓱클럽 제외·기존 상품쿠폰 안 덮음. 🔴SSG PDP 는 **서버 fetch 403**(실브라우저 전용) — 저장 HTML 없이 셀렉터를 추측하면 조용한 실패 | `lemouton/sourcing/crawlers/ssg.py::_parse_download_coupons` |
| P34 | 전 소싱처(확장 crawl-result) | 백엔드 | ✅ | 돈 무결성 감시기가 며칠째 빨강 — INV-5 「가격 있음+재고 NULL」 285건 · INV-4 「옵션 재고 stale」 373건. 실체는 **같은 285건이 두 규칙에 겹쳐 세진 것**(총합 658 로 부풀어 보고됨) | `save_crawl_result` 의 「옵션단위 표시가 일괄 갱신」에 **문이 하나도 없었다** — `price` 만 있으면 status·options[] 와 무관하게 그 상품의 **모든 옵션**에 `current_price` 를 칠한다. 2026-07-31 06:34:50 하드리셋(`_reset_bundle_crawl_state` 가 옵션 price·stock 을 NULL 로)  직후 06:36:59 에 도착한 **단 한 번의** crawl-result(상품 51개·소싱처 8곳이 **같은 마이크로초**로 도장 — 요청당 `now` 1개)가 `options[]` 없이 상품가만 들고 왔다 → 가격만 되채워지고 재고는 NULL, 옵션 `last_fetched_at` 도 안 올라가 INV-4 까지 동시 발화. 실측 증거: 285건 **전부** `current_price == sp.last_price`, 상품당 서로 다른 가격 1종 | 문 두 개 — `status == 'ok'` **그리고** `it.get('options')` 가 있을 때만 일괄 갱신(정합성 원칙 ②: 옵션 목록을 보지도 못한 크롤은 그 옵션 가격을 말할 자격이 없다. 상품 대표가는 `sp.last_price` 에 남아 화면 폴백으로 계속 쓰인다). 🔴**하드리셋이 `last_fetched_at` 은 안 지운다** — 값만 NULL 이고 시각은 예전 그대로라 「방금 긁은 것처럼」 보인다. 리셋 뒤 되채움 경로를 만들 땐 값·시각을 **같이** 본다. 핀 = `tests/pricing/test_crawl_result_price_no_fallback.py` | `webapp/routes/api_pricing.py::save_crawl_result` |


### 🛠️ 기타 (33)  · O7~O10 = 저장(save) 단계

| # | 영역 | 계층 | 상태 | 증상 | 근본원인 | 재발방지 체크 | 커밋 |
|---|---|---|---|---|---|---|---|
| O1 | 구조 | 기타 | 📌주의 | 조용한 실패 버그 클래스(상시 주의 — 메이트 중복·표면가 불일치·SSG 완료 위장) | 무결성 제약 부재(A) + SSOT 분열(last_price 평균 vs 최저)(B) + 누락 무경고(None 탈락)(C) | **버그 단건 아님** — §4 무결성 4철칙(상단)으로 상시 적용: DB 제약 + SSOT 통일 + None 후보 경고 | — |
| O2 | 배포 | 기타(인프라) | ✅ | 배포 success인데 옛 코드 영속 | `docker run`이 기존 컨테이너 미교체로 백엔드 옛 코드로 계속 돎 | `docker rm -f` + 교체 검증(success ≠ 코드 라이브) | — |
| O3 | 확장 | 기타 | 📌주의 | 수정해도 라이브 미반영 (상시 주의) | 확장 버전 분열 + 로드된 확장 = 데스크톱 사본(dev 사본 고쳐도 무효) | **버그 아님 — 작업 주의**: 로드 확장(v0.6.7) 경로 확인 후 reload | — |
| O25 | 확장 | 프론트(확장) | ✅ | 화면이 확장 버전을 실제보다 낮게 판단(구버전 안내가 엉뚱한 숫자) | `content_mou.js` 의 `MOUM_EXT_VERSION`(data-moum-ext 원천)이 manifest 와 어긋남 — 버전 상수가 3곳(manifest·background·content) | 3곳을 항상 같이 올린다(주석 박제) — 릴리스 시 3곳 일괄 확인 | 7ad2643f |
| O26 | 롯데아이몰 | 백엔드 | ✅ | 상품 카테고리 경로에 **형제 카테고리**(여성브랜드의류·패션잡화…)가 섞여 들어감 | div.location > div.his 안의 div.hislayer 는 그 단계의 형제 카테고리 드롭다운인데 하위 a 를 전부 긁으면 경로로 오인. SSG 와 동일 구조의 함정 | 직계 자식만 읽는다(recursive=False) — a.home + div.his > a.one. 라이브 SSR fixture 로 회귀 고정 | `lemouton/sourcing/crawlers/lotteon.py::_parse_category_path` |
| O27 | 현대H몰 | 백엔드 | ✅ | 상품 카테고리 '이름'을 어디서도 못 얻음 — 탐색을 반복하게 됨 | PDP 의 SSR __NEXT_DATA__ 와 실브라우저 렌더 DOM 둘 다에 빵부스러기 마크업·JSON-LD BreadcrumbList·메타·카테고리 링크가 0건. 있는 건 숫자 분류코드 itemPtc.itemDScfCd 뿐이고 코드→이름 공개 경로 없음(item-ctg 404·item-ptc 401) | category_path 를 **빈 문자열=카테고리 확인불가**로 고정(숫자코드를 이름인 척 넣거나 상품명으로 추측 금지). 사유를 모듈 docstring 에 남기고 라이브 SSR fixture 로 회귀 핀 — 다음 세션이 같은 탐색을 반복하지 않게 | `lemouton/sourcing/crawlers/hmall.py::category_path=""` |
| O28 | 크롤 공통(Playwright) | 백엔드 | ✅ | 렌더 경로에서 상품 사진 URL 이 전부 같은 회색 플레이스홀더로 수집됨(대표 1 + 추가 5 = 6장 전부) | 속도용 `block_heavy_resources` 가 image 를 `route.abort()` → 브라우저가 로드 실패로 보고 → Cafe24 인라인 `onerror="this.src='…'"` 가 실행돼 `img.echosting.cafe24.com/thumb/img_product_big.gif` 로 치환. "차단은 URL 수집과 무관하다"는 **추론이 틀렸고 실측으로 뒤집힘** | image 는 abort 금지 — **1×1 투명 GIF(43B) 로 fulfill**(요청이 성공으로 끝나 onerror 미실행, 전송량은 여전히 0). media/font 만 abort. + 스킨 자산 호스트 배제 목록(`_NON_PRODUCT_IMG_HOSTS`). 가짜 route 단위테스트로 핀 | `lemouton/sourcing/crawlers/base.py::block_heavy_resources` |
| O29 | SSF·SSG | 백엔드 | 📌한계 | 상세 HTML 이 안 잡힘(4마켓 등록 필수값) | 상세가 **페이지 HTML 밖**에 있다 — SSF 는 상품정보 탭을 AJAX 로 채우고(raw GET 응답은 빈 껍데기), SSG 는 교차출처 iframe(`itemdesc.ssg.com`) 안에 둔다. 렌더 outerHTML 로도 SSG 는 못 읽는다 | 지어내지 않고 **빈 문자열=상세 확인불가**로 둔다. SSF 는 확장 navGrab(창 렌더) 경로면 잡힌다. SSG 는 iframe URL 별도 GET 필요(curl_cffi chrome120 으로 200·3.5KB·img 5장 확인) — '크롤=로컬 PC' 원칙상 서버 parse 에서 부르면 안 되므로 로컬 크롤 경로 배선이 다음 단계 | `crawlers/ssf.py::_parse_detail_html` · `crawlers/ssg.py::_parse_detail_html` |
| O30 | 크롤 공통(상세 HTML) | 백엔드 | ✅ | 소싱처 상세를 마켓에 올리면 **남의 몰 링크·추적픽셀이 같이 실린다** | `sanitize_detail_html` 이 유일한 관문인데(compile_more·compile_coupang 은 검사 없이 spec 에 넣는다) ① 상대 `<a href>` 를 **절대화**해 없던 남의 몰 링크를 작동하게 만들었고 ② 상세 img 엔 비상품 필터가 아예 없어 `//log.ssfshop.com/px.gif` 1×1 비콘이 통과했다 | `a`·`picture` **unwrap**(주소 폐기, 글·사진 보존) · 상세 img 에도 hint/host 필터 + 1×1 크기힌트 · 태그 경계 컷 · video/audio/source/svg 드롭 · 수신 경계(crawl-result·parse) 에서 `status=='ok'` 게이트 + **재정제** | `crawlers/base.py::sanitize_detail_html` · `api_pricing.py::save_crawl_result` · `api_sources_parse.py::_persist_images_and_detail` |
| O31 | 크롤 공통(Playwright) | 백엔드 | ✅확인 | O28 의 abort→fulfill 이 **다른 소싱처를 깨뜨리지 않았는지**(이미지 요청이 이제 '성공'으로 끝나 onload 기반 lazy-load·갤러리 스크립트가 돌 수 있다) | 르무통(Cafe24) 한 곳에서만 실검증됐는데 같은 라우트를 무신사(`musinsa.py:236`·`musinsa_playwright.py:562,588`)·롯데온(`lotteon.py:907`)도 쓴다 | **A/B 실측(2026-07-23, 같은 URL 을 abort 판·fulfill 판으로 각각 렌더)**: 무신사 3728480 → `_EXTRACT_JS` 원자료가 **바이트 단위로 동일**(상품명·브랜드·정상가 149,000·판매가 119,900·options·breakdown 전부) · innerText 차이는 「빈 줄 + '상품 정보 더보기'」 한 곳뿐(상세 이미지 영역이 더 렌더된 것) · DOM `<img>` 107→214. 롯데온 LO2158462914 → 캡처 API 세트·색 9·사이즈 13·제목 **완전 동일**. 결론 = 가격·재고·옵션 회귀 없음 | `crawlers/base.py::block_heavy_resources` |
| O4 | DB | 기타(데이터) | 📌주의 | dev 값이 라이브와 다름 (상시 주의) | dev DB는 라이브보다 수십~수백 커밋 stale | **버그 아님 — 작업 주의**: 라이브 대조 필수(dev 신뢰 금지) | — |
| O5 | 크롤 위젯 | 프론트 | ✅ | 가격 없어도 '성공 ✓' 표기 | 위젯이 `level!=='warn'`이면 표면노출가 없어도 성공 카운트(`crawl_log.js`) | item-done 시 `surf`(표면가) 없으면 실패로 집계(매입가는 fx 이후라 surf로 판정) | 2026-06-28 |
| O6 | 롯데온 per-size | 프론트(확장) | ✅ | 롯데온 사이즈별 수량 미수집(옛 상품단위 999/0만) | 옛 `lotteonExtractor`가 per-option 재고 미수집(상품 DOM만) | **라이브 확장 v0.6.7에 구현됨** — 옵션매핑 API(`pbf.../option/mapping/{spd}/{sitm}`)의 `optionMappingInfo`로 색×사이즈 실수량(`stkQty`, SALE만) 수집 + 대체상품 가드. ⏳였던 건 카탈로그가 stale repo 기준이라(2026-06-28 v0.6.7 감사 확인) | v0.6.7 |
| O7 | 옵션조합 모달 | 프론트 | ✅ | 저장 실패해도 "저장 완료"로 표시 | 에러를 `console.warn`으로 삼킴 | 저장 함수는 `{ok,errors[]}` 반환·버튼이 실패 표면화 | c948d4ec |
| O8 | 옵션조합 모달 | 프론트 | ✅ | 저장 직전 옵션정보 재로딩 빈응답이면 `r.json()` 터져 저장 전체 무산 | 외부 응답 파싱 전 검증 없음 | `res.ok` + 본문 검증 후 명확 메시지로 중단 | c5406e92 |
| O9 | 옵션조합 모달 | 프론트 | ✅ | 옵션 구성 저장 실패해도 계속 진행→stale 기준 신규 매핑 유실 | 실패 무시하고 진행 | 실패 시 즉시 중단(재시도 유도) | c5406e92 |
| O10 | 옵션조합 모달 | 프론트 | ✅ | X·취소·배경 클릭으로 닫으면 저장 실패가 무음 | 닫기 경로 실패 미표면화 | 닫혀도 실패를 알림으로 표면화 | c5406e92 |
| O11 | 전체크롤 위젯 | 프론트(확장) | ✅ | '목표 0/진행 0'에서 영구멈춤(중지도 무력) | SW의 bgFetch가 사용자가 열어둔 탭에 주입하는데 크롬이 그 탭을 discard/bfcache→영구 대기(타임아웃 없음)→엔진 wedge | `withTimeout` + 전용 백그라운드탭 매 크롤 생성·핀 | — |
| O12 | 전체크롤 위젯 | 프론트(확장) | ✅ | 새로고침·탭이동하면 크롤 멈춤(자동재개 없음) | 큐·진행·결과(`_mgr`)가 in-memory만이라 MV3가 유휴 서비스워커 재우면 소실(chrome.storage 미사용) | `chrome.storage.session` 영속 + 기동 시 bootResume 자동재개 | — |
| O13 | 서버 parse | 백엔드 | ✅ | 전체크롤 일부 건 SyntaxError(`<!DOCTYPE` 응답) | `/api/sources/parse`가 JSON 대신 HTML 에러페이지를 간헐 반환→클라 `r.json()` throw | **2026-06-28**: ① 클라 `ext_bridge.js` `fetchJson` 가드(비-JSON→명확 에러) ② 서버 blueprint 전용 errorhandler(미처리 예외도 항상 JSON, app 전역 아님). 인프라 502/504 프록시 HTML 은 Flask 밖이라 클라 가드가 보완 | 2026-06-28 |
| O14 | 전 소싱처 | 프론트(확장) | ✅ | 일부 등록 URL이 전체크롤 후 pending(옛 의심) | 옛 의심: 확장 enqueue 커버리지 갭(미확정) | **해소 확인(v0.6.7, 2026-06-28)** — `runQueueBG`가 option-matrix의 전 source(`ALL`=BG_JS+BG_PARSE=8소싱처) `product_url`을 `source_key\|url` 중복제거로 enqueue, 갭 없음. 옛 SSF pending건은 별도 데이터(staleness) 이슈로 추정 | v0.6.7 |
| O15 | 검증탭 | 프론트 | ✅ | 재검증이 무신사·롯데온을 실제로 안 긁고 옛 stale를 '크롤실패' 표시 | 재검증이 서버 recrawl-url만 호출(브라우저 없어 need_extension) + 확장 메시지 경로 불일치(crawl vs crawlBundleAll) | `crawlBundleAll`로 전체크롤과 동일화 | — |
| O16 | 서버 refetch | 백엔드 | ✅ | 단건 재크롤(refetch)이 SSG를 SSF 크롤러로 보내 오파싱/실패 | `_detect_site_from_url`(`api_pricing.py:1656`)이 `'ssg.com' in u → 'ssf'`로 묶음(`build_crawlers`에 'ssg' 키는 실재) | `ssfshop.com→'ssf'` / `ssg.com→'ssg'` 분리(부분문자열 충돌 없음) | 2026-06-28 |
| O17 | SSF 스크린샷 | 백엔드 | ✅ | SSF 기준 스크린샷에서 멤버십포인트 적립 행이 잘림(가이드 영수증과 불일치) | `screenshot.py` SSF 프로파일이 고정 box(500,250) → 250px 아래 '포인트 적립(멤버십포인트)' 섹션 잘림 | `bottom_anchors=gods-benefit`로 캡처 영역 동적 확장(표시 전용 · 가격 무영향) | — |
| O18 | 매트릭스/위젯 | 백엔드+프(확장) | ✅ | 커스텀 소싱처(hmall·롯데아이몰)가 **정상 크롤됐는데** 셀·매트릭스보기·위젯에 안 뜸 | 매트릭스가 소싱처를 `source_id`(레지스트리 id)로 컬럼·셀 매칭 → 커스텀은 `_key_domain`(builtin 6 하드코딩) 밖이라 셀 `source_id=null` → `deriveSourceColumns`가 null 컬럼 skip. 위젯 카운트도 builtin 기준 | **셀·매트릭스보기 ✅(9db59f6f)**: 셀 `source_id='key:'+source_key` 합성 + `DATA.sources` 커스텀 컬럼 + `get_labels` 한글. **위젯 ✅(5ecf8c46)**: `crawl_log.js orderedUrlCards`가 `b.sources` 커스텀 베이스도 순회 + `SOURCE_LABELS` 한글(페이지측 JS·확장 재로드 불필요). | 9db59f6f·5ecf8c46 |
| O19 | 매트릭스 | 백엔드 | ✅ | 롯데아이몰 소싱처 카드가 **두 개**(하나는 100%·9/9·방금크롤, 하나는 —·0/0·"크롤 전") → 빈 카드가 "미크롤" 오인 | O18 `key:` 합성과 별개로, 레지스트리(SourceRegistry)에 **동명 행**(롯데아이몰)이 있으면 그 행이 셀·통계 미연결 빈 카드로 같이 뜸. 카드 그리드(`_matrix_v3.html`)가 `DATA.sources` 직접 순회 → 빈 트윈 렌더. (hmall은 레지스트리 행이 없어 1개만) | `_option_matrix_data`가 `DATA.sources` 조립 시 셀/통계 붙은 컬럼을 진짜로 보고 **같은 이름의 데이터 없는 빈 트윈만 제거**(고유 빈 소싱처는 유지) | ded0aace |
| O20 | 크롤 위젯 | 프론트(확장) | ✅ | 전체크롤 눌러도 우상단 진행 위젯이 안 뜸(크롤 자체는 돌아 카드는 갱신) | `content_mou.js`(콘텐츠 스크립트)에 `chrome.runtime.onMessage` 리스너가 없어 background `bgEmit`→`__moumPush:log`를 페이지로 미중계 → `ext_bridge`의 `__moum:log` 변환이 입력을 못 받음 → `crawl_log.js` 패널 미표시. content_mou.js가 0.4.3에 멈춘 빈틈 | `content_mou.js`에 `onMessage` 리스너 추가(`__moumPush:log`→`postMessage __moum:log`). 확장 0.7.4 재다운로드 필요(설치된 콘텐츠 스크립트라 repo 수정만으론 라이브 미반영) | — |
| O21 | 매트릭스 | 프론트 | ✅ | 옵션 매트릭스 셀 전체가 '로딩 중'에서 안 뜸(특정 모음전) — 크롤·값은 정상, 렌더만 죽음 | `renderAutoExpand`가 `auto_enabled=true`·비활성(`is_active=false`·`pur_price=null`) 옵션의 `null` `ss_breakdown/cp_breakdown`을 가드 없이 `.purchase_price` 읽어 TypeError → `renderPriceMatrix` 루프 전체 중단 → 셀 전체 미렌더 | `renderAutoExpand` 진입부 null 가드 — breakdown 없으면 '비활성 옵션(판매 안 함)' 대체 렌더 후 return. 라이브: 셀 1426개 정상·에러0 | fcb7984f |
| O22 | 판매처 일괄수집 | 백엔드 | ✅ | 일괄수집 중 일부 판매처 실패가 성공(success)으로 묻힘 | collect_all_linked_sets 가 개별 fetch 예외를 삼켜 실패 건수 없이 종료 → 부분 실패가 '전부 성공'처럼 보임(누락 무경고) | 실패 건수·사유를 결과에 표면화(로그+카운트), 스케줄러가 부분실패 리포트 | `lemouton/sets/collect_service.py::collect_all_linked_sets` |
| O23 | 스마트스토어 | 백엔드 | ✅ | 옵션 파싱 일부 실패가 success=True 로 위장(부분수집을 전량성공으로) | fetch_product_options 가 파싱 실패 옵션을 조용히 건너뛰고 success 반환 → 누락이 무경고로 묻힘 | parse_failed 카운트·partial_failure 플래그로 표면화(성공분은 쓰되 실패 건수 노출) | `shared/platforms/smartstore/get_options.py::fetch_product_options` |
| O24 | 업로더 | 백엔드 | ✅ | 라이브 전송 후 변동감지 기준선이 커밋 안 돼 매 사이클 전체 재전송 | run_uploader 가 전송 성공분의 기준선(스냅샷)을 persist 안 함 → 다음 사이클이 변동으로 오인해 안 바뀐 옵션까지 재전송(금전·레이트리밋 위험) | 라이브 전송 시 기준선 커밋(persist=True), dry-run 은 무변경 유지 | `lemouton/uploader/orchestrator.py::run_uploader` |

| O32 | 축 맞추기 → 매트릭스 | 백엔드 | ✅ | 축을 **모델·색상·사이즈** 로 짜면 손으로 맞춘 색·사이즈와 「이 소싱처엔 없다」고 정한 것이 **둘 다 무시**될 수 있었다(남의 색 가격이 붙는 길) | 저장·축맞추기 화면은 축 **이름**으로 고쳤는데 **매트릭스 읽기 경로만 위치 기준**으로 남아 있었다 — `axis_match_audit._axis_names` 가 `names[0], names[1]` 을 그대로 색·사이즈로 써서, 3축이면 색을 「모델」 사전에서 찾았다. 그 값은 `api_pricing.py:1225 match_source_option` 으로 흘러가 어느 소싱처 옵션을 붙일지 정한다 | `_axis_names` 도 `axis_slot.semantic_slots` 를 쓰게 통일(규칙은 한 곳뿐). 이름을 못 알아보는 옛 매트릭스는 **위치 폴백** 유지 → 라이브 조합 전수 대조 **달라지는 것 0건**. 시험 `tests/sourcing/test_axis_slot.py` 4건이 지킨다 | 2026-08-12 |
| O33 | 축 맞추기 | 백엔드 | ✅ | 매트릭스를 만들다 **취소·삭제한 뒤 다시 맞추려 하면** 「이미 쓰고 있다」로 **영영 막혔다**(사장님 실사고) | `source_axis_aliases` 는 **소싱처 전역 사전**이라 매트릭스에 안 매인다. 묶음 삭제는 `models.model_code` FK 를 가진 표만 훑는데 이 표엔 그 FK 가 없어 행이 남고, 화면은 **지금 매트릭스의 축 값 줄만** 그려 그 「유령」을 놓아줄 방법이 없었다 — 오류 문구의 「먼저 그 줄에서 놓아야 합니다」가 가리킬 줄이 없었다 | 충돌 409 에 `conflict{holder, holder_used_by, ghost}` 를 실어 **「빼앗아 오기」**(놓기+잡기를 한 트랜잭션 → 1:1 유지). ❌ 삭제 시 사전 정리 = 다른 매트릭스 맞춤이 같이 날아감 / ❌ 자동 해제 = 전송 중 판정이 흔들림. `axis_alias.users_of()` 로 유령 판정(축 설계 `values_json` 을 파이썬에서 파싱 — `LIKE '%…%'` 는 JSON 이스케이프 때문에 틀린다) | 2026-08-12 |

> **참고(해소됨)**: SSG/SSF 혜택은 navGrab→`/api/sources/parse`의 `_save_navgrab_dynamic_benefits`가 `dynamic_benefits_json`에 저장(P3 ✅, 2026-06-22). 확장 직읽기 `save_crawl_result` 경로(무신사·롯데온)는 별도. refetch의 SSG→SSF 오분류도 O16에서 수정됨(2026-06-28).

> **빠른 복귀 인덱스** — 혜택 안 뜸→P3 / 가짜 '재고있음'→S1·S2·S3·S8(`_resolve_stock`·중복행) / 셀≠계산식→P4 / 다운로드 쿠폰 안 깎임→P31(플러스 칸 — 경유 쿠폰과 택1) / 계산식(fx)이 안 열림·500→P28·P29(카탈로그 소싱처는 source_id 가 문자키) / 경유·캐시백 둘 다 깎임→P30 / SSG 제휴쿠폰 안 깎임→P33(상품쿠폰 블록 아니라 「쿠폰보기」 레이어) / 「구현했는데 지도는 미구현」→P32 / 신규 URL '미실시'→P6 / 크롤 후 매트릭스 stale→`window.loadMatrix`·`SM_BREAKDOWNS` / dev 값 이상→O4 / 표면가≠매입가 혼동→§3(셀 큰 숫자=최종매입가) / 모델 축을 넣었더니 색 가격이 이상→O32(축 자리는 이름으로) / 재매칭이 「이미 쓰고 있다」로 막힘→O33(유령 사전 — 빼앗아 오기).

> **2026-06-28 추적·수정** — ⏳ 17건 현행 코드 추적 → **6건 수정**(O16·P15·P13·P23·O5 + P9 기수정 확인 → ✅). 2차 추적에서 잔여 11건은 대부분 **repo 단독 수정 불가**로 확인: ① **라이브 데스크톱 확장(v0.7.x) 소관** — P3(혜택 전송)·S16(무신사 999)·O14(enqueue 커버리지)·O6(per-size 미구현)·O13(parse 호출도 확장 측). repo 확장은 stale(v0.4.3)이라 라이브 미반영 → 확장 배포 필요. ② **S14 ⓑ 복원 완료(2026-06-28)** — 하드리셋·판매차단 함수(`_reset_bundle_crawl_state`·`_finalize_bundle_crawl_block`·`_sources_have_valid_price`)와 `crawl_blocked` 컬럼이 2026-06-22 stale 브랜치 머지(94466889)에서 동반 유실 → `service.py`의 `try/except: pass`가 ImportError를 삼켜 게이트 inert였던 것을 **복원**(컬럼=`models.py`+`shared/db.py` ensure-columns 양 dialect, 함수=`api_pricing.py`). 통합테스트 3건 PASS, 라이브 배포·실크롤 검증 대기. ⓐ **None=재고있음**(`_resolve_stock:216`) 시맨틱 변경은 광범위 영향이라 여전히 별도(미수정). ③ **fragile/app-전역** — P12(매핑 모달 rename 흐름 깊은 변경)·O13 서버측(글로벌 에러핸들러 app 전역). ④ **코드 단건 대상 아님** — P14(준비중 표시 완료, 실제 재계산은 feature)·O1(철칙=상단 3철칙)·O3·O4(운영 주의). + **방어 가드 추가**(2026-06-28): `ext_bridge.js` `fetchJson`로 비-JSON 응답 시 조용한 SyntaxError 차단(option-matrix·codes). S14 reset/finalize 정의누락은 별도 조사 task 위임.

---

## §6. 신규 소싱처 수집 방식 판정 래더 (성능 우선순위)

🟢 **쉬운 설명** — 새 소싱처 URL을 받으면 **빠르고 정확한 순서**대로 적용 가능 여부를 따져 가능한 가장 높은 방식을 쓴다.

| 순위 | 방식 | 적용 가능 판단 | 차단 게이트 | 레퍼런스 |
|---|---|---|---|---|
| 1 | 전용 API 직접 호출 | 상세에서 호출되는 내부 JSON 엔드포인트 존재 + 직접 호출 가능 | 방화벽·게이트웨이·CORS | 무신사·롯데온 |
| 2 | 내장 구조 JSON 파싱 | `__NEXT_DATA__`·`option_stock_data`·`uitemObj`·`itemInvQtyInfo` 페이지에 통째로(API 준함) | — | 현대H몰·르무통·SSG·롯데아이몰 |
| 3 | SSR HTML 정규식/셀렉터 | raw HTML/JS문자열에 값 존재 | lazy 렌더 | SSF |
| 4 | 렌더 DOM(확장 navGrab) | 위 셋 불가·JS 렌더 후에만 생김 | (최후) | 롯데온 혜택가 |

**가로 게이트(어느 순위든)**: 방화벽/게이트웨이→한 단계 내림 · WAF/봇차단→curl_cffi 위장 또는 확장 navGrab 강제 · 로그인=혜택만→혜택만 확장 · 정량 비공개(무신사 정상재고·SSF 일반재고)→999가 정답(가짜 숫자 금지).

> 기존 §1·§2가 "각 소싱처가 어느 형인지", 본 절이 "새 소싱처를 어느 형으로 분류할지"를 담당.

---

## §7. 신규 소싱처 온보딩 — 전체크롤 "배선" 체크리스트

🟢 **쉬운 설명** — 새 소싱처를 붙이는 일은 둘로 나뉩니다.
- **(가) 머리 쓰는 절반** — "이 사이트의 재고·가격·혜택을 *어떻게 읽나*?" → **§6 수집 래더**로 형(API/JSON/HTML/DOM)을 정하고 §1·§2로 소싱처별 로직을 짭니다.
- **(나) 빠뜨리기 쉬운 절반** — 읽은 값을 시스템 파이프라인에 **등록(배선)** 하기. 크롤러가 완벽해도 등록을 빠뜨리면 **화면에 아무것도 안 뜹니다.** ← 이 절이 그 (나)를 빠짐없이 적은 곳.

> 💡 §6이 "새 소싱처를 *어떻게 읽을지*"라면, §7은 "읽은 값을 *어디에 끼울지*"입니다. 둘을 합쳐야 신규 소싱처 전체크롤이 완성됩니다.

### 0단계 — 가장 먼저 "경로"를 정한다

확장이 페이지를 다루는 방식이 두 갈래입니다(§6 래더의 1·2·3형 ↔ 4형 갈림과 대응). 무엇을 고르냐가 전체 작업량을 가릅니다.

| | **경로 A — 서버 파싱(navGrab)** | **경로 B — 확장 JS 추출(navExtract)** |
|---|---|---|
| 언제 | 비로그인 HTML/내장 JSON만으로 재고·가격이 보임(§6 2·3형) | 로그인 회원가·SKU별 재고·렌더 후에만 생기는 값(§6 1·4형) |
| 확장이 하는 일 | 페이지 렌더 HTML만 떠서 서버로 전송 | 페이지 안에서 추출기(`EXTRACTORS`) 실행해 직접 파싱 |
| 추출 위치 | **서버**(`/api/sources/parse`가 크롤러 `fetch`/파서 호출) | **확장**(`EXTRACTORS[source_key]` 함수) |
| 기존 예 | 르무통·SSF·SSG·스마트스토어 | 무신사·롯데온 |

⚠️ 확장은 재설계가 잦으니(현재 v0.7.x) 아래 확장 라인은 **심볼 기준**으로 보고 변경 시 실제 코드와 대조하세요. 경로 분기 = `background.js:92` `crawlOne`이 `EXTRACTORS[source_key]`를 찾고, **없으면 "레시피 없음(미구현 소싱처)" 에러로 그 소싱처를 건너뜁니다.**

### 🔧💻 백엔드 배선 9지점 (순서대로 · origin/main 기준 라인)

| # | 무엇 | 파일:함수 | 안 하면 생기는 증상 |
|---|---|---|---|
| B1 | 크롤러 클래스 작성(`fetch → CrawlResult`) | `crawlers/<소싱처>.py` (계약: `crawlers/base.py:78` `fetch`, `:23` `CrawlResult`) | 읽을 수단 자체가 없음 |
| B2 | 크롤러 등록 dict | `crawlers/__init__.py:21` `build_crawlers()` | 크롤러를 만들어도 시스템이 못 부름 |
| B3 | 소싱처 레지스트리(라벨·로고·키) | `lemouton/sourcing/source_registry.py:15` `SOURCES` | 드롭다운·소싱처 카드에 소싱처가 안 보임 |
| B4 | URL→소싱처 판정 | `webapp/routes/api_pricing.py:1620` `_detect_site_from_url` | 재크롤·refetch·자동크롤이 도메인을 못 알아봄 |
| B5 | URL 저장 후 자동크롤 크롤러 선택 | `api_pricing.py:1264` `_auto_crawl_after_url_save` (크롤러 import 그 아래) | URL 붙여도 자동크롤 안 됨 |
| B6 | 동적혜택 키 화이트리스트 | `lemouton/sources/service.py:631` `PRODUCT_DYNAMIC_KEYS` | 혜택 값이 DB에 저장 안 됨(=계산식에서 사라짐) |
| B7 | **혜택 source_id ↔ site 매핑** | `lemouton/sourcing/source_ids.py` `site_key`/`_SITE_BY_PRICING_ID` (2026-07-20 단일 원천 이관, 종전 `api_benefits.py` `_SITE_BY_SRC` 폐기) | **혜택을 다 짜도 계산식에 영영 안 뜸** (가장 자주 놓침) |
| B8 | 소싱처별 혜택 차감 블록 | `api_benefits.py:496` `compute_breakdown` (소싱처 분기는 `source_ids.site_key()` 호출 이후) | 혜택이 매입가에서 안 빠짐 |
| B9 | 재고 센티넬 분기(필요 시) | `api_pricing.py:196` `_resolve_stock` | 특수 상한값이 '재고있음'으로 둔갑 |

> ℹ️ 재고/가격 **영속**은 크롤러가 `CrawlResult.options`에 색·사이즈·재고·가격을 올바로 채우면 **자동**입니다(추가 배선 불필요). 경로: `api_pricing.py:148` `_persist_option_stocks` / `api_pricing.py:1115` `save_crawl_result` / `lemouton/sources/service.py:159` `upsert_source_option`. 단 옵션별 `current_stock`이 채워지는 건 ① 서버사이드 `_ingest` ② 확장 navGrab 의 `/api/sources/parse` (`api_sources_parse.py` `_persist_navgrab_option_stocks` — **옵션행 생성** upsert) ③ 확장 `crawl-result`의 `_persist_option_stocks`(**기존행 갱신만**) **중 그 소싱처가 쓰는 경로**에서.
> ⚠️ **함정(2026-06-26 수정)**: ②가 없을 때 ③만으로는 신규 등록 URL 의 옵션행이 **생성 안 됨**(③은 갱신 전용). 그러면 매트릭스가 옵션행 부재 → 상품 `last_stock`(전 사이즈 합계)을 **전 사이즈 균일 폴백**(르무통 올리브그린 전 사이즈 53개·품절 265 도 '있음' 둔갑). 또한 `crawl-result` 페이로드(`ext_bridge.js`)는 `options[]` 자체를 전송하지 않음. → 그래서 navGrab 4개 소싱처(르무통·SSF·SSG·스스)는 ②`parse` 단계에서 서버사이드 `_ingest`와 동일하게 옵션행을 **생성**(단품 색스코프+stale prune)한다. `[[project_lemouton_persize_stock_dropped_at_save]]` `[[project_smartstore_stock_not_persisted_extension_path]]` `[[project_ssg_single_color_size_parse_bug]]`.
> ⚠️ **함정 · 남은 변종(2026-07-03 수정)**: 위 2026-06-26 수정은 "옵션행이 **아예 없을 때**의 전 사이즈 **균일** 폴백"만 막았다. **옵션행은 있는데(=매칭됨) 그 사이즈의 `current_stock`이 NULL**(일부 사이즈만 미수집)이면 여전히 `else sp.last_stock`으로 폴백해 **그 칸만 합계로 둔갑**했다(실사례: SSG 단품 블랙 13사이즈 중 7칸이 380=전 사이즈 합계). → `api_pricing.py` `_option_matrix_data`의 `crawled_stock` 산식을 **`_opt_stock if _so_matched`**(매칭됐으면 None이어도 그대로 = 미상/크롤실패 표면화, `last_stock` 폴백 금지. 상품레벨 전용 소싱처=옵션행 자체 부재일 때만 `last_stock`)로 수정. 회귀 테스트 `tests/sourcing/test_option_matrix_bsu_id.py::test_matched_so_null_stock_no_aggregate_fallback`. 근본(왜 SSG 단품 일부 사이즈가 NULL인지=저장 누락)은 별도 추적. `[[feedback_no_fallback_price_on_match_fail]]`.
> ⚠️ **함정 · 화면 표기(2026-07-04 수정)**: 위에서 매칭-NULL 셀을 `None`으로 바꿔도, `_resolve_stock`이 `None`+`last_status='ok'` 를 **'재고있음'으로 렌더**해(상품은 크롤 ok지만 이 셀은 미수집) **품절인데 '재고있음' 둔갑(금전위험)이 그대로**였다. → 매칭-미수집 셀에 per-셀 `status='uncollected'` 를 부여, `_resolve_stock`/`_stock_state` 가 **'확인 불가'(state=`uncollected`)** 로 확정. '재고있음'·'품절' 어느 쪽으로도 단정 안 함(무결성: 누락 표면화). 셀 dict `stock_uncollected` 플래그. 회귀 `test_uncollected_cell_is_confirm_unavailable_not_instock` + `test_matched_so_null_stock_no_aggregate_fallback`(라벨 검증). 이렇게 **어느 셀이 미수집인지 화면에 드러나므로**, 소싱처별 수집 로직을 정확 크롤로 수렴시키는 기준점이 된다.
> ✅ **근본 해결(2026-07-04)**: '확인 불가'로 드러난 미수집(SSG 36·롯데온 47·스스 71 = 154칸)의 **공통 근본 = 중복 SourceOption**. `size_text` 표기차(옛 `'220'` + 신규 `'220mm'` 등)로 **정규화-동일한 중복행**이 생겨(stale prune 은 정규화 비교라 둘 다 생존), `_match_option_so` 가 **옛 stale 행(current_stock=None)을 먼저 골라** 실재고 행을 무시했다(크롤은 데이터를 넣고 있었음). → `_match_option_so` 가 정확매칭·size-only 후보 중 **current_stock 이 None 아닌 실재고 행을 우선** 선택하도록 수정. 이 **한 곳 수정**으로 **SSG·롯데온·스스 154칸 전부 해결**(라이브 검증: 8소싱처 전부 미수집 0, 롯데온 올리브 230=품절·다크235=49·SSG블랙255=10, 실측 일치). 회귀 `test_stale_dup_none_does_not_beat_stocked_row`. 즉 "부정확 크롤"이 아니라 **"중복행 중 stale이 이기던 것"** 이 품절둔갑의 공통 뿌리였다. `[[project_lotteon_dan_stock_dup_rows]]`.
> ℹ️ 빌트인(코드 고정)으로 레거시 `Model.url_*` 컬럼까지 쓰려면 `lemouton/sourcing/pipeline.py:17` `SOURCE_URL_FIELD` + DB 컬럼 추가가 더 필요. **DB 등록만 하는 커스텀 소싱처는 관리자 UI(`webapp/routes/accounts.py:307` `SourcingSource` 테이블)로 추가** — 이 경우 B3는 자동.

### 🔧💻 확장 배선 4지점 (경로 B는 전부 / 경로 A는 ★만 · 심볼 기준)

> ⚠️ **전체크롤 실제 진입점**: `toss.js`의 `enqueueCrawl(code)` → `ext_bridge.js`가 `send("crawl.enqueue",{code})` → **확장 백그라운드 큐러너**가 수행(탭 닫아도 지속). 큐러너가 소싱처별로 비로그인 4=navGrab(→서버 `/api/sources/parse`)·로그인 2=navExtract(확장 추출기)로 분기.
> ⚠️ **실제 확장 코드 = 데스크톱 로드본**. 2026-07-23 기준 repo·데스크톱 모두 **v0.7.62** 로 정합(옛 「repo 는 v0.4.3 구버전」 기록은 폐기 — 지금은 repo 가 정본이고, 데스크톱 사본은 그걸 복사해 로드한다 → `reference_loaded_extension_path`). 다만 **파일:라인은 계속 표류**하므로 심볼 기준으로 인용할 것.
> 🔴 **버전 상수는 3곳**(`manifest.json` · `background.js` · `content_mou.js`) — **항상 같이 올린다**. 어긋나면 페이지 `data-moum-ext` 가 거짓 버전을 말해 「확장이 안 올라갔다」로 오판한다(§5 O25 · 2026-07-23 재발해 다시 맞춤).

| # | 무엇 | 파일:심볼 | 비고 |
|---|---|---|---|
| E1 ★ | 도메인 권한 | `extension/moum-crawler/manifest.json:7` `host_permissions` | 두 경로 모두 필수. 없으면 페이지 접근 차단 |
| E2 | 추출기 등록 | `background.js:76` `EXTRACTORS = { ... }` | 경로 B만. `source_key → 추출함수`. 없으면 `crawlOne`(:92)이 건너뜀 |
| E3 | 추출 함수 작성 | `background.js` (무신사·롯데온 함수 모방) | 반환: `{ok, price, stock, options:[{color,size,price,stock}], product_name}` (+선택 `benefit_lines`·`member_price`·`surface_price`) |
| E3-b 🔴 | **전용 어댑터 확인** | `background.js` `FETCH_ADAPTERS`(:1971) — `fetchRawParseAdapter`(범용) / `fetchHmallAdapter`(현대H몰 전용) / `fetchMusinsa…` 등 | **그 소싱처가 어느 어댑터를 타는지 먼저 확인**하고 **그 어댑터 반환부**에 넣는다. 범용에 넣으면 전용 어댑터 소싱처에서 **에러 없이 조용히 안 돈다**(§5 P25 실제 사고) |
| E4 | 크롤 대상 목록·진입 | `webapp/static/ext_bridge.js` `enqueueCrawl`→`crawl.enqueue`(확장 큐러너) / `crawlBundle`은 구버전 스케줄러 경로 | 새 소싱처는 모음전 등록 URL에 포함되면 큐러너가 자동 순회 · 혜택은 `crawl-result` 페이로드 `benefit_lines` 전달 필요 |

> ⚠️ **경로 A의 서버측 추출**: 확장이 보낸 HTML은 서버 파싱 핸들러(`/api/sources/parse`)가 `source_key`로 해당 크롤러를 골라 파싱합니다. 즉 경로 A도 **B1·B2(서버 크롤러 등록)가 그대로 필요** — 확장은 HTML 운반만 합니다.

### 🔧💻 표시 배선 2지점 (전체크롤 위젯·매트릭스에 새 소싱처가 보이게)

백엔드·확장만 배선하면 **크롤·계산은 되지만 화면 표시에서 누락**됩니다. "동일한 *방식*의 전체크롤"을 완성하려면 표시 층도 등록해야 합니다.

| # | 무엇 | 파일:심볼 | 안 하면 생기는 증상 |
|---|---|---|---|
| D1 | 전체크롤 위젯 소싱처 라벨·순서 | `webapp/static/crawl_log.js:12` `SOURCE_LABELS` + `:20` `SOURCE_ORDER` | **전체크롤 위젯에 그 소싱처 진행카드가 안 뜸**(`bundleProgress`가 `SOURCE_ORDER.forEach`로만 그림, :166·:227). 라벨 없으면 원시 key 노출 |
| D2 | 매트릭스 소싱처 색상 | `webapp/templates/bundles/_matrix_v3.html:1198~` 색상맵 | 새 소싱처가 기본 파랑(`#5b8def`)으로 표시(폴백 있어 경미) |

> ℹ️ DB 등록 커스텀 소싱처는 한글 라벨이 `webapp/routes/api.py:57` `_custom_source_labels`(SourcingSource.label)에서 주입되기도 함. 빌트인은 D1이 정본.

### ✅ 마지막 — 라이브 자가검증 7종 (하나라도 빠지면 위 표 역추적)

1. 소싱처가 **드롭다운/소싱처 카드**에 뜨나? → 안 뜨면 **B3**
2. URL 붙이면 **자동크롤이 도나**? → 안 돌면 **B4·B5** (+확장 경로면 E1·E2)
3. 매트릭스 셀에 **표면노출가**가 뜨나? → 안 뜨면 크롤러 출력·영속(B1) / 추출기 등록(E2)
4. 계산식(영수증)에 **혜택**이 빠지나? → 빠지면 **B6 → B7 → B8** (DB엔 저장됐는데 안 뜨면 **B7**이 범인) / 라이브 갱신이면 **E4**
5. 재고 **3상태**(품절·실수량·재고있음)가 맞나? → 둔갑하면 크롤러 정규식 / **B9**
6. **전체크롤 위젯**에 그 소싱처 진행카드가 뜨나? → 안 뜨면 **D1** / 색이 기본 파랑이면 **D2**
7. **동기화 게이트** — 변경 후 `/sourcing-guide/map` 상단 **배너가 0(조용함)** 인가? 빨강이면: 소싱처 누락→§1·§2·§6·§7 행 추가(원문+보기) / 심볼 사라짐→인용·SYMBOL_MANIFEST 갱신 / 확장버전→§7 설명 + GUIDE_EXT_BASELINE 갱신.

⚠️ dev DB는 stale일 수 있으니 반드시 **라이브 대조** (`[[project_db_topology_supabase_vs_aws]]` `[[project_stock_3state_live_verified]]`). 크롤 실패 시 옛값 금지·"크롤 실패" 표기 (§4).

---

## §8. 이 가이드의 동기화 규칙 (코드 ↔ 원문·보기)

🟢 **쉬운 설명** — 크롤 코드가 바뀌면 이 가이드(원문·보기)도 같이 바뀌어야 한다. 둘이 어긋나면 잘못된 설명서가 된다. **동기화가 필요한 경우 2가지**:
- **A. 크롤 방식 업데이트** — 크롤 로직·확장이 바뀜 (예: 전체크롤 확장 버전 업)
- **B. 신규 소싱처 추가**

### 동작 순서 (3 주체)

| 순서 | 주체 | 동작 |
|---|---|---|
| 0 | 👤 **사용자** | "소싱처 추가" / "크롤 방식 변경" 요청·결정 |
| 1 | 🤖 **Claude Code** | 크롤 **코드** 변경 (크롤러·배선 B/D·확장 방식) |
| 2 | 🤖 **Claude Code** | **같은 작업에서** 가이드 **원문(.md)+보기(map.html) 둘 다** 갱신 (§1·§2·§6·§7) |
| 3 | 🤖 **Claude Code** | origin/main 배포 |
| 4 | 🖥️ **MOU-M 프로그램** | 데이터지도 열릴 때 **자동 검사** (소싱처 누락·확장버전·심볼) |
| 5 | 🖥️ **MOU-M 프로그램** | 어긋나면 **상단 배너** / 0이면 조용함 |
| 6 | 🤖 **Claude Code** | 배포 직후 배너 0인지 **스스로 확인** (빨강이면 2로 복귀) |
| 7 | 👤 **사용자** | 평소 데이터지도 보다 배너 뜨면 인지(장기 안전망) |

### 무엇이 자동 / 무엇이 절차

| 어긋남 | 자동 감지 |
|---|---|
| 신규 소싱처가 가이드에 빠짐 | ✅ (적용) |
| 확장 버전이 가이드 기준과 다름 (전체크롤 방식 바뀜) | ✅ (적용) |
| 인용 심볼 사라짐·이름변경 | ✅ (적용) |
| 순수 로직만 조용히 바뀜(버전·심볼 그대로) | ❌ → 코드 고칠 때 가이드도 같이(절차) |

> ✅ **현재 상태**: 4·5의 자동 배너 **적용 완료** — 데이터지도 상단에 소싱처 누락(P1)·심볼 사라짐(C2)·확장 버전 불일치(C1) 자동 표시. 순수 로직 변경은 여전히 절차로.

*최종 수정: 2026-07-23(2) **혜택엔진 1·2차 전수 반영** — §2 현대H몰 카드 즉시할인 = 결제 택1(`item-prmo-lst`+`uh2oxid` 헤더·창 0개·보유가드·당일 로테이션)·SSG 제휴쿠폰=경유 축·저장배선 표에 현대H몰 전용 어댑터 행 + 불리언 `_BOOL_KEYS` 예외·경유 절에 **사장님 확정 택1 규칙**과 4몰 판별표 + §5 +3건(P28 fx 단건 라우트 정수전용·P29 문자키 Integer filter_by 500·P30 SSG 제휴쿠폰 중복차감) → 가격 30·기타 27 + P24 진단 함정 보강 + §2-b 표 **+3 소싱처**(르무통 공홈 Cafe24·SSF JSON-LD·SSG 직계 lo_depth_01 → 수집 7곳·부재확정 1곳)·함정 +2 + §7 확장 배선 **E3-b 전용 어댑터 지점** 신설 및 「repo 확장은 v0.4.3 구버전」 낡은 기록 폐기(repo·데스크톱 모두 v0.7.62) + §0 「라이브 경로가 혜택 저장을 우회」 낡은 기록 정정 + 인용 파일:라인 17곳 실코드 재대조. / 2026-07-23 **§2-b 카테고리편 신설**(소싱처 5곳 원천표 + 함정 4 — 무신사/롯데온=확장 추출·아이몰 직계자식만·스스 반쪽경로 금지·현대H몰 부재확정) + §2 「경유(N쇼핑) 판별·쿠폰 요율」 절 (선반영 게이트·Hmall tcDcInf·롯데온 favorBox 제휴할인·아이몰 플러스쿠폰 1장·실구매 피드백 우선순위) + §5 +4건(P25 Hmall 카드 조용한 무동작·P26 경유 플래그 False 고착·O26 아이몰 형제 드롭다운 혼입·O27 현대H몰 카테고리 부재확정) → 가격 29·기타 27. 확장 기준버전 0.7.62. / 2026-07-22 §5 +5건(P24~P27 가격·O21 기타 — 2026-07-20~21 타 세션 수정분 소급 기록: compute_breakdown 정답무시/공유혜택 캐시오염/저장경로 비대칭/이름 공백 게이트/확장 버전 3곳) → 가격 27·기타 21. / 2026-06-28 ⏳ 추적·수정 — O16(ssg→ssf 오분류)·P15(허용목록밖 보고)·P13(무효SKU 보고)·P23(95000 폴백 제거)·O5(위젯 honesty) 수정 + P9 기수정 확인 → ✅(미해결 17→11). S14 reset/finalize 정의누락 발견(별도 조사) → **2026-06-28 ⓑ 복원 완료**(함수·`crawl_blocked` 컬럼 stale머지 유실분 복원, 통합테스트 3건 PASS). / 2026-06-27 §5를 '에러 이력 카탈로그'(재고16·가격23·기타16 = 55건, 2축 분류 + 상태 + 커밋, 조용한 실패 3철칙)로 전면 교체 — 기존 '재발' 복귀표 흡수 + 메모리·코드 전수 스윕으로 18건 추가(딜파서·전소싱처pending·무신사API999·1원둔갑·배치savepoint·cross-source혜택·override가림·정규화중복·N+1·SW수면·95000폴백·ssg오분류 등). 상단 안내문 추가. / 2026-06-26 §8 동기화 규칙(코드↔원문·보기, 3주체 순서·자동/절차) 신설. + §7 신규 소싱처 온보딩 배선 체크리스트(B1~B9·E1~E4·D1~D2·경로 A/B). 코드 변경 시 해당 `💻`·배선 라인 동기화. 소싱처 추가 시 §1·§2 표 행 + §6 형 분류 + §7 자가검증 + §8 절차.*
