src/insert/spacefinder/spacefinder.ts (464 lines of code) (raw):

// total_hours_spent_maintaining_this = 81.5 import { log } from '@guardian/libs'; import { memoize } from 'lodash-es'; import fastdom from '../../lib/fastdom-promise'; import { getUrlVars } from '../../lib/url'; type RuleSpacing = { /** * Don't place an ad closer than this to the bottom of the opponent */ marginBottom: number; /** * Don't place an ad closer than this to the top of the opponent */ marginTop: number; bypassMinTop?: string; }; type SpacefinderMetaItem = { required?: number; actual?: number; element: HTMLElement; }; type SpacefinderItem = { top: number; bottom: number; element: HTMLElement; meta: { tooClose: SpacefinderMetaItem[]; overlaps: SpacefinderMetaItem[]; }; }; type OpponentSelectorRules = Record<string, RuleSpacing>; type SpacefinderRules = { bodySelector: string; body?: HTMLElement | Document; /** * Selector(s) for the elements that we want to allow inserting ads above */ candidateSelector: string; /** * Minimum distance from slot to top of page */ absoluteMinDistanceFromTop?: number; /** * Minimum distance from paragraph to top of article */ minDistanceFromTop: number; /** * Minimum distance from (top of) paragraph to bottom of article */ minDistanceFromBottom: number; /** * Vertical px to clear the content meta element (byline etc) by. 0 to ignore. * used for carrot ads */ clearContentMeta?: number; /** * This is a map of selectors to rules. Each selector will be used to find opponents * which are elements that we want to avoid placing ads too close to. If the opponent * is too close to a candidate by the specified marginTop or marginBottom, the * candidate will be excluded. */ opponentSelectorRules?: OpponentSelectorRules; /** * Will run each slot through this fn to check if it must be counted in */ filter?: (x: SpacefinderItem, lastWinner?: SpacefinderItem) => boolean; /** * Will remove slots before this one */ startAt?: HTMLElement; /** * Will remove slots from this one on */ stopAt?: HTMLElement; /** * Will reverse the order of slots (this is useful for lazy loaded content) */ fromBottom?: boolean; }; type SpacefinderWriter = (paras: HTMLElement[]) => Promise<void>; type SpacefinderPass = | 'inline1' | 'subsequent-inlines' | 'mobile-inlines' | 'carrot'; type SpacefinderOptions = { waitForImages?: boolean; waitForInteractives?: boolean; pass: SpacefinderPass; }; type ExcludedItem = SpacefinderItem | HTMLElement; type SpacefinderExclusions = Record<string, ExcludedItem[]>; type ElementDimensionMap = Record<string, SpacefinderItem[]>; type Measurements = { bodyTop: number; bodyHeight: number; candidates: SpacefinderItem[]; contentMeta?: SpacefinderItem; opponents?: ElementDimensionMap; }; const query = (selector: string, context?: HTMLElement | Document) => [ ...(context ?? document).querySelectorAll<HTMLElement>(selector), ]; /** maximum time (in ms) to wait for images to be loaded */ const LOADING_TIMEOUT = 5_000; const defaultOptions: SpacefinderOptions = { waitForImages: true, waitForInteractives: false, pass: 'inline1', }; const isIframe = (node: Node): node is HTMLIFrameElement => node instanceof HTMLIFrameElement; const isIframeLoaded = (iframe: HTMLIFrameElement) => { try { return iframe.contentWindow?.document.readyState === 'complete'; } catch (err) { return true; } }; const getFuncId = (rules: SpacefinderRules) => rules.bodySelector || 'document'; const isImage = (element: HTMLElement): element is HTMLImageElement => element instanceof HTMLImageElement; const onImagesLoaded = memoize((rules: SpacefinderRules) => { const notLoaded = query('img', rules.body) .filter(isImage) .filter((img) => !img.complete && img.loading !== 'lazy'); const imgPromises = notLoaded.map( (img) => new Promise((resolve) => { img.addEventListener('load', resolve); }), ); return Promise.all(imgPromises).then(() => Promise.resolve()); }, getFuncId); const waitForSetHeightMessage = ( iframe: HTMLIFrameElement, callback: () => void, ) => { window.addEventListener('message', (event) => { if (event.source !== iframe.contentWindow) return; try { const message = JSON.parse(event.data) as Record< string, string | number >; if (message.type === 'set-height' && Number(message.value) > 0) { callback(); } } catch (ex) { log('commercial', 'Unparsable message sent from iframe', ex); } }); }; const onInteractivesLoaded = memoize(async (rules: SpacefinderRules) => { const notLoaded = query('.element-interactive', rules.body).filter( (interactive) => { const iframes = Array.from(interactive.children).filter(isIframe); return !(iframes[0] && isIframeLoaded(iframes[0])); }, ); if (notLoaded.length === 0 || !('MutationObserver' in window)) { return Promise.resolve(); } const mutations = notLoaded.map( (interactive) => new Promise<void>((resolve) => { // Listen for when iframes are added as children to interactives new MutationObserver((records, instance) => { if ( !records[0]?.addedNodes[0] || !isIframe(records[0]?.addedNodes[0]) ) { return; } const iframe = records[0].addedNodes[0]; // Listen for when the iframes are resized // This is a sign they have fully loaded and spacefinder can proceed waitForSetHeightMessage(iframe, () => { instance.disconnect(); resolve(); }); }).observe(interactive, { childList: true, }); }), ); await Promise.all(mutations); }, getFuncId); const partitionCandidates = <T>( list: T[], filterElement: (element: T, lastFilteredElement: T | undefined) => boolean, ): [T[], T[]] => { const filtered: T[] = []; const exclusions: T[] = []; list.forEach((element) => { if (filterElement(element, filtered[filtered.length - 1])) { filtered.push(element); } else { exclusions.push(element); } }); return [filtered, exclusions]; }; /** * Check if the top of the candidate is far enough from the opponent * * The candidate is the element where we would like to insert an ad above. Candidates satisfy the `selector` rule. * * Opponents are other elements in the article that are in the spacefinder ruleset * for the current pass. This includes slots inserted by a previous pass but not * those in the current pass as they're all inserted at the end. * * │ * Opponent Below │ Opponent Above * │ * ─────────────────── Top of container │ ─────────────────── Top of container * ▲ ▲ │ ▲ ▲ * │ │ │ │ │ opponent.top * │ ┌──────────┐ │ │ │ ┌──────────┐ ▼ (insertion point) * │ │ │ |candidate.bottom │ │ │ │ * │ │ Candidate│ │ │ │ │ Opponent | * opponent.top │ │ │ │ candidate.top│ │ │ * │ └──────────┘ ▼ │ │ └──────────┘ * │ │ │ ▲ * │ ──────────── │ │ │ marginBottom * │ ▲ │ │ ▼ * │ │ marginTop │ │ ─────────── * │ ▼ │ │ * (insertion point) ▼ ┌──────────┐ │ ▼ ┌──────────┐ * │ │ │ │ │ * │ Opponent | │ │ Candidate│ * │ │ │ │ │ * └──────────┘ │ └──────────┘ * │ * │ */ const isTopOfCandidateFarEnoughFromOpponent = ( candidate: SpacefinderItem, opponent: SpacefinderItem, rule: RuleSpacing, isOpponentBelow: boolean, ): boolean => { const potentialInsertPosition = candidate.top; if (isOpponentBelow && rule.marginTop) { if (rule.bypassMinTop && candidate.element.matches(rule.bypassMinTop)) { return true; } return opponent.top - potentialInsertPosition >= rule.marginTop; } if (!isOpponentBelow && rule.marginBottom) { return potentialInsertPosition - opponent.bottom >= rule.marginBottom; } // if no rule is set (or they're 0), return true return true; }; /** * Check if 1 - the candidate is the same as the opponent or 2- if the opponent contains the candidate * * 1 can happen when the candidate and opponent selectors overlap * 2 can happen when there are nested candidates, it may try t avoid it's own ancestor */ const bypassTestCandidate = ( candidate: SpacefinderItem, opponent: SpacefinderItem, ) => candidate.element === opponent.element || opponent.element.contains(candidate.element); // test one element vs another for the given rules const testCandidate = ( rule: RuleSpacing, candidate: SpacefinderItem, opponent: SpacefinderItem, ): boolean => { if (bypassTestCandidate(candidate, opponent)) { return true; } const isOpponentBelow = opponent.bottom > candidate.bottom && opponent.top >= candidate.bottom; const isOpponentAbove = opponent.top < candidate.top && opponent.bottom <= candidate.top; // this can happen when the an opponent like an image or interactive is floated right const opponentOverlaps = (isOpponentAbove && isOpponentBelow) || (!isOpponentAbove && !isOpponentBelow); const pass = !opponentOverlaps && isTopOfCandidateFarEnoughFromOpponent( candidate, opponent, rule, isOpponentBelow, ); if (!pass) { if (opponentOverlaps) { candidate.meta.overlaps.push({ element: opponent.element, }); } else { // if the test fails, add debug information to the candidate metadata const required = isOpponentBelow ? rule.marginTop : rule.marginBottom; const actual = isOpponentBelow ? opponent.top - candidate.top : candidate.top - opponent.bottom; candidate.meta.tooClose.push({ required, actual, element: opponent.element, }); } } return pass; }; // test one element vs an array of other elements for the given rule const testCandidates = ( rule: RuleSpacing, candidate: SpacefinderItem, opponents: SpacefinderItem[], ): boolean => opponents .map((opponent) => testCandidate(rule, candidate, opponent)) .every(Boolean); const enforceRules = ( measurements: Measurements, rules: SpacefinderRules, spacefinderExclusions: SpacefinderExclusions, ) => { let candidates = measurements.candidates; // enforce absoluteMinDistanceFromTop rule let [filtered, exclusions] = partitionCandidates( candidates, (candidate) => !rules.absoluteMinDistanceFromTop || candidate.top + measurements.bodyTop >= rules.absoluteMinDistanceFromTop, ); spacefinderExclusions.absoluteMinDistanceFromTop = exclusions; candidates = filtered; // enforce minAbove and minBelow rules [filtered, exclusions] = partitionCandidates(candidates, (candidate) => { const farEnoughFromTopOfBody = candidate.top >= rules.minDistanceFromTop; const farEnoughFromBottomOfBody = candidate.top + rules.minDistanceFromBottom <= measurements.bodyHeight; return farEnoughFromTopOfBody && farEnoughFromBottomOfBody; }); spacefinderExclusions.aboveAndBelow = exclusions; candidates = filtered; // enforce content meta rule const { clearContentMeta } = rules; if (clearContentMeta) { [filtered, exclusions] = partitionCandidates( candidates, (candidate) => !!measurements.contentMeta && candidate.top > measurements.contentMeta.bottom + clearContentMeta, ); spacefinderExclusions.contentMeta = exclusions; candidates = filtered; } // enforce selector rules if (rules.opponentSelectorRules) { const selectorExclusions: SpacefinderItem[] = []; for (const [selector, rule] of Object.entries( rules.opponentSelectorRules, )) { [filtered, exclusions] = partitionCandidates( candidates, (candidate) => testCandidates( rule, candidate, measurements.opponents?.[selector] ?? [], ), ); spacefinderExclusions[selector] = exclusions; selectorExclusions.push(...exclusions); } candidates = candidates.filter( (candidate) => !selectorExclusions.includes(candidate), ); } if (rules.filter) { [filtered, exclusions] = partitionCandidates(candidates, rules.filter); spacefinderExclusions.custom = exclusions; candidates = filtered; } return candidates; }; class SpaceError extends Error { constructor(rules: SpacefinderRules) { super(); this.name = 'SpaceError'; this.message = `There is no space left matching rules from ${rules.bodySelector}`; } } /** * Wait for the page to be ready (images loaded, interactives loaded) * or for LOADING_TIMEOUT to elapse, whichever comes first. * @param {SpacefinderRules} rules * @param {SpacefinderOptions} options */ const getReady = (rules: SpacefinderRules, options: SpacefinderOptions) => Promise.race([ new Promise((resolve) => window.setTimeout(() => resolve('timeout'), LOADING_TIMEOUT), ), Promise.all([ options.waitForImages ? onImagesLoaded(rules) : Promise.resolve(), options.waitForInteractives ? onInteractivesLoaded(rules) : Promise.resolve(), ]), ]).then((value) => { if (value === 'timeout') { log('commercial', 'Spacefinder timeout hit'); } }); const getCandidates = ( rules: SpacefinderRules, spacefinderExclusions: SpacefinderExclusions, ) => { let candidates = query(rules.candidateSelector, rules.body); if (rules.fromBottom) { candidates.reverse(); } if (rules.startAt) { let drop = true; const [filtered, exclusions] = partitionCandidates( candidates, (candidate) => { if (candidate === rules.startAt) { drop = false; } return !drop; }, ); spacefinderExclusions.startAt = exclusions; candidates = filtered; } if (rules.stopAt) { let keep = true; const [filtered, exclusions] = partitionCandidates( candidates, (candidate) => { if (candidate === rules.stopAt) { keep = false; } return keep; }, ); spacefinderExclusions.stopAt = exclusions; candidates = filtered; } return candidates; }; const getDimensions = (element: HTMLElement): Readonly<SpacefinderItem> => Object.freeze({ top: element.offsetTop, bottom: element.offsetTop + element.offsetHeight, element, meta: { tooClose: [], overlaps: [], }, }); const getMeasurements = ( rules: SpacefinderRules, candidates: HTMLElement[], ): Promise<Measurements> => { const contentMeta = rules.clearContentMeta ? (document.querySelector<HTMLElement>('.js-content-meta') ?? undefined) : undefined; const opponents = rules.opponentSelectorRules ? Object.keys(rules.opponentSelectorRules).map( (selector) => [selector, query(selector, rules.body)] as const, ) : []; return fastdom.measure((): Measurements => { let bodyDistanceToTopOfPage = 0; let bodyHeight = 0; if (rules.body instanceof Element) { const bodyElement = rules.body.getBoundingClientRect(); // bodyElement is relative to the viewport, so we need to add scroll position to get the distance bodyDistanceToTopOfPage = bodyElement.top + window.scrollY; bodyHeight = bodyElement.height; } const candidatesWithDims = candidates.map(getDimensions); const contentMetaWithDims = rules.clearContentMeta && contentMeta ? getDimensions(contentMeta) : undefined; const opponentsWithDims = opponents.reduce< Record<string, SpacefinderItem[]> >((result, [selector, selectedElements]) => { result[selector] = selectedElements.map(getDimensions); return result; }, {}); return { bodyTop: bodyDistanceToTopOfPage, bodyHeight, candidates: candidatesWithDims, contentMeta: contentMetaWithDims, opponents: opponentsWithDims, }; }); }; // Rather than calling this directly, use spaceFiller to inject content into the page. // SpaceFiller will safely queue up all the various asynchronous DOM actions to avoid any race conditions. const findSpace = async ( rules: SpacefinderRules, options?: SpacefinderOptions, exclusions: SpacefinderExclusions = {}, ): Promise<HTMLElement[]> => { options = { ...defaultOptions, ...options }; rules.body = rules.bodySelector ? (document.querySelector<HTMLElement>(rules.bodySelector) ?? document) : document; window.performance.mark('commercial:spacefinder:findSpace:start'); await getReady(rules, options); const candidates = getCandidates(rules, exclusions); const measurements = await getMeasurements(rules, candidates); const winners = enforceRules(measurements, rules, exclusions); const enableDebug = !!getUrlVars().sfdebug; if (enableDebug) { const pass = options.pass; void import('./spacefinder-debug-tools').then(({ init }) => { init(exclusions, winners, rules, pass); }); } window.performance.mark('commercial:spacefinder:findSpace:end'); const measure = window.performance.measure( 'commercial:spacefinder:findSpace', 'commercial:spacefinder:findSpace:start', 'commercial:spacefinder:findSpace:end', ); log( 'commercial', `Spacefinder took ${Math.round(measure?.duration ?? 0)}ms for '${ options.pass }' pass`, { rules, options, }, ); // TODO Is this really an error condition? if (!winners.length) { throw new SpaceError(rules); } return winners.map((candidate) => candidate.element); }; export { findSpace, SpaceError }; export type { RuleSpacing, OpponentSelectorRules, SpacefinderRules, SpacefinderWriter, SpacefinderOptions, SpacefinderItem, SpacefinderExclusions, SpacefinderPass, SpacefinderMetaItem, };