디퍼드 딥링크에서 매칭이 0으로 나오는 문제 수정

This commit is contained in:
2026-08-03 16:22:51 +09:00
parent ac9fbf6264
commit 8cb67d52c0
4 changed files with 281 additions and 73 deletions
+32
View File
@@ -147,6 +147,38 @@ describe('calculateConfidenceScore', () => {
expect(score).toBe(20);
expect(matchedFactors.sort()).toEqual(['screen', 'timezone'].sort());
});
it('matches a mobile browser click to a native-app install on platform + device signals', () => {
const click = {
ipAddress: '100.64.0.5',
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1',
timezone: 'Asia/Seoul',
language: 'ko-KR',
screenWidth: 1179,
screenHeight: 2556,
platform: 'ios',
platformVersion: '17.4',
};
const install = {
ipAddress: '100.64.0.9',
userAgent: 'LinkFortySDK/1.4.0 (iOS 17.4; iPhone15,3)',
timezone: 'Asia/Seoul',
language: 'ko-KR',
screenWidth: 1179,
screenHeight: 2556,
platform: 'iOS',
platformVersion: '17.4',
};
const { score, matchedFactors } = fingerprint.calculateConfidenceScore(click, install);
expect(score).toBe(80);
expect(matchedFactors).toEqual(
expect.arrayContaining(['user_agent', 'platform', 'timezone', 'language', 'screen'])
);
});
});
describe('isAttributableIp', () => {
+241 -69
View File
@@ -26,6 +26,42 @@ export interface FingerprintMatch {
clickedAt: Date;
}
type FingerprintFactorDebug = {
ip: {
matched: boolean;
installAttributable: boolean;
clickAttributable: boolean;
installValue: string;
clickValue: string;
};
userAgent: {
matched: boolean;
installValue: string;
clickValue: string;
mobilePlatformBridge: boolean;
};
platform: {
matched: boolean;
installValue: string;
clickValue: string;
};
timezone: {
matched: boolean;
installValue: string;
clickValue: string;
};
language: {
matched: boolean;
installValue: string;
clickValue: string;
};
screen: {
matched: boolean;
installValue: string;
clickValue: string;
};
};
/**
* Scoring weights for probabilistic matching
* Total should equal 100 for percentage-based confidence
@@ -36,6 +72,7 @@ const FINGERPRINT_WEIGHTS = {
TIMEZONE: 10,
LANGUAGE: 10,
SCREEN_RESOLUTION: 10,
PLATFORM: 20,
};
/**
@@ -47,6 +84,7 @@ export const DEFAULT_ATTRIBUTION_WINDOW_HOURS = 168;
* Minimum confidence threshold for attribution (70%)
*/
export const CONFIDENCE_THRESHOLD = 70;
const ATTRIBUTION_DEBUG_ENV = 'LINKFORTY_DEBUG_ATTRIBUTION';
/**
* Generate a fingerprint hash from device data
@@ -175,6 +213,157 @@ function normalizeUserAgent(ua: string): string {
return `${platform}|${browser}`.toLowerCase();
}
function extractPlatformFromUserAgent(ua: string): string {
if (!ua) return '';
const platformMatch = ua.match(/(iPhone|iPad|iOS|Android|Windows|Macintosh|Linux)/i);
return platformMatch ? normalizePlatform(platformMatch[1]) : '';
}
function normalizePlatform(platform: string | undefined): string {
if (!platform) return '';
const normalized = platform.trim().toLowerCase();
if (!normalized) return '';
if (['iphone', 'ipad', 'ios'].includes(normalized)) return 'ios';
if (normalized === 'android') return 'android';
if (['mac', 'macos', 'macintosh'].includes(normalized)) return 'mac';
if (['win', 'windows'].includes(normalized)) return 'windows';
return normalized;
}
function resolvePlatformFingerprint(data: FingerprintData): string {
return normalizePlatform(data.platform) || extractPlatformFromUserAgent(data.userAgent);
}
function isMobilePlatform(platform: string): boolean {
return platform === 'ios' || platform === 'android';
}
function attributionDebugEnabled(): boolean {
const value = process.env[ATTRIBUTION_DEBUG_ENV]?.trim().toLowerCase();
return value === '1' || value === 'true' || value === 'yes';
}
function logAttributionDebug(event: string, payload: Record<string, unknown>): void {
if (!attributionDebugEnabled()) return;
console.info(`[linkforty][attribution] ${event} ${JSON.stringify(payload)}`);
}
function scoreFingerprintMatch(
fingerprint1: FingerprintData,
fingerprint2: FingerprintData
): { score: number; matchedFactors: string[]; debug: FingerprintFactorDebug } {
let score = 0;
const matchedFactors: string[] = [];
const platform1 = resolvePlatformFingerprint(fingerprint1);
const platform2 = resolvePlatformFingerprint(fingerprint2);
const installIpAttributable = isAttributableIp(fingerprint1.ipAddress);
const clickIpAttributable = isAttributableIp(fingerprint2.ipAddress);
const ip1 = fingerprint1.ipAddress ? normalizeIP(fingerprint1.ipAddress) : '';
const ip2 = fingerprint2.ipAddress ? normalizeIP(fingerprint2.ipAddress) : '';
const ua1 = fingerprint1.userAgent ? normalizeUserAgent(fingerprint1.userAgent) : '';
const ua2 = fingerprint2.userAgent ? normalizeUserAgent(fingerprint2.userAgent) : '';
const mobilePlatformBridge = platform1 !== '' && platform1 === platform2 && isMobilePlatform(platform1);
const language1 = fingerprint1.language?.substring(0, 2).toLowerCase() || '';
const language2 = fingerprint2.language?.substring(0, 2).toLowerCase() || '';
const screen1 =
fingerprint1.screenWidth && fingerprint1.screenHeight
? `${fingerprint1.screenWidth}x${fingerprint1.screenHeight}`
: '';
const screen2 =
fingerprint2.screenWidth && fingerprint2.screenHeight
? `${fingerprint2.screenWidth}x${fingerprint2.screenHeight}`
: '';
const debug: FingerprintFactorDebug = {
ip: {
matched: false,
installAttributable: installIpAttributable,
clickAttributable: clickIpAttributable,
installValue: ip1,
clickValue: ip2,
},
userAgent: {
matched: false,
installValue: ua1,
clickValue: ua2,
mobilePlatformBridge,
},
platform: {
matched: false,
installValue: platform1,
clickValue: platform2,
},
timezone: {
matched: false,
installValue: fingerprint1.timezone || '',
clickValue: fingerprint2.timezone || '',
},
language: {
matched: false,
installValue: language1,
clickValue: language2,
},
screen: {
matched: false,
installValue: screen1,
clickValue: screen2,
},
};
if (
fingerprint1.ipAddress &&
fingerprint2.ipAddress &&
installIpAttributable &&
clickIpAttributable &&
ip1 === ip2
) {
score += FINGERPRINT_WEIGHTS.IP_ADDRESS;
matchedFactors.push('ip');
debug.ip.matched = true;
}
if (fingerprint1.userAgent && fingerprint2.userAgent && (ua1 === ua2 || mobilePlatformBridge)) {
score += FINGERPRINT_WEIGHTS.USER_AGENT;
matchedFactors.push('user_agent');
debug.userAgent.matched = true;
}
if (mobilePlatformBridge) {
score += FINGERPRINT_WEIGHTS.PLATFORM;
matchedFactors.push('platform');
debug.platform.matched = true;
}
if (fingerprint1.timezone && fingerprint2.timezone && fingerprint1.timezone === fingerprint2.timezone) {
score += FINGERPRINT_WEIGHTS.TIMEZONE;
matchedFactors.push('timezone');
debug.timezone.matched = true;
}
if (language1 !== '' && language1 === language2) {
score += FINGERPRINT_WEIGHTS.LANGUAGE;
matchedFactors.push('language');
debug.language.matched = true;
}
if (
fingerprint1.screenWidth &&
fingerprint1.screenHeight &&
fingerprint2.screenWidth &&
fingerprint2.screenHeight &&
fingerprint1.screenWidth === fingerprint2.screenWidth &&
fingerprint1.screenHeight === fingerprint2.screenHeight
) {
score += FINGERPRINT_WEIGHTS.SCREEN_RESOLUTION;
matchedFactors.push('screen');
debug.screen.matched = true;
}
return { score: Math.min(score, 100), matchedFactors, debug };
}
/**
* Calculate confidence score by comparing two fingerprints
* Returns a score from 0-100 based on matched components
@@ -183,74 +372,7 @@ export function calculateConfidenceScore(
fingerprint1: FingerprintData,
fingerprint2: FingerprintData
): { score: number; matchedFactors: string[] } {
let score = 0;
const matchedFactors: string[] = [];
// Compare IP addresses (normalized to /24 subnet for IPv4). Only count IPs
// that actually identify a device — shared/NAT ranges (CGNAT, RFC1918, etc.)
// are skipped so unrelated users behind the same NAT can't match on IP.
if (
fingerprint1.ipAddress &&
fingerprint2.ipAddress &&
isAttributableIp(fingerprint1.ipAddress) &&
isAttributableIp(fingerprint2.ipAddress)
) {
const ip1 = normalizeIP(fingerprint1.ipAddress);
const ip2 = normalizeIP(fingerprint2.ipAddress);
if (ip1 === ip2) {
score += FINGERPRINT_WEIGHTS.IP_ADDRESS;
matchedFactors.push('ip');
}
}
// Compare user agents (normalized to platform + browser)
if (fingerprint1.userAgent && fingerprint2.userAgent) {
const ua1 = normalizeUserAgent(fingerprint1.userAgent);
const ua2 = normalizeUserAgent(fingerprint2.userAgent);
if (ua1 === ua2) {
score += FINGERPRINT_WEIGHTS.USER_AGENT;
matchedFactors.push('user_agent');
}
}
// Compare timezone
if (fingerprint1.timezone && fingerprint2.timezone) {
if (fingerprint1.timezone === fingerprint2.timezone) {
score += FINGERPRINT_WEIGHTS.TIMEZONE;
matchedFactors.push('timezone');
}
}
// Compare language
if (fingerprint1.language && fingerprint2.language) {
// Match first 2 characters (e.g., "en-US" matches "en-GB")
const lang1 = fingerprint1.language.substring(0, 2).toLowerCase();
const lang2 = fingerprint2.language.substring(0, 2).toLowerCase();
if (lang1 === lang2) {
score += FINGERPRINT_WEIGHTS.LANGUAGE;
matchedFactors.push('language');
}
}
// Compare screen resolution
if (
fingerprint1.screenWidth &&
fingerprint1.screenHeight &&
fingerprint2.screenWidth &&
fingerprint2.screenHeight
) {
if (
fingerprint1.screenWidth === fingerprint2.screenWidth &&
fingerprint1.screenHeight === fingerprint2.screenHeight
) {
score += FINGERPRINT_WEIGHTS.SCREEN_RESOLUTION;
matchedFactors.push('screen');
}
}
const { score, matchedFactors } = scoreFingerprintMatch(fingerprint1, fingerprint2);
return { score, matchedFactors };
}
@@ -324,11 +446,38 @@ export async function matchInstallToClick(
platformVersion: row.platform_version,
};
const { score, matchedFactors } = calculateConfidenceScore(
const { score, matchedFactors, debug } = scoreFingerprintMatch(
installFingerprint,
clickFingerprint
);
logAttributionDebug('candidate_scored', {
clickId: row.click_id,
linkId: row.link_id,
score,
threshold: CONFIDENCE_THRESHOLD,
matchedFactors,
timeDiffHours,
attributionWindowHours: linkWindowHours,
installFingerprint: {
ipAddress: installFingerprint.ipAddress,
platform: installFingerprint.platform || null,
timezone: installFingerprint.timezone || null,
language: installFingerprint.language || null,
screenWidth: installFingerprint.screenWidth || null,
screenHeight: installFingerprint.screenHeight || null,
},
clickFingerprint: {
ipAddress: clickFingerprint.ipAddress,
platform: clickFingerprint.platform || null,
timezone: clickFingerprint.timezone || null,
language: clickFingerprint.language || null,
screenWidth: clickFingerprint.screenWidth || null,
screenHeight: clickFingerprint.screenHeight || null,
},
factors: debug,
});
// Track the best match
if (score > highestScore && score >= CONFIDENCE_THRESHOLD) {
highestScore = score;
@@ -342,6 +491,29 @@ export async function matchInstallToClick(
}
}
logAttributionDebug('install_match_result', {
matched: bestMatch !== null,
highestScore,
threshold: CONFIDENCE_THRESHOLD,
installFingerprint: {
ipAddress: installFingerprint.ipAddress,
platform: installFingerprint.platform || null,
timezone: installFingerprint.timezone || null,
language: installFingerprint.language || null,
screenWidth: installFingerprint.screenWidth || null,
screenHeight: installFingerprint.screenHeight || null,
},
bestMatch: bestMatch
? {
clickId: bestMatch.clickId,
linkId: bestMatch.linkId,
confidenceScore: bestMatch.confidenceScore,
matchedFactors: bestMatch.matchedFactors,
clickedAt: bestMatch.clickedAt.toISOString(),
}
: null,
});
return bestMatch;
}
+4 -2
View File
@@ -287,6 +287,8 @@ export async function redirectRoutes(fastify: FastifyInstance) {
const fpLanguage = query?.fp_lang || acceptLanguage.split(',')[0]?.split(';')[0] || undefined;
const fpScreenWidth = query?.fp_sw ? parseInt(query.fp_sw, 10) : undefined;
const fpScreenHeight = query?.fp_sh ? parseInt(query.fp_sh, 10) : undefined;
const fpPlatform = query?.fp_platform || deviceType;
const fpPlatformVersion = query?.fp_pv || platformVersion;
// Insert click event with the pre-generated id (see above) so the row
// matches the lf_click value already placed on the redirect URL.
@@ -327,8 +329,8 @@ export async function redirectRoutes(fastify: FastifyInstance) {
language: fpLanguage,
screenWidth: fpScreenWidth,
screenHeight: fpScreenHeight,
platform: deviceType,
platformVersion,
platform: fpPlatform,
platformVersion: fpPlatformVersion,
};
await storeFingerprintForClick(clickId, fingerprintData);
+4 -2
View File
@@ -609,6 +609,8 @@ export async function sdkRoutes(fastify: FastifyInstance) {
const fpLanguage = query?.fp_lang || acceptLanguage.split(',')[0]?.split(';')[0] || undefined;
const fpScreenWidth = query?.fp_sw ? parseInt(query.fp_sw, 10) : undefined;
const fpScreenHeight = query?.fp_sh ? parseInt(query.fp_sh, 10) : undefined;
const fpPlatform = query?.fp_platform || deviceType;
const fpPlatformVersion = query?.fp_pv || platformVersion;
// Insert click event
const clickResult = await db.query(
@@ -650,8 +652,8 @@ export async function sdkRoutes(fastify: FastifyInstance) {
language: fpLanguage,
screenWidth: fpScreenWidth,
screenHeight: fpScreenHeight,
platform: deviceType,
platformVersion,
platform: fpPlatform,
platformVersion: fpPlatformVersion,
};
await storeFingerprintForClick(clickId, fingerprintData);