Merge autoland to mozilla-central. a=merge
[gecko.git] / browser / actors / SearchSERPTelemetryChild.sys.mjs
blobf94d29b72b6faf366369234f543423eb184c9be5
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs";
7 const lazy = {};
9 ChromeUtils.defineESModuleGetters(lazy, {
10   clearTimeout: "resource://gre/modules/Timer.sys.mjs",
11   setTimeout: "resource://gre/modules/Timer.sys.mjs",
12 });
14 XPCOMUtils.defineLazyPreferenceGetter(
15   lazy,
16   "serpEventTelemetryCategorization",
17   "browser.search.serpEventTelemetryCategorization.enabled",
18   false
21 export const CATEGORIZATION_SETTINGS = {
22   MAX_DOMAINS_TO_CATEGORIZE: 10,
25 // Duplicated from SearchSERPTelemetry to avoid loading the module on content
26 // startup.
27 const SEARCH_TELEMETRY_SHARED = {
28   PROVIDER_INFO: "SearchTelemetry:ProviderInfo",
29   LOAD_TIMEOUT: "SearchTelemetry:LoadTimeout",
30   SPA_LOAD_TIMEOUT: "SearchTelemetry:SPALoadTimeout",
33 /**
34  * Standard events mapped to the telemetry action.
35  */
36 const EVENT_TYPE_TO_ACTION = {
37   click: "clicked",
40 /**
41  * A map of object conditions mapped to the condition that should be run when
42  * an event is triggered. The condition name is referenced in Remote Settings
43  * under the optional `condition` string for an event listener.
44  */
45 const CONDITIONS = {
46   keydownEnter: event => event.key == "Enter",
49 export const VISIBILITY_THRESHOLD = 0.5;
51 /**
52  * SearchProviders looks after keeping track of the search provider information
53  * received from the main process.
54  *
55  * It is separate to SearchTelemetryChild so that it is not constructed for each
56  * tab, but once per process.
57  */
58 class SearchProviders {
59   constructor() {
60     this._searchProviderInfo = null;
61     Services.cpmm.sharedData.addEventListener("change", this);
62   }
64   /**
65    * Gets the search provider information for any provider with advert information.
66    * If there is nothing in the cache, it will obtain it from shared data.
67    *
68    * @returns {object} Returns the search provider information.
69    * @see SearchTelemetry.sys.mjs
70    */
71   get info() {
72     if (this._searchProviderInfo) {
73       return this._searchProviderInfo;
74     }
76     this._searchProviderInfo = Services.cpmm.sharedData.get(
77       SEARCH_TELEMETRY_SHARED.PROVIDER_INFO
78     );
80     if (!this._searchProviderInfo) {
81       return null;
82     }
84     this._searchProviderInfo = this._searchProviderInfo
85       // Filter-out non-ad providers so that we're not trying to match against
86       // those unnecessarily.
87       .filter(p => "extraAdServersRegexps" in p)
88       // Pre-build the regular expressions.
89       .map(p => {
90         p.adServerAttributes = p.adServerAttributes ?? [];
91         if (p.shoppingTab?.inspectRegexpInSERP) {
92           p.shoppingTab.regexp = new RegExp(p.shoppingTab.regexp);
93         }
94         return {
95           ...p,
96           searchPageRegexp: new RegExp(p.searchPageRegexp),
97           extraAdServersRegexps: p.extraAdServersRegexps.map(
98             r => new RegExp(r)
99           ),
100         };
101       });
103     return this._searchProviderInfo;
104   }
106   /**
107    * Handles events received from sharedData notifications.
108    *
109    * @param {object} event The event details.
110    */
111   handleEvent(event) {
112     switch (event.type) {
113       case "change": {
114         if (event.changedKeys.includes(SEARCH_TELEMETRY_SHARED.PROVIDER_INFO)) {
115           // Just null out the provider information for now, we'll fetch it next
116           // time we need it.
117           this._searchProviderInfo = null;
118         }
119         break;
120       }
121     }
122   }
126  * @typedef {object} EventListenerParam
127  * @property {string} eventType
128  *  The type of event the listener should listen for. If the event type is
129  *  is non-standard, it should correspond to a definition in
130  *  CUSTOM_EVENT_TYPE_TO_DATA that will re-map it to a standard type. TODO
131  * @property {string} target
132  *  The type of component that was the source of the event.
133  * @property {string | null} action
134  *  The action that should be reported in telemetry.
135  */
138  * Provides a way to add listeners to elements, as well as unload them.
139  */
140 class ListenerHelper {
141   /**
142    * Adds each event listener in an array of event listeners to each element
143    * in an array of elements, and sets their unloading.
144    *
145    * @param {Array<Element>} elements
146    *  DOM elements to add event listeners to.
147    * @param {Array<EventListenerParam>} eventListenerParams
148    *  The type of event to add the listener to.
149    * @param {string} target
150    */
151   static addListeners(elements, eventListenerParams, target) {
152     if (!elements?.length || !eventListenerParams?.length) {
153       return;
154     }
156     let document = elements[0].ownerGlobal.document;
157     let callback = documentToEventCallbackMap.get(document);
158     if (!callback) {
159       return;
160     }
162     // The map might have entries from previous callers, so we must ensure
163     // we don't discard existing event listener callbacks.
164     let removeListenerCallbacks = [];
165     if (documentToRemoveEventListenersMap.has(document)) {
166       removeListenerCallbacks = documentToRemoveEventListenersMap.get(document);
167     }
169     for (let params of eventListenerParams) {
170       let removeListeners = ListenerHelper.addListener(
171         elements,
172         params,
173         target,
174         callback
175       );
176       removeListenerCallbacks = removeListenerCallbacks.concat(removeListeners);
177     }
179     documentToRemoveEventListenersMap.set(document, removeListenerCallbacks);
180   }
182   /**
183    * Add an event listener to each element in an array of elements.
184    *
185    * @param {Array<Element>} elements
186    *  DOM elements to add event listeners to.
187    * @param {EventListenerParam} eventListenerParam
188    * @param {string} target
189    * @param {Function} callback
190    * @returns {Array<function>} Array of remove event listener functions.
191    */
192   static addListener(elements, eventListenerParam, target, callback) {
193     let { action, eventType, target: customTarget } = eventListenerParam;
195     if (customTarget) {
196       target = customTarget;
197     }
199     if (!action) {
200       action = EVENT_TYPE_TO_ACTION[eventType];
201       if (!action) {
202         return [];
203       }
204     }
206     // Some events might have specific conditions we want to check before
207     // registering an engagement event.
208     let eventCallback;
209     if (eventListenerParam.condition) {
210       if (CONDITIONS[eventListenerParam.condition]) {
211         let condition = CONDITIONS[eventListenerParam.condition];
212         eventCallback = async event => {
213           let start = Cu.now();
214           if (condition(event)) {
215             callback({ action, target });
216           }
217           ChromeUtils.addProfilerMarker(
218             "SearchSERPTelemetryChild._eventCallback",
219             start,
220             "Call cached function before callback."
221           );
222         };
223       } else {
224         // If a component included a condition, but it wasn't found it is
225         // due to the fact that it was added in a more recent Firefox version
226         // than what is provided via search-telemetry-v2. Since the version of
227         // Firefox the user is using doesn't include this condition,
228         // we shouldn't add the event.
229         return [];
230       }
231     } else {
232       eventCallback = () => {
233         callback({ action, target });
234       };
235     }
237     let removeListenerCallbacks = [];
238     for (let element of elements) {
239       element.addEventListener(eventType, eventCallback);
240       removeListenerCallbacks.push(() => {
241         element.removeEventListener(eventType, eventCallback);
242       });
243     }
244     return removeListenerCallbacks;
245   }
249  * Scans SERPs for ad components.
250  */
251 class SearchAdImpression {
252   /**
253    * A reference to ad component information that is used if an anchor
254    * element could not be categorized to a specific ad component.
255    *
256    * @type {object}
257    */
258   #defaultComponent = null;
260   /**
261    * Maps DOM elements to AdData.
262    *
263    * @type {Map<Element, AdData>}
264    *
265    * @typedef AdData
266    * @type {object}
267    * @property {string} type
268    *  The type of ad component.
269    * @property {number} adsLoaded
270    *  The number of ads counted as loaded for the component.
271    * @property {boolean} countChildren
272    *  Whether all the children were counted for the component.
273    */
274   #elementToAdDataMap = new Map();
276   /**
277    * An array of components to do a top-down search.
278    */
279   #topDownComponents = [];
281   /**
282    * A reference the providerInfo for this SERP.
283    *
284    * @type {object}
285    */
286   #providerInfo = null;
288   set providerInfo(providerInfo) {
289     if (this.#providerInfo?.telemetryId == providerInfo.telemetryId) {
290       return;
291     }
293     this.#providerInfo = providerInfo;
295     // Reset values.
296     this.#topDownComponents = [];
298     for (let component of this.#providerInfo.components) {
299       if (component.default) {
300         this.#defaultComponent = component;
301         continue;
302       }
303       if (component.topDown) {
304         this.#topDownComponents.push(component);
305       }
306     }
307   }
309   /**
310    * Check if the page has a shopping tab.
311    *
312    * @param {Document} document
313    * @return {boolean}
314    *   Whether the page has a shopping tab. Defaults to false.
315    */
316   hasShoppingTab(document) {
317     if (!this.#providerInfo?.shoppingTab) {
318       return false;
319     }
321     // If a provider has the inspectRegexpInSERP, we assume there must be an
322     // associated regexp that must be used on any hrefs matched by the elements
323     // found using the selector. If inspectRegexpInSERP is false, then check if
324     // the number of items found using the selector matches exactly one element
325     // to ensure we've used a fine-grained search.
326     let elements = document.querySelectorAll(
327       this.#providerInfo.shoppingTab.selector
328     );
329     if (this.#providerInfo.shoppingTab.inspectRegexpInSERP) {
330       let regexp = this.#providerInfo.shoppingTab.regexp;
331       for (let element of elements) {
332         let href = element.getAttribute("href");
333         if (href && regexp.test(href)) {
334           this.#recordElementData(element, {
335             type: "shopping_tab",
336             count: 1,
337           });
338           return true;
339         }
340       }
341     } else if (elements.length == 1) {
342       this.#recordElementData(elements[0], {
343         type: "shopping_tab",
344         count: 1,
345       });
346       return true;
347     }
348     return false;
349   }
351   /**
352    * Examine the list of anchors and the document object and find components
353    * on the page.
354    *
355    * With the list of anchors, go through each and find the component it
356    * belongs to and save it in elementToAdDataMap.
357    *
358    * Then, with the document object find components and save the results to
359    * elementToAdDataMap.
360    *
361    * Lastly, combine the results together in a new Map that contains the number
362    * of loaded, visible, and blocked results for the component.
363    *
364    * @param {HTMLCollectionOf<HTMLAnchorElement>} anchors
365    * @param {Document} document
366    *
367    * @returns {Map<string, object>}
368    *  A map where the key is a string containing the type of ad component
369    *  and the value is an object containing the number of adsLoaded,
370    *  adsVisible, and adsHidden within the component.
371    */
372   categorize(anchors, document) {
373     // Used for various functions to make relative URLs absolute.
374     let origin = new URL(document.documentURI).origin;
376     // Bottom up approach.
377     this.#categorizeAnchors(anchors, origin);
379     // Top down approach.
380     this.#categorizeDocument(document);
382     let componentToVisibilityMap = new Map();
383     let hrefToComponentMap = new Map();
385     let innerWindowHeight = document.ownerGlobal.innerHeight;
386     let scrollY = document.ownerGlobal.scrollY;
388     // Iterate over the results:
389     // - If it's searchbox add event listeners.
390     // - If it is a non_ads_link, map its href to component type.
391     // - For others, map its component type and check visibility.
392     for (let [element, data] of this.#elementToAdDataMap.entries()) {
393       if (data.type == "incontent_searchbox") {
394         // Bug 1880413: Deprecate hard coding the incontent search box.
395         // If searchbox has child elements, observe those, otherwise
396         // fallback to its parent element.
397         let searchElements = data.childElements.length
398           ? data.childElements
399           : [element];
400         ListenerHelper.addListeners(
401           searchElements,
402           [
403             { eventType: "click", target: data.type },
404             {
405               eventType: "keydown",
406               target: data.type,
407               action: "submitted",
408               condition: "keydownEnter",
409             },
410           ],
411           data.type
412         );
413         continue;
414       }
415       if (data.childElements.length) {
416         for (let child of data.childElements) {
417           let href = this.#extractHref(child, origin);
418           if (href) {
419             hrefToComponentMap.set(href, data.type);
420           }
421         }
422       } else {
423         let href = this.#extractHref(element, origin);
424         if (href) {
425           hrefToComponentMap.set(href, data.type);
426         }
427       }
429       // If the component is a non_ads_link, skip visibility checks.
430       if (data.type == "non_ads_link") {
431         continue;
432       }
434       // If proxy children were found, check the visibility of all of them
435       // otherwise just check the visiblity of the first child.
436       let childElements;
437       if (data.proxyChildElements.length) {
438         childElements = data.proxyChildElements;
439       } else if (data.childElements.length) {
440         childElements = [data.childElements[0]];
441       }
443       let count = this.#countVisibleAndHiddenAds(
444         element,
445         data.adsLoaded,
446         childElements,
447         innerWindowHeight,
448         scrollY
449       );
450       if (componentToVisibilityMap.has(data.type)) {
451         let componentInfo = componentToVisibilityMap.get(data.type);
452         componentInfo.adsLoaded += data.adsLoaded;
453         componentInfo.adsVisible += count.adsVisible;
454         componentInfo.adsHidden += count.adsHidden;
455       } else {
456         componentToVisibilityMap.set(data.type, {
457           adsLoaded: data.adsLoaded,
458           adsVisible: count.adsVisible,
459           adsHidden: count.adsHidden,
460         });
461       }
462     }
464     // Release the DOM elements from the Map.
465     this.#elementToAdDataMap.clear();
467     return { componentToVisibilityMap, hrefToComponentMap };
468   }
470   /**
471    * Given an element, find the href that is most likely to make the request if
472    * the element is clicked. If the element contains a specific data attribute
473    * known to contain the url used to make the initial request, use it,
474    * otherwise use its href. Specific character conversions are done to mimic
475    * conversions likely to take place when urls are observed in network
476    * activity.
477    *
478    * @param {Element} element
479    *  The element to inspect.
480    * @param {string} origin
481    *  The origin for relative urls.
482    * @returns {string}
483    *   The href of the element.
484    */
485   #extractHref(element, origin) {
486     let href;
487     // Prioritize the href from a known data attribute value instead of
488     // its href property, as the former is the initial url the page will
489     // navigate to before being re-directed to the href.
490     for (let name of this.#providerInfo.adServerAttributes) {
491       if (
492         element.dataset[name] &&
493         this.#providerInfo.extraAdServersRegexps.some(regexp =>
494           regexp.test(element.dataset[name])
495         )
496       ) {
497         href = element.dataset[name];
498         break;
499       }
500     }
501     // If a data attribute value was not found, fallback to the href.
502     href = href ?? element.getAttribute("href");
503     if (!href) {
504       return "";
505     }
507     // Avoid extracting or fixing up Javascript URLs.
508     if (href.startsWith("javascript")) {
509       return "";
510     }
512     // Hrefs can be relative.
513     if (!href.startsWith("https://") && !href.startsWith("http://")) {
514       href = origin + href;
515     }
516     // Per Bug 376844, apostrophes in query params are escaped, and thus, are
517     // percent-encoded by the time they are observed in the network. Even
518     // though it's more comprehensive, we avoid using newURI because its more
519     // expensive and conversions should be the exception.
520     // e.g. /path'?q=Mozilla's -> /path'?q=Mozilla%27s
521     let arr = href.split("?");
522     if (arr.length == 2 && arr[1].includes("'")) {
523       href = arr[0] + "?" + arr[1].replaceAll("'", "%27");
524     }
525     return href;
526   }
528   /**
529    * Given a list of anchor elements, group them into ad components.
530    *
531    * The first step in the process is to check if the anchor should be
532    * inspected. This is based on whether it contains an href or a
533    * data-attribute values that matches an ad link, or if it contains a
534    * pattern caught by a components included regular expression.
535    *
536    * Determine which component it belongs to and the number of matches for
537    * the component. The heuristic is described in findDataForAnchor.
538    * If there was a result and we haven't seen it before, save it in
539    * elementToAdDataMap.
540    *
541    * @param {HTMLCollectionOf<HTMLAnchorElement>} anchors
542    *  The list of anchors to inspect.
543    * @param {string} origin
544    *  The origin of the document the anchors belong to.
545    */
546   #categorizeAnchors(anchors, origin) {
547     for (let anchor of anchors) {
548       if (this.#shouldInspectAnchor(anchor, origin)) {
549         let result = this.#findDataForAnchor(anchor);
550         if (result) {
551           this.#recordElementData(result.element, {
552             type: result.type,
553             count: result.count,
554             proxyChildElements: result.proxyChildElements,
555             childElements: result.childElements,
556           });
557         }
558         if (result.relatedElements?.length) {
559           // Bug 1880413: Deprecate related elements.
560           // Bottom-up approach with related elements are only used for
561           // non-link elements related to ads, like carousel arrows.
562           ListenerHelper.addListeners(
563             result.relatedElements,
564             [
565               {
566                 action: "expanded",
567                 eventType: "click",
568               },
569             ],
570             result.type
571           );
572         }
573       }
574     }
575   }
577   /**
578    * Find components from the document object. This is mostly relevant for
579    * components that are non-ads and don't have an obvious regular expression
580    * that could match the pattern of the href.
581    *
582    * @param {Document} document
583    */
584   #categorizeDocument(document) {
585     // using the subset of components that are top down,
586     // go through each one.
587     for (let component of this.#topDownComponents) {
588       // Top-down searches must have the topDown attribute.
589       if (!component.topDown) {
590         continue;
591       }
592       // Top down searches must include a parent.
593       if (!component.included?.parent) {
594         continue;
595       }
596       let parents = document.querySelectorAll(
597         component.included.parent.selector
598       );
599       if (parents.length) {
600         let eventListeners = component.included.parent.eventListeners;
601         if (eventListeners?.length) {
602           ListenerHelper.addListeners(parents, eventListeners, component.type);
603         }
604         for (let parent of parents) {
605           // Bug 1880413: Deprecate related elements.
606           // Top-down related elements are either used for auto-suggested
607           // elements of a searchbox, or elements on a page which we can't
608           // find through a bottom up approach but we want an add a listener,
609           // like carousels with arrows.
610           if (component.included.related?.selector) {
611             let relatedElements = parent.querySelectorAll(
612               component.included.related.selector
613             );
614             if (relatedElements.length) {
615               // For the search box, related elements with event listeners are
616               // auto-suggested terms. For everything else (e.g. carousels)
617               // they are expanded.
618               ListenerHelper.addListeners(
619                 relatedElements,
620                 [
621                   {
622                     action:
623                       component.type == "incontent_searchbox"
624                         ? "submitted"
625                         : "expanded",
626                     eventType: "click",
627                   },
628                 ],
629                 component.type
630               );
631             }
632           }
633           if (component.included.children) {
634             for (let child of component.included.children) {
635               let childElements = parent.querySelectorAll(child.selector);
636               if (childElements.length) {
637                 if (child.eventListeners) {
638                   childElements = Array.from(childElements);
639                   ListenerHelper.addListeners(
640                     childElements,
641                     child.eventListeners,
642                     child.type ?? component.type
643                   );
644                 }
645                 if (!child.skipCount) {
646                   this.#recordElementData(parent, {
647                     type: component.type,
648                     childElements: Array.from(childElements),
649                   });
650                 }
651               }
652             }
653           } else if (!component.included.parent.skipCount) {
654             this.#recordElementData(parent, {
655               type: component.type,
656             });
657           }
658         }
659       }
660     }
661   }
663   /**
664    * Evaluates whether an anchor should be inspected based on matching
665    * regular expressions on either its href or specified data-attribute values.
666    *
667    * @param {HTMLAnchorElement} anchor
668    * @param {string} origin
669    * @returns {boolean}
670    */
671   #shouldInspectAnchor(anchor, origin) {
672     let href = anchor.getAttribute("href");
673     if (!href) {
674       return false;
675     }
677     // Some hrefs might be relative.
678     if (!href.startsWith("https://") && !href.startsWith("http://")) {
679       href = origin + href;
680     }
682     let regexps = this.#providerInfo.extraAdServersRegexps;
683     // Anchors can contain ad links in a data-attribute.
684     for (let name of this.#providerInfo.adServerAttributes) {
685       let attributeValue = anchor.dataset[name];
686       if (
687         attributeValue &&
688         regexps.some(regexp => regexp.test(attributeValue))
689       ) {
690         return true;
691       }
692     }
693     // Anchors can contain ad links in a specific href.
694     if (regexps.some(regexp => regexp.test(href))) {
695       return true;
696     }
697     return false;
698   }
700   /**
701    * Find the component data for an anchor.
702    *
703    * To categorize the anchor, we iterate over the list of possible components
704    * the anchor could be categorized. If the component is default, we skip
705    * checking because the fallback option for all anchor links is the default.
706    *
707    * First, get the "parent" of the anchor which best represents the DOM element
708    * that contains the anchor links for the component and no other component.
709    * This parent will be cached so that other anchors that share the same
710    * parent can be counted together.
711    *
712    * The check for a parent is a loop because we can define more than one best
713    * parent since on certain SERPs, it's possible for a "better" DOM element
714    * parent to appear occassionally.
715    *
716    * If no parent is found, skip this component.
717    *
718    * If a parent was found, check for specific child elements.
719    *
720    * Finding child DOM elements of a parent is optional. One reason to do so is
721    * to use child elements instead of anchor links to count the number of ads for
722    * a component via the `countChildren` property. This is provided because some ads
723    * (i.e. carousels) have multiple ad links in a single child element that go to the
724    * same location. In this scenario, all instances of the child are recorded as ads.
725    * Subsequent anchor elements that map to the same parent are ignored.
726    *
727    * Whether or not a child was found, return the information that was found,
728    * including whether or not all child elements were counted instead of anchors.
729    *
730    * If another anchor belonging to a parent that was previously recorded is the input
731    * for this function, we either increment the ad count by 1 or don't increment the ad
732    * count because the parent used `countChildren` completed the calculation in a
733    * previous step.
734    *
735    *
736    * @param {HTMLAnchorElement} anchor
737    *  The anchor to be inspected.
738    * @returns {object}
739    *  An object containing the element representing the root DOM element for
740    *  the component, the type of component, how many ads were counted,
741    *  and whether or not the count was of all the children.
742    */
743   #findDataForAnchor(anchor) {
744     for (let component of this.#providerInfo.components) {
745       // First, check various conditions for skipping a component.
747       // A component should always have at least one included statement.
748       if (!component.included) {
749         continue;
750       }
752       // Top down searches are done after the bottom up search.
753       if (component.topDown) {
754         continue;
755       }
757       // The default component doesn't need to be checked,
758       // as it will be the fallback option.
759       if (component.default) {
760         continue;
761       }
763       // The anchor shouldn't belong to an excluded parent component if one
764       // is provided.
765       if (
766         component.excluded?.parent?.selector &&
767         anchor.closest(component.excluded.parent.selector)
768       ) {
769         continue;
770       }
772       // All components with included should have a parent entry.
773       if (!component.included.parent) {
774         continue;
775       }
777       // Find the parent of the anchor.
778       let parent = anchor.closest(component.included.parent.selector);
780       if (!parent) {
781         continue;
782       }
784       // If we've already inspected the parent, add the child element to the
785       // list of anchors. Don't increment the ads loaded count, as we only care
786       // about grouping the anchor with the correct parent.
787       if (this.#elementToAdDataMap.has(parent)) {
788         return {
789           element: parent,
790           childElements: [anchor],
791         };
792       }
794       let relatedElements = [];
795       if (component.included.related?.selector) {
796         relatedElements = parent.querySelectorAll(
797           component.included.related.selector
798         );
799       }
801       // If the component has no defined children, return the parent element.
802       if (component.included.children) {
803         // Look for the first instance of a matching child selector.
804         for (let child of component.included.children) {
805           // If counting by child, get all of them at once.
806           if (child.countChildren) {
807             let proxyChildElements = parent.querySelectorAll(child.selector);
808             if (proxyChildElements.length) {
809               return {
810                 element: parent,
811                 type: child.type ?? component.type,
812                 proxyChildElements: Array.from(proxyChildElements),
813                 count: proxyChildElements.length,
814                 childElements: [anchor],
815                 relatedElements,
816               };
817             }
818           } else if (parent.querySelector(child.selector)) {
819             return {
820               element: parent,
821               type: child.type ?? component.type,
822               childElements: [anchor],
823               relatedElements,
824             };
825           }
826         }
827       }
828       // If no children were defined for this component, or none were found
829       // in the DOM, use the default definition.
830       return {
831         element: parent,
832         type: component.type,
833         childElements: [anchor],
834         relatedElements,
835       };
836     }
837     // If no component was found, use default values.
838     return {
839       element: anchor,
840       type: this.#defaultComponent.type,
841     };
842   }
844   /**
845    * Determines whether or not an ad was visible or hidden.
846    *
847    * An ad is considered visible if the parent element containing the
848    * component has non-zero dimensions, and all child element in the
849    * component have non-zero dimensions and mostly (50% height) fits within
850    * the window at the time when the impression was taken. If the element is to
851    * the left of the visible area, we also consider it viewed as it's possible
852    * the user interacted with a carousel which typically scrolls new content
853    * leftward.
854    *
855    * For some components, like text ads, we don't send every child
856    * element for visibility, just the first text ad. For other components
857    * like carousels, we send all child elements because we do care about
858    * counting how many elements of the carousel were visible.
859    *
860    * @param {Element} element
861    *  Element to be inspected
862    * @param {number} adsLoaded
863    *  Number of ads initially determined to be loaded for this element.
864    * @param {Array<Element>} childElements
865    *  List of children belonging to element.
866    * @param {number} innerWindowHeight
867    *  Current height of the window containing the elements.
868    * @param {number} scrollY
869    *  Current distance the window has been scrolled.
870    * @returns {object}
871    *  Contains adsVisible which is the number of ads shown for the element
872    *  and adsHidden, the number of ads not visible to the user.
873    */
874   #countVisibleAndHiddenAds(
875     element,
876     adsLoaded,
877     childElements,
878     innerWindowHeight,
879     scrollY
880   ) {
881     let elementRect =
882       element.ownerGlobal.windowUtils.getBoundsWithoutFlushing(element);
884     // If the parent element is not visible, assume all ads within are
885     // also not visible.
886     if (
887       !element.checkVisibility({
888         visibilityProperty: true,
889         opacityProperty: true,
890       })
891     ) {
892       Glean.serp.adsBlockedCount.hidden_parent.add();
893       return {
894         adsVisible: 0,
895         adsHidden: adsLoaded,
896       };
897     }
899     // If an ad is far above the possible visible area of a window, an
900     // adblocker might be doing it as a workaround for blocking the ad.
901     if (
902       elementRect.bottom < 0 &&
903       innerWindowHeight + scrollY + elementRect.bottom < 0
904     ) {
905       Glean.serp.adsBlockedCount.beyond_viewport.add();
906       return {
907         adsVisible: 0,
908         adsHidden: adsLoaded,
909       };
910     }
912     // If the element has no child elements, check if the element
913     // was ever viewed by the user at this moment.
914     if (!childElements?.length) {
915       // Most ads don't require horizontal scrolling to view it. Thus, we only
916       // check if it could've appeared with some vertical scrolling.
917       let visible = VisibilityHelper.elementWasVisibleVertically(
918         elementRect,
919         innerWindowHeight,
920         VISIBILITY_THRESHOLD
921       );
922       return {
923         adsVisible: visible ? 1 : 0,
924         adsHidden: 0,
925       };
926     }
928     let adsVisible = 0;
929     let adsHidden = 0;
930     for (let child of childElements) {
931       if (
932         !child.checkVisibility({
933           visibilityProperty: true,
934           opacityProperty: true,
935         })
936       ) {
937         adsHidden += 1;
938         Glean.serp.adsBlockedCount.hidden_child.add();
939         continue;
940       }
942       let itemRect =
943         child.ownerGlobal.windowUtils.getBoundsWithoutFlushing(child);
944       // If the child element is to the right of the containing element and
945       // can't be viewed, skip it. We do this check because some elements like
946       // carousels can hide additional content horizontally. We don't apply the
947       // same logic if the element is to the left because we assume carousels
948       // scroll elements to the left when the user wants to see more contents.
949       // Thus, the elements to the left must've been visible.
950       if (
951         !VisibilityHelper.childElementWasVisibleHorizontally(
952           elementRect,
953           itemRect,
954           VISIBILITY_THRESHOLD
955         )
956       ) {
957         continue;
958       }
960       // If the height of child element is not visible, skip it.
961       if (
962         !VisibilityHelper.elementWasVisibleVertically(
963           itemRect,
964           innerWindowHeight,
965           VISIBILITY_THRESHOLD
966         )
967       ) {
968         continue;
969       }
970       ++adsVisible;
971     }
973     return {
974       adsVisible,
975       adsHidden,
976     };
977   }
979   /**
980    * Caches ad data for a DOM element. The key of the map is by Element rather
981    * than Component for fast lookup on whether an Element has been already been
982    * categorized as a component. Subsequent calls to this passing the same
983    * element will update the list of child elements.
984    *
985    * @param {Element} element
986    *  The element considered to be the root for the component.
987    * @param {object} params
988    *  Various parameters that can be recorded. Whether the input values exist
989    *  or not depends on which component was found, which heuristic should be used
990    *  to determine whether an ad was visible, and whether we've already seen this
991    *  element.
992    * @param {string | null} params.type
993    *  The type of component.
994    * @param {number} params.count
995    *  The number of ads found for a component. The number represents either
996    *  the number of elements that match an ad expression or the number of DOM
997    *  elements containing an ad link.
998    * @param {Array<Element>} params.proxyChildElements
999    *  An array of DOM elements that should be inspected for visibility instead
1000    *  of the actual child elements, possibly because they are grouped.
1001    * @param {Array<Element>} params.childElements
1002    *  An array of DOM elements to inspect.
1003    */
1004   #recordElementData(
1005     element,
1006     { type, count = 1, proxyChildElements = [], childElements = [] } = {}
1007   ) {
1008     if (this.#elementToAdDataMap.has(element)) {
1009       let recordedValues = this.#elementToAdDataMap.get(element);
1010       if (childElements.length) {
1011         recordedValues.childElements =
1012           recordedValues.childElements.concat(childElements);
1013       }
1014     } else {
1015       this.#elementToAdDataMap.set(element, {
1016         type,
1017         adsLoaded: count,
1018         proxyChildElements,
1019         childElements,
1020       });
1021     }
1022   }
1025 export class VisibilityHelper {
1026   /**
1027    * Whether the element was vertically visible. It assumes elements above the
1028    * viewable area were visible at some point in time.
1029    *
1030    * @param {DOMRect} rect
1031    *   The bounds of the element.
1032    * @param {number} innerWindowHeight
1033    *   The height of the window.
1034    * @param {number} threshold
1035    *   What percentage of the element should vertically be visible.
1036    * @returns {boolean}
1037    *   Whether the element was visible.
1038    */
1039   static elementWasVisibleVertically(rect, innerWindowHeight, threshold) {
1040     return rect.top + rect.height * threshold <= innerWindowHeight;
1041   }
1043   /**
1044    * Whether the child element was horizontally visible. It assumes elements to
1045    * the left were visible at some point in time.
1046    *
1047    * @param {DOMRect} parentRect
1048    *   The bounds of the element that contains the child.
1049    * @param {DOMRect} childRect
1050    *   The bounds of the child element.
1051    * @param {number} threshold
1052    *   What percentage of the child element should horizontally be visible.
1053    * @returns {boolean}
1054    *   Whether the child element was visible.
1055    */
1056   static childElementWasVisibleHorizontally(parentRect, childRect, threshold) {
1057     return (
1058       childRect.left + childRect.width * threshold <=
1059       parentRect.left + parentRect.width
1060     );
1061   }
1065  * An object indicating which elements to examine for domains to extract and
1066  * which heuristic technique to use to extract that element's domain.
1068  * @typedef {object} ExtractorInfo
1069  * @property {string} selectors
1070  *  A string representing the CSS selector that targets the elements on the
1071  *  page that contain domains we want to extract.
1072  * @property {string} method
1073  *  A string representing which domain extraction heuristic to use.
1074  *  One of: "href", "dataAttribute" or "textContent".
1075  * @property {object | null} options
1076  *  Options related to the domain extraction heuristic used.
1077  * @property {string | null} options.dataAttributeKey
1078  *  The key name of the data attribute to lookup.
1079  * @property {string | null} options.queryParamKey
1080  *  The key name of the query param value to lookup.
1081  * @property {boolean | null} options.queryParamValueIsHref
1082  *  Whether the query param value is expected to contain an href.
1083  */
1086  * DomainExtractor examines elements on a page to retrieve the domains.
1087  */
1088 class DomainExtractor {
1089   /**
1090    * Extract domains from the page using an array of information pertaining to
1091    * the SERP.
1092    *
1093    * @param {Document} document
1094    *  The document for the SERP we are extracting domains from.
1095    * @param {Array<ExtractorInfo>} extractorInfos
1096    *  Information used to target the domains we need to extract.
1097    * @param {string} providerName
1098    *  Name of the search provider.
1099    * @return {Set<string>}
1100    *  A set of the domains extracted from the page.
1101    */
1102   extractDomainsFromDocument(document, extractorInfos, providerName) {
1103     let extractedDomains = new Set();
1104     if (!extractorInfos?.length) {
1105       return extractedDomains;
1106     }
1108     for (let extractorInfo of extractorInfos) {
1109       if (!extractorInfo.selectors) {
1110         continue;
1111       }
1113       let elements = document.querySelectorAll(extractorInfo.selectors);
1114       if (!elements) {
1115         continue;
1116       }
1118       switch (extractorInfo.method) {
1119         case "href": {
1120           // Origin is used in case a URL needs to be made absolute.
1121           let origin = new URL(document.documentURI).origin;
1122           this.#fromElementsConvertHrefsIntoDomains(
1123             elements,
1124             origin,
1125             providerName,
1126             extractedDomains,
1127             extractorInfo.options?.queryParamKey,
1128             extractorInfo.options?.queryParamValueIsHref
1129           );
1130           break;
1131         }
1132         case "dataAttribute": {
1133           this.#fromElementsRetrieveDataAttributeValues(
1134             elements,
1135             providerName,
1136             extractorInfo.options?.dataAttributeKey,
1137             extractedDomains
1138           );
1139           break;
1140         }
1141         case "textContent": {
1142           this.#fromElementsRetrieveTextContent(
1143             elements,
1144             extractedDomains,
1145             providerName
1146           );
1147           break;
1148         }
1149       }
1150     }
1152     return extractedDomains;
1153   }
1155   /**
1156    * Given a list of elements, extract domains using href attributes. If the
1157    * URL in the href includes the specified query param, the domain will be
1158    * that query param's value. Otherwise it will be the hostname of the href
1159    * attribute's URL.
1160    *
1161    * @param {NodeList<Element>} elements
1162    *  A list of elements from the page whose href attributes we want to
1163    *  inspect.
1164    * @param {string} origin
1165    *  Origin of the current page.
1166    * @param {string} providerName
1167    *  The name of the search provider.
1168    * @param {Set<string>} extractedDomains
1169    *  The result set of domains extracted from the page.
1170    * @param {string | null} queryParam
1171    *  An optional query param to search for in an element's href attribute.
1172    * @param {boolean | null} queryParamValueIsHref
1173    *  Whether the query param value is expected to contain an href.
1174    */
1175   #fromElementsConvertHrefsIntoDomains(
1176     elements,
1177     origin,
1178     providerName,
1179     extractedDomains,
1180     queryParam,
1181     queryParamValueIsHref
1182   ) {
1183     for (let element of elements) {
1184       if (this.#exceedsThreshold(extractedDomains.size)) {
1185         return;
1186       }
1188       let href = element.getAttribute("href");
1190       let url;
1191       try {
1192         url = new URL(href, origin);
1193       } catch (ex) {
1194         continue;
1195       }
1197       // Ignore non-standard protocols.
1198       if (url.protocol != "https:" && url.protocol != "http:") {
1199         continue;
1200       }
1202       if (queryParam) {
1203         let paramValue = url.searchParams.get(queryParam);
1204         if (queryParamValueIsHref) {
1205           try {
1206             paramValue = new URL(paramValue).hostname;
1207           } catch (e) {
1208             continue;
1209           }
1210           paramValue = this.#processDomain(paramValue, providerName);
1211         }
1212         if (paramValue && !extractedDomains.has(paramValue)) {
1213           extractedDomains.add(paramValue);
1214         }
1215       } else if (url.hostname) {
1216         let processedHostname = this.#processDomain(url.hostname, providerName);
1217         if (processedHostname && !extractedDomains.has(processedHostname)) {
1218           extractedDomains.add(processedHostname);
1219         }
1220       }
1221     }
1222   }
1224   /**
1225    * Given a list of elements, examine each for the specified data attribute.
1226    * If found, add that data attribute's value to the result set of extracted
1227    * domains as is.
1228    *
1229    * @param {NodeList<Element>} elements
1230    *  A list of elements from the page whose data attributes we want to
1231    *  inspect.
1232    * @param {string} providerName
1233    *  The name of the search provider.
1234    * @param {string} attribute
1235    *  The name of a data attribute to search for within an element.
1236    * @param {Set<string>} extractedDomains
1237    *  The result set of domains extracted from the page.
1238    */
1239   #fromElementsRetrieveDataAttributeValues(
1240     elements,
1241     providerName,
1242     attribute,
1243     extractedDomains
1244   ) {
1245     for (let element of elements) {
1246       if (this.#exceedsThreshold(extractedDomains.size)) {
1247         return;
1248       }
1249       let value = element.dataset[attribute];
1250       value = this.#processDomain(value, providerName);
1251       if (value && !extractedDomains.has(value)) {
1252         extractedDomains.add(value);
1253       }
1254     }
1255   }
1257   /* Given a list of elements, examine the text content for each element, which
1258    * may be 1) a URL from which we can extract a domain or 2) text we can fix
1259    * up to create a best guess as to a URL. If either condition is met, we add
1260    * the domain to the result set.
1261    *
1262    * @param {NodeList<Element>} elements
1263    *  A list of elements from the page whose text content we want to inspect.
1264    * @param {Set<string>} extractedDomains
1265    *  The result set of domains extracted from the page.
1266    * @param {string} providerName
1267    *  The name of the search provider.
1268    */
1269   #fromElementsRetrieveTextContent(elements, extractedDomains, providerName) {
1270     // Not an exhaustive regex, but it fits our purpose for this method.
1271     const LOOSE_URL_REGEX =
1272       /^(?:https?:\/\/)?(?:www\.)?(?:[\w\-]+\.)+(?:[\w\-]{2,})/i;
1274     // Known but acceptable limitations to this function, where the return
1275     // value won't be correctly fixed up:
1276     //   1) A url is embedded within other text. Ex: "xkcd.com is cool."
1277     //   2) The url contains legal but unusual characters. Ex: $ ! * '
1278     function fixup(textContent) {
1279       return textContent
1280         .toLowerCase()
1281         .replaceAll(" ", "")
1282         .replace(/\.$/, "")
1283         .concat(".com");
1284     }
1286     for (let element of elements) {
1287       if (this.#exceedsThreshold(extractedDomains.size)) {
1288         return;
1289       }
1290       let textContent = element.textContent;
1291       if (!textContent) {
1292         continue;
1293       }
1295       let domain;
1296       if (LOOSE_URL_REGEX.test(textContent)) {
1297         // Creating a new URL object will throw if the protocol is missing.
1298         if (!/^https?:\/\//.test(textContent)) {
1299           textContent = "https://" + textContent;
1300         }
1302         try {
1303           domain = new URL(textContent).hostname;
1304         } catch (e) {
1305           domain = fixup(textContent);
1306         }
1307       } else {
1308         domain = fixup(textContent);
1309       }
1311       let processedDomain = this.#processDomain(domain, providerName);
1312       if (processedDomain && !extractedDomains.has(processedDomain)) {
1313         extractedDomains.add(processedDomain);
1314       }
1315     }
1316   }
1318   /**
1319    * Processes a raw domain extracted from the SERP into its final form before
1320    * categorization.
1321    *
1322    * @param {string} domain
1323    *   The domain extracted from the page.
1324    * @param {string} providerName
1325    *   The provider associated with the page.
1326    * @returns {string}
1327    *   The domain without any subdomains.
1328    */
1329   #processDomain(domain, providerName) {
1330     if (
1331       domain.startsWith(`${providerName}.`) ||
1332       domain.includes(`.${providerName}.`)
1333     ) {
1334       return "";
1335     }
1336     return this.#stripDomainOfSubdomains(domain);
1337   }
1339   /**
1340    * Helper to strip domains of any subdomains.
1341    *
1342    * @param {string} domain
1343    *   The domain to strip of any subdomains.
1344    * @returns {object} browser
1345    *   The given domain with any subdomains removed.
1346    */
1347   #stripDomainOfSubdomains(domain) {
1348     let tld;
1349     // Can throw an exception if the input has too few domain levels.
1350     try {
1351       tld = Services.eTLD.getKnownPublicSuffixFromHost(domain);
1352     } catch (ex) {
1353       return "";
1354     }
1356     let domainWithoutTLD = domain.substring(0, domain.length - tld.length);
1357     let secondLevelDomain = domainWithoutTLD.split(".").at(-2);
1359     return secondLevelDomain ? `${secondLevelDomain}.${tld}` : "";
1360   }
1362   /**
1363    * Per a request from Data Science, we need to limit the number of domains
1364    * categorized to 10 non-ad domains and 10 ad domains.
1365    *
1366    * @param {number} nDomains The number of domains processed.
1367    * @returns {boolean} Whether or not the threshold was exceeded.
1368    */
1369   #exceedsThreshold(nDomains) {
1370     return nDomains >= CATEGORIZATION_SETTINGS.MAX_DOMAINS_TO_CATEGORIZE;
1371   }
1374 export const domainExtractor = new DomainExtractor();
1375 const searchProviders = new SearchProviders();
1376 const searchAdImpression = new SearchAdImpression();
1378 const documentToEventCallbackMap = new WeakMap();
1379 const documentToRemoveEventListenersMap = new WeakMap();
1380 const documentToSubmitMap = new WeakMap();
1383  * SearchTelemetryChild monitors for pages that are partner searches, and
1384  * looks through them to find links which looks like adverts and sends back
1385  * a notification to SearchTelemetry for possible telemetry reporting.
1387  * Only the partner details and the fact that at least one ad was found on the
1388  * page are returned to SearchTelemetry. If no ads are found, no notification is
1389  * given.
1390  */
1391 export class SearchSERPTelemetryChild extends JSWindowActorChild {
1392   /**
1393    * Amount of time to wait after a page event before examining the page
1394    * for ads.
1395    *
1396    * @type {number | null}
1397    */
1398   #adTimeout;
1399   /**
1400    * Determines if there is a provider that matches the supplied URL and returns
1401    * the information associated with that provider.
1402    *
1403    * @param {string} url The url to check
1404    * @returns {array|null} Returns null if there's no match, otherwise an array
1405    *   of provider name and the provider information.
1406    */
1407   _getProviderInfoForUrl(url) {
1408     return searchProviders.info?.find(info => info.searchPageRegexp.test(url));
1409   }
1411   /**
1412    * Checks to see if the page is a partner and has an ad link within it. If so,
1413    * it will notify SearchTelemetry.
1414    */
1415   _checkForAdLink(eventType) {
1416     try {
1417       if (!this.contentWindow) {
1418         return;
1419       }
1420     } catch (ex) {
1421       // unload occurred before the timer expired
1422       return;
1423     }
1425     let doc = this.document;
1426     let url = doc.documentURI;
1427     let providerInfo = this._getProviderInfoForUrl(url);
1428     if (!providerInfo) {
1429       return;
1430     }
1432     let regexps = providerInfo.extraAdServersRegexps;
1433     let anchors = doc.getElementsByTagName("a");
1434     let hasAds = false;
1435     for (let anchor of anchors) {
1436       if (!anchor.href) {
1437         continue;
1438       }
1439       for (let name of providerInfo.adServerAttributes) {
1440         hasAds = regexps.some(regexp => regexp.test(anchor.dataset[name]));
1441         if (hasAds) {
1442           break;
1443         }
1444       }
1445       if (!hasAds) {
1446         hasAds = regexps.some(regexp => regexp.test(anchor.href));
1447       }
1448       if (hasAds) {
1449         break;
1450       }
1451     }
1453     if (hasAds) {
1454       this.sendAsyncMessage("SearchTelemetry:PageInfo", {
1455         hasAds,
1456         url,
1457       });
1458     }
1460     if (
1461       providerInfo.components?.length &&
1462       (eventType == "load" || eventType == "pageshow")
1463     ) {
1464       // Start performance measurements.
1465       let start = Cu.now();
1466       let timerId = Glean.serp.categorizationDuration.start();
1468       let pageActionCallback = info => {
1469         if (info.action == "submitted") {
1470           documentToSubmitMap.set(doc, true);
1471         }
1472         this.sendAsyncMessage("SearchTelemetry:Action", {
1473           target: info.target,
1474           url: info.url,
1475           action: info.action,
1476         });
1477       };
1478       documentToEventCallbackMap.set(this.document, pageActionCallback);
1480       let componentToVisibilityMap, hrefToComponentMap;
1481       try {
1482         let result = searchAdImpression.categorize(anchors, doc);
1483         componentToVisibilityMap = result.componentToVisibilityMap;
1484         hrefToComponentMap = result.hrefToComponentMap;
1485       } catch (e) {
1486         // Cancel the timer if an error encountered.
1487         Glean.serp.categorizationDuration.cancel(timerId);
1488       }
1490       if (componentToVisibilityMap && hrefToComponentMap) {
1491         // End measurements.
1492         ChromeUtils.addProfilerMarker(
1493           "SearchSERPTelemetryChild._checkForAdLink",
1494           start,
1495           "Checked anchors for visibility"
1496         );
1497         Glean.serp.categorizationDuration.stopAndAccumulate(timerId);
1498         this.sendAsyncMessage("SearchTelemetry:AdImpressions", {
1499           adImpressions: componentToVisibilityMap,
1500           hrefToComponentMap,
1501           url,
1502         });
1503       }
1504     }
1506     if (
1507       lazy.serpEventTelemetryCategorization &&
1508       providerInfo.domainExtraction &&
1509       (eventType == "load" || eventType == "pageshow")
1510     ) {
1511       let start = Cu.now();
1512       let nonAdDomains = domainExtractor.extractDomainsFromDocument(
1513         doc,
1514         providerInfo.domainExtraction.nonAds,
1515         providerInfo.telemetryId
1516       );
1517       let adDomains = domainExtractor.extractDomainsFromDocument(
1518         doc,
1519         providerInfo.domainExtraction.ads,
1520         providerInfo.telemetryId
1521       );
1523       this.sendAsyncMessage("SearchTelemetry:Domains", {
1524         url,
1525         nonAdDomains,
1526         adDomains,
1527       });
1529       ChromeUtils.addProfilerMarker(
1530         "SearchSERPTelemetryChild._checkForAdLink",
1531         start,
1532         "Extract domains from elements"
1533       );
1534     }
1535   }
1537   /**
1538    * Checks for the presence of certain components on the page that are
1539    * required for recording the page impression.
1540    */
1541   #checkForPageImpressionComponents() {
1542     let url = this.document.documentURI;
1543     let providerInfo = this._getProviderInfoForUrl(url);
1544     if (providerInfo.components?.length) {
1545       searchAdImpression.providerInfo = providerInfo;
1546       let start = Cu.now();
1547       let shoppingTabDisplayed = searchAdImpression.hasShoppingTab(
1548         this.document
1549       );
1550       ChromeUtils.addProfilerMarker(
1551         "SearchSERPTelemetryChild.#recordImpression",
1552         start,
1553         "Checked for shopping tab"
1554       );
1555       this.sendAsyncMessage("SearchTelemetry:PageImpression", {
1556         url,
1557         shoppingTabDisplayed,
1558       });
1559     }
1560   }
1562   #removeEventListeners() {
1563     let callbacks = documentToRemoveEventListenersMap.get(this.document);
1564     if (callbacks) {
1565       for (let callback of callbacks) {
1566         callback();
1567       }
1568       documentToRemoveEventListenersMap.delete(this.document);
1569     }
1570   }
1572   /**
1573    * Handles events received from the actor child notifications.
1574    *
1575    * @param {object} event The event details.
1576    */
1577   handleEvent(event) {
1578     if (!this.#urlIsSERP(this.document.documentURI)) {
1579       return;
1580     }
1581     switch (event.type) {
1582       case "pageshow": {
1583         // If a page is loaded from the bfcache, we won't get a "DOMContentLoaded"
1584         // event, so we need to rely on "pageshow" in this case. Note: we do this
1585         // so that we remain consistent with the *.in-content:sap* count for the
1586         // SEARCH_COUNTS histogram.
1587         if (event.persisted) {
1588           this.#checkForPageImpressionComponents();
1589           this.#check(event.type);
1590         }
1591         break;
1592       }
1593       case "DOMContentLoaded": {
1594         this.#checkForPageImpressionComponents();
1595         this.#check(event.type);
1596         break;
1597       }
1598       case "load": {
1599         // We check both DOMContentLoaded and load in case the page has
1600         // taken a long time to load and the ad is only detected on load.
1601         // We still check at DOMContentLoaded because if the page hasn't
1602         // finished loading and the user navigates away, we still want to know
1603         // if there were ads on the page or not at that time.
1604         this.#check(event.type);
1605         break;
1606       }
1607       case "pagehide": {
1608         let callbacks = documentToRemoveEventListenersMap.get(this.document);
1609         if (callbacks) {
1610           for (let removeEventListenerCallback of callbacks) {
1611             removeEventListenerCallback();
1612           }
1613           documentToRemoveEventListenersMap.delete(this.document);
1614         }
1615         this.#cancelCheck();
1616         break;
1617       }
1618     }
1619   }
1621   async receiveMessage(message) {
1622     switch (message.name) {
1623       case "SearchSERPTelemetry:WaitForSPAPageLoad":
1624         lazy.setTimeout(() => {
1625           this.#checkForPageImpressionComponents();
1626           this._checkForAdLink("load");
1627         }, Services.cpmm.sharedData.get(SEARCH_TELEMETRY_SHARED.SPA_LOAD_TIMEOUT));
1628         break;
1629       case "SearchSERPTelemetry:StopTrackingDocument":
1630         this.#removeDocumentFromSubmitMap();
1631         this.#removeEventListeners();
1632         break;
1633       case "SearchSERPTelemetry:DidSubmit":
1634         return this.#didSubmit();
1635     }
1636     return null;
1637   }
1639   #didSubmit() {
1640     return documentToSubmitMap.get(this.document);
1641   }
1643   #removeDocumentFromSubmitMap() {
1644     documentToSubmitMap.delete(this.document);
1645   }
1647   #urlIsSERP(url) {
1648     let provider = this._getProviderInfoForUrl(this.document.documentURI);
1649     if (provider) {
1650       // Some URLs can match provider info but also be the provider's homepage
1651       // instead of a SERP.
1652       // e.g. https://example.com/ vs. https://example.com/?foo=bar
1653       // To check this, we look for the presence of the query parameter
1654       // that contains a search term.
1655       let queries = new URLSearchParams(url.split("#")[0].split("?")[1]);
1656       for (let queryParamName of provider.queryParamNames) {
1657         if (queries.get(queryParamName)) {
1658           return true;
1659         }
1660       }
1661     }
1662     return false;
1663   }
1665   #cancelCheck() {
1666     if (this._waitForContentTimeout) {
1667       lazy.clearTimeout(this._waitForContentTimeout);
1668     }
1669   }
1671   #check(eventType) {
1672     if (!this.#adTimeout) {
1673       this.#adTimeout = Services.cpmm.sharedData.get(
1674         SEARCH_TELEMETRY_SHARED.LOAD_TIMEOUT
1675       );
1676     }
1677     this.#cancelCheck();
1678     this._waitForContentTimeout = lazy.setTimeout(() => {
1679       this._checkForAdLink(eventType);
1680     }, this.#adTimeout);
1681   }