디퍼드 딥링크에서 매칭이 0으로 나오는 문제 수정
This commit is contained in:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user