PageSourceSearch

https://chapeul.com/js/home.js

js chapeul.com collected 2026-10-05 23:14:07 UTC 17,252 bytes, 418 lines download raw bytes

1/* 홈 v2(배움 카탈로그): 통합 검색, 강의·지표 카드, 데이터 목차, 거래소 카드, 시장 요약, 미리보기 */
2(function () {
3  const { EXCHANGES, fmt } = L;
4  const esc = (x) =>
5    String(x ?? "")
6      .replace(/&/g, "&")
7      .replace(/</g, "&lt;")
8      .replace(/>/g, "&gt;")
9      .replace(/"/g, "&quot;");
10
11  // ---- 카탈로그 원본 ----
12  const LESSONS = [
13    [
14      "00-mindset-and-chart-analysis",
15      0,
16      "마인드 셋업과 차트분석의 중요성",
17      "기준점·원칙·목표·리스크 관리, 매매 전에 갖춰야 할 다섯 가지",
18      10,
19      6,
20    ],
21    [
22      "01-candle-patterns",
23      1,
24      "캔들 패턴을 활용한 매매법",
25      "도지·스타·장악형·삼병·삼법형, 비트코인에서 자주 나오는 패턴과 실전 대응",
26      21,
27      13,
28    ],
29    [
30      "02-trend-and-chart-patterns",
31      2,
32      "추세 + 차트 패턴을 활용한 매매법",
33      "추세와 추세선, 변곡점을 읽고 비트코인에 자주 나오는 차트 패턴으로 대응하는 법",
34      27,
35      16,
36    ],
37    [
38      "03-volume-and-rsi",
39      3,
40      "거래량과 RSI 지표를 활용한 매매법",
41      "RSI 과매수·과매도와 다이버전스, 선행지표인 거래량으로 패턴의 신뢰도를 높이는 법",
42      21,
43      13,
44    ],
45    [
46      "04-supply-zone-support-resistance",
47      4,
48      "매물대와 지지·저항의 구조",
49      "매물이 쌓인 구간이 왜 가장 강한 지지·저항이 되는지, 추세선과 수평선으로 읽는 법",
50      15,
51      9,
52    ],
53    ["05-moving-averages", 5, "이평선이 알려주는 신호", "이평선의 종류와 기간별 쓰임, 크로스·배열·이격과 밀집으로 읽는 추세 신호", 12, 7],
54    ["06-dominance", 6, "도미넌스의 중요성 (비트코인, 테더)", "비트코인·테더 도미넌스로 시장 자금이 어디로 흐르는지 읽는 법", 13, 8],
55    ["07-trading-psychology", 7, "실전 매매 심리와 마인드 컨트롤", "원칙·기다림·습관으로 욕심과 조급함, 포모를 이기는 법", 18, 11],
56  ];
57  const INDS = [
58    ["magnus", "Magnus", "매그너스", "자동 추세 감지 + 구조적 지지/저항"],
59    ["solid", "Solid", "솔리드", "트렌드 파악 + 단기 리젝션 마커"],
60    ["helper", "Helper", "헬퍼", "피벗 지지/저항선 + 돌파 시그널"],
61    ["clone", "Clone", "클론", "과열 → 반전 후보 → 구조 확정 3단계"],
62  ];
63  const GUIDES = [
64    ["/exchange", "지원 거래소 · 가입 코드", "거래소"],
65    ["/guide/bitget", "Bitget 레퍼럴 변경", "가이드"],
66    ["/guide/okx", "OKX 레퍼럴 변경", "가이드"],
67    ["/guide/bybit", "Bybit 레퍼럴 변경", "가이드"],
68    ["/room", "Alpha Room 소개 · 입장 방법", "안내"],
69    ["/briefing", "Alpha Room 브리핑", "브리핑"],
70    ["/news", "실시간 코인 뉴스", "뉴스"],
71    ["/channel", "차플 채널 모음", "안내"],
72    ["/review", "후기 · 수익 인증", "안내"],
73  ];
74
75  const $ = (id) => document.getElementById(id);
76  if ($("cp-lessons"))
77    $("cp-lessons").innerHTML = LESSONS.map(
78      ([slug, no, title, sub, pages, mins]) =>
79        `<li><a href="/study/${slug}"><b>${no}강</b><span class="t">${esc(title)}<small>${esc(sub)}</small></span><em>${pages}장 · 약 ${mins}분</em><i>→</i></a></li>`,
80    ).join("");
81  if ($("cp-inds"))
82    $("cp-inds").innerHTML = INDS.map(
83      ([slug, name, ko, d]) =>
84        `<li><a href="/indicator/${slug}"><img src="/img/cp/ind-${slug}-thumb.jpg" width="132" height="66" loading="lazy" alt="${name}(${ko}) 시그널 예시"><b>${name}<em>${ko}</em></b><span>${esc(d)}</span><i>→</i></a></li>`,
85    ).join("");
86
87  // ---- 거래소 ----
88  if ($("cp-ex")) L.renderExRows($("cp-ex"));
89
90  // ---- 통합 검색 + 데이터 목차 (/api/seo 의 페이지 메타 사용) ----
91  let INDEX = [
92    ...LESSONS.map(([slug, no, title, sub]) => ({ href: `/study/${slug}`, t: `${no}강 ${title}`, k: "차트 교육", x: sub })),
93    ...INDS.map(([slug, name, ko, d]) => ({ href: `/indicator/${slug}`, t: `${name} (${ko})`, k: "전용 지표", x: d })),
94    ...GUIDES.map(([href, t, k]) => ({ href, t, k, x: "" })),
95  ];
96  let PAGES = {},
97    curTab = 0;
98  const LIVE = ["liq-heatmap", "kimp", "funding", "fear-greed"];
99  const GLABEL = { 실시간: "실시간", 청산: "청산", 계약: "계약", 지표: "지표" };
100  function renderData() {
101    const el = $("cp-dtiles");
102    if (!el) return;
103    const list = Object.entries(PAGES).filter(([k]) => !LIVE.includes(k));
104    el.innerHTML = list
105      .map(([k, v]) => `<a class="cp-dtile" href="/data/${k}"><small>${esc(GLABEL[v.group] || v.group)}</small><b>${esc(v.h1)}</b></a>`)
106      .join("");
107  }
108  renderData();
109  fetch("/api/seo")
110    .then((r) => r.json())
111    .then((r) => {
112      PAGES = r.pages || {};
113      INDEX = INDEX.concat(Object.entries(PAGES).map(([k, v]) => ({ href: `/data/${k}`, t: v.h1, k: "실시간 데이터", x: v.desc })));
114      renderData();
115    })
116    .catch(() => {});
117
118  const q = $("cp-q"),
119    box = $("cp-results"),
120    chips = $("cp-chips");
121  if (q && box) {
122    const CHIPS = ["캔들 패턴", "청산 히트맵", "김프", "Magnus", "레퍼럴 변경"];
123    chips.innerHTML = CHIPS.map((c) => `<button type="button">${c}</button>`).join("");
124    chips.querySelectorAll("button").forEach(
125      (b) =>
126        (b.onclick = () => {
127          q.value = b.textContent;
128          run();
129          q.focus();
130        }),
131    );
132    let sel = -1,
133      hits = [];
134    const norm = (s) => s.toLowerCase().replace(/\s+/g, "");
135    function run() {
136      const v = norm(q.value);
137      sel = -1;
138      if (!v) {
139        box.hidden = true;
140        box.innerHTML = "";
141        return;
142      }
143      hits = INDEX.map((it) => {
144        const a = norm(it.t),
145          b = norm(it.x + it.k);
146        return { it, s: a.startsWith(v) ? 3 : a.includes(v) ? 2 : b.includes(v) ? 1 : 0 };
147      })
148        .filter((h) => h.s)
149        .sort((a, b) => b.s - a.s)
150        .slice(0, 8)
151        .map((h) => h.it);
152      box.hidden = false;
153      box.innerHTML = hits.length
154        ? hits.map((h, i) => `<a role="option" href="${h.href}" data-i="${i}"><span>${h.k}</span><b>${esc(h.t)}</b><i>→</i></a>`).join("")
155        : `<p>‘${esc(q.value)}’에 맞는 강의·지표·데이터가 없습니다. 다른 낱말로 찾아보세요.</p>`;
156    }
157    const paint = () => box.querySelectorAll("a").forEach((a, i) => a.classList.toggle("on", i === sel));
158    q.addEventListener("input", run);
159    q.addEventListener("keydown", (e) => {
160      if (e.key === "ArrowDown" || e.key === "ArrowUp") {
161        e.preventDefault();
162        if (!hits.length) return;
163        sel = (sel + (e.key === "ArrowDown" ? 1 : -1) + hits.length) % hits.length;
164        paint();
165      } else if (e.key === "Enter") {
166        const h = hits[sel < 0 ? 0 : sel];
167        if (h) location.href = h.href;
168      } else if (e.key === "Escape") {
169        q.value = "";
170        run();
171      }
172    });
173    document.addEventListener("keydown", (e) => {
174      if (e.key === "/" && document.activeElement !== q && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName)) {
175        e.preventDefault();
176        q.focus();
177      }
178    });
179    // 예시 낱말이 바뀌는 placeholder (움직임 줄이기 설정이면 고정)
180    if (!matchMedia("(prefers-reduced-motion: reduce)").matches) {
181      const ex = ["캔들 패턴", "김프", "Magnus", "청산 히트맵", "이평선", "펀딩비"];
182      let n = 0;
183      setInterval(() => {
184        if (document.activeElement !== q && !q.value) q.placeholder = ex[(n = (n + 1) % ex.length)] + "…";
185      }, 2600);
186    }
187  }
188
189  // ---- 브리핑 · 뉴스 ----
190  const rel = (ts) => {
191    const m = Math.floor((Date.now() - ts) / 6e4),
192      h = Math.floor(m / 60);
193    return m < 1 ? "방금" : m < 60 ? m + "분 전" : h < 24 ? h + "시간 전" : Math.floor(h / 24) + "일 전";
194  };
195  if ($("home-briefs"))
196    fetch("/api/posts?limit=4")
197      .then((r) => r.json())
198      .then((r) => {
199        const el = $("home-briefs");
200        if (!r.posts.length) {
201          el.innerHTML = `<div class="cp-empty"><b>아직 게시된 브리핑이 없습니다</b><p>Alpha Room에 새 브리핑이 올라오면 이곳에 제목이 먼저 보입니다. 본문은 UID 인증 회원에게 열립니다.</p><a href="/room#join">입장 방법 보기 →</a></div>`;
202          return;
203        }
204        el.innerHTML = `<ul class="cp-rows">${r.posts.map((p) => `<li><a href="/briefing/${p.slug}"><b>${esc(p.title)}</b><span>${esc(p.topic || "Alpha Room")} · ${rel(p.date)}${p.locked ? " · 회원 전용" : ""}</span><i>→</i></a></li>`).join("")}</ul>`;
205      })
206      .catch(() => {});
207  if ($("home-news"))
208    fetch("/api/news?limit=6")
209      .then((r) => r.json())
210      .then((r) => {
211        const sm = Object.fromEntries((r.sources || []).map((s) => [s.id, s]));
212        $("home-news").innerHTML =
213          r.news
214            .map(
215              (n) =>
216                `<li><a href="${esc(n.link)}" target="_blank" rel="noopener nofollow"><b>${esc(n.title)}</b><span>${esc((sm[n.source] || {}).name || n.source)} · ${rel(n.date)}</span><i>↗</i></a></li>`,
217            )
218            .join("") || `<li class="muted">새 뉴스가 없습니다.</li>`;
219      })
220      .catch(() => {});
221
222  // ---- 실시간 데이터 보드(메인 v3.1): 숫자 6칸. 빠른 값 15초, 느린 값 5분. 탭이 가려져 있으면 쉼 ----
223  if ($("cp-live")) {
224    const jf = (u) =>
225      fetch(u).then((r) => {
226        if (!r.ok) throw new Error(u + " " + r.status);
227        return r.json();
228      });
229    const put = (id, v) => {
230      const el = $(id);
231      if (el && v != null) el.textContent = v;
232    };
233    const usdB = (v) => "$" + (v / 1e9).toFixed(2) + "B";
234    async function fast() {
235      const [px, spot, up, fx, cb] = await Promise.allSettled([
236        jf("https://fapi.binance.com/fapi/v1/premiumIndex?symbol=BTCUSDT"),
237        jf("https://api.binance.com/api/v3/ticker/price?symbol=BTCUSDT"),
238        M.pub("upbitTicker", { markets: "KRW-BTC" }),
239        M.fx(),
240        M.pub("coinbaseTicker", { product: "BTC-USD" }),
241      ]).then((a) => a.map((r) => (r.status === "fulfilled" ? r.value : null)));
242      const usd = spot ? +spot.price : 0;
243      if (up && usd && fx) {
244        put("lv-kimp", fmt.pct((up[0].trade_price / (usd * fx) - 1) * 100));
245        put("lv-kimp-s", "업비트 " + fmt.krw(up[0].trade_price) + " · 환율 " + fx.toFixed(0));
246      }
247      if (px) {
248        const fr = +px.lastFundingRate * 100;
249        put("lv-fund", fmt.pct(fr, 4));
250        put("lv-fund-s", "바이낸스 BTC · 연율 " + fmt.pct(fr * 3 * 365, 1));
251      }
252      if (cb && usd && +cb.price) {
253        const d = +cb.price - usd;
254        put("lv-cb", (d >= 0 ? "+$" : "−$") + Math.abs(d).toFixed(1));
255        put("lv-cb-s", "코인베이스 $" + fmt.price(+cb.price) + " · " + fmt.pct((d / usd) * 100, 3));
256      }
257      put("lv-time", new Date().toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit", second: "2-digit" }) + " 기준");
258    }
259    async function slow() {
260      M.multiOI("BTC")
261        .then(({ list }) => {
262          if (list.length) {
263            put("lv-oi", usdB(list.reduce((a, b) => a + b.oi, 0)));
264            put("lv-oi-s", "BTC · " + list.map((x) => x.ex).join(" · "));
265          }
266        })
267        .catch(() => {});
268      M.pub("cgGlobal")
269        .then((r) => {
270          const d = r.data;
271          put("lv-dom", d.market_cap_percentage.btc.toFixed(1) + "%");
272          put(
273            "lv-dom-s",
274            "ETH " + d.market_cap_percentage.eth.toFixed(1) + "% · 전체 시총 $" + (d.total_market_cap.usd / 1e12).toFixed(2) + "T",
275          );
276        })
277        .catch(() => {});
278      M.fng(2)
279        .then((d) => {
280          const last = d[d.length - 1],
281            prev = d[d.length - 2];
282          put("lv-fng", last.v);
283          put(
284            "lv-fng-s",
285            ({ "Extreme Fear": "극단적 공포", Fear: "공포", Neutral: "중립", Greed: "탐욕", "Extreme Greed": "극단적 탐욕" }[last.label] ||
286              last.label) + (prev ? " · 어제 " + prev.v : ""),
287          );
288        })
289        .catch(() => {});
290    }
291    fast().catch(() => {});
292    slow();
293    // 첫 로드는 항상 실행, 이후 주기 갱신만 탭이 보일 때로 제한. 다시 보이면 밀린 갱신을 바로 실행
294    let slowAt = Date.now();
295    const tickSlow = () => {
296      slowAt = Date.now();
297      slow();
298    };
299    setInterval(() => {
300      if (!document.hidden) fast().catch(() => {});
301    }, 15000);
302    setInterval(() => {
303      if (!document.hidden) tickSlow();
304    }, 300e3);
305    document.addEventListener("visibilitychange", () => {
306      if (document.hidden) return;
307      fast().catch(() => {});
308      if (Date.now() - slowAt > 300e3) tickSlow();
309    });
310  }
311
312  // ---- 실시간 스냅샷 ----
313  const set = (id, fn) => {
314    const el = $(id);
315    if (el) fn(el);
316  };
317  async function ticker() {
318    try {
319      const [t24, prem, fx] = await Promise.all([M.ticker24(), M.premiumIndex(), M.fx()]);
320      const btc = t24.find((t) => t.symbol === "BTCUSDT");
321      const p = prem.find((t) => t.symbol === "BTCUSDT");
322      set("tk-btc", (el) => (el.textContent = "$" + fmt.price(+btc.lastPrice)));
323      set("tk-btc-chg", (el) => (el.textContent = "24h " + fmt.pct(+btc.priceChangePercent)));
324      set("tk-btc-chg", (el) => (el.className = "s mono " + fmt.cls(+btc.priceChangePercent)));
325      const fr = +p.lastFundingRate * 100;
326      set("tk-fund", (el) => (el.textContent = fmt.pct(fr, 4)));
327      set("tk-fund", (el) => (el.className = "v mono " + fmt.cls(fr)));
328      set("tk-fund-sub", (el) => (el.textContent = "연율 " + fmt.pct(fr * 3 * 365, 1)));
329      const up = await M.pub("upbitTicker", { markets: "KRW-BTC" });
330      const kimp = (up[0].trade_price / (+btc.lastPrice * fx) - 1) * 100;
331      set("tk-kimp", (el) => (el.textContent = fmt.pct(kimp)));
332      set("tk-kimp", (el) => (el.className = "v mono " + fmt.cls(kimp)));
333      set("tk-kimp-sub", (el) => (el.textContent = "업비트 " + fmt.krw(up[0].trade_price) + " · 환율 " + fx.toFixed(0)));
334      set(
335        "tk-time",
336        (el) => (el.textContent = new Date().toLocaleTimeString("ko-KR", { hour: "2-digit", minute: "2-digit", second: "2-digit" })),
337      );
338    } catch (e) {
339      console.warn("ticker", e);
340    }
341  }
342  if (document.getElementById("tk-btc")) {
343    ticker();
344    setInterval(ticker, 15000);
345  }
346
347  async function fng() {
348    try {
349      const d = await M.fng(60);
350      const last = d[d.length - 1];
351      if ($("tk-fng")) {
352        set("tk-fng", (el) => (el.textContent = last.v));
353        set("tk-fng-sub", (el) => (el.textContent = last.label));
354      }
355      // 미니 라인
356      const c = $("pv-fng");
357      if (!c) return;
358      const ctx = c.getContext("2d"),
359        dpr = devicePixelRatio || 1,
360        w = c.clientWidth,
361        h = c.clientHeight;
362      c.width = w * dpr;
363      c.height = h * dpr;
364      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
365      ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue("--paper").trim() || "#fff";
366      ctx.fillRect(0, 0, w, h);
367      ["rgba(224,53,61,.10)", "rgba(255,96,0,.08)", "rgba(10,157,99,.10)"].forEach((col, i) => {
368        ctx.fillStyle = col;
369        ctx.fillRect(0, (h / 3) * (2 - i), w, h / 3);
370      });
371      ctx.beginPath();
372      ctx.strokeStyle = "#ff6000";
373      ctx.lineWidth = 1.6;
374      d.forEach((p, i) => {
375        const x = (i / (d.length - 1)) * w,
376          y = h - (p.v / 100) * h;
377        i ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
378      });
379      ctx.stroke();
380      $("pv-fng-sub").innerHTML = `현재 <b>${last.v}</b> · ${last.label} · 60일 추이`;
381    } catch (e) {
382      console.warn("fng", e);
383    }
384  }
385  fng();
386
387  async function previews() {
388    if (!$("pv-kimp")) return; // 메인 v3 에는 미리보기 위젯이 없음
389    try {
390      const { rows } = await M.kimpTable();
391      const top = rows.slice(0, 5);
392      $("pv-kimp").innerHTML =
393        `<table class="dtable" style="font-size:12px">${top.map((r) => `<tr><td>${r.symbol}</td><td>${fmt.krw(r.krw)}</td><td class="${fmt.cls(r.kimp)}">${fmt.pct(r.kimp)}</td></tr>`).join("")}</table>`;
394    } catch (e) {
395      $("pv-kimp").textContent = "로드 실패";
396    }
397    try {
398      const prem = await M.premiumIndex();
399      const want = ["BTCUSDT", "ETHUSDT", "SOLUSDT", "XRPUSDT", "DOGEUSDT"];
400      $("pv-fund").innerHTML = `<table class="dtable" style="font-size:12px">${want
401        .map((s) => {
402          const p = prem.find((x) => x.symbol === s);
403          const v = p ? +p.lastFundingRate * 100 : 0;
404          return `<tr><td>${s.slice(0, -4)}</td><td>$${fmt.price(+p.markPrice)}</td><td class="${fmt.cls(v)}">${fmt.pct(v, 4)}</td></tr>`;
405        })
406        .join("")}</table>`;
407    } catch (e) {
408      $("pv-fund").textContent = "로드 실패";
409    }
410    try {
411      const model = await Liq.build({ symbol: "BTCUSDT", interval: "1h", limit: 300, buckets: 160 });
412      Liq.mini($("pv-heat"), model);
413    } catch (e) {
414      console.warn("heat preview", e);
415    }
416  }
417  previews();
418})();

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.