feat: 업체 링크 추적을 실제 아임웹 Header Code 로 합치고 신청 유입정보 누락 수정

- 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>
This commit is contained in:
2026-10-06 00:11:52 +09:00
parent 59c84dcac5
commit e91f7620a3
12 changed files with 713 additions and 133 deletions
@@ -47,7 +47,7 @@ export class MemberApplication {
@Column({ name: 'kakao_clicked_at', type: 'timestamptz', nullable: true })
kakaoClickedAt!: Date | null;
/** 신청 순간의 유입정보 원본 (header-top.html 이 저장해 둔 값, 파이프 형식) */
/** 신청 순간의 유입정보 원본 (imweb-header-top.html 이 저장해 둔 값, 파이프 형식) */
@Column({ name: 'inflow_raw', type: 'text', nullable: true })
inflowRaw!: string | null;
@@ -14,7 +14,7 @@ import { CLICK_REF_PATTERN } from '@/shared/utils/partner-link';
const MEMBER_ID_MAX_LENGTH = 255;
const PRODUCT_NAME_MAX_LENGTH = 200;
/** header-top.html 이 만드는 값은 200자 남짓 — 넉넉히 두되 무한정 받지 않는다 */
/** imweb-header-top.html 이 만드는 값은 200자 남짓 — 넉넉히 두되 무한정 받지 않는다 */
const INFLOW_RAW_MAX_LENGTH = 1000;
/** 신청 완료 화면이 보내는 신청 기록 */
@@ -44,7 +44,7 @@ export class CreateApplicationDto {
@ApiPropertyOptional({
description:
'신청 순간의 유입정보 (header-top.html 형식: src=..|med=..|cmp=..)',
'신청 순간의 유입정보 (imweb-header-top.html 형식: src=..|med=..|cmp=..)',
example: 'src=facebook|med=paid|cmp=fall|jd=2026-10-05',
})
@IsOptional()
@@ -54,7 +54,7 @@ export class CreateApplicationDto {
@ApiPropertyOptional({
description:
'브라우저가 보관한 업체 링크 클릭 ID 목록 (imweb/partner-ref.html 의 MG_PARTNER.refs())',
'브라우저가 보관한 업체 링크 클릭 ID 목록 (imweb/imweb-header-top.html 의 MG_PARTNER.refs())',
example: ['AbCd1234EfGh5678'],
type: [String],
})
+1 -1
View File
@@ -39,7 +39,7 @@ export const IMWEB_CALLBACK_PATH = '/imweb/oauth/callback';
const DEFAULT_SYNC_INTERVAL_MINUTES = 60;
const DEFAULT_PAGE_SIZE = 100;
/** header-top.html 이 홈페이지 항목에 쓰는 가짜 호스트 ('https://utm.' + OWN_HOST) */
/** imweb-header-top.html 이 홈페이지 항목에 쓰는 가짜 호스트 ('https://utm.' + OWN_HOST) */
export const DEFAULT_TRACKING_HOST = 'utm.moneyguider.co.kr';
/**
@@ -19,6 +19,10 @@ import { CreatePartnerTouchDto } from './dto/partner-touch.dto';
import { PartnerAttributionService } from './partner-attribution.service';
import { PartnerLinksService, SHORT_LINK_PATH } from './partner-links.service';
const REFERRER_POLICY_HEADER = 'Referrer-Policy';
/** 브라우저 기본값 — 다른 사이트로는 출처(도메인)만 보낸다 */
const REDIRECT_REFERRER_POLICY = 'strict-origin-when-cross-origin';
/**
* 업체 단축 링크 — moneyguide.wandergo.net/r/{코드} (main.ts 에서 /api prefix 제외).
*
@@ -46,11 +50,14 @@ export class PartnerRedirectController {
);
// 매 클릭을 서버가 받아야 클릭 ID 가 새로 발급된다 — 브라우저·프록시가 저장하지 않게 한다
res.setHeader('Cache-Control', 'no-store');
// helmet 기본값(no-referrer)이 리다이렉트에 붙으면 업체가 링크를 올린 곳(블로그 등)이 지워져
// 상품 페이지의 UTM 스크립트가 유입원을 direct 로 기록한다 — 출처(도메인)만은 넘긴다
res.setHeader(REFERRER_POLICY_HEADER, REDIRECT_REFERRER_POLICY);
res.redirect(HttpStatus.FOUND, target);
}
}
/** 아임웹 Header Code 스크립트(imweb/partner-ref.html)가 회원 ↔ 링크 클릭 연결을 보낸다 */
/** 아임웹 Header Code(imweb/imweb-header-top.html 의 업체 링크 추적)가 회원 ↔ 링크 클릭 연결을 보낸다 */
@ApiTags('Partner Touches')
@Controller('partner-touches')
export class PartnerTouchesController {
+1 -1
View File
@@ -2,7 +2,7 @@ import { InflowStatus, parseInflow } from './inflow';
const HOST = 'utm.moneyguider.co.kr';
/** header-top.html 의 serialize(asUrl) 와 같은 방식으로 홈페이지 값을 만든다 */
/** imweb-header-top.html 의 serialize(asUrl) 와 같은 방식으로 홈페이지 값을 만든다 */
function homePage(params: Record<string, string>): string {
const query = Object.entries(params)
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* 회원 유입경로 파싱 유틸 (순수 함수).
*
* 아임웹 Header Code 의 UTM 스크립트(header-top.html)가 회원가입 폼의 '홈페이지' 항목에
* 아임웹 Header Code 의 UTM 스크립트(imweb/imweb-header-top.html)가 회원가입 폼의 '홈페이지' 항목에
* 아래 형식으로 유입정보를 기록한다. 아임웹 Open API 회원 조회에는 사용자 정의 가입 항목이
* 나오지 않고 homePage 만 나오기 때문에 이 값을 풀어서 쓴다.
*
+2 -2
View File
@@ -2,11 +2,11 @@
* 업체 링크 유틸 (순수 함수) — 이동 주소 검증, 클릭 ID 부착, 봇 판별.
*
* 업체에 주는 단축 링크(/r/{코드})를 누르면 서버가 클릭마다 고유 ID 를 발급해
* 상품 페이지 주소에 `mg_ref` 로 붙여 보낸다. 아임웹 Header Code 스크립트(imweb/partner-ref.html)가
* 상품 페이지 주소에 `mg_ref` 로 붙여 보낸다. 아임웹 Header Code 스크립트(imweb/imweb-header-top.html 의 업체 링크 추적)가
* 이 값을 브라우저에 보관했다가, 로그인한 회원이 확인되면 서버에 알려 회원과 클릭을 잇는다.
*/
/** 상품 페이지 주소에 붙이는 클릭 ID 파라미터 — imweb/partner-ref.html 과 같아야 한다 */
/** 상품 페이지 주소에 붙이는 클릭 ID 파라미터 — imweb/imweb-header-top.html 과 같아야 한다 */
export const PARTNER_REF_PARAM = 'mg_ref';
/** 업체 링크 코드 — /r/{코드} */
+19 -5
View File
@@ -12,10 +12,22 @@
아임웹 Open API /member-info/members ← homePage 에 UTM 스크립트가 기록한 유입정보
```
유입정보는 아임웹 Header Code 의 UTM 스크립트(`header-top.html`)가 회원가입 폼의
유입정보는 아임웹 Header Code 의 UTM 스크립트(`imweb-header-top.html`)가 회원가입 폼의
'홈페이지' 항목에 `https://utm.moneyguider.co.kr/?src=..&med=..&jd=..` 형식으로 남긴 값이다.
백엔드가 이 값을 풀어서 유입원·매체·캠페인별로 조회·집계한다.
## 파일 — 아임웹 어디에 붙이나
아임웹에 실제로 들어가 있는 코드의 원본이다. 고친 뒤에는 아임웹에 **다시 붙여 넣어야** 반영된다.
| 파일 | 아임웹 위치 | 하는 일 |
| --- | --- | --- |
| `imweb-header-top.html` | Header Code (사이트 전체) | UTM 유입경로 기록(`MG_UTM`) + 업체 링크 클릭 보관(`MG_PARTNER`) |
| `imweb-header.html` | Header Code (사이트 전체) | 로그인 링크의 back_url 에 UTM 을 붙여 로그인 후에도 유지 |
| `imweb-free-apply.html` | `/apply` 페이지 HTML 위젯 | 신청 완료 화면 + 신청 기록·오픈카톡 클릭 전송 |
| `imweb-members.html` | 관리자 전용 페이지 코드 위젯 | 관리자 대시보드 |
| `serve-local.js` | — | 로컬 테스트용 정적 서버 |
## 로컬에서 띄우기
1. 백엔드 env 에 아임웹 앱 키와 관리자 계정 입력 — `backend/.env.development`
@@ -65,7 +77,7 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
아임웹 `/apply` 페이지의 신청 완료 위젯(`imweb-free-apply.html`)이 표시될 때
`POST /api/applications` 로 신청을 남긴다. 관리자 화면 **신청 내역** 메뉴에서 본다.
- 보내는 값: `MEMBER_UID`(회원 아이디 또는 회원 코드), 상품번호(`idx`), 상품명, 그 순간의 유입정보(`MG_UTM.peek()`), 업체 링크 클릭 ID(`MG_PARTNER.refs()`)
- 보내는 값: `MEMBER_UID`(회원 아이디 또는 회원 코드), 상품번호(`idx`), 상품명, 그 순간의 유입정보(`MG_UTM.peek().value` — peek() 는 객체를 돌려준다), 업체 링크 클릭 ID(`MG_PARTNER.refs()`)
- 백엔드는 아임웹에 실제로 있는 회원인지 확인하고(동기화 전 신규 가입자는 아임웹 API 로 직접 확인),
회원·상품당 한 건만 남긴다. 이름·연락처는 브라우저 값이 아니라 아임웹 회원 정보를 쓴다
- 오픈카톡 버튼 클릭은 `POST /api/applications/{id}/kakao-clicks` 로 처음 한 번만 남는다
@@ -83,7 +95,8 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
업체에 링크 전달 ── https://moneyguide.wandergo.net/r/{코드}
│ ① 클릭 기록 (카톡 미리보기·검색봇 제외) 후 상품 페이지로 이동하며 클릭 ID(mg_ref)를 붙인다
▼
상품 페이지 ── partner-ref.html(Header Code)이 클릭 ID 를 30일 보관하고 주소창에서 지운다
상품 페이지 ── imweb-header-top.html(Header Code)의 업체 링크 추적이 클릭 ID 를 30일 보관하고 주소창에서 지운다
│ (UTM 스크립트처럼 localStorage + .moneyguider.co.kr 쿠키 — www ↔ apex 이동에도 유지)
│ ② 로그인 회원이 확인되면 "이 회원이 이 클릭으로 들어왔다"를 POST /api/partner-touches 로 알린다
▼
신청: 신청 완료 위젯이 신청 기록에 클릭 ID 를 함께 보낸다 → 바로 귀속
@@ -108,7 +121,8 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
2. 백엔드 배포 (마이그레이션 자동 실행)
3. 관리자 화면 **연동 관리 → 아임웹 연결**을 다시 한다 — 새 권한이 담긴 토큰을 받는다.
다시 연결하기 전에는 상품 불러오기·구매 집계가 `IMWEB_006`(권한 부족)으로 실패한다. 회원 동기화는 그대로 돈다
4. `partner-ref.html` 을 아임웹 **Header Code** 에 붙여 넣는다 (사이트 전체)
4. `imweb-header-top.html` 을 아임웹 **Header Code** 에 다시 붙여 넣는다 (UTM 스크립트 아래에 업체 링크 추적이 추가됨).
`imweb-header.html`(로그인 링크 UTM 보존)은 바꾸지 않았다
5. `imweb-free-apply.html` 을 신청 완료 위젯에 다시 붙여 넣는다 (신청 기록·클릭 ID 전송 추가됨)
6. 업체 링크 메뉴에서 **상품 불러오기** → 업체 추가 → 링크 발급
@@ -147,7 +161,7 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
- `imweb-members.html` 의 `API_BASE` 운영 주소(`https://moneyguide.wandergo.net/api`)를 확인하고 아임웹 코드 위젯에 붙여 넣는다
- 아임웹에서 그 페이지의 접근 권한을 관리자로 제한한다
- 업체 링크를 쓰려면 위 '업체 링크 → 처음 켤 때' 순서를 따른다
(`partner-ref.html`·`imweb-free-apply.html` 의 `API_BASE` 도 같은 운영 주소)
(`imweb-header-top.html`·`imweb-free-apply.html` 의 `API_BASE` 도 같은 운영 주소)
## 유입 기록 상태
+7 -4
View File
@@ -434,16 +434,19 @@
})();
}
/* header-top.html(UTM 스크립트)이 보관한 유입정보 — 없으면 빈 값 */
/* imweb-header-top.html(UTM 스크립트)이 보관한 유입정보 — 없으면 빈 값.
MG_UTM.peek() 는 { first, last, value, … } 객체를 돌려주고,
서버가 받는 파이프 형식(src=..|med=..|…)은 그중 value 다. */
function readInflow() {
try {
var value = window.MG_UTM && typeof window.MG_UTM.peek === 'function'
? window.MG_UTM.peek() : '';
var peeked = window.MG_UTM && typeof window.MG_UTM.peek === 'function'
? window.MG_UTM.peek() : null;
var value = peeked && typeof peeked === 'object' ? peeked.value : peeked;
return typeof value === 'string' ? value.slice(0, INFLOW_MAX) : '';
} catch (e) { return ''; }
}
/* partner-ref.html(업체 링크 스크립트)이 보관한 클릭 ID — 없으면 빈 배열 */
/* imweb-header-top.html(업체 링크 추적)이 보관한 클릭 ID — 없으면 빈 배열 */
function readPartnerRefs() {
try {
var refs = window.MG_PARTNER && typeof window.MG_PARTNER.refs === 'function'
+622
View File
@@ -0,0 +1,622 @@
<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>
+48
View File
@@ -0,0 +1,48 @@
<script>
//UTM 트래킹을 위한 코드 (무료강의 랜딩용)
document.addEventListener('mousedown', function(e) {
// 클릭한 요소가 로그인 링크인지 확인
const anchor = e.target.closest('a[href*="/login"]');
if (anchor) {
const urlParams = new URLSearchParams(window.location.search);
const utms = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term'];
// 1. 현재 주소창에서 UTM 파라미터들 추출
let foundUtmParams = [];
utms.forEach(key => {
if (urlParams.has(key)) {
foundUtmParams.push(key + '=' + encodeURIComponent(urlParams.get(key)));
}
});
// UTM이 있을 때만 실행
if (foundUtmParams.length > 0) {
const url = new URL(anchor.href, window.location.origin);
let backUrl = url.searchParams.get('back_url');
if (backUrl) {
try {
// 2. 기존의 암호화된 back_url을 복호화 (예: /shop_view?idx=38&metaconversion=true)
let decodedBackUrl = atob(backUrl);
// 아임웹이 이미 UTM을 넣어둔 경우(또는 앞선 mousedown에서 넣은 경우) 중복으로 붙이지 않음
if (/[?&]utm_/.test(decodedBackUrl)) return;
// 3. 복호화된 주소 뒤에 UTM 결합
const separator = decodedBackUrl.includes('?') ? '&' : '?';
let updatedBackUrl = decodedBackUrl + separator + foundUtmParams.join('&');
// 4. 다시 Base64로 암호화해서 back_url 파라미터 교체
url.searchParams.set('back_url', btoa(updatedBackUrl));
anchor.href = url.toString();
console.log('UTM이 back_url 내부로 성공적으로 삽입되었습니다.');
} catch (err) {
console.error('Base64 디코딩/인코딩 오류:', err);
}
}
}
}
}, true);
</script>
-114
View File
@@ -1,114 +0,0 @@
<!-- ===== 아임웹 Header Code 삽입용 — 업체 링크 추적 (partner-ref) ===== -->
<!--
어디에 넣나 : 아임웹 관리자 > 환경설정 > SEO/HTML > Header Code (사이트 전체)
header-top.html(UTM 스크립트)과 함께 두어도 서로 영향이 없습니다.
하는 일
1) 업체 단축 링크(moneyguide.wandergo.net/r/코드)를 누르고 들어오면 주소에 붙은
mg_ref(클릭 ID)를 이 브라우저에 30일간 보관하고, 주소창에서는 지웁니다.
(주소를 그대로 공유해도 다른 사람이 그 업체 실적으로 잡히지 않게)
2) 로그인한 회원이 확인되면 "이 회원이 이 링크로 들어왔다"를 서버에 한 번 알립니다.
→ 구매는 서버가 아임웹 주문을 1시간마다 확인해 이 기록으로 업체에 귀속합니다.
3) 신청 완료 위젯(imweb-free-apply.html)이 window.MG_PARTNER.refs() 로 같은 값을 읽어
신청 기록에 함께 보냅니다.
저장하는 값 : 클릭 ID 와 보관 시각만 (회원 정보는 저장하지 않습니다)
-->
<script>
(function () {
'use strict';
/* 백엔드 주소 */
var API_BASE = 'https://moneyguide.wandergo.net/api';
/* 아래는 수정할 필요 없습니다 — 백엔드(shared/utils/partner-link.ts)와 같은 값입니다 */
var PARAM = 'mg_ref';
var REF_PATTERN = /^[0-9A-Za-z]{16}$/;
var STORE_KEY = 'mg_partner_refs'; /* [{ ref, at }] — 최근 것이 뒤 */
var SENT_KEY = 'mg_partner_sent'; /* 이미 서버에 알린 클릭 ID */
var WINDOW_MS = 30 * 24 * 60 * 60 * 1000; /* 귀속 기간 30일 */
var MAX_REFS = 20;
/* 아임웹 전역(MEMBER_UID)은 Header Code 보다 늦게 생깁니다 */
var WAIT_MS = 5000;
var POLL_MS = 200;
function readJson(key) {
try {
var value = JSON.parse(window.localStorage.getItem(key) || '[]');
return Array.isArray(value) ? value : [];
} catch (e) { return []; }
}
function writeJson(key, value) {
try { window.localStorage.setItem(key, JSON.stringify(value)); } catch (e) { }
}
/* 30일이 지났거나 형식이 아닌 값은 버립니다 */
function freshRefs() {
var now = Date.now();
return readJson(STORE_KEY).filter(function (item) {
return item && REF_PATTERN.test(item.ref) &&
typeof item.at === 'number' && now - item.at <= WINDOW_MS;
});
}
/* 1) 주소의 클릭 ID 보관 + 주소창 정리 */
var refs = freshRefs();
try {
var url = new URL(window.location.href);
var ref = url.searchParams.get(PARAM);
if (ref && REF_PATTERN.test(ref)) {
refs = refs.filter(function (item) { return item.ref !== ref; });
refs.push({ ref: ref, at: Date.now() });
if (refs.length > MAX_REFS) refs = refs.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) { }
writeJson(STORE_KEY, refs);
/* 3) 다른 위젯이 읽는 창구 */
window.MG_PARTNER = {
refs: function () {
return freshRefs().map(function (item) { return item.ref; });
}
};
/* 2) 로그인 회원 ↔ 클릭 연결 */
function report(memberUid) {
var current = window.MG_PARTNER.refs();
var sent = readJson(SENT_KEY);
var pending = current.filter(function (r) { return sent.indexOf(r) === -1; });
if (pending.length === 0 || 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; /* 실패하면 다음 페이지에서 다시 보냅니다 */
/* 보관 중인 것만 남겨 목록이 끝없이 늘지 않게 합니다 */
writeJson(SENT_KEY, sent.concat(pending).filter(function (r) {
return current.indexOf(r) !== -1;
}));
}).catch(function () { });
}
if (refs.length === 0) 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>
<!-- ===== 코드 끝 ===== -->