AmpSuggestions.sys.mjs (13003B)
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/. */ 4 5 import { SuggestProvider } from "moz-src:///browser/components/urlbar/private/SuggestFeature.sys.mjs"; 6 7 const lazy = {}; 8 9 ChromeUtils.defineESModuleGetters(lazy, { 10 AmpMatchingStrategy: 11 "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs", 12 CONTEXTUAL_SERVICES_PING_TYPES: 13 "resource:///modules/PartnerLinkAttribution.sys.mjs", 14 ContextId: "moz-src:///browser/modules/ContextId.sys.mjs", 15 QuickSuggest: "moz-src:///browser/components/urlbar/QuickSuggest.sys.mjs", 16 rawSuggestionUrlMatches: 17 "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustSuggest.sys.mjs", 18 Region: "resource://gre/modules/Region.sys.mjs", 19 UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs", 20 UrlbarResult: "moz-src:///browser/components/urlbar/UrlbarResult.sys.mjs", 21 UrlbarUtils: "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs", 22 }); 23 24 const TIMESTAMP_TEMPLATE = "%YYYYMMDDHH%"; 25 const TIMESTAMP_LENGTH = 10; 26 const TIMESTAMP_REGEXP = /^\d{10}$/; 27 28 /** 29 * A feature that manages AMP suggestions. 30 */ 31 export class AmpSuggestions extends SuggestProvider { 32 get enablingPreferences() { 33 return [ 34 "ampFeatureGate", 35 "suggest.amp", 36 "suggest.quicksuggest.all", 37 "suggest.quicksuggest.sponsored", 38 ]; 39 } 40 41 get primaryUserControlledPreferences() { 42 return ["suggest.amp"]; 43 } 44 45 get merinoProvider() { 46 return "adm"; 47 } 48 49 get rustSuggestionType() { 50 return "Amp"; 51 } 52 53 get rustProviderConstraints() { 54 let intValue = lazy.UrlbarPrefs.get("ampMatchingStrategy"); 55 if (!intValue) { 56 // If the value is zero or otherwise falsey, use the usual default 57 // exact-keyword strategy by returning null here. 58 return null; 59 } 60 if (!Object.values(lazy.AmpMatchingStrategy).includes(intValue)) { 61 this.logger.error( 62 "Unknown AmpMatchingStrategy value, using default strategy", 63 { intValue } 64 ); 65 return null; 66 } 67 return { 68 ampAlternativeMatching: intValue, 69 }; 70 } 71 72 isSuggestionSponsored() { 73 return true; 74 } 75 76 getSuggestionTelemetryType() { 77 return "adm_sponsored"; 78 } 79 80 enable(enabled) { 81 if (enabled) { 82 GleanPings.quickSuggest.setEnabled(true); 83 GleanPings.quickSuggestDeletionRequest.setEnabled(true); 84 } else { 85 // Submit the `deletion-request` ping. Both it and the `quick-suggest` 86 // ping must remain enabled in order for it to be successfully submitted 87 // and uploaded. That's fine: It's harmless for both pings to remain 88 // enabled until shutdown, and they won't be submitted again since AMP 89 // suggestions are now disabled. On restart they won't be enabled again. 90 this.#submitQuickSuggestDeletionRequestPing(); 91 } 92 } 93 94 makeResult(queryContext, suggestion) { 95 let normalized = Object.assign({}, suggestion); 96 if (suggestion.source == "merino") { 97 // Normalize the Merino suggestion so it has the same properties as Rust 98 // AMP suggestions: camelCased properties plus a `rawUrl` property whose 99 // value is `url` without replacing the timestamp template. 100 normalized.rawUrl = suggestion.url; 101 normalized.fullKeyword = suggestion.full_keyword; 102 normalized.impressionUrl = suggestion.impression_url; 103 normalized.clickUrl = suggestion.click_url; 104 normalized.blockId = suggestion.block_id; 105 normalized.iabCategory = suggestion.iab_category; 106 normalized.requestId = suggestion.request_id; 107 108 // Replace URL timestamp templates inline. This isn't necessary for Rust 109 // AMP suggestions because the Rust component handles it. 110 this.#replaceSuggestionTemplates(normalized); 111 } 112 113 let isTopPick = 114 lazy.UrlbarPrefs.get("quickSuggestAmpTopPickCharThreshold") && 115 lazy.UrlbarPrefs.get("quickSuggestAmpTopPickCharThreshold") <= 116 queryContext.trimmedLowerCaseSearchString.length; 117 118 let { value: title, highlights: titleHighlights } = 119 lazy.QuickSuggest.getFullKeywordTitleAndHighlights({ 120 tokens: queryContext.tokens, 121 highlightType: isTopPick 122 ? lazy.UrlbarUtils.HIGHLIGHT.TYPED 123 : lazy.UrlbarUtils.HIGHLIGHT.SUGGESTED, 124 fullKeyword: normalized.fullKeyword, 125 title: normalized.title, 126 }); 127 128 let payload = { 129 url: normalized.url, 130 originalUrl: normalized.rawUrl, 131 title, 132 requestId: normalized.requestId, 133 urlTimestampIndex: normalized.urlTimestampIndex, 134 sponsoredImpressionUrl: normalized.impressionUrl, 135 sponsoredClickUrl: normalized.clickUrl, 136 sponsoredBlockId: normalized.blockId, 137 sponsoredAdvertiser: normalized.advertiser, 138 sponsoredIabCategory: normalized.iabCategory, 139 isBlockable: true, 140 isManageable: true, 141 }; 142 143 let resultParams = {}; 144 if (isTopPick) { 145 resultParams.isBestMatch = true; 146 resultParams.suggestedIndex = 1; 147 } else { 148 if (lazy.UrlbarPrefs.get("quickSuggestSponsoredPriority")) { 149 resultParams.isBestMatch = true; 150 resultParams.suggestedIndex = 1; 151 } else { 152 resultParams.richSuggestionIconSize = 16; 153 } 154 payload.descriptionL10n = { 155 id: "urlbar-result-action-sponsored", 156 }; 157 } 158 159 return new lazy.UrlbarResult({ 160 type: lazy.UrlbarUtils.RESULT_TYPE.URL, 161 source: lazy.UrlbarUtils.RESULT_SOURCE.SEARCH, 162 isRichSuggestion: true, 163 ...resultParams, 164 payload, 165 highlights: { 166 title: titleHighlights, 167 }, 168 }); 169 } 170 171 onImpression(state, queryContext, controller, featureResults, details) { 172 // For the purpose of the `quick-suggest` impression ping, "impression" 173 // means that one of these suggestions was visible at the time of an 174 // engagement regardless of the engagement type or engagement result, so 175 // submit the ping if `state` is "engagement". 176 if (state == "engagement") { 177 for (let result of featureResults) { 178 this.#submitQuickSuggestImpressionPing({ 179 result, 180 queryContext, 181 details, 182 }); 183 } 184 } 185 } 186 187 onEngagement(queryContext, controller, details, _searchString) { 188 let { result } = details; 189 190 // Handle commands. These suggestions support the Dismissal and Manage 191 // commands. Dismissal is the only one we need to handle here. `UrlbarInput` 192 // handles Manage. 193 if (details.selType == "dismiss") { 194 lazy.QuickSuggest.dismissResult(result); 195 controller.removeResult(result); 196 } 197 198 // A `quick-suggest` impression ping must always be submitted on engagement 199 // regardless of engagement type. Normally we do that in `onImpression()`, 200 // but that's not called when the session remains ongoing, so in that case, 201 // submit the impression ping now. 202 if (details.isSessionOngoing) { 203 this.#submitQuickSuggestImpressionPing({ queryContext, result, details }); 204 } 205 206 // Submit the `quick-suggest` engagement ping. 207 let pingData; 208 switch (details.selType) { 209 case "quicksuggest": 210 pingData = { 211 pingType: lazy.CONTEXTUAL_SERVICES_PING_TYPES.QS_SELECTION, 212 reportingUrl: result.payload.sponsoredClickUrl, 213 }; 214 break; 215 case "dismiss": 216 pingData = { 217 pingType: lazy.CONTEXTUAL_SERVICES_PING_TYPES.QS_BLOCK, 218 iabCategory: result.payload.sponsoredIabCategory, 219 }; 220 break; 221 } 222 if (pingData) { 223 this.#submitQuickSuggestPing({ queryContext, result, ...pingData }); 224 } 225 } 226 227 isUrlEquivalentToResultUrl(url, result) { 228 // If the URLs aren't the same length, they can't be equivalent. 229 let resultURL = result.payload.url; 230 if (resultURL.length != url.length) { 231 return false; 232 } 233 234 if (result.payload.source == "rust") { 235 // Rust has its own equivalence function. 236 return lazy.rawSuggestionUrlMatches(result.payload.originalUrl, url); 237 } 238 239 // If the result URL doesn't have a timestamp, then do a straight string 240 // comparison. 241 let { urlTimestampIndex } = result.payload; 242 if (typeof urlTimestampIndex != "number" || urlTimestampIndex < 0) { 243 return resultURL == url; 244 } 245 246 // Compare the first parts of the strings before the timestamps. 247 if ( 248 resultURL.substring(0, urlTimestampIndex) != 249 url.substring(0, urlTimestampIndex) 250 ) { 251 return false; 252 } 253 254 // Compare the second parts of the strings after the timestamps. 255 let remainderIndex = urlTimestampIndex + TIMESTAMP_LENGTH; 256 if (resultURL.substring(remainderIndex) != url.substring(remainderIndex)) { 257 return false; 258 } 259 260 // Test the timestamp against the regexp. 261 let maybeTimestamp = url.substring( 262 urlTimestampIndex, 263 urlTimestampIndex + TIMESTAMP_LENGTH 264 ); 265 return TIMESTAMP_REGEXP.test(maybeTimestamp); 266 } 267 268 async #submitQuickSuggestPing({ 269 queryContext, 270 result, 271 pingType, 272 ...pingData 273 }) { 274 if (queryContext.isPrivate) { 275 return; 276 } 277 278 let allPingData = { 279 pingType, 280 // Suggest initialization awaits `Region.init()`, so safe to assume it's 281 // already been initialized here. 282 country: lazy.Region.home, 283 ...pingData, 284 matchType: result.isBestMatch ? "best-match" : "firefox-suggest", 285 // Always use lowercase to make the reporting consistent. 286 advertiser: result.payload.sponsoredAdvertiser.toLocaleLowerCase(), 287 blockId: result.payload.sponsoredBlockId, 288 improveSuggestExperience: 289 lazy.UrlbarPrefs.get("quickSuggestOnlineAvailable") && 290 lazy.UrlbarPrefs.get("quicksuggest.online.enabled"), 291 // `position` is 1-based, unlike `rowIndex`, which is zero-based. 292 position: result.rowIndex + 1, 293 suggestedIndex: result.suggestedIndex.toString(), 294 suggestedIndexRelativeToGroup: !!result.isSuggestedIndexRelativeToGroup, 295 requestId: result.payload.requestId, 296 source: result.payload.source, 297 contextId: await lazy.ContextId.request(), 298 }; 299 300 for (let [gleanKey, value] of Object.entries(allPingData)) { 301 let glean = Glean.quickSuggest[gleanKey]; 302 if (value !== undefined && value !== "") { 303 glean.set(value); 304 } 305 } 306 GleanPings.quickSuggest.submit(); 307 } 308 309 #submitQuickSuggestImpressionPing({ queryContext, result, details }) { 310 this.#submitQuickSuggestPing({ 311 result, 312 queryContext, 313 pingType: lazy.CONTEXTUAL_SERVICES_PING_TYPES.QS_IMPRESSION, 314 isClicked: 315 // `selType` == "quicksuggest" if the result itself was clicked. It will 316 // be a command name if a command was clicked, e.g., "dismiss". 317 result == details.result && details.selType == "quicksuggest", 318 reportingUrl: result.payload.sponsoredImpressionUrl, 319 }); 320 } 321 322 async #submitQuickSuggestDeletionRequestPing() { 323 if (lazy.ContextId.rotationEnabled) { 324 // The ContextId module will take care of sending the appropriate 325 // deletion requests if rotation is enabled. 326 lazy.ContextId.forceRotation(); 327 } else { 328 Glean.quickSuggest.contextId.set(await lazy.ContextId.request()); 329 GleanPings.quickSuggestDeletionRequest.submit(); 330 } 331 } 332 333 /** 334 * Some AMP suggestion URL properties include timestamp templates that must be 335 * replaced with timestamps at query time. This method replaces them in place. 336 * 337 * Example URL with template: 338 * 339 * http://example.com/foo?bar=%YYYYMMDDHH% 340 * 341 * It will be replaced with a timestamp like this: 342 * 343 * http://example.com/foo?bar=2021111610 344 * 345 * @param {object} suggestion 346 * An AMP suggestion. 347 */ 348 #replaceSuggestionTemplates(suggestion) { 349 let now = new Date(); 350 let timestampParts = [ 351 now.getFullYear(), 352 now.getMonth() + 1, 353 now.getDate(), 354 now.getHours(), 355 ]; 356 let timestamp = timestampParts 357 .map(n => n.toString().padStart(2, "0")) 358 .join(""); 359 for (let key of ["url", "clickUrl"]) { 360 let value = suggestion[key]; 361 if (!value) { 362 continue; 363 } 364 365 let timestampIndex = value.indexOf(TIMESTAMP_TEMPLATE); 366 if (timestampIndex >= 0) { 367 if (key == "url") { 368 suggestion.urlTimestampIndex = timestampIndex; 369 } 370 // We could use replace() here but we need the timestamp index for 371 // `suggestion.urlTimestampIndex`, and since we already have that, avoid 372 // another O(n) substring search and manually replace the template with 373 // the timestamp. 374 suggestion[key] = 375 value.substring(0, timestampIndex) + 376 timestamp + 377 value.substring(timestampIndex + TIMESTAMP_TEMPLATE.length); 378 } 379 } 380 } 381 382 static get TIMESTAMP_TEMPLATE() { 383 return TIMESTAMP_TEMPLATE; 384 } 385 386 static get TIMESTAMP_LENGTH() { 387 return TIMESTAMP_LENGTH; 388 } 389 }