src/meta/offerFamilyClassifier.js Repository Demand-Radar Original path src/meta/offerFamilyClassifier.jsRole SOURCE Size 27192 bytes Lines 925 SHA-256 8e6a6229b4d651e598884af1b184d18eaf9b0081bfc0b4bb937d012c87283c5cDisplayed range 501–925 Previous file/page · Project index · Next file/page
if (gapDays >= OFFER_FAMILY_THRESHOLDS.RELAUNCH_MIN_GAP) {
return {
relationshipType: 'RELAUNCH',
gapDays,
};
}
return {
relationshipType: 'UNKNOWN',
gapDays,
};
}
function classifyFamilyClass(metrics) {
if (!metrics.adsCount) return 'UNCLASSIFIED';
if (metrics.maxAdDurationDays >= OFFER_FAMILY_THRESHOLDS.EVERGREEN_MIN_DAYS) {
return 'EVERGREEN';
}
if (
metrics.coveredDeliveryDays >= OFFER_FAMILY_THRESHOLDS.ESTABLISHED_MIN_DAYS
|| metrics.maxAdDurationDays >= OFFER_FAMILY_THRESHOLDS.ESTABLISHED_MIN_DAYS
) {
return 'ESTABLISHED';
}
if (
metrics.adsCount === 1
&& metrics.maxAdDurationDays <= OFFER_FAMILY_THRESHOLDS.TEST_MAX_DAYS
&& metrics.endedAdsCount === 1
) {
return 'TEST_ONLY';
}
if (
metrics.adsCount >= 2
&& metrics.maxAdDurationDays <= OFFER_FAMILY_THRESHOLDS.REPEATED_TEST_MAX_DAYS
&& metrics.successorCount === 0
&& metrics.parallelCount <= 1
&& metrics.activeAdsCount === 0
) {
return 'REPEATED_TEST';
}
if (
metrics.maxAdDurationDays > OFFER_FAMILY_THRESHOLDS.TEST_MAX_DAYS
|| metrics.successorCount > 0
|| metrics.activeAdsCount > 0
) {
return 'PROMISING';
}
return 'UNCLASSIFIED';
}
function classifyFamilyStatus(metrics) {
if (!metrics.adsCount) return 'UNKNOWN';
if (metrics.activeAdsCount > 0) return 'ACTIVE';
return 'ENDED';
}
function buildFamilyConfidence(familyClass, metrics, averageEdgeConfidence) {
const classBase = {
TEST_ONLY: 44,
REPEATED_TEST: 52,
PROMISING: 60,
ESTABLISHED: 72,
EVERGREEN: 80,
UNCLASSIFIED: 36,
};
let score = classBase[familyClass] || 36;
score += Math.min(12, Math.max(0, metrics.adsCount - 1) * 3);
score += Math.min(10, metrics.successorCount * 4);
score += Math.min(6, metrics.parallelCount * 2);
score += Math.min(8, metrics.relaunchCount * 4);
score += Math.min(8, Math.floor(metrics.coveredDeliveryDays / 30) * 2);
score += Math.min(8, Math.max(0, averageEdgeConfidence - 65) * 0.4);
if (familyClass === 'EVERGREEN' && metrics.adsCount === 1 && metrics.successorCount === 0) {
score = Math.min(score, 58);
}
if (familyClass === 'EVERGREEN' && metrics.adsCount >= 3 && metrics.successorCount >= 2) {
score += 6;
}
return Math.max(0, Math.min(100, Math.round(score)));
}
function buildFamilyReason(familyClass, metrics, adsSorted) {
const durations = adsSorted
.map(ad => Number(ad.runtimeDays))
.filter(value => Number.isFinite(value) && value > 0);
const durationChain = durations.length
? durations.join(' -> ')
: 'brak pelnych danych o czasie emisji';
if (familyClass === 'EVERGREEN') {
return `${metrics.adsCount} reklamy tej samej oferty; ${durationChain} dni; ${metrics.successorCount} nastepcow bezposrednich; najdluzsza emisja ${metrics.maxAdDurationDays} dni.`;
}
if (familyClass === 'ESTABLISHED') {
return `${metrics.adsCount} reklamy oferty; laczny zasieg czasowy ${metrics.coveredDeliveryDays} dni; co najmniej jedna dluga emisja.`;
}
if (familyClass === 'PROMISING') {
return `${metrics.adsCount} reklamy oferty; widac kontynuacje po testach (${durationChain}).`;
}
if (familyClass === 'REPEATED_TEST') {
return `${metrics.adsCount} krotkie emisje (${durationChain}); brak mocnego przejscia do dlugiej kampanii.`;
}
if (familyClass === 'TEST_ONLY') {
return 'Pojedynczy krotki test oferty bez nastepcy.';
}
return `${metrics.adsCount} reklamy; zbyt malo danych do jednoznacznej klasyfikacji.`;
}
function buildFamilyPatterns({
successorCount,
parallelCount,
relaunchCount,
hasCreativeRefresh,
longestGapDays,
}) {
const patterns = [];
if (parallelCount > 0) patterns.push('PARALLEL_TEST');
if (successorCount > 0) patterns.push('DIRECT_SUCCESSOR');
if (relaunchCount > 0) patterns.push('RELAUNCH');
if (hasCreativeRefresh) patterns.push('CREATIVE_REFRESH');
if (longestGapDays != null && longestGapDays <= OFFER_FAMILY_THRESHOLDS.DIRECT_SUCCESSOR_MAX_GAP) {
patterns.push('CONTINUOUS_CAMPAIGN');
}
return patterns;
}
function normalizeAdRecord(ad) {
const canonical = canonicalizeUrl(ad.destinationUrl);
const parsed = getDomainAndPath(canonical || ad.destinationUrl);
const runtimeDays = Number(ad.runtimeDays);
return {
...ad,
pageId: ad.pageId ? String(ad.pageId) : null,
title: String(ad.title || ''),
bodyText: String(ad.bodyText || ''),
destinationUrl: ad.destinationUrl || null,
canonicalDestinationUrl: canonical,
destinationDomain: parsed.domain,
destinationPath: parsed.path,
collationId: ad.collationId ? String(ad.collationId) : null,
runtimeDays: Number.isFinite(runtimeDays) ? Math.max(0, Math.round(runtimeDays)) : null,
};
}
function findBestEdge(currentAd, candidateAds, edgeMap) {
let best = null;
for (const candidate of candidateAds) {
const key = buildPairKey(currentAd.metaAdId, candidate.metaAdId);
const edge = edgeMap.get(key);
if (!edge) continue;
if (!best || edge.confidence > best.confidence) {
best = edge;
}
}
return best;
}
function buildOfferFamilies(projectId, ads, {
classifierVersion = 'offer-family-v1',
now = new Date(),
} = {}) {
const prepared = (Array.isArray(ads) ? ads : []).map(normalizeAdRecord);
const ids = prepared.map(ad => ad.metaAdId);
const unionFind = new UnionFind(ids);
const edgeMap = new Map();
const byPage = new Map();
for (const ad of prepared) {
const key = ad.pageId || `no-page:${ad.metaAdId}`;
if (!byPage.has(key)) {
byPage.set(key, []);
}
byPage.get(key).push(ad);
}
for (const pageAds of byPage.values()) {
for (let leftIndex = 0; leftIndex < pageAds.length; leftIndex += 1) {
for (let rightIndex = leftIndex + 1; rightIndex < pageAds.length; rightIndex += 1) {
const leftAd = pageAds[leftIndex];
const rightAd = pageAds[rightIndex];
const edge = buildOfferMatch(leftAd, rightAd);
if (!edge.shouldLink) continue;
const key = buildPairKey(leftAd.metaAdId, rightAd.metaAdId);
edgeMap.set(key, edge);
unionFind.union(leftAd.metaAdId, rightAd.metaAdId);
}
}
}
const components = new Map();
for (const ad of prepared) {
const root = unionFind.find(ad.metaAdId);
if (!components.has(root)) {
components.set(root, []);
}
components.get(root).push(ad);
}
const families = [];
for (const componentAds of components.values()) {
const adsSorted = [...componentAds].sort((left, right) => {
const leftStart = toDate(left.startDate || left.firstSeenAt);
const rightStart = toDate(right.startDate || right.firstSeenAt);
if (leftStart && rightStart && leftStart.getTime() !== rightStart.getTime()) {
return leftStart.getTime() - rightStart.getTime();
}
if (leftStart && !rightStart) return -1;
if (!leftStart && rightStart) return 1;
return String(left.adArchiveId || left.metaAdId).localeCompare(String(right.adArchiveId || right.metaAdId));
});
const intervals = buildIntervals(adsSorted, now);
const coveredDeliveryDays = unionIntervalDays(intervals);
const firstAdStart = adsSorted
.map(ad => toDate(ad.startDate || ad.firstSeenAt))
.filter(Boolean)
.sort((left, right) => left.getTime() - right.getTime())[0] || null;
const latestAdStart = adsSorted
.map(ad => toDate(ad.startDate || ad.firstSeenAt))
.filter(Boolean)
.sort((left, right) => right.getTime() - left.getTime())[0] || null;
const latestAdEnd = adsSorted
.map(ad => toDate(ad.endDate) || (ad.isActive ? now : toDate(ad.lastSeenAt)))
.filter(Boolean)
.sort((left, right) => right.getTime() - left.getTime())[0] || null;
const familyCalendarSpanDays = firstAdStart && latestAdEnd
? (diffDays(firstAdStart, latestAdEnd) + 1)
: coveredDeliveryDays;
const durations = adsSorted
.map(ad => Number(ad.runtimeDays))
.filter(value => Number.isFinite(value) && value >= 0);
const metrics = {
adsCount: adsSorted.length,
activeAdsCount: adsSorted.filter(ad => ad.isActive).length,
endedAdsCount: adsSorted.filter(ad => !ad.isActive).length,
firstAdStart,
latestAdStart,
latestAdEnd,
familyCalendarSpanDays,
coveredDeliveryDays,
maxAdDurationDays: durations.length ? Math.max(...durations) : 0,
medianAdDurationDays: durations.length ? median(durations) : 0,
successorCount: 0,
parallelCount: 0,
relaunchCount: 0,
longestGapDays: 0,
currentlyActive: adsSorted.some(ad => ad.isActive),
};
const members = [];
let sumEdgeConfidence = 0;
let countedEdges = 0;
let hasCreativeRefresh = false;
for (let index = 0; index < adsSorted.length; index += 1) {
const ad = adsSorted[index];
const previous = index > 0 ? adsSorted[index - 1] : null;
const previousCandidates = adsSorted.slice(0, index);
const temporal = classifyTemporalRelationship(previous, ad, now);
if (temporal.relationshipType === 'DIRECT_SUCCESSOR' || temporal.relationshipType === 'SUCCESSOR_AFTER_GAP') {
metrics.successorCount += 1;
}
if (temporal.relationshipType === 'PARALLEL') {
metrics.parallelCount += 1;
}
if (temporal.relationshipType === 'RELAUNCH') {
metrics.relaunchCount += 1;
}
if (Number.isFinite(temporal.gapDays)) {
metrics.longestGapDays = Math.max(metrics.longestGapDays, temporal.gapDays || 0);
}
let provenance = null;
if (previous) {
const directKey = buildPairKey(ad.metaAdId, previous.metaAdId);
provenance = edgeMap.get(directKey) || null;
}
if (!provenance && previousCandidates.length) {
provenance = findBestEdge(ad, previousCandidates, edgeMap);
}
if (provenance) {
sumEdgeConfidence += provenance.confidence;
countedEdges += 1;
if (
provenance.combinedSimilarity >= 0.55
&& ad.adArchiveId
&& previous
&& ad.adArchiveId !== previous.adArchiveId
) {
hasCreativeRefresh = true;
}
}
members.push({
projectId,
metaAdId: ad.metaAdId,
relationshipType: temporal.relationshipType,
previousAdId: previous ? previous.metaAdId : null,
offerMatchConfidence: provenance ? provenance.confidence : null,
matchReasons: provenance ? provenance.reasons : ['brak wystarczajacego dowodu pary z poprzednikiem'],
matchType: provenance ? provenance.matchType : 'NONE',
titleSimilarity: provenance ? Number(provenance.titleSimilarity.toFixed(4)) : null,
bodySimilarity: provenance ? Number(provenance.bodySimilarity.toFixed(4)) : null,
combinedSimilarity: provenance ? Number(provenance.combinedSimilarity.toFixed(4)) : null,
sortOrder: index + 1,
});
}
const averageEdgeConfidence = countedEdges ? (sumEdgeConfidence / countedEdges) : 0;
const familyClass = classifyFamilyClass(metrics);
const familyStatus = classifyFamilyStatus(metrics);
const familyPatterns = buildFamilyPatterns({
successorCount: metrics.successorCount,
parallelCount: metrics.parallelCount,
relaunchCount: metrics.relaunchCount,
hasCreativeRefresh,
longestGapDays: metrics.longestGapDays,
});
const familyConfidence = buildFamilyConfidence(familyClass, metrics, averageEdgeConfidence);
const familyReason = buildFamilyReason(familyClass, metrics, adsSorted);
const memberIds = members.map(member => member.metaAdId).sort((left, right) => left.localeCompare(right));
const familyId = buildFamilyId(projectId, memberIds);
families.push({
id: familyId,
projectId,
familyClass,
familyStatus,
familyConfidence,
familyReason,
firstAdStart: toIso(metrics.firstAdStart),
latestAdStart: toIso(metrics.latestAdStart),
latestAdEnd: toIso(metrics.latestAdEnd),
familyCalendarSpanDays: metrics.familyCalendarSpanDays || 0,
coveredDeliveryDays: metrics.coveredDeliveryDays || 0,
adsCount: metrics.adsCount,
activeAdsCount: metrics.activeAdsCount,
endedAdsCount: metrics.endedAdsCount,
maxAdDurationDays: metrics.maxAdDurationDays,
medianAdDurationDays: metrics.medianAdDurationDays,
successorCount: metrics.successorCount,
parallelCount: metrics.parallelCount,
relaunchCount: metrics.relaunchCount,
longestGapDays: metrics.longestGapDays || 0,
currentlyActive: metrics.currentlyActive,
familyPatterns,
classifierVersion,
members,
});
}
families.sort((left, right) => {
const classDiff = (FAMILY_CLASS_ORDER[left.familyClass] || 99) - (FAMILY_CLASS_ORDER[right.familyClass] || 99);
if (classDiff !== 0) return classDiff;
if (right.familyConfidence !== left.familyConfidence) {
return right.familyConfidence - left.familyConfidence;
}
if (right.coveredDeliveryDays !== left.coveredDeliveryDays) {
return right.coveredDeliveryDays - left.coveredDeliveryDays;
}
if (right.adsCount !== left.adsCount) {
return right.adsCount - left.adsCount;
}
return String(left.id).localeCompare(String(right.id));
});
return {
families,
canonicalDestinationByAdId: Object.fromEntries(prepared.map(ad => [ad.metaAdId, ad.canonicalDestinationUrl || null])),
};
}
module.exports = {
OFFER_FAMILY_THRESHOLDS,
FAMILY_CLASS_ORDER,
canonicalizeUrl,
normalizeText,
jaccardSimilarity,
buildOfferMatch,
buildOfferFamilies,
};