PageSourceSearch

https://tstrillion.com/skin-skin184/24d/add_part/js/ts-plp-timedeal.js?v=20260903_member1

js tstrillion.com collected 2026-10-04 23:33:32 UTC 56,041 bytes, 934 lines download raw bytes

1// ============================================================
2// [파일] ts-plp-timedeal.js
3// [역할] PLP 타임딜 카드 훅 · 진열구조에서 타임딜 카드를 감지해 li.is-timedeal + Event 뱃지 주입, 가격줄 순서 정렬
4// [로딩] layout.html + main.html 의 head 직접 script defer (?v 캐시버스트). 스타일은 ts-plp-timedeal.css 전담
5// [적용범위] .prdList li[id^=anchorBoxId] (Cafe24 진열 카드 li). 감지된 카드만 is-timedeal
6// [수정 시 주의] 셀렉터가 Cafe24 진열 DOM(anchorBoxId · spec_price · sale_box)에 의존 → 진열모듈 변경 시 재확인. 노드 이동만 하고 삭제/모듈변수 변경 금지. 수정 후 ?v 갱신(G-6)
7// [연관] ts-plp-timedeal.css(라임 타이머바 스타일) · ts-plp-name.css(가격줄 숨김/통일 규칙)
8//
9// [Figma 919:858 · Cafe24 네이티브 타임세일(wp-timer) 기반]
10// - 트리거: 카드에 .wp-timer(네이티브 타임세일 타이머) 또는 .prd_price_sale(할인판매가)가 있으면 타임딜
11// - 처리: li.is-timedeal 부여 + Event 뱃지(.ts-td-event) 주입 + 최종 할인가를 할인율(%) 앞으로 이동
12// - wp-timer 는 앱이 async 주입 → 폴링으로 지연 대응. 마크업/모듈 변수 불변(부가 클래스/요소·노드순서만)
13// ============================================================
14(function () {
15  'use strict';
16
17  /* [TODO-467] 판정 조회 전용 프로브 — detail.html 은 방문자 트래픽으로 여러 변형(쿠키·UA)이
18     캐시돼 관리자 변경 직후 낡은 사본 복권이 다수가 되면 다수결도 못 이긴다(실측: 진행안함 전환 후
19     PDP 원복까지 10분+). td_probe.html 은 스크립트만 버스터로 조회하므로 캐시 오염이 없어
20     항상 신선한 렌더를 받는다 — 같은 스펙 행(할인판매가·할인 기간)이라 파서는 그대로다. */
21  var PROBE_URL = '/product/td_probe.html?product_no=';
22
23  function num(el) {
24    if (!el) return NaN;
25    var t = (el.textContent || '').replace(/[^0-9]/g, '');
26    return t ? parseInt(t, 10) : NaN;
27  }
28
29  /* 회원 등급 할인가(예: "웰컴 할인가") li 를 찾는다 — Cafe24 가 클래스를 주지 않는다(실측 `class=" xans-record-"`).
30     `.spec` 직계 li 중 spec_* / sale_box 가 아니고, 라벨(strong.title)에 '할인가' 가 든 것.
31     '할인 기간' li 는 `.discountPeriod` 를 갖고 display:none 이라 제외된다.
32     등급명이 앞에 붙어(웰컴/트릴리온/빌리언…) 라벨 전체를 못 박으므로 '할인가' 부분만 본다. */
33  function findMemberPrice(spec) {
34    if (!spec) return null;
35    var found = null;
36    Array.prototype.forEach.call(spec.children, function (el) {
37      if (found) return;
38      var cls = el.className || '';
39      if (/spec_info|spec_consumer|spec_price|sale_box/.test(cls)) return;
40      if (el.querySelector('.discountPeriod')) return;
41      var label = el.querySelector('.title');
42      if (label && /할인가/.test(label.textContent || '')) found = el;
43    });
44    return found;
45  }
46
47  /* 가격 li 의 텍스트에서 **앞쪽 금액**만 읽는다.
48     "할인판매가 : 13,750원 ( 150원 할인)" 처럼 숫자가 둘이면 뒤의 할인액(150)을 잡는 순간
49     할인율이 엉뚱해지므로 첫 번째 `N원` 만 쓴다. 라벨(소비자가·판매가·할인판매가)엔 숫자가 없어 안전. */
50  function firstWon(el) {
51    if (!el) return 0;
52    var m = String(el.textContent || '').replace(/\s+/g, ' ').match(/([\d,]+)\s*원/);
53    return m ? parseInt(m[1].replace(/,/g, ''), 10) : 0;
54  }
55
56  /* ── 할인율 뱃지(sale_box) 재계산 (2026-09-01 발주처 지시) ──────────────────
57     Cafe24 의 % 는 **할인 전 판매가** 기준으로 계산해 놓고, 금액은 **할인 적용가**를 보여준다.
58       실측: 소비자가 19,000 / 판매가 13,900 / 할인판매가 13,750 → 뱃지 `27%`
59       27% 는 13,900 의 할인율(26.8%)이라 화면에 찍힌 13,750(27.6% → 28%)과 어긋난다.
60     → 화면에 실제로 보이는 **최종가 기준**으로 다시 쓴다. PDP 타임딜 박스도 같은 규칙이라 두 화면이 일치한ë
60‹¤.
61     ⚠️소비자가·최종가 중 하나라도 못 읽으면 **손대지 않는다**(fail-open — Cafe24 값 유지).
62     ⚠️mark() 안에서만 호출 = 타임딜 카드 한정. 일반 상품 뱃지는 건드리지 않는다. */
63  function recomputeRate(spec, saleBox) {
64    if (!spec || !saleBox) return;
65    var consumer = firstWon(spec.querySelector('.spec_consumer'));
66    var finalEl = spec.querySelector('.ts-td-final');   /* 회원 등급 할인가 우선 */
67    if (!finalEl) {
68      var ps = spec.querySelectorAll('.spec_price');
69      if (ps.length >= 2) finalEl = ps[ps.length - 1];  /* 마지막 = 할인판매가 */
70    }
71    /* [F2] 시작 전/종료 원복 상태에선 딜 가격줄에 ts-td-over 가 붙는다 — 그 상태의 뱃지는
72       setCardPriceDeal/markEnded 가 관리하므로 여기서 숨겨진 딜가 기준으로 되돌리면 안 된다
73       (실증: 더보기·load 재실행이 27% 를 28% 로 역행시킬 수 있었다). */
74    if (finalEl && finalEl.classList && finalEl.classList.contains('ts-td-over')) return;
75    var fin = firstWon(finalEl);
76    if (!consumer || !fin || consumer <= fin) return;
77    var rate = Math.round((consumer - fin) / consumer * 100);
78    if (!rate) return;
79    var txt = rate + '%';
80    /* 같으면 쓰지 않는다 → 폴링 재실행에도 idempotent */
81    if (String(saleBox.textContent).replace(/\s+/g, '') !== txt) saleBox.textContent = txt;
82  }
83
84  function mark(li) {
85    if (li.classList.contains('ts-td-ended')) return;   /* 종료 확정 카드는 다시 칠하지 않는다(폴링 재실행 대비) */
86    /* ⚠️회원 전용 기간할인은 타이머(.sale_countdown) 없이 **할인가 행만** 오는 렌더가 있다(실측).
87       그때도 타임딜 카드로 처리해야 가격줄이 정리된다 → 판정에 회원 할인가를 포함한다. */
88    var specEl = li.querySelector('.description .spec');
89    var memberPriceEl = findMemberPrice(specEl);
90    var isTd = li.querySelector('.sale_countdown, .wp-timer, .prd_price_sale') || memberPriceEl;
91    if (!isTd) return;
92    if (!li.classList.contains('is-timedeal')) li.classList.add('is-timedeal');
93
94    /* Event 뱃지 (제목 앞) */
95    var name = li.querySelector('.description .name');
96    if (name && !name.querySelector('.ts-td-event')) {
97      var ev = document.createElement('span');
98      ev.className = 'ts-td-event';
99      ev.textContent = 'Event';
100      name.insertBefore(ev, name.firstChild);
101    }
102
103    /* 이중할인 가격줄 통일: 최종 할인판매가(마지막 spec_price)를 %(sale_box) 앞으로 이동
104       → 일반카드와 동일한 [정가(취소선) → 할인가(볼드) → 할인율뱃지] 순서.
105       중간 판매가·라벨·"(N원 할인)" 문구는 ts-plp-name.css 가 숨김. 마크업/모듈변수 불변(노드 순서만 조정, 삭제 X). */
106    var spec = li.querySelector('.description .spec');
107    if (spec) {
108      var saleBox = spec.querySelector('.sale_box');
109      var prices = spec.querySelectorAll('.spec_price');
110      if (saleBox && prices.length >= 2) {
111        var finalPrice = prices[prices.length - 1]; /* 마지막 spec_price = 할인판매가(최종가) */
112        /* 최종가가 %(sale_box) 뒤에 있으면 % 앞으로 이동(이미 앞이면 무동작 → 폴링 재실행에도 idempotent) */
113        if (saleBox.compareDocumentPosition(finalPrice) & Node.DOCUMENT_POSITION_FOLLOWING) {
114          spec.insertBefore(finalPrice, saleBox);
115        }
116      }
117
118      /* ── 회원 등급 할인가(예: "웰컴 할인가") 처리 (2026-09-01 발주처 제보) ──
119         회원 전용 기간할인은 위 `할인판매가`(.spec_price) 와 **마크업이 다르다**. 실측(로그인 상태):
120           li.spec_price  "판매가 : 29,900원"
121           li.sale_box    "57%"
122           li." xans-record-"  "웰컴 할인가 : 20,000원 ( 9,900원 할인)"   ← **클래스가 없다**
123         그래서 `.spec_price` 만 보던 기존 규칙이 전부 빗나가 판매가·할인가가 **둘 다 보이고 줄바꿈**됐다.
124         판정: `.spec` 직계 li 중 spec_* / sale_box 가 아니고, 라벨(strong.title)에 '할인가' 가 든 것.
125               '할인 기간' li 는 `.discountPeriod` 를 갖고 이미 display:none 이라 제ì
125™¸ëœë‹¤.
126         등급명이 앞에 붙어(웰컴/트릴리온/빌리언…) 라벨 전체를 못 박으므로 '할인가' 부분만 본다.
127         찾으면 우리 클래스(ts-td-final)를 붙이고 %(sale_box) 앞으로 옮긴다 — CSS 는 그 클래스만 본다. */
128      var memberPrice = findMemberPrice(spec);
129      if (memberPrice) {
130        if (!memberPrice.classList.contains('ts-td-final')) memberPrice.classList.add('ts-td-final');
131        if (saleBox && (saleBox.compareDocumentPosition(memberPrice) & Node.DOCUMENT_POSITION_FOLLOWING)) {
132          spec.insertBefore(memberPrice, saleBox);   /* 순서: 소비자가 → 할인가 → % */
133        }
134      }
135
136      recomputeRate(spec, saleBox);
137
138      /* [TODO-475·B안] 회원 전용 딜을 비로그인으로 보는 경우 — 기간 행(.discountPeriod)은 오지만
139         할인가 행이 없어 가격이 평소가 그대로다. 오해 방지 + 가입 유도로 "회원 전용 특가" 라벨을 단다.
140         로그인(할인가 행 존재)이나 회원+비회원 딜에는 붙지 않는다. */
141      var hasPeriodRow = !!spec.querySelector('.discountPeriod');
142      var hasDealPrice = !!(memberPrice || spec.querySelectorAll('.spec_price').length >= 2);
143      var oldLabel = spec.querySelector('.ts-td-mlabel');
144      if (hasPeriodRow && !hasDealPrice) {
145        if (!oldLabel) {
146          var ml = document.createElement('li');
147          ml.className = ' xans-record- ts-td-mlabel';
148          ml.textContent = '회원 전용 특가';
149          if (saleBox) spec.insertBefore(ml, saleBox); else spec.appendChild(ml);
150        }
151      } else if (oldLabel && oldLabel.parentNode) {
152        oldLabel.parentNode.removeChild(oldLabel);   /* 로그인 등으로 가격이 생기면 라벨 제거 */
153      }
154    }
155  }
156
157  // ── 스캔: Cafe24 진열 카드 li(anchorBoxId 접두 id)를 훑어 mark() 적용. 폴링/더보기로 반복 호출되므로 idempotent ──
158  /* ══ 시작 전 타임딜 타이머 숨김 (2026-09-01 발주처 지시) ══════════════════
159     Cafe24 는 혜택을 '진행함' 으로 등록한 순간부터 종료시각까지 카운트다운을 노출한다.
160     그래서 **시작 전인데 "N 종료"** 로 보여 이미 진행 중인 것처럼 오해된다
161     (실측: 13:00 시작 혜택이 11:52 에 이미 `0일 04:07:43 종료` 로 노출).
162     ▶PDP 는 상세정보 "할인 기간" 행에 Cafe24 가 시작 전 상태를 직접 준다:
163         .product_promotion_date = "0일 01시간 1분 전입니다."   ← '전입니다' 가 시작 전 신호
164         .period                 = "2026-09-01 13:00 ~ 2026-09-01 16:00"
165     ▶목록 카드에는 그 정보가 없다(실측: `<div class="sale_countdown">` 클래스뿐, 속성 0개).
166       그래서 해당 상품 상세를 **1회만** 조회해 판단하고 상품번호로 캐시한다.
167     ⚠️판정 실패·조회 실패·시간초과는 **보이는 쪽으로 되돌린다(fail-open)** —
168       진행 중인 딜을 가려 매출을 막는 쪽이 훨씬 나쁘다. */
169  var preStartCache = {};   /* 상품번호 → true(시작전) / false(진행중) */
170  var endedCache = {};      /* 상품번호 → true(종료됨). 원본 조회로만 채운다 */
171  var inflight = {};
172
173  /* ⚠️판정은 **절대시각(.period) 이 1순위**다.
174     `.product_promotion_date`("N 전입니다.")는 서버가 렌더 시점 기준으로 계산해 넣는 상대 문구라
175     **캐시된 응답에서는 낡은 값**이 온다(실측: 시작 후에도 목록이 "전입니다" 를 읽어 계속 숨겼다).
176     `.period` 는 `2026-09-01 12:30 ~ 16:00` 같은 절대시각이라 캐시돼도 값이 상하지 않는다.
177     상대 문구는 `.period` 가 없을 때만 보조로 쓴다. */
178  /* `.period` 의 시작시각(ms). 없으면 null */
179  function startMsFrom(root) {
180    var period = root.querySelector('.period');
181    if (!period) return null;
182    var m = (period.textContent || '').match(/(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})/);
183    if (!m) return null;
184    return new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], 0).getTime();
185  }
186
187  /* `.period` = "2026-09-01 15:20 ~ 2026-09-01 17:50" 에서 **뒤쪽(종료) 시각**.
188     startMsFrom 은 첫 번째만 읽으므로 종료 판정에는 쓸 수 없다. */
189  function endMsFrom(root) {
190    var period = root.querySelector('.period');
191    if (!period) return null;
192    var all = String(period.textContent || '').match(/(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})/g);
193    if (!all || all.length < 2) return null;
194    var m = all[all.length - 1].match(/(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})/);
195    return new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], 0).getTime();
196  }
197
198  /* ══ 종료된 딜 판정 (2026-09-01 발주처 제보) ═══════════════════════════
199     발주처가 딜을 **예정보다 일찍 강제 종료**하자, 원본(cache MISS)은 정상인데
200     **엣지 캐시된 PLP 가 약 20분간 옛 화면을 계속 서빙**했다(실측 16:50):
201         PLP  19,000 / 13,750 / 28% + 타이머 "0일 00:59:29 종료"(옛 17:50 기준)
202         PDP  19,000 / 27% / 13,900  ← 이미 정상
203     → 목록엔 할인가, 들어가면 정상가. 결제는 서버가 다시 계산하므로 금액 사고는 없지만
204       고객에게는 "가격이 다르다"로 보인다.
205     기존 게이트는 **시작 전 숨김만** 했고 종료는 아예 보지 않았다. 여기서 닫는다.
206     ⚠️`.period` 의 종료시각은 **강제 종료엔 무력**하다(캐시된 문서엔 옛 종료시각이 박혀 있다).
207       그래서 캐시를 우회한 **원본 조회 결과**를 1순위로 쓴다. */
208  function dealGoneFrom(doc) {
209    /* 먼저 이 문서가 정상 상세페이지인지 확인 — 파싱 실패·에러페이지면 판단하지 않는다 */
210    if (!doc.querySelector('#span_product_price_text, .xans-product-detail')) return null;
211    if (doc.querySelector('.period')) return false;                    /* 할인 기간 행이 있으면 딜 있음 */
212    if (doc.querySelector('.product_promotion_date')) return false;
213    if (doc.querySelector('.sale_countdown_detail')) return false;     /* 상세 타이머가 있으면 딜 있음 */
214    var sale = doc.querySelector('#span_product_price_sale, #span_optimum_discount_price');
215    if (sale && String(sale.textContent || '').replace(/\s/g, '')) return false;
216    return true;                                                       /* 딜 흔적 0 = 종료됨 */
217  }
218
219  /* 목록 카드에서 딜 흔적을 걷어낸다.
220     ⭐딜 표시는 **전부 `li.is-timedeal` 스코프**라 그 클래스만 떼면 라임 타이머바·Event 뱃지 스타일·
221       중간 판매가 숨김·할인가 강조·초록 % 알약이 한꺼번에 원복된다. 남는 3가지만 손으로 처리한다. */
222  /* 원본 문서 하나를 '지금 어떤 상태냐'로 요약한다: 'gone'(딜 없음/종료) · 'pre'(시작 전) · 'run'(진행 중) */
223  /* ⚠️원본 조회는 복권이다 — 관리자가 기간을 바꾼 직후엔 **옛 기간 사본·공백기 사본**이 섞여 온다
224     (2026-09-02 실측 2건: ①공백기 사본 'gone' 복권에 진행 중 딜이 꺼짐 ②시작을 뒤로 미루자
225      옛 기간 사본 'run' 복권에 시작 전 딜이 켜짐 — 부정 판정만 합의를 받던 비대칭 규칙의 역효과).
226     'run' 도 'pre' 도 'gone' 도 어느 쪽이든 낡은 사본일 수 있다 → 방향 비대칭을 버리고
227     **첫 판정을 2/3 다수결**로 확정한다: 1발 → 1.2초 뒤 2발(일치 시 확정) → 불일치면 3발(다수결).
228     3자 전원 불일치는 보이는 쪽(fail-open run). 이후의 흔들림은 45/60초 재검증 루프(2연속 합의)가 잡는다. */
229  function fetchVerdict(key, cb) {
230    window.fetch(PROBE_URL + key + '&_td=' + Date.now(), { credentials: 'same-origin', cache: 'reload' })
231      .then(function (r) { return r.text(); })
232      .then(function (html) {
233        var doc = new DOMParser().parseFromString(html, 'text/html');
234        cb(verdictFrom(doc), doc);
235      })
236      .catch(function () { cb(undefined, null); });
237  }
238
239  function settleVerdict(key, v1, doc1, apply) {
240    if (v1 === null || !/^\d+$/.test(String(key))) { apply(v1, doc1); return; }
241    window.setTimeout(function () {
242      fetchVerdict(key, function (v2, doc2) {
243        if (v2 === v1) { apply(v1, doc2 || doc1); return; }
244        window.setTimeout(function () {
245          fetchVerdict(key, function (v3, doc3) {
246            if (v3 === v1) { apply(v1, doc3 || doc1); return; }
247            if (v3 === v2 && v2 != null) { apply(v2, doc3 || doc2); return; }
248            apply('run', doc1);   /* 전원 불일치 → 보이는 쪽 */
249          });
250        }, 1200);
251      });
252    }, 1200);
253  }
254
255  function verdictFrom(doc) {
256    if (dealGoneFrom(doc) === true) return 'gone';
257    var eMs = endMsFrom(doc);
258    if (eMs !== null && Date.now() >= eMs) return 'gone';
259    var sMs = startMsFrom(doc);
260    if (sMs !== null) return Date.now() < sMs ? 'pre' : 'run';
261    var pre = preStartFrom(doc);
262    if (pre === true) return 'pre';
263    if (pre === false) return 'run';
264    return null;
265  }
266
267  /* markEnded 를 되돌린다 — 검증 루프가 '진행 중' 합의에 도달했을 때만 쓴다. */
268  function unmarkEnded(li) {
269    if (!li || !li.classList.contains('ts-td-ended')) return;
270    li.classList.remove('ts-td-ended');
271    var t = li.querySelector('.sale_countdown, .wp-timer');
272    if (t) t.classList.remove('ts-td-over');
273    var spec = li.querySelector('.description .spec');
274    if (spec) {
275      var over = spec.querySelectorAll('.ts-td-over');
276      for (var i = 0; i < over.length; i++) over[i].classList.remove('ts-td-over');
277      var sb = spec.querySelector('.sale_box');
278      var keep = sb && sb.getAttribute('data-td-rate');
279      if (keep) sb.textContent = keep;
280    }
281    try { mark(li); } catch (e) {}   /* Event 뱃지·가격줄 재구성 */
282  }
283
284  function markEnded(li) {
285    if (!li || li.classList.contains('ts-td-ended')) return;
286    li.classList.remove('is-timedeal', 'ts-td-checking-card', 'ts-td-pre');
287    li.classList.add('ts-td-ended');
288
289    var t = li.querySelector('.sale_countdown, .wp-timer');            /* ①타이머 요소 자체 */
290    if (t) { t.classList.remove('ts-td-checking', 'ts-td-prestart'); t.classList.add('ts-td-over'); clearSchedules(t); }
291
292    var ev = li.querySelector('.description .name .ts-td-event');      /* ②우리가 넣은 Event 뱃지 */
293    if (ev && ev.parentNode) ev.parentNode.removeChild(ev);
294
295    var spec = li.querySelector('.description .spec');                 /* ③할인판매가 줄 */
296    if (!spec) return;
297    var fin = spec.querySelector('.ts-td-final');
298    if (!fin) { var ps = spec.querySelectorAll('.spec_price'); if (ps.length >= 2) fin = ps[ps.length - 1]; }
299    if (fin) fin.classList.add('ts-td-over');
300
301    /* 할인율은 이제 **남아 있는 판매가** 기준으로 되돌린다(28% → 27%).
302       읽지 못하면 손대지 않는다 — 틀린 %가 남더라도 가격줄을 깨뜨리는 것보단 낫다. */
303    var saleBox = spec.querySelector('.sale_box');
304    var consumer = firstWon(spec.querySelector('.spec_consumer'));
305    var visPrice = null, all = spec.querySelectorAll('.spec_price');
306    for (var i = 0; i < all.length; i++) { if (all[i] !== fin) { visPrice = all[i]; break; } }
307    var now = firstWon(visPrice);
308    if (saleBox && consumer && now && consumer > now) {
309      var rate = Math.round((consumer - now) / consumer * 100);
310      if (rate > 0) saleBox.textContent = rate + '%';
311    }
312  }
313
314  /* 상세페이지: 타이머만 감추면 pdp_buy_sheet.js 의 P6 박스가 카운트다운 computed display 를
315     보고 스스로 닫는다(#slide_thumbnail MutationObserver). 금액(옵션 행)은 플래그로 알린다. */
316  function markDetailEnded() {
317    unshadowDetailTimer();
318    var el = document.querySelector('.xans-product-detail .sale_countdown_detail');
319    if (el) { el.classList.remove('ts-td-checking', 'ts-td-prestart'); el.classList.add('ts-td-over'); }
320    window.__tsDealLive = null;   /* 되살린 박스(아래 setDealLive)도 함께 끈다 */
321    setDetailDealOff(true);
322  }
323
324  /* ── [TODO-463] 딜 마크업이 없는 캐시 페이지에서 박스 되살리기 ─────────────
325     딜을 새로 걸면 엣지가 **딜 시작 전 사본**을 주는 동안 PDP 는 강제 새로고침해도
326     딜이 안 뜬다(발주처 제보 — PLP 는 앱이 타이머를 라이브 주입해 한 번에 뜨는데 PDP 만 늦음).
327     페이지에 마크업이 없어도 **원본 조회가 '진행 중'이라면** 우리 박스(#ts-pdp-timedeal-price)와
328     옵션 행 금액은 살릴 수 있다 — 가격·종료시각을 원본 문서에서 가져와 플래그로 넘긴다.
329     라임 타이머바는 앱 주입이라 이 경로로는 안 생긴다(박스+가격이 실질이라 수용). */
330  function dealPriceFromDoc(doc) {
331    var el = doc.querySelector('#span_product_price_sale') || doc.querySelector('#span_optimum_discount_price');
332    if (!el) return 0;
333    var first = (el.firstChild && el.firstChild.nodeType === 3) ? el.firstChild.textContent : el.textContent;
334    var m = String(first || '').match(/([\d,]+)\s*원/);
335    return m ? (parseInt(m[1].replace(/,/g, ''), 10) || 0) : 0;
336  }
337
338  /* [TODO-468] PDP 카운트다운 "숫자" 교정 — 앱은 페이지에 박힌(캐시본이면 낡은) 데이터로
339     매초 숫자를 그린다. 지우거나 가리는 대신, 앱이 쓸 때마다 MutationObserver 로
340     **페인트 전에**(같은 프레임의 마이크로태스크) 원본 종료시각 기준 값으로 고쳐 쓴다 —
341     화면엔 우리 값만 보이고 깜빡임이 없다. 같은 값이면 무접촉이라 무한 루프도 없다.
342     형식은 앱과 동일("0일 HH:MM:SS 종료"). PLP 쪽 wp-timer 는 내부 마크업 변형이 있어 제외. */
343  function fmtLeft(endMs) {
344    var left = endMs - Date.now();
345    if (left <= 0) return null;
346    var sec = Math.floor(left / 1000);
347    var d = Math.floor(sec / 86400); sec -= d * 86400;
348    var h = Math.floor(sec / 3600); sec -= h * 3600;
349    var mi = Math.floor(sec / 60); sec -= mi * 60;
350    var pad = function (n) { return (n < 10 ? '0' : '') + n; };
351    return d + '일 ' + pad(h) + ':' + pad(mi) + ':' + pad(sec) + ' 종료';
352  }
353
354  function shadowDetailTimer(endMs) {
355    var el = document.querySelector('.xans-product-detail .sale_countdown_detail');
356    if (!el || !endMs || typeof MutationObserver === 'undefined') return;
357    var t = timersOf(el);
358    if (t.shadowEnd === endMs && t.shadow) return;   /* 같은 종료시각으로 이미 가동 중 */
359    if (t.shadow) { t.shadow.disconnect(); t.shadow = null; }
360    t.shadowEnd = endMs;
361    function fix() {
362      var want = fmtLeft(endMs);
363      if (want === null) { unshadowDetailTimer(); return; }   /* 종료 — 이후는 markDetailEnded 몫 */
364      if (el.textContent.trim() !== want) el.textContent = want;
365    }
366    t.shadow = new MutationObserver(fix);
367    t.shadow.observe(el, { childList: true, characterData: true, subtree: true });
368    fix();
369  }
370
371  function unshadowDetailTimer() {
372    var el = document.querySelector('.xans-product-detail .sale_countdown_detail');
373    if (!el) return;
374    var t = timersOf(el);
375    if (t.shadow) { t.shadow.disconnect(); t.shadow = null; }
376    t.shadowEnd = null;
377  }
378
379  function setDealLive(endMs, price) {
380    window.__tsDealLive = { endMs: endMs || 0, price: price || 0 };
381    setDetailDealOff(false);
382    try { document.dispatchEvent(new CustomEvent('ts:deallive')); } catch (e) {}
383    scheduleEnd(document.documentElement, endMs, markDetailEnded);   /* 종료 시 자동 소등 */
384  }
385
386  /* [S6] 진행 중 가격 변경 대응 — 카드의 딜가 **텍스트**를 원본 조회 값으로 동기화한다.
387     페이지 마크업은 캐시본일 수 있어(강제 새로고침으로도 안 뚫림) 옛 가격이 남는다.
388     라벨엔 숫자가 없으므로 fin 안의 첫 `N원` 텍스트 노드만 바꾼다. 같으면 무접촉. */
389  function syncCardDealPrice(li, price) {
390    if (!price) return;
391    var spec = li && li.querySelector('.description .spec');
392    if (!spec) return;
393    var fin = spec.querySelector('.ts-td-final');
394    if (!fin) { var ps = spec.querySelectorAll('.spec_price'); if (ps.length >= 2) fin = ps[ps.length - 1]; }
395    if (!fin || firstWon(fin) === price) return;
396    var node = (function find(el) {
397      for (var i = 0; i < el.childNodes.length; i++) {
398        var n = el.childNodes[i];
399        if (n.nodeType === 3 && /[\d,]+\s*원/.test(n.textContent)) return n;
400        if (n.nodeType === 1) { var r = find(n); if (r) return r; }
401      }
402      return null;
403    })(fin);
404    if (!node) return;
405    node.textContent = node.textContent.replace(/[\d,]+\s*원/, price.toLocaleString('k
405o-KR') + '원');
406    try { recomputeRate(spec, spec.querySelector('.sale_box')); } catch (e) {}
407  }
408
409  /* 목록 카드의 **가격줄**을 딜 적용/해제로 토글한다.
410     ⚠️딜 진행 중에 캐시된 목록은 Cafe24 가 이미 할인가로 그려놔서, 타이머·뱃지만 감추면
411       `13,750원 / 28%` 가 그대로 남는다(2026-09-01 실측 — 발주처 "PLP는 계속 노출 중").
412     되돌릴 수 있어야 하므로 `markEnded()` 처럼 클래스를 떼지 않고, 할인가만 감추고
413     %는 원래 값을 `data-td-rate` 에 보관했다가 복원한다. */
414  function setCardPriceDeal(li, on) {
415    var spec = li && li.querySelector('.description .spec');
416    if (!spec) return;
417    var fin = spec.querySelector('.ts-td-final');
418    if (!fin) { var ps = spec.querySelectorAll('.spec_price'); if (ps.length >= 2) fin = ps[ps.length - 1]; }
419    if (!fin) return;                       /* 딜 가격줄이 없으면 손댈 것이 없다(정상 렌더) */
420    var saleBox = spec.querySelector('.sale_box');
421    if (on) {
422      fin.classList.remove('ts-td-over');
423      var keep = saleBox && saleBox.getAttribute('data-td-rate');
424      if (keep && saleBox.textContent.trim() !== keep) saleBox.textContent = keep;
425      return;
426    }
427    fin.classList.add('ts-td-over');
428    if (!saleBox) return;
429    if (!saleBox.getAttribute('data-td-rate')) saleBox.setAttribute('data-td-rate', saleBox.textContent.trim());
430    /* 남아 있는 판매가 기준으로 할인율 재계산(28% → 27%). 못 읽으면 손대지 않는다. */
431    var consumer = firstWon(spec.querySelector('.spec_consumer'));
432    var visPrice = null, all = spec.querySelectorAll('.spec_price');
433    for (var i = 0; i < all.length; i++) { if (all[i] !== fin) { visPrice = all[i]; break; } }
434    var now = firstWon(visPrice);
435    if (!consumer || !now || consumer <= now) return;
436    var rate = Math.round((consumer - now) / consumer * 100);
437    if (rate > 0) saleBox.textContent = rate + '%';
438  }
439
440  /* 상세페이지에서 **딜 금액을 쓰면 안 되는 상태**(시작 전 · 종료 후)를 pdp_buy_sheet.js 에 알린다.
441     ⚠️종료뿐 아니라 **시작 전**도 포함해야 한다 — 캐시된 상세는 옛 딜의 할인적용가
442     (`#span_product_price_sale`)를 그대로 갖고 있어, 시작 전인데 옵션 행이 딜 가격을 찍는다
443     (실측 캡처: 옵션 13,750원인데 총 상품금액 13,900원). */
444  function setDetailDealOff(off) {
445    if (off) window.__tsDealLive = null;   /* off 상태에서 원본 동기화 가격이 남으면 안 된다 */
446    if (window.__tsDealOff === off) return;
447    window.__tsDealOff = off;
448    try { document.dispatchEvent(new CustomEvent('ts:dealstate', { detail: { off: off } })); } catch (e) {}
449  }
450
451  /* [F3] 예약 타이머 보관소 — 판정마다 **clear 후 재예약**(재무장) 한다.
452     종전의 원샷 속성 가드(data-td-release/endset)는 관리자가 기간을 바꾸면 **옛 시각에 발화**하고
453     새 시각 재예약이 막혔다(TODO-460 교훈이 이벤트 카드에만 있고 이 파일엔 빠져 있던 것). */
454  var timerStore = (typeof WeakMap !== 'undefined') ? new WeakMap() : null;
455  function timersOf(el) {
456    if (!timerStore) { el.__tsTimers = el.__tsTimers || {}; return el.__tsTimers; }
457    var t = timerStore.get(el);
458    if (!t) { t = {}; timerStore.set(el, t); }
459    return t;
460  }
461  function clearSchedules(el) {
462    if (!el) return;
463    var t = timersOf(el);
464    if (t.rel) { window.clearTimeout(t.rel); t.rel = null; }
465    if (t.end) { window.clearTimeout(t.end); t.end = null; }
466  }
467
468  /* 종료 시각에 맞춰 스스로 닫는다 — 딜 중에 열어둔 탭이 종료 후에도 딜을 계속 띄우는 것 방지.
469     (강제 종료는 이 경로로 못 잡는다 → 원본 조회가 담당) */
470  function scheduleEnd(el, endMs, fn) {
471    if (!el) return;
472    var t = timersOf(el);
473    if (t.end) { window.clearTimeout(t.end); t.end = null; }   /* 재무장 */
474    if (endMs === null) return;
475    var wait = endMs - Date.now();
476    if (wait <= 0 || wait > 24 * 3600 * 1000) return;
477    t.end = window.setTimeout(function () { t.end = null; fn(); }, wait + 1500);
478  }
479
480  function preStartFrom(root) {
481    var ms = startMsFrom(root);
482    if (ms !== null) return Date.now() < ms;
483    var promo = root.querySelector('.product_promotion_date');
484    if (promo && /전입니다/.test(promo.textContent || '')) return true;
485    if (promo && (promo.textContent || '').trim()) return false;
486    return null;   /* 판단 불가 → 호출부에서 보임 처리(fail-open) */
487  }
488
489  /* 시작 전에는 타이머뿐 아니라 **Event 뱃지도 함께 감춘다** (발주처 지시 2026-09-01)
490     — 뱃지만 남으면 아직 시작 안 한 딜이 진행 중처럼 보인다.
491     PDP 의 "타임딜 혜택가" 박스는 pdp_buy_sheet.js 가 카운트다운의 computed display 를
492     보고 켜고 끄므로(P6) 여기서 타이머만 감추면 자동으로 함께 사라진다. */
493  /* ⚠️시작 시각이 지나면 **새로고침 없이 스스로 푼다** (2026-09-01 발주처 제보).
494     타임딜 시작 전에 페이지를 연 브라우저는 Cafe24 가 캐시된 HTML 을 계속 주기 때문에
495     강제 새로고침을 해도 낡은 상태 그대로였다(다른 브라우저에서는 정상 — 실측 제보).
496     `.period` 는 절대시각이라 그 시점을 알 수 있으므로, 남은 시간만큼 타이머를 걸어 해제한다.
497     추가로 30초마다 다시 판정해 시계·캐시 어긋남까지 흡수한다. */
498  function scheduleRelease(el, startMs) {
499    if (!el) return;
500    var t = timersOf(el);
501    if (t.rel) { window.clearTimeout(t.rel); t.rel = null; }   /* 재무장([F3]) */
502    if (startMs === null) return;
503    var wait = startMs - Date.now();
504    if (wait <= 0 || wait > 24 * 3600 * 1000) return;   /* 이미 지났거나 하루 넘게 남으면 예약하지 않는다 */
505    t.rel = window.setTimeout(function () { t.rel = null; setTimerHidden(el, false); }, wait + 1500);
506  }
507
508  function setTimerHidden(el, hidden) {
509    el.classList.remove('ts-td-checking');
510    if (hidden) el.classList.add('ts-td-prestart');
511    else el.classList.remove('ts-td-prestart');
512    var li = el.closest ? el.closest('li[id^="anchorBoxId"]') : null;
513    if (li && !li.classList.contains('ts-td-ended')) {   /* ended 상태는 unmarkEnded 로만 되돌린다([F3] 안전망) */
514      li.classList.remove('ts-td-checking-card');
515      if (hidden) li.classList.add('ts-td-pre');
516      else li.classList.remove('ts-td-pre');
517      try { setCardPriceDeal(li, !hidden); } catch (e) {}   /* 가격줄도 함께 */
518    }
519    /* 상세 타이머면 금액 플래그·선차단 클래스도 같이 움직인다. 종료(ts-td-over)는 되돌리지 않는다. */
520    if (el.classList.contains('sale_countdown_detail')) {
521      setDetailDealOff(hidden || el.classList.contains('ts-td-over'));
522      if (!hidden) document.documentElement.classList.remove('ts-td-pre-doc');
523    }
524  }
525
526  /* ⚠️상세페이지도 **원본 판정을 따른다** (2026-09-01 발주처 제보 — 시작 전인데 PDP 에 딜이 떴다).
527     PLP 카드는 처음부터 상세를 조회해 판정했지만 **PDP 는 자기 문서만 봤다.** 그래서
528     **옛 딜이 진행 중이던 시점에 캐시된 상세**를 받으면 `.period` 의 시작시각이 이미 지난 것처럼
529     보여 "진행 중"으로 오판한다(실측 캡처: 타이머 `0일 00:12:51 종료` + 옵션 13,750원인데
530     총 상품금액은 13,900원 — 새 딜은 아직 시작 전이었다).
531     ⚠️'딜이 있느냐'만 묻는 것으로는 부족하다 — 원본에 새 딜이 **등록만** 돼 있어도 딜은 존재하므로
532       종료 판정만으로는 이 경우를 못 잡는다. **원본의 시작·종료 시각으로 상태 자체를 다시 판정**한다. */
533  var originState = null;   /* 원본 판정 스냅샷 { gone, startMs, endMs, preFallback } */
534
535  /* 원본 기준 '시작 전' 여부. `.period` 가 없을 때만 문구 기반 폴백을 쓴다. */
536  function originPre() {
537    if (!originState) return null;
538    if (originState.startMs !== null) return Date.now() < originState.startMs;
539    return originState.preFallback;
540  }
541  function gateDetailTimer() {
542    var el = document.querySelector('.xans-product-detail .sale_countdown_detail');
543    if (!el) {
544      /* 딜 마크업이 없는 페이지(캐시 사본 or 실제로 딜 없음) — 박스가 있는 PDP 면 원본으로 확인 */
545      if (document.getElementById('ts-pdp-timedeal-price')) probeDetail();
546      return;
547    }
548
549    /* ① 예정 종료시각이 이미 지났으면 즉시 닫는다(네트워크 불필요).
550       [F3] 원본 스냅샷이 있으면 **원본의 종료시각을 우선** — 로컬 문서는 캐시본일 수 있다. */
551    var endMs = originState ? originState.endMs : endMsFrom(document);
552    if (endMs !== null && Date.now() >= endMs) { markDetailEnded(); return; }
553
554    if (!el.getAttribute('data-td-gated')) {
555      el.setAttribute('data-td-gated', '1');
556      var pre = preStartFrom(document);
557      setTimerHidden(el, pre === true);
558      if (pre === true) scheduleRelease(el, startMsFrom(document));
559    }
560    /* ② 딜 중에 열어둔 탭이 종료 후에도 계속 띄우지 않도록 종료 시각에 예약 */
561    scheduleEnd(el, endMs, markDetailEnded);
562
563    /* ③ 원본 조회 — 상태 판정의 최종 근거. 실패·판단불가는 무시(fail-open). */
564    probeDetail();
565  }
566
567  /* 상세 원본 조회. **`cache:'reload'`(네트워크 강제)** 로 묻는다 — no-store 는 옛 문서를 받았다(실측).
568     ⚠️1회로 끝내지 않는다: 조회가 캐시본에 걸려 '시작 전'으로 굳으면 **진행 중인 딜이 계속 가려진다.**
569     시작 전으로 숨긴 동안에는 30초 재판정이 다시 부른다(상한 있음). */
570  var detailProbes = 0;
571  var probeBusy = false;
572  var probeLastAt = 0;
573  function probeDetail() {
574    if (detailProbes >= 12 || !window.fetch) return;
575    /* ⚠️run() 이 400ms 폴링으로 이 함수를 반복 호출한다 — 스로틀 없이는 첫 5초에
576       상한 12회(×240KB)를 다 태운다(실사용 낭비). 진행 중 1건 + 최소 25초 간격. */
577    if (probeBusy || Date.now() - probeLastAt < 25000) return;
578    probeBusy = true;
579    probeLastAt = Date.now();
580    detailProbes++;
581    var pno = (location.search.match(/product_no=(\d+)/) || [])[1] ||
582              (location.pathname.match(/\/(\d+)\/(?:category|display)/) || [])[1];
583    var base = pno ? PROBE_URL + pno + '&' : location.href.split('#')[0] + (location.href.indexOf('?') >= 0 ? '&' : '?');
584    window.fetch(base + '_td=' + Date.now(), { credentials: 'same-origin', cache: 'reload' })
585      .then(function (r) { return r.text(); })
586      .then(function (html) {
587        var doc = new DOMParser().parseFromString(html, 'text/html');
588        probeBusy = false;
589        /* [F1] 원본도 '딜 없음'이고 **이 페이지에도 딜 흔적이 없으면** 여기서 끝 —
590           끌 것이 없는데 markDetailEnded 로 __tsDealOff 를 세우면 45초 검증 루프 가드가 뚫려
591           딜 없는 모든 상품 페이지가 15회×240KB 를 계속 조회했다(실측 110초 4회). 합의샷도 생략. */
592        if (verdictFrom(doc) === 'gone' && dealGoneFrom(document) === true && !window.__tsDealLive) return;
593        var pno2 = (location.search.match(/product_no=(\d+)/) || [])[1] ||
594                   (location.pathname.match(/\/(\d+)\/(?:category|display)/) || [])[1];
595        /* 첫 판정은 2/3 다수결(settleVerdict) — 어느 방향이든 복권 1장에 상태를 바꾸지 않는다 */
596        settleVerdict(pno2 || '', verdictFrom(doc), doc, function (v, doc2) {
597          if (!doc2) return;
598          if (v === 'gone') { markDetailEnded(); return; }
599          originState = {
600            startMs: startMsFrom(doc2),
601            endMs: endMsFrom(doc2),
602            preFallback: preStartFrom(doc2),
603            price: dealPriceFromDoc(doc2)
604          };
605          applyOriginVerdict();
606        });
607      })
608      .catch(function () { probeBusy = false; });
609  }
610
611  /* 원본 스냅샷으로 현재 상태를 다시 칠한다 — 로컬(캐시된) 문서 판정을 덮어쓴다. */
612  function applyOriginVerdict() {
613    var el = document.querySelector('.xans-product-detail .sale_countdown_detail');
614    if (!originState) return;
615    if (originState.endMs !== null && Date.now() >= originState.endMs) { markDetailEnded(); return; }
616    var pre = originPre();
617    if (pre === null) return;                    /* 판단 불가 → 로컬 판정 유지(fail-open) */
618    if (!el) {
619      /* 캐시 사본이라 마크업이 없다 — 진행 중이면 박스·옵션가라도 되살린다 */
620      if (pre === false && originState.price) setDealLive(originState.endMs, originState.price);
621      return;
622    }
623    setTimerHidden(el, pre === true);
624    if (pre === true) scheduleRelease(el, originState.startMs);
625    else {
626      scheduleEnd(el, originState.endMs, markDetailEnded);
627      /* [S6] 마크업이 있어도 **원본 가격을 우선** — 캐시된 페이지의 옛 가격이 박스·옵션에 남지 않게 */
628      if (originState.price) setDealLive(originState.endMs, originState.price);
629      if (originState.endMs) shadowDetailTimer(originState.endMs);   /* [468] 숫자도 원본 기준 */
630    }
631  }
632
633  /* 목록 카드: 상품 상세를 1회 조회해 판단 */
634  function gateCardTimer(li) {
635    var el = li.querySelector('.sale_countdown, .wp-timer');
636    if (!el || el.getAttribute('data-td-gated')) return;
637    var a = li.querySelector('a[href*="/product/"]');
638    var href = a && a.getAttribute('href');
639    var m = href && href.match(/\/(\d+)\/(?:category|display)/);
640    var key = m ? m[1] : href;
641    if (!href || !key) return;                       /* 주소를 못 찾으면 그대로 둔다(보임) */
642    el.setAttribute('data-td-gated', '1');
643
644    if (endedCache[key]) { markEnded(li); return; }
645    if (preStartCache[key] !== undefined) { setTimerHidden(el, preStartCache[key]); return; }
646
647    el.classList.add('ts-td-checking');               /* 판정 전까지는 타이머·뱃지를 잠시 감춘다 */
648    li.classList.add('ts-td-checking-card');
649    window.setTimeout(function () {                   /* 시간초과 fail-open */
650      el.classList.remove('ts-td-checking');
651      li.classList.remove('ts-td-checking-card');
652    }, 2500);
653    if (inflight[key]) return;
654    inflight[key] = 1;
655    if (!window.fetch) { el.classList.remove('ts-td-checking'); li.classList.remove('ts-td-checking-card'); return; }
656    /* ⚠️조회 주소·캐시모드를 잘못 잡으면 **옛 문서**를 받아 진행 중인 딜을 가린다(2026-09-01 실측 사고).
657       같은 시각 같은 주소에 대해:
658         pretty URL + cache:'no-store'  → 기간 `19:20`(옛것) ❌
659         pretty URL + cache:'reload'    → 기간 `17:20` ✅
660         detail.html?product_no= + _td  → 기간 `17:20` ✅
661       → **상품번호가 있으면 detail.html 형식**으로 묻고, 캐시모드는 **reload**(네트워크 강제)로 고정. */
662    var probeUrl = (/^\d+$/.test(key) ? PROBE_URL + key + '&' : href + (href.indexOf('?') >= 0 ? '&' : '?')) + '_td=' + Date.now();
663    window.fetch(probeUrl, { credentials: 'same-origin', cache: 'reload' })
664      .then(function (r) { return r.text(); })
665      .then(function (html) {
666        var doc = new DOMParser().parseFromString(html, 'text/html');
667        /* 첫 판정은 2/3 다수결(위 settleVerdict 주석) — 어느 방향이든 복권 1장에 상태를 바꾸지 않는다 */
668        settleVerdict(key, verdictFrom(doc), doc, function (v, doc2) {
669          if (v === null || !doc2) v = 'run';
669                    /* 판단 불가 → 보이는 쪽(fail-open) */
670          endedCache[key] = (v === 'gone');
671          preStartCache[key] = (v === 'pre');
672          var endMs = endMsFrom(doc2);
673          var all = document.querySelectorAll('.sale_countdown, .wp-timer');
674          for (var i = 0; i < all.length; i++) {
675            var host = all[i].closest ? all[i].closest('li[id^="anchorBoxId"]') : null;
676            var ha = host && host.querySelector('a[href*="/product/"]');
677            var hm = ha && (ha.getAttribute('href') || '').match(/\/(\d+)\/(?:category|display)/);
678            if (!hm || hm[1] !== key) continue;
679            if (v === 'gone') { markEnded(host); continue; }
680            setTimerHidden(all[i], v === 'pre');
681            if (v === 'pre') scheduleRelease(all[i], startMsFrom(doc2));
682            else { try { syncCardDealPrice(host, dealPriceFromDoc(doc2)); } catch (e) {} }   /* [S6] */
683            /* 예정 종료시각이 지나면 목록 카드도 스스로 닫는다(탭 열어둔 경우) */
684            (function (h) { scheduleEnd(all[i], endMs, function () { markEnded(h); }); })(host);
685          }
686        });
687      })
688      .catch(function () {                                             /* fail-open */
689        el.classList.remove('ts-td-checking');
690        li.classList.remove('ts-td-checking-card');
691      });
692  }
693
694  /* ══ [TODO-467②] PLP 카드 되살리기 — 페이지 사본에 딜 마크업이 아예 없을 때 ═══════
695     PLP 표시는 서버 마크업(할인판매가 행·타이머)이 재료라, 딜 시작 전 사본을 받으면
696     그릴 재료가 없어 강제 새로고침에도 안 뜬다(실측: 진행함 전환 후 PDP 3.3초 vs PLP 미노출).
697     → 신선한 event_items 데이터($cache 없음·버스터)로 "지금 할인 적용 중"인 상품을 알아내고,
698       그 카드에 우리 요소(타이머·할인가 행)를 만들어 붙인다. 기존 CSS 계약 재사용:
699       is-timedeal + .sale_countdown(라임바) + .ts-td-final(딜가·판매가 자동 숨김) + mark()의 뱃지·%.
700     종료·취소는 기존 장치가 처리(markEnded 가 주입 요소도 걷는다 · 60초 검증 루프 대상). */
701  var resurrectDone = false;
702  function plpCateNo() {
703    var m = location.pathname.match(/\/category\/[^\/]*\/(\d+)/) || location.search.match(/cate_no=(\d+)/);
704    return m ? m[1] : null;
705  }
706  function cardTick(el, endMs) {
707    var t = timersOf(el);
708    if (t.tick) { window.clearInterval(t.tick); t.tick = null; }
709    function draw() {
710      var left = endMs - Date.now();
711      if (left <= 0) { window.clearInterval(t.tick); t.tick = null; return; }
712      var s = Math.floor(left / 1000);
713      var d = Math.floor(s / 86400); s -= d * 86400;
714      var h = Math.floor(s / 3600); s -= h * 3600;
715      var mi = Math.floor(s / 60); s -= mi * 60;
716      var pad = function (n) { return (n < 10 ? '0' : '') + n; };
717      el.textContent = d + '일 ' + pad(h) + ':' + pad(mi) + ':' + pad(s) + ' 종료';
718    }
719    draw();
720    t.tick = window.setInterval(draw, 1000);
721  }
722  function resurrectCard(li, price) {
723    if (li.getAttribute('data-td-made')) return;
724    li.setAttribute('data-td-made', '1');
725    var no = li.id.replace('anchorBoxId_', '');
726    fetchVerdict(no, function (v, doc) {
727      if (v !== 'run' || !doc) return;                       /* 프로브는 신선 — 단발 신뢰 */
728      var endMs = endMsFrom(doc);
729      var op = dealPriceFromDoc(doc) || price;
730      var thumb = li.querySelector('.thumbnail');
731      var spec = li.querySelector('.description .spec');
732      if (!thumb || !spec) return;
733      if (!li.querySelector('.sale_countdown, .wp-timer')) {
734        var bar = document.createElement('div');
735        bar.className = 'sale_countdown';
736        thumb.insertBefore(bar, thumb.firstChild);
737        if (endMs) { cardTick(bar, endMs); scheduleEnd(bar, endMs, function () { markEnded(li); }); }
738      }
739      if (!spec.querySelector('.ts-td-final') && spec.querySelectorAll('.spec_price').length < 2) {
740        var row = document.createElement('li');
741        row.className = ' xans-record- ts-td-final';
742        var lab = document.createElement('strong');
743        lab.className = 'title displaynone';
744        lab.textContent = '할인판매가 :';
745        var val = document.createElement('span');
746        val.textContent = ' ' + op.toLocaleString('ko-KR') + '원';
747        row.appendChild(lab); row.appendChild(val);
748        var sb = spec.querySelector('.sale_box');
749        if (sb) spec.insertBefore(row, sb); else spec.appendChild(row);
750      }
751      li.classList.add('is-timedeal');
752      try { mark(li); } catch (e) {}                         /* 뱃지 + % 재계산 */
753    });
754  }
755  function resurrectPlpCards() {
756    if (resurrectDone || !window.fetch) return;
757    var cate = plpCateNo();
758    var lis = document.querySelectorAll('.prdList li[id^="anchorBoxId"]');
759    if (!cate || !lis.length) return;
760    resurrectDone = true;
761    window.fetch('/product/event_items.html?cate_no=' + cate + '&_ts=' + Date.now(), { credentials: 'same-origin', cache: 'reload' })
762      .then(function (r) { return r.text(); })
763      .then(function (html) {
764        var doc = new DOMParser().parseFromString(html, 'text/html');
765        var els = doc.querySelectorAll('.ev-item');
766        for (var i = 0; i < els.length; i++) {
767          var no = (els[i].id || '').replace(/^evItem_/, '');
768          if (!no) continue;
769          var deal = 0;
770          var rows = els[i].querySelectorAll('.ev-spec li');
771          for (var r = 0; r < rows.length; r++) {
772            var txt = rows[r].textContent || '';
773            if (txt.indexOf('할인판매가') === -1) continue;
774            var m = txt.match(/([\d,]{2,})\s*원/);
775            deal = m ? (parseInt(m[1].replace(/,/g, ''), 10) || 0) : 0;
776            break;
777          }
778          if (!deal) continue;
779          var li = document.getElementById('anchorBoxId_' + no);
780          if (!li) continue;
781          /* 이미 딜 마크업이 있는 카드는 기존 파이프라인 몫 */
782          if (li.querySelector('.sale_countdown, .wp-timer') || li.classList.contains('is-timedeal') || li.classList.contains('ts-td-ended')) continue;
783          try { resurrectCard(li, deal); } catch (e) {}
784        }
785      })
786      .catch(function () {});
787  }
788
789  function run() {
790    /* 상세 타이머를 **먼저** 처리한다 — 카드 루프에서 예외가 나면 뒤가 통째로 막히기 때문
791       (실측: PDP 의 관련상품 목록 때문에 gateDetailTimer 가 실행되지 않았다). */
792    try { gateDetailTimer(); } catch (e) {}
793    var lis = document.querySelectorAll('.prdList li[id^="anchorBoxId"]');
794    for (var i = 0; i < lis.length; i++) {
795      try { mark(lis[i]); } catch (e) {}
796      try { gateCardTimer(lis[i]); } catch (e) {}
797    }
798    try { resurrectPlpCards(); } catch (e) {}
799  }
800
801  /* ⭐카운트다운이 주입되기 **전에** 미리 막는다 — 주입 후 감추면 라임바가 번쩍인다
802     (발주처 "깜빡거리면서 나오다가 노출 안함"). `.period` 는 서버 HTML 에 이미 있어서
803     defer 스크립트 실행 시점이면 판정이 가능하다. 진행 중으로 밝혀지면 setTimerHidden 이 걷는다. */
804  try {
805    if (document.querySelector('.xans-product-detail') && preStartFrom(document) === true) {
806      document.documentElement.classList.add('ts-td-pre-doc');
807    }
808  } catch (e) {}
809
810  if (document.readyState !== 'loading') run();
811  else document.addEventListener('DOMContentLoaded', run);
812  window.addEventListener('load', run);
813
814  /* 30초마다 상세 타이머를 다시 판정한다(시작·종료 경과, 캐시·시계 어긋남 흡수).
815     ⚠️**원본 스냅샷이 있으면 그것을 쓴다** — 로컬 문서는 캐시본이라 시각을 믿을 수 없다.
816       (예전엔 로컬 `preStartFrom(document)` 만 봐서, 원본 판정으로 숨긴 타이머를 30초 뒤에
817        스스로 다시 켜버렸다.) */
818  /* ⚠️원본 스냅샷도 복권이다(조회 8회 중 1회는 캐시본 — 실측). 스냅샷 하나를 신봉하지 않고
819     45초마다 다시 조회해 **연속 2회 같은 판정일 때만** 상태를 바꾼다(상한 15회). */
820  var pdpVerify = null;   /* { last, streak } */
821  var pdpVerifyN = 0;
822  window.setInterval(function () {
823    if (document.visibilityState === 'hidden') return;   /* [F4] 안 보는 탭에서 낭비하지 않는다 */
824    var el = document.querySelector('.xans-product-detail .sale_countdown_detail');
825    if (!el && !window.__tsDealOff && !window.__tsDealLive) return;
826    if (++pdpVerifyN > 15 || !window.fetch) return;
827    var pno = (location.search.match(/product_no=(\d+)/) || [])[1] ||
828              (location.pathname.match(/\/(\d+)\/(?:category|display)/) || [])[1];
829    if (!pno) return;
830    window.fetch(PROBE_URL + pno + '&_td=' + Date.now(), { credentials: 'same-origin', cache: 'reload' })
831      .then(function (r) { return r.text(); })
832      .then(function (html) {
833        var doc = new DOMParser().parseFromString(html, 'text/html');
834        var v = verdictFrom(doc);
835        if (v === null) return;
836        pdpVerify = (pdpVerify && pdpVerify.last === v) ? { last: v, streak: pdpVerify.streak + 1 } : { last: v, streak: 1 };
837        if (pdpVerify.streak < 2) return;
838        var cd = document.querySelector('.xans-product-detail .sale_countdown_detail');
839        if (v === 'gone') { markDetailEnded(); return; }
840        if (!cd) { if (v === 'run') setDealLive(endMsFrom(doc), dealPriceFromDoc(doc)); return; }
841        if (v === 'pre') { setTimerHidden(cd, true); scheduleRelease(cd, startMsFrom(doc)); }
842        else {
843          cd.classList.remove('ts-td-over');
844          setTimerHidden(cd, false);
845          scheduleEnd(cd, endMsFrom(doc), markDetailEnded);
846          var lp = dealPriceFromDoc(doc);
847          if (lp) setDealLive(endMsFrom(doc), lp);   /* [S6] 가격·종료시각 재동기화 */
848          if (endMsFrom(doc)) shadowDetailTimer(endMsFrom(doc));   /* [468] */
849        }
850      })
851      .catch(function () {});
852  }, 45000);
853
854  /* ══ 다수결 검증 루프 (2026-09-01) ═══════════════════════════════════════
855     ⚠️원본 조회 자체가 복권이다 — 같은 요청을 8연발로 쏘면 7번은 신선, 1번은 딜 시절
856       캐시본이 온다(실측). 헤더로는 구별 불가(둘 다 x-cache HIT · date 신선).
857       그래서 첫 조회 한 번의 판정을 영구 캐시하면, 운 나쁘게 옛 문서를 받은 페이지는
858       **영원히 틀린 상태로 굳는다**(발주처: 진행 안함으로 바꿨는데도 계속 노출).
859     → 60초마다 다시 조회하고, **연속 2회 같은 판정일 때만** 표시 상태를 바꾼다.
860       소수인 캐시본이 두 번 연속 걸릴 확률은 낮고, 신선한 응답
860은 금방 2연속이 된다.
861       상한 15회(≈15분) — 그 안에 엣지 캐시는 수렴한다(실측 최대 20분 내). */
862  var verify = {};   /* key → { last: 'gone'|'pre'|'run', streak: n } */
863  function applyCardVerdict(key, v, doc) {
864    var all = document.querySelectorAll('li[id^="anchorBoxId"]');
865    for (var i = 0; i < all.length; i++) {
866      var a = all[i].querySelector('a[href*="/product/"]');
867      var m = a && (a.getAttribute('href') || '').match(/\/(\d+)\/(?:category|display)/);
868      if (!m || m[1] !== key) continue;
869      var el = all[i].querySelector('.sale_countdown, .wp-timer');
870      if (v === 'gone') { markEnded(all[i]); continue; }
871      if (!el) continue;
872      if (v === 'pre') { setTimerHidden(el, true); scheduleRelease(el, startMsFrom(doc)); }
873      else {
874        unmarkEnded(all[i]); setTimerHidden(el, false);
875        try { syncCardDealPrice(all[i], dealPriceFromDoc(doc)); } catch (e) {}   /* [S6] */
876        scheduleEnd(el, endMsFrom(doc), (function (h) { return function () { markEnded(h); }; })(all[i]));
877      }
878    }
879  }
880  var verifyN = 0;
881  /* [F4] 상한(15회)은 "페이지 로드 후 15분"이 아니라 "마지막 탭 복귀 후 15분" —
882     오래 열어둔 탭도 사용자가 돌아오면 자가치유가 살아난다(TODO-462 검증 패턴). */
883  document.addEventListener('visibilitychange', function () {
884    if (document.visibilityState === 'visible') { pdpVerifyN = 0; verifyN = 0; resurrectDone = false; try { resurrectPlpCards(); } catch (e) {} }
885  });
886  window.setInterval(function () {
887    if (document.visibilityState === 'hidden') return;
888    if (++verifyN > 15) return;
889    var seen = {};
890    var lis = document.querySelectorAll('li[id^="anchorBoxId"]');
891    for (var i = 0; i < lis.length; i++) {
892      var li = lis[i];
893      /* 검증 대상: 딜 흔적이 있(었)던 카드 전부 — 보이는 것도, 숨긴 것도, 종료 처리한 것도 */
894      if (!li.classList.contains('is-timedeal') && !li.classList.contains('ts-td-ended')) continue;
895      var a = li.querySelector('a[href*="/product/"]');
896      var m = a && (a.getAttribute('href') || '').match(/\/(\d+)\/(?:category|display)/);
897      if (!m || seen[m[1]]) continue;
898      seen[m[1]] = 1;
899      (function (key) {
900        window.fetch(PROBE_URL + key + '&_td=' + Date.now(), { credentials: 'same-origin', cache: 'reload' })
901          .then(function (r) { return r.text(); })
902          .then(function (html) {
903            var doc = new DOMParser().parseFromString(html, 'text/html');
904            var v = verdictFrom(doc);
905            if (v === null) return;
906            var st = verify[key];
907            verify[key] = (st && st.last === v) ? { last: v, streak: st.streak + 1 } : { last: v, streak: 1 };
908            if (verify[key].streak >= 2) applyCardVerdict(key, v, doc);
909          })
910          .catch(function () {});
911      })(m[1]);
912    }
913  }, 60000);
914
915  /* wp-timer 는 네이티브 스크립트가 async 주입 → 최대 10초 폴링 후 종료 */
916  var n = 0;
917  var iv = window.setInterval(function () {
918    run();
919    if (++n >= 25) window.clearInterval(iv);
920  }, 400);
921
922  /* [더보기] 지연 로드 대응 */
923  document.addEventListener('click', function (e) {
924    var t = e.target;
925    if (t && t.closest && t.closest('.btnMore, .ec-base-paginate a, .xans-product-listmore, .more')) {
926      window.setTimeout(run, 700);
927      window.setTimeout(run, 1500); /* 메인 진열(product_listmore) AJAX 지연 대비 2차 재실행 */
928    }
929  });
930
931  /* [TODO-461] 이벤트 글 상품 그리드(ts-event-post.js)가 같은 원본 판정을 쓰도록 최소 공개.
932     표시 조작(mark/markEnded 등)은 Cafe24 진열 DOM 전용이라 공개하지 않는다. */
933  window.TSTimedeal = { verdictFrom: verdictFrom, startMsFrom: startMsFrom, endMsFrom: endMsFrom, settleVerdict: settleVerdict };
934})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.