generated from ttipo/project-base
e91f7620a3
- imweb-header-top.html·imweb-header.html: 아임웹에 실제로 들어가 있는 Header Code 원본을 저장소에 둔다 - 업체 링크 추적을 imweb-header-top.html 의 UTM 스크립트 아래로 옮기고 partner-ref.html 은 삭제 (UTM 스크립트처럼 localStorage + .moneyguider.co.kr 쿠키에 저장 — www ↔ apex 이동·인앱브라우저 대비) - 신청 완료 위젯: MG_UTM.peek() 는 객체라 문자열 검사에 걸려 유입정보가 빠지고 있었다 — peek().value 를 보낸다 - 단축 링크 리다이렉트에 Referrer-Policy strict-origin-when-cross-origin (helmet 기본 no-referrer 때문에 업체가 링크를 올린 곳이 지워져 유입원이 direct 로 잡혔다) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
622 lines
26 KiB
HTML
622 lines
26 KiB
HTML
<meta name="facebook-domain-verification" content="sldnjjycwvg0acy344qq4zu3mhxh72" />
|
|
<meta name="facebook-domain-verification" content="rjbwdcpgd8bp4a5220oshav850duv1" />
|
|
|
|
<!-- ============================================================
|
|
[자본주의가이드] UTM 기반 회원가입 유입경로 자동추적 v1.1
|
|
설치 위치: 아임웹 관리자 > 환경설정 > SEO(검색엔진최적화)
|
|
> 고급 > 공통 코드 삽입 > "Header Code"
|
|
대상 필드: 회원가입 사용자 정의 항목 "유입경로(필수)" = join_form_64454
|
|
+ 기본 항목 "홈페이지" (URL 형식으로 같은 정보 기록)
|
|
사용자 정의 항목은 Open API 회원 조회에 나오지 않고,
|
|
홈페이지는 homePage 필드로 나오기 때문에 둘 다 채운다.
|
|
커버 범위: 카카오 1초 회원가입 / 카카오 소셜 가입 / 아임웹 일반 회원가입
|
|
============================================================ -->
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
|
|
/* ===================== 설정 ===================== */
|
|
var CFG = {
|
|
FIELD_ID: 'join_64454', // 아임웹이 렌더링하는 input id (name=join_form_64454)
|
|
FIELD_LABEL: '유입경로', // id를 못 찾을 때 라벨 텍스트로 재탐색 (항목 재생성 대비)
|
|
COOKIE_DOMAIN: '.moneyguider.co.kr', // www / apex 양쪽 공유
|
|
TTL_DAYS: 2, // 최초유입 보관 기간
|
|
HIDE_FIELD: true, // true=사용자에게 숨김, false=읽기전용으로 노출
|
|
HP_ENABLED: true, // 홈페이지 항목에도 유입정보 기록 (Open API homePage 로 조회용)
|
|
HP_LABEL: '홈페이지', // 홈페이지 입력칸을 name/id로 못 찾을 때 라벨로 재탐색
|
|
OWN_HOST: 'moneyguider.co.kr',
|
|
DEBUG: false // true로 바꾸면 콘솔에 로그 출력
|
|
};
|
|
|
|
var K_FIRST = 'mg_utm_first'; // 최초 유입 (first-touch)
|
|
var K_LAST = 'mg_utm_last'; // 최종 유입 (last-touch)
|
|
|
|
function log() {
|
|
if (CFG.DEBUG && window.console && console.log) {
|
|
console.log.apply(console, ['[MG-UTM]'].concat([].slice.call(arguments)));
|
|
}
|
|
}
|
|
|
|
/* ============ 저장소: localStorage + 쿠키 이중화 ============
|
|
카카오 인앱브라우저 / Safari ITP 등에서 localStorage가 막히거나
|
|
초기화되는 경우를 대비해 쿠키에도 동일 값을 기록한다. */
|
|
function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
|
|
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { } }
|
|
|
|
function ckGet(k) {
|
|
var m = document.cookie.match('(^|;)\\s*' + k + '\\s*=\\s*([^;]*)');
|
|
if (!m) return null;
|
|
try { return decodeURIComponent(m[2]); } catch (e) { return m[2]; }
|
|
}
|
|
function ckSet(k, v, days) {
|
|
var d = new Date();
|
|
d.setTime(d.getTime() + days * 86400000);
|
|
document.cookie = k + '=' + encodeURIComponent(v) +
|
|
';expires=' + d.toUTCString() +
|
|
';path=/;domain=' + CFG.COOKIE_DOMAIN + ';SameSite=Lax';
|
|
}
|
|
|
|
/* localStorage에는 만료 개념이 없으므로 저장값 안에 만료 시각(_e)을 함께 넣고
|
|
읽을 때 직접 확인한다. 이렇게 해야 쿠키가 지워진 뒤에도 TTL_DAYS가 실제로 지켜진다. */
|
|
function saveTouch(k, t) {
|
|
var o = merge(t, { _e: Date.now() + CFG.TTL_DAYS * 86400000 });
|
|
var json = JSON.stringify(o);
|
|
lsSet(k, json);
|
|
ckSet(k, json, CFG.TTL_DAYS);
|
|
}
|
|
|
|
function dropTouch(k) {
|
|
try { localStorage.removeItem(k); } catch (e) { }
|
|
ckSet(k, '', -1);
|
|
}
|
|
|
|
function readTouch(k) {
|
|
var raw = lsGet(k) || ckGet(k);
|
|
if (!raw) return null;
|
|
var o = null;
|
|
try { o = JSON.parse(raw); } catch (e) { dropTouch(k); return null; }
|
|
if (!o || typeof o !== 'object') { dropTouch(k); return null; }
|
|
if (o._e && Date.now() > o._e) { dropTouch(k); return null; } // 보관기간 경과
|
|
delete o._e;
|
|
return o;
|
|
}
|
|
|
|
/* ===================== 유틸 ===================== */
|
|
// 파이프 포맷을 깨뜨리는 문자 제거 + 길이 제한
|
|
function clean(v, n) {
|
|
return String(v == null ? '' : v)
|
|
.replace(/[|=\r\n\t]+/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.slice(0, n || 80);
|
|
}
|
|
|
|
function parseQuery() {
|
|
var out = {}, s = location.search.replace(/^\?/, '');
|
|
if (!s) return out;
|
|
var parts = s.split('&');
|
|
for (var i = 0; i < parts.length; i++) {
|
|
if (!parts[i]) continue;
|
|
var p = parts[i], j = p.indexOf('=');
|
|
var k = j < 0 ? p : p.slice(0, j);
|
|
var v = j < 0 ? '' : p.slice(j + 1);
|
|
try { out[decodeURIComponent(k)] = decodeURIComponent(v.replace(/\+/g, ' ')); }
|
|
catch (e) { out[k] = v; }
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function merge(a, b) {
|
|
var o = {}, k;
|
|
for (k in a) if (Object.prototype.hasOwnProperty.call(a, k)) o[k] = a[k];
|
|
for (k in b) if (Object.prototype.hasOwnProperty.call(b, k)) o[k] = b[k];
|
|
return o;
|
|
}
|
|
|
|
// 한국 시간 기준 날짜 (toISOString 은 UTC 라서 0~9시에는 전날로 찍힌다)
|
|
function today() { return new Date(Date.now() + 9 * 3600000).toISOString().slice(0, 10); }
|
|
|
|
/* ============ 리퍼러 분류 (UTM이 없는 자연유입 보정) ============ */
|
|
var REF_MAP = [
|
|
[/^(m\.)?(blog|cafe|post|in)\.naver\.com$/i, 'naver_blog', 'referral'],
|
|
[/(^|\.)naver\.com$/i, 'naver', 'organic'],
|
|
[/(^|\.)google\./i, 'google', 'organic'],
|
|
[/(^|\.)bing\.com$/i, 'bing', 'organic'],
|
|
[/(^|\.)daum\.net$/i, 'daum', 'organic'],
|
|
[/(^|\.)kakao\.com$/i, 'kakao', 'social'],
|
|
[/(^|\.)instagram\.com$/i, 'instagram', 'social'],
|
|
[/(^|\.)(facebook|fb)\.com$/i, 'facebook', 'social'],
|
|
[/(^|\.)(youtube\.com|youtu\.be)$/i, 'youtube', 'social'],
|
|
[/(^|\.)tiktok\.com$/i, 'tiktok', 'social'],
|
|
[/(^|\.)threads\.(net|com)$/i, 'threads', 'social'],
|
|
[/(^|\.)(tistory|brunch|velog|medium)\./i, 'blog', 'referral']
|
|
];
|
|
|
|
/* 인증·가입 관련 페이지.
|
|
카카오/네이버 소셜 로그인은 외부 인증 도메인을 왕복하는데, 돌아올 때의
|
|
리퍼러(kauth.kakao.com 등)를 새 유입으로 오해하면 원래 유입원이 가려진다.
|
|
이 경로들에서는 URL에 UTM이 직접 있을 때만 기록한다. */
|
|
var AUTH_PATH = /^\/(oauth|site_join|login|logout|member|social|join)/i;
|
|
|
|
/* 소셜 로그인 인증 도메인.
|
|
위 AUTH_PATH는 '인증 페이지에 있을 때'를 막지만, 인증을 마치고
|
|
사이트의 일반 페이지로 곧장 되돌아오는 경우는 못 막는다.
|
|
예) 신청 버튼 → 가입 → 카카오 인증 → /apply 로 복귀
|
|
이때 /apply 는 AUTH_PATH에 걸리지 않고 리퍼러가
|
|
kauth.kakao.com 이라, REF_MAP이 '카카오 소셜 유입'으로
|
|
오인해 최종유입을 덮어쓴다. 원래 유입원(네이버 광고 등)이
|
|
지워지므로 리퍼러 쪽에서도 막는다.
|
|
※ kakao.com / naver.com 전체가 아니라 인증 전용 호스트만 넣는다.
|
|
카카오톡 공유로 들어온 진짜 유입까지 무시하면 안 된다. */
|
|
var AUTH_REF = /^(kauth\.kakao\.com|accounts\.kakao\.com|nid\.naver\.com|accounts\.google\.com|appleid\.apple\.com)$/i;
|
|
|
|
// 광고 클릭 ID -> 매체 추정 (UTM이 누락된 광고 트래픽 보정)
|
|
var CID_SRC = {
|
|
gclid: 'google', gbraid: 'google', wbraid: 'google',
|
|
fbclid: 'meta', kclid: 'kakao', ttclid: 'tiktok',
|
|
msclkid: 'bing', yclid: 'yandex', n_ad_group: 'naver'
|
|
};
|
|
|
|
/* ============ 현재 페이지의 유입정보 추출 ============ */
|
|
function buildTouch() {
|
|
var q = parseQuery();
|
|
var t = {};
|
|
var hasUtm = false;
|
|
|
|
var UTM = {
|
|
utm_source: 'src', utm_medium: 'med', utm_campaign: 'cmp',
|
|
utm_term: 'trm', utm_content: 'cnt'
|
|
};
|
|
for (var k in UTM) {
|
|
if (q[k]) { t[UTM[k]] = clean(q[k]); hasUtm = true; }
|
|
}
|
|
|
|
// 광고 클릭 ID
|
|
for (var cid in CID_SRC) {
|
|
if (q[cid]) {
|
|
t.cid = cid;
|
|
if (!hasUtm) { t.src = CID_SRC[cid]; t.med = 'cpc'; hasUtm = true; }
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 리퍼러 (외부 도메인일 때만)
|
|
var ref = '';
|
|
try {
|
|
if (document.referrer) {
|
|
var h = document.referrer.split('/')[2] || '';
|
|
h = h.split('?')[0];
|
|
if (h && h.indexOf(CFG.OWN_HOST) === -1) ref = h.toLowerCase();
|
|
}
|
|
} catch (e) { }
|
|
|
|
if (!hasUtm) {
|
|
if (ref) {
|
|
var named = null;
|
|
for (var i = 0; i < REF_MAP.length; i++) {
|
|
if (REF_MAP[i][0].test(ref)) { named = REF_MAP[i]; break; }
|
|
}
|
|
t.src = named ? named[1] : clean(ref, 40);
|
|
t.med = named ? named[2] : 'referral';
|
|
} else {
|
|
t.src = 'direct';
|
|
t.med = 'none';
|
|
}
|
|
}
|
|
|
|
if (ref) t.ref = clean(ref, 40);
|
|
t.lp = clean(location.pathname, 60);
|
|
// 아임웹 상품/게시글 식별자 (/free-shop/?idx=52 처럼 idx로 페이지가 갈리는 경우)
|
|
if (q.idx) t.pid = clean(q.idx, 20);
|
|
t.ts = today();
|
|
t._utm = hasUtm; // URL에 UTM/클릭ID가 실제로 있었는지 (저장 전 제거)
|
|
return t;
|
|
}
|
|
|
|
/* ============ 유입정보 기록 ============ */
|
|
function capture() {
|
|
var t = buildTouch();
|
|
var explicitUtm = t._utm;
|
|
delete t._utm;
|
|
|
|
// 소셜 로그인 왕복(kauth.kakao.com 등)을 새 유입으로 오해하지 않도록,
|
|
// 인증·가입 페이지에서는 URL에 UTM이 직접 붙어 있을 때만 기록한다.
|
|
if (AUTH_PATH.test(location.pathname) && !explicitUtm) return;
|
|
|
|
// 인증을 마치고 일반 페이지(/apply 등)로 곧장 복귀한 경우도 동일하게 무시.
|
|
if (t.ref && AUTH_REF.test(t.ref) && !explicitUtm) return;
|
|
|
|
var isDirect = (t.src === 'direct');
|
|
|
|
// 최종유입: 실제 외부 유입일 때만 갱신 (사이트 내부 이동으로 덮어쓰지 않음)
|
|
if (!isDirect) saveTouch(K_LAST, t);
|
|
|
|
var prev = readTouch(K_FIRST);
|
|
if (!prev) {
|
|
saveTouch(K_FIRST, t); // 최초 1회 (또는 보관기간 만료 후 재시작)
|
|
} else if (!isDirect && prev.src === 'direct') {
|
|
saveTouch(K_FIRST, t); // 이전 기록이 direct였다면 실제 유입원으로 승격
|
|
}
|
|
log('capture', t);
|
|
}
|
|
|
|
/* ============ 필드에 넣을 최종 값 계산 ============ */
|
|
function resolve() {
|
|
var f = readTouch(K_FIRST);
|
|
var l = readTouch(K_LAST);
|
|
|
|
var t = f || l || { src: 'direct', med: 'none', lp: clean(location.pathname, 60), ts: today() };
|
|
|
|
// 최초유입과 최종유입이 다르면 last 항목을 덧붙임 (재방문 전환 추적)
|
|
if (f && l && (f.src !== l.src || f.cmp !== l.cmp)) {
|
|
t = merge(t, { last: clean(l.src + (l.cmp ? '/' + l.cmp : ''), 60) });
|
|
}
|
|
t.jd = today(); // 가입일
|
|
return t;
|
|
}
|
|
|
|
var ORDER = ['src', 'med', 'cmp', 'trm', 'cnt', 'cid', 'ref', 'lp', 'pid', 'ts', 'last', 'jd'];
|
|
|
|
/* 홈페이지 항목용 순서. 길이 제한(maxlength)에 걸리면 뒤에서부터 뺀다.
|
|
jd(기록일)는 API 의 joinTime 과 비교해, 가입 때가 아니라 나중에
|
|
회원정보 수정 화면에서 채워진 값을 걸러내는 데 쓰므로 src/med 바로 뒤에 둔다. */
|
|
var HP_ORDER = ['src', 'med', 'jd', 'cmp', 'cnt', 'trm', 'last', 'cid', 'lp', 'pid', 'ref', 'ts'];
|
|
|
|
function serialize(t, asUrl, order) {
|
|
order = order || ORDER;
|
|
var pairs = [];
|
|
for (var i = 0; i < order.length; i++) {
|
|
var k = order[i];
|
|
if (t[k]) pairs.push([k, t[k]]);
|
|
}
|
|
if (asUrl) {
|
|
// 항목 형식이 'URL'인 동안의 호환 모드 (HTML5 url 검증 통과용)
|
|
var qsv = [];
|
|
for (var j = 0; j < pairs.length; j++) {
|
|
qsv.push(encodeURIComponent(pairs[j][0]) + '=' + encodeURIComponent(pairs[j][1]));
|
|
}
|
|
return 'https://utm.' + CFG.OWN_HOST + '/?' + qsv.join('&');
|
|
}
|
|
var out = [];
|
|
for (var n = 0; n < pairs.length; n++) out.push(pairs[n][0] + '=' + pairs[n][1]);
|
|
return out.join('|');
|
|
}
|
|
|
|
function fallbackValue(asUrl) {
|
|
return asUrl ? ('https://utm.' + CFG.OWN_HOST + '/?src=direct&med=none')
|
|
: 'src=direct|med=none';
|
|
}
|
|
|
|
// 홈페이지 항목 값: 항상 URL 형식, maxlength 가 있으면 넘지 않게 낮은 우선순위부터 뺀다
|
|
function homepageValue(el) {
|
|
var t = resolve();
|
|
var max = el && el.maxLength > 0 ? el.maxLength : 0;
|
|
var order = HP_ORDER.slice();
|
|
var v = serialize(t, true, order);
|
|
while (max && v.length > max && order.length > 2) { // src, med 는 끝까지 남긴다
|
|
order.pop();
|
|
v = serialize(t, true, order);
|
|
}
|
|
return v;
|
|
}
|
|
|
|
/* ============ 대상 필드 찾기 ============
|
|
기본은 고정 id(join_64454). 관리자에서 항목을 지웠다 다시 만들면 아임웹이
|
|
새 인덱스를 발급하므로, id를 못 찾으면 라벨 텍스트로 한 번 더 찾는다. */
|
|
function findField() {
|
|
var el = document.getElementById && document.getElementById(CFG.FIELD_ID);
|
|
if (el) return el;
|
|
if (!CFG.FIELD_LABEL || !document.querySelectorAll) return null;
|
|
|
|
try {
|
|
var labels = document.querySelectorAll('#join_form label');
|
|
for (var i = 0; i < labels.length; i++) {
|
|
var txt = (labels[i].textContent || '').replace(/\s+/g, '');
|
|
if (txt.indexOf(CFG.FIELD_LABEL) === -1) continue;
|
|
var blk = labels[i].parentElement;
|
|
var input = blk && blk.querySelector(
|
|
'input[name^="join_form_"], select[name^="join_form_"], textarea[name^="join_form_"]');
|
|
if (input) {
|
|
log('id 미발견 - 라벨로 찾음', input.name);
|
|
return input;
|
|
}
|
|
}
|
|
} catch (e) { log('findField 실패', e); }
|
|
return null;
|
|
}
|
|
|
|
/* 홈페이지 입력칸. 아임웹 기본 항목이라 name/id 가 고정일 가능성이 높지만,
|
|
항목을 켠 뒤의 실제 마크업을 확인하기 전이므로 후보 이름과 라벨로 찾는다. */
|
|
function findHomepage() {
|
|
if (!CFG.HP_ENABLED || !document.querySelector) return null;
|
|
try {
|
|
var el = document.querySelector(
|
|
'input[name="homepage"], input[name="homePage"], input[name="home_page"], ' +
|
|
'input[id*="homepage"], input[id*="homePage"], input[name*="homepage"]');
|
|
if (el) return el;
|
|
|
|
var labels = document.querySelectorAll('form label');
|
|
for (var i = 0; i < labels.length; i++) {
|
|
var txt = (labels[i].textContent || '').replace(/\s+/g, '');
|
|
if (txt.indexOf(CFG.HP_LABEL) === -1) continue;
|
|
var blk = labels[i].parentElement;
|
|
var input = blk && blk.querySelector('input[type="url"], input[type="text"]');
|
|
if (input) return input;
|
|
}
|
|
} catch (e) { log('findHomepage 실패', e); }
|
|
return null;
|
|
}
|
|
|
|
/* ============ 회원가입 폼에 주입 ============ */
|
|
// 회원가입 폼(#join_form) 안의 입력칸인지
|
|
function inJoinForm(el) {
|
|
return !!(el.form && el.form.id === 'join_form');
|
|
}
|
|
|
|
function conceal(el) {
|
|
if (el.getAttribute('data-mg-utm') === '1') return;
|
|
el.setAttribute('data-mg-utm', '1');
|
|
var block = el.closest ? el.closest('.input_block') : null;
|
|
if (CFG.HIDE_FIELD) {
|
|
if (block) block.style.display = 'none';
|
|
else el.style.display = 'none';
|
|
} else {
|
|
el.readOnly = true;
|
|
el.setAttribute('tabindex', '-1');
|
|
el.style.background = '#f5f6f7';
|
|
el.style.color = '#8a8f98';
|
|
}
|
|
}
|
|
|
|
function put(el, value) {
|
|
// 가입 폼이 아닌 곳(회원정보 수정 화면 등)에 이미 값이 있으면 가입 때 기록된 값이므로 덮어쓰지 않는다
|
|
if (el.value && !inJoinForm(el)) { conceal(el); return; }
|
|
|
|
el.value = value;
|
|
|
|
// HTML5 검증 실패 시 URL 포맷으로 폴백 (항목 형식이 URL인 경우 방어)
|
|
if (el.checkValidity && !el.checkValidity()) {
|
|
el.value = serialize(resolve(), true);
|
|
if (!el.checkValidity()) el.value = fallbackValue(true);
|
|
}
|
|
|
|
// 아임웹 자체 검증 로직에 값 변경을 알림
|
|
var evts = ['input', 'change', 'keyup', 'blur'];
|
|
for (var i = 0; i < evts.length; i++) {
|
|
try { el.dispatchEvent(new Event(evts[i], { bubbles: true })); } catch (e) { }
|
|
}
|
|
if (window.jQuery) { try { jQuery(el).trigger('change').trigger('blur'); } catch (e) { } }
|
|
|
|
conceal(el);
|
|
log('fill', el.name || el.id, el.value);
|
|
}
|
|
|
|
function fill() {
|
|
var el = findField();
|
|
if (!el) return false;
|
|
put(el, serialize(resolve(), el.type === 'url'));
|
|
|
|
// 홈페이지 항목은 같은 폼에 함께 렌더링되므로 유입경로 칸을 찾은 시점에 같이 채운다
|
|
var hp = findHomepage();
|
|
if (hp) put(hp, homepageValue(hp));
|
|
return true;
|
|
}
|
|
|
|
/* ============ 폼 제출 직전 최종 방어 ============
|
|
필수 + 숨김 상태에서 값이 비면 브라우저가 제출을 막으므로
|
|
어떤 경우에도 빈 값으로 남지 않게 한다. */
|
|
function guard() {
|
|
function ensure() {
|
|
var el = findField();
|
|
if (el && !el.value) {
|
|
var asUrl = (el.type === 'url');
|
|
el.value = serialize(resolve(), asUrl) || fallbackValue(asUrl);
|
|
log('guard refill', el.value);
|
|
}
|
|
var hp = findHomepage();
|
|
if (hp && !hp.value) {
|
|
hp.value = homepageValue(hp) || fallbackValue(true);
|
|
log('guard refill homepage', hp.value);
|
|
}
|
|
}
|
|
document.addEventListener('submit', ensure, true);
|
|
document.addEventListener('click', function (e) {
|
|
var tg = e.target;
|
|
if (!tg || !tg.closest) return;
|
|
if (tg.closest('button, input[type=submit], a')) ensure();
|
|
}, true);
|
|
}
|
|
|
|
/* ===================== 부팅 ===================== */
|
|
capture();
|
|
|
|
function start() {
|
|
guard();
|
|
if (fill()) return;
|
|
|
|
// 아임웹은 폼을 지연 렌더링/재렌더링하므로 DOM 변화를 감시
|
|
var stop = false, mo = null;
|
|
function done() {
|
|
stop = true;
|
|
if (mo) { try { mo.disconnect(); } catch (e) { } }
|
|
}
|
|
if (window.MutationObserver && document.body) {
|
|
mo = new MutationObserver(function () { if (!stop && fill()) done(); });
|
|
mo.observe(document.body, { childList: true, subtree: true });
|
|
}
|
|
// 관측자 미지원/누락 대비 폴링 (최대 20초)
|
|
var tries = 0;
|
|
var timer = setInterval(function () {
|
|
if (stop || fill() || ++tries > 40) { clearInterval(timer); done(); }
|
|
}, 500);
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', start);
|
|
} else {
|
|
start();
|
|
}
|
|
|
|
// 디버그용 핸들: 콘솔에서 MG_UTM.peek() 실행
|
|
window.MG_UTM = {
|
|
peek: function () {
|
|
var raw = lsGet(K_FIRST) || ckGet(K_FIRST);
|
|
var exp = null;
|
|
try { var o = JSON.parse(raw); if (o && o._e) exp = new Date(o._e).toISOString().slice(0, 10); } catch (e) { }
|
|
return {
|
|
first: readTouch(K_FIRST),
|
|
last: readTouch(K_LAST),
|
|
expiresOn: exp, // 이 날짜가 지나면 최초유입이 초기화됨
|
|
value: serialize(resolve(), false),
|
|
homepage: homepageValue(findHomepage()) // 홈페이지 항목에 들어갈 값
|
|
};
|
|
},
|
|
reset: function () {
|
|
dropTouch(K_FIRST); dropTouch(K_LAST);
|
|
return 'cleared';
|
|
},
|
|
refill: fill
|
|
};
|
|
})();
|
|
</script>
|
|
|
|
<!-- ============================================================
|
|
[자본주의가이드] 업체 링크 추적 v1.0
|
|
위 UTM 스크립트와 따로 동작한다 (한쪽이 실패해도 다른 쪽은 영향 없음).
|
|
|
|
1) 업체 단축 링크(moneyguide.wandergo.net/r/코드)를 누르고 들어오면 주소에 붙은
|
|
mg_ref(클릭 ID)를 30일간 보관하고 주소창에서는 지운다.
|
|
(주소를 그대로 공유해도 다른 사람이 그 업체 실적으로 잡히지 않게)
|
|
2) 로그인한 회원이 확인되면 "이 회원이 이 클릭으로 들어왔다"를 서버에 한 번 알린다.
|
|
→ 구매는 서버가 아임웹 주문을 1시간마다 확인해 이 기록으로 업체에 귀속한다.
|
|
3) 신청 완료 위젯(imweb-free-apply.html)이 window.MG_PARTNER.refs() 로 같은 값을 읽어
|
|
신청 기록에 함께 보낸다.
|
|
|
|
저장: UTM 스크립트처럼 localStorage + .moneyguider.co.kr 쿠키 이중화
|
|
(www ↔ apex 이동, 카카오 인앱브라우저·Safari ITP 대비).
|
|
클릭 ID 와 보관 시각만 저장한다 — 회원 정보는 저장하지 않는다.
|
|
============================================================ -->
|
|
<script>
|
|
(function () {
|
|
'use strict';
|
|
|
|
/* ===================== 설정 ===================== */
|
|
var API_BASE = 'https://moneyguide.wandergo.net/api'; // 백엔드 주소
|
|
var COOKIE_DOMAIN = '.moneyguider.co.kr'; // 위 UTM 스크립트와 같은 값
|
|
|
|
/* 아래는 백엔드(shared/utils/partner-link.ts)와 같아야 한다 */
|
|
var PARAM = 'mg_ref';
|
|
var REF_PATTERN = /^[0-9A-Za-z]{16}$/;
|
|
var TTL_DAYS = 30; // 귀속 기간
|
|
var DAY_MS = 86400000;
|
|
var MAX_REFS = 10; // 쿠키가 커지지 않게 최근 것만 둔다
|
|
var K_REFS = 'mg_partner_refs';
|
|
var K_SENT = 'mg_partner_sent'; // 이미 서버에 알린 클릭 ID
|
|
/* 아임웹 전역(MEMBER_UID)은 Header Code 보다 늦게 생긴다 */
|
|
var WAIT_MS = 5000;
|
|
var POLL_MS = 200;
|
|
|
|
/* ============ 저장소: localStorage + 쿠키 이중화 ============ */
|
|
function lsGet(k) { try { return localStorage.getItem(k); } catch (e) { return null; } }
|
|
function lsSet(k, v) { try { localStorage.setItem(k, v); } catch (e) { } }
|
|
function ckGet(k) {
|
|
var m = document.cookie.match('(^|;)\\s*' + k + '\\s*=\\s*([^;]*)');
|
|
if (!m) return null;
|
|
try { return decodeURIComponent(m[2]); } catch (e) { return m[2]; }
|
|
}
|
|
function ckSet(k, v, days) {
|
|
var d = new Date();
|
|
d.setTime(d.getTime() + days * DAY_MS);
|
|
document.cookie = k + '=' + encodeURIComponent(v) +
|
|
';expires=' + d.toUTCString() + ';path=/;domain=' + COOKIE_DOMAIN + ';SameSite=Lax';
|
|
}
|
|
function read(k) { return lsGet(k) || ckGet(k) || ''; }
|
|
function write(k, v) { lsSet(k, v); ckSet(k, v, TTL_DAYS); }
|
|
|
|
/* 저장 형식 'ref~보관시각(36진수),…' — 쿠키 크기를 줄이려고 JSON 대신 짧게 쓴다.
|
|
30일이 지났거나 형식이 아닌 값은 버린다. */
|
|
function loadRefs() {
|
|
var out = [], now = Date.now(), parts = read(K_REFS).split(',');
|
|
for (var i = 0; i < parts.length; i++) {
|
|
var p = parts[i].split('~');
|
|
var at = parseInt(p[1], 36);
|
|
if (REF_PATTERN.test(p[0]) && at > 0 && now - at <= TTL_DAYS * DAY_MS) {
|
|
out.push({ ref: p[0], at: at });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
function saveRefs(list) {
|
|
var parts = [];
|
|
for (var i = 0; i < list.length; i++) parts.push(list[i].ref + '~' + list[i].at.toString(36));
|
|
write(K_REFS, parts.join(','));
|
|
}
|
|
function refsOnly(list) {
|
|
var out = [];
|
|
for (var i = 0; i < list.length; i++) out.push(list[i].ref);
|
|
return out;
|
|
}
|
|
|
|
/* ============ 1) 주소의 클릭 ID 보관 + 주소창 정리 ============ */
|
|
var refs = loadRefs();
|
|
try {
|
|
var url = new URL(window.location.href);
|
|
var ref = url.searchParams.get(PARAM);
|
|
if (ref && REF_PATTERN.test(ref)) {
|
|
var kept = [];
|
|
for (var i = 0; i < refs.length; i++) if (refs[i].ref !== ref) kept.push(refs[i]);
|
|
kept.push({ ref: ref, at: Date.now() }); // 최근 것이 뒤
|
|
refs = kept.slice(-MAX_REFS);
|
|
}
|
|
if (url.searchParams.has(PARAM)) {
|
|
url.searchParams.delete(PARAM);
|
|
window.history.replaceState(window.history.state, '', url.pathname + url.search + url.hash);
|
|
}
|
|
} catch (e) { }
|
|
saveRefs(refs);
|
|
|
|
/* ============ 3) 다른 위젯이 읽는 창구 ============ */
|
|
window.MG_PARTNER = {
|
|
refs: function () { return refsOnly(loadRefs()); }
|
|
};
|
|
|
|
/* ============ 2) 로그인 회원 ↔ 클릭 연결 ============ */
|
|
function report(memberUid) {
|
|
var current = window.MG_PARTNER.refs();
|
|
var sent = read(K_SENT) ? read(K_SENT).split(',') : [];
|
|
var pending = [];
|
|
for (var i = 0; i < current.length; i++) {
|
|
if (sent.indexOf(current[i]) === -1) pending.push(current[i]);
|
|
}
|
|
if (!pending.length || typeof window.fetch !== 'function') return;
|
|
|
|
window.fetch(API_BASE + '/partner-touches', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
credentials: 'omit',
|
|
body: JSON.stringify({ memberUid: memberUid, refs: pending })
|
|
}).then(function (res) {
|
|
if (!res.ok) return; // 실패하면 다음 페이지에서 다시 보낸다
|
|
// 보관 중인 것만 남겨 목록이 끝없이 늘지 않게 한다
|
|
var next = [];
|
|
var all = sent.concat(pending);
|
|
for (var j = 0; j < all.length; j++) {
|
|
if (current.indexOf(all[j]) !== -1 && next.indexOf(all[j]) === -1) next.push(all[j]);
|
|
}
|
|
write(K_SENT, next.join(','));
|
|
}).catch(function () { });
|
|
}
|
|
|
|
if (!refs.length) return; // 보관한 클릭이 없으면 할 일이 없다
|
|
|
|
var waited = 0;
|
|
(function poll() {
|
|
var uid = window.MEMBER_UID;
|
|
if (uid) { report(String(uid)); return; }
|
|
// 빈 문자열이면 비로그인 확정 — 로그인한 뒤 다른 페이지에서 다시 시도된다
|
|
if (typeof uid === 'string' || waited >= WAIT_MS) return;
|
|
waited += POLL_MS;
|
|
window.setTimeout(poll, POLL_MS);
|
|
})();
|
|
})();
|
|
</script> |