diff --git a/build-system/dep-check-config.js b/build-system/dep-check-config.js index 92311b0353cb..69ffc0487e4c 100644 --- a/build-system/dep-check-config.js +++ b/build-system/dep-check-config.js @@ -259,6 +259,10 @@ exports.rules = [ 'extensions/amp-subscriptions-google/0.1/amp-subscriptions-google.js->extensions/amp-subscriptions/0.1/doc-impl.js', 'extensions/amp-subscriptions-google/0.1/amp-subscriptions-google.js->extensions/amp-subscriptions/0.1/entitlement.js', 'extensions/amp-subscriptions-google/0.1/amp-subscriptions-google.js->extensions/amp-subscriptions/0.1/score-factors.js', + + // amp-smartlinks depends on amp-skimlinks/link-rewriter + 'extensions/amp-smartlinks/0.1/amp-smartlinks.js->extensions/amp-skimlinks/0.1/link-rewriter/link-rewriter-manager.js', + 'extensions/amp-smartlinks/0.1/linkmate.js->extensions/amp-skimlinks/0.1/link-rewriter/two-steps-response.js', ], }, { diff --git a/build-system/tasks/compile.js b/build-system/tasks/compile.js index 37967a937b54..7046ae2d021b 100644 --- a/build-system/tasks/compile.js +++ b/build-system/tasks/compile.js @@ -245,6 +245,8 @@ function compile(entryModuleFilenames, outputDir, outputFilename, options) { 'extensions/amp-viewer-assistance/**/*.js', // Needed for AmpViewerIntegrationVariableService 'extensions/amp-viewer-integration/**/*.js', + // Needed for amp-smartlinks dep on amp-skimlinks + 'extensions/amp-skimlinks/0.1/**/*.js', 'src/*.js', 'src/**/*.js', '!third_party/babel/custom-babel-helpers.js', diff --git a/bundles.config.js b/bundles.config.js index 04926eb402c5..e7856bfd8cec 100644 --- a/bundles.config.js +++ b/bundles.config.js @@ -261,6 +261,7 @@ exports.extensionBundles = [ type: TYPES.MISC, }, {name: 'amp-skimlinks', version: '0.1', type: TYPES.MISC}, + {name: 'amp-smartlinks', version: '0.1', type: TYPES.MISC}, {name: 'amp-soundcloud', version: '0.1', type: TYPES.MEDIA}, {name: 'amp-springboard-player', version: '0.1', type: TYPES.MEDIA}, { diff --git a/examples/amp-smartlinks.html b/examples/amp-smartlinks.html new file mode 100644 index 000000000000..d190c6143218 --- /dev/null +++ b/examples/amp-smartlinks.html @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + AMP Smartlinks +

Hello from Narrativ

+ + + + + + + + diff --git a/extensions/amp-smartlinks/0.1/OWNERS.yaml b/extensions/amp-smartlinks/0.1/OWNERS.yaml new file mode 100644 index 000000000000..0c53f75ee089 --- /dev/null +++ b/extensions/amp-smartlinks/0.1/OWNERS.yaml @@ -0,0 +1,4 @@ +- PhilWinchester +- pbecotte +- c-nichols +- zhouyx diff --git a/extensions/amp-smartlinks/0.1/amp-smartlinks.js b/extensions/amp-smartlinks/0.1/amp-smartlinks.js new file mode 100644 index 000000000000..e2accbfbcf45 --- /dev/null +++ b/extensions/amp-smartlinks/0.1/amp-smartlinks.js @@ -0,0 +1,220 @@ +/** + * Copyright 2019 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {CommonSignals} from '../../../src/common-signals'; +import {CustomEventReporterBuilder} from '../../../src/extension-analytics.js'; +import {Services} from '../../../src/services'; +import {dict} from '../../../src/utils/object'; +import {getData} from './../../../src/event-helper'; + +import {ENDPOINTS} from './constants'; +import {LinkRewriterManager} from + '../../amp-skimlinks/0.1/link-rewriter/link-rewriter-manager'; +import {Linkmate} from './linkmate'; +import {getConfigOptions} from './linkmate-options'; + +const TAG = 'amp-smartlinks'; + + +export class AmpSmartlinks extends AMP.BaseElement { + /** + * @param {!AmpElement} element + */ + constructor(element) { + super(element); + + /** @private {?../../../src/service/xhr-impl.Xhr} */ + this.xhr_ = null; + + /** @private {?../../../src/service/ampdoc-impl.AmpDoc} */ + this.ampDoc_ = null; + + /** @private {?../../amp-skimlinks/0.1/link-rewriter/link-rewriter-manager.LinkRewriterManager} */ + this.linkRewriterService_ = null; + + /** @private {?../../amp-skimlinks/0.1/link-rewriter/link-rewriter.LinkRewriter} */ + this.smartLinkRewriter_ = null; + + /** + * This will store config attributes from the extension options and an API + * request. The attributes from options are: + * exclusiveLinks, linkAttribute, linkSelector, linkmateEnabled, nrtvSlug + * The attributes from the API are: + * linkmateExpected, publisherID + * @private {?Object} */ + this.linkmateOptions_ = null; + + /** @private {?./linkmate.Linkmate} */ + this.linkmate_ = null; + + /** @private {?string} */ + this.referrer_ = null; + } + + /** @override */ + buildCallback() { + this.ampDoc_ = this.getAmpDoc(); + this.xhr_ = Services.xhrFor(this.ampDoc_.win); + const viewer = Services.viewerForDoc(this.ampDoc_); + + this.linkmateOptions_ = getConfigOptions(this.element); + this.linkRewriterService_ = new LinkRewriterManager(this.ampDoc_); + + return this.ampDoc_.whenBodyAvailable() + .then(() => viewer.getReferrerUrl()) + .then(referrer => { + this.referrer_ = referrer; + viewer.whenFirstVisible().then(() => { + this.runSmartlinks_(); + }); + }); + } + + /** + * Wait for the config promise to resolve and then proceed to functionality + * @private + */ + runSmartlinks_() { + this.getLinkmateOptions_().then(config => { + this.linkmateOptions_.linkmateExpected = config['linkmate_enabled']; + this.linkmateOptions_.publisherID = config['publisher_id']; + + this.postPageImpression_(); + this.linkmate_ = new Linkmate( + /** @type {!../../../src/service/ampdoc-impl.AmpDoc} */ + (this.ampDoc_), + /** @type {!../../../src/service/xhr-impl.Xhr} */ + (this.xhr_), + /** @type {!Object} */ + (this.linkmateOptions_) + ); + this.smartLinkRewriter_ = this.initLinkRewriter_(); + + // If the config specified linkmate to run and our API is expecting + // linkmate to run + if (this.linkmateOptions_.linkmateEnabled && + this.linkmateOptions_.linkmateExpected) { + this.smartLinkRewriter_.getAnchorReplacementList(); + } + }); + } + + /** + * API call to retrieve the Narrativ config for this extension. + * API response will be a list containing nested json values. For the purpose + * of this extension there will only ever be one value in the list: + * {amp_config: {linkmate_enabled: , publisher_id: }} + * @return {?Promise} + * @private + */ + getLinkmateOptions_() { + const fetchUrl = ENDPOINTS.NRTV_CONFIG_ENDPOINT.replace( + '.nrtv_slug.', this.linkmateOptions_.nrtvSlug + ); + + try { + return this.xhr_.fetchJson(fetchUrl, { + method: 'GET', + ampCors: false, + }) + .then(res => res.json()) + .then(res => { + return getData(res)[0]['amp_config']; + }); + } catch (err) { + return null; + } + } + + + /** + * API call to indicate a page load event happened + * @private + */ + postPageImpression_() { + // When using layout='nodisplay' manually trigger CustomEventReporterBuilder + this.signals().signal(CommonSignals.LOAD_START); + const payload = this.buildPageImpressionPayload_(); + + const builder = new CustomEventReporterBuilder(this.element); + + builder.track('page-impression', ENDPOINTS.PAGE_IMPRESSION_ENDPOINT); + + builder.setTransportConfig(dict({ + 'beacon': true, + 'image': false, + 'xhrpost': true, + 'useBody': true, + })); + + builder.setExtraUrlParams(payload); + const reporter = builder.build(); + + reporter.trigger('page-impression'); + } + + /** + * Initialize and register a Narrativ LinkRewriter instance + * @return {!../../amp-skimlinks/0.1/link-rewriter/link-rewriter.LinkRewriter} + * @private + */ + initLinkRewriter_() { + const options = {linkSelector: this.linkmateOptions_.linkSelector}; + + return this.linkRewriterService_.registerLinkRewriter( + TAG, + anchorList => { + return this.linkmate_.runLinkmate(anchorList); + }, + options + ); + } + + /** + * Build the payload for our page load event. + * @return {!JsonObject} + * @private + */ + buildPageImpressionPayload_() { + return /** @type {!JsonObject} */ (dict({ + 'events': [{'is_amp': true}], + 'organization_id': this.linkmateOptions_.publisherID, + 'organization_type': 'publisher', + 'user': { + 'page_session_uuid': this.generateUUID_(), + 'source_url': this.ampDoc_.getUrl(), + 'previous_url': this.referrer_, + 'user_agent': this.ampDoc_.win.navigator.userAgent, + }, + })); + } + + /** + * Generate a unique UUID for this session. + * @return {string} + * @private + */ + generateUUID_() { + return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c => + (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4) + .toString(16) + ); + } +} + +AMP.extension('amp-smartlinks', '0.1', AMP => { + AMP.registerElement('amp-smartlinks', AmpSmartlinks); +}); diff --git a/extensions/amp-smartlinks/0.1/constants.js b/extensions/amp-smartlinks/0.1/constants.js new file mode 100644 index 000000000000..350524dfd15b --- /dev/null +++ b/extensions/amp-smartlinks/0.1/constants.js @@ -0,0 +1,26 @@ +/** + * Copyright 2019 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const BASE_API_URL = 'https://api.narrativ.com/api'; +/** @const @enum {string} */ +export const ENDPOINTS = { + PAGE_IMPRESSION_ENDPOINT: + `${BASE_API_URL}/v1/events/impressions/page_impression/`, + NRTV_CONFIG_ENDPOINT: + `${BASE_API_URL}/v0/publishers/.nrtv_slug./amp_config/`, + LINKMATE_ENDPOINT: + `${BASE_API_URL}/v1/publishers/.pub_id./linkmate/smart_links/`, +}; diff --git a/extensions/amp-smartlinks/0.1/linkmate-options.js b/extensions/amp-smartlinks/0.1/linkmate-options.js new file mode 100644 index 000000000000..af3cf51d1cab --- /dev/null +++ b/extensions/amp-smartlinks/0.1/linkmate-options.js @@ -0,0 +1,86 @@ +/** + * Copyright 2019 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +/** + * Get the config values from the tag on the amp page + * @param {!Element} element + * @return {!Object} + */ +export function getConfigOptions(element) { + return { + nrtvSlug: getNrtvAccountName_(element), + linkmateEnabled: hasLinkmateFlag_(element), + exclusiveLinks: hasExclusiveLinksFlag_(element), + linkAttribute: getLinkAttribute_(element), + linkSelector: getLinkSelector_(element), + }; +} + +/** + * The slug used to distinguish Narrativ accounts. + * @param {!Element} element + * @return {string} + * @private + */ +function getNrtvAccountName_(element) { + const nrtvSlug = element.getAttribute('nrtv-account-name'); + + return nrtvSlug.toLowerCase(); +} + +/** + * Flag to run the Linkmate service on an article. + * @param {!Element} element + * @return {boolean} + * @private + */ +function hasLinkmateFlag_(element) { + return !!element.hasAttribute('linkmate'); +} + +/** + * Flag to mark links as exclusive. + * @param {!Element} element + * @return {boolean} + */ +function hasExclusiveLinksFlag_(element) { + return !!element.hasAttribute('exclusive-links'); +} + +/** + * What attribute the outbound link variable is stored in an anchor. + * @param {!Element} element + * @return {string} + * @private + */ +function getLinkAttribute_(element) { + const linkAttribute = element.getAttribute('link-attribute'); + + return linkAttribute ? linkAttribute.toLowerCase() : 'href'; +} + +/** + * Selector used to get all links that are meant to be monetized. + * @param {!Element} element + * @return {string} + * @private + */ +function getLinkSelector_(element) { + const linkSelector = element.getAttribute('link-selector'); + + return linkSelector ? linkSelector.toLowerCase() : 'a'; +} diff --git a/extensions/amp-smartlinks/0.1/linkmate.js b/extensions/amp-smartlinks/0.1/linkmate.js new file mode 100644 index 000000000000..b736d31bce8d --- /dev/null +++ b/extensions/amp-smartlinks/0.1/linkmate.js @@ -0,0 +1,192 @@ +/** + * Copyright 2019 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {deepEquals} from '../../../src/json'; +import {dict} from '../../../src/utils/object'; + +import {ENDPOINTS} from './constants'; +import {TwoStepsResponse} from + '../../amp-skimlinks/0.1/link-rewriter/two-steps-response'; +import {getData} from '../../../src/event-helper'; + + +export class Linkmate { + /** + * @param {!../../../src/service/ampdoc-impl.AmpDoc} ampDoc + * @param {!../../../src/service/xhr-impl.Xhr} xhr + * @param {!Object} linkmateOptions + */ + constructor(ampDoc, xhr, linkmateOptions) { + /** @private {!../../../src/service/ampdoc-impl.AmpDoc} */ + this.ampDoc_ = ampDoc; + + /** @private {!../../../src/service/xhr-impl.Xhr} */ + this.xhr_ = xhr; + + /** @private {?boolean} */ + this.requestExclusiveLinks_ = linkmateOptions.exclusiveLinks; + + /** @private {?number} */ + this.publisherID_ = linkmateOptions.publisherID; + + /** @private {?string} */ + this.linkAttribute_ = linkmateOptions.linkAttribute; + + /** @private {!Document|!ShadowRoot} */ + this.rootNode_ = this.ampDoc_.getRootNode(); + + /** @private {?Array} */ + this.anchorList_ = null; + + /** @private {?Array}*/ + this.linkmateResponse_ = null; + } + + /** + * Callback used by LinkRewriter. Whenever there is a change in the anchors + * on the page we want make a new API call. + * @param {!Array} anchorList + * @return {!../../amp-skimlinks/0.1/link-rewriter/two-steps-response.TwoStepsResponse} + * @public + */ + runLinkmate(anchorList) { + // If we already have an API response and the anchor list has + // changed since last API call then map any new anchors to existing + // API response + let syncMappedLinks = null; + const anchorListChanged = this.anchorList_ && + !deepEquals(this.anchorList_, anchorList); + + if (this.linkmateResponse_ && anchorListChanged) { + syncMappedLinks = this.mapLinks_(); + } + + // If we don't have an API response or the anchor list has changed since + // last API call then build a new payload and post to API + if (!this.linkmateResponse_ || anchorListChanged) { + const asyncMappedLinks = this.postToLinkmate_(anchorList) + .then(res => { + this.linkmateResponse_ = getData(res)[0]['smart_links']; + this.anchorList_ = anchorList; + return this.mapLinks_(); + }); + + return new TwoStepsResponse(syncMappedLinks, asyncMappedLinks); + } else { + // If we didn't need to make an API call return the synchronous response + this.anchorList_ = anchorList; + return new TwoStepsResponse(syncMappedLinks, null); + } + } + + /** + * Build the payload for the Linkmate API call and POST. + * @param {!Array} anchorList + * @private + * @return {?Promise} + */ + postToLinkmate_(anchorList) { + const linksPayload = this.buildLinksPayload_(anchorList); + const editPayload = this.getEditInfo_(); + + const payload = dict({ + 'article': editPayload, + 'links': linksPayload, + }); + + const fetchUrl = ENDPOINTS.LINKMATE_ENDPOINT.replace( + '.pub_id.', this.publisherID_.toString() + ); + const postOptions = { + method: 'POST', + ampCors: false, + headers: dict({'Content-Type': 'application/json'}), + body: payload, + }; + + return this.xhr_.fetchJson(fetchUrl, postOptions) + .then(res => res.json()); + } + + /** + * Build the links portion for Linkmate payload. We need to check each link + * if it has #donotlink to comply with business rules. + * @param {!Array} anchorList + * @return {!Array} + * @private + */ + buildLinksPayload_(anchorList) { + // raw links needs to be stored as a global somewhere + // for later association with the response + const postLinks = []; + anchorList.forEach(anchor => { + const link = anchor[this.linkAttribute_]; + // If a link is already a Narrativ link. + if (/shop-links.co/.test(link)) { + // Check if amp flag is there. Add if necessary. Don't add to payload. + if (!/\?amp=true$/.test(link)) { + anchor[this.linkAttribute_] = + `${anchor[this.linkAttribute_]}?amp=true`; + } + return; + } + + if (!/#donotlink$/.test(link)) { + const exclusive = this.requestExclusiveLinks_ || /#locklink$/.test(link); + const linkObj = { + 'raw_url': link, + 'exclusive_match_requested': exclusive, + }; + + postLinks.push(linkObj); + } + }); + + return postLinks; + } + + /** + * This is just article information used in the edit part of Linkmate payload. + * @return {!JsonObject} + * @private + */ + getEditInfo_() { + return dict({ + 'name': this.rootNode_.title || null, + 'url': this.ampDoc_.getUrl(), + }); + } + + /** + * The API response returns unique links. Map those unique links to as many + * urls in the anchorList as possible. Set the replacement url as a shop-link. + * @return {!../../amp-skimlinks/0.1/link-rewriter/link-rewriter.AnchorReplacementList} + * @public + */ + mapLinks_() { + return this.linkmateResponse_.map(smartLink => { + return Array.prototype.slice.call(this.anchorList_) + .map(anchor => { + return { + anchor, + replacementUrl: anchor[this.linkAttribute_] === smartLink['url'] + && smartLink['auction_id'] + ? `https://shop-links.co/${smartLink['auction_id']}/?amp=true` : null, + }; + }); + })[0]; + } +} diff --git a/extensions/amp-smartlinks/0.1/test/test-amp-smartlinks.js b/extensions/amp-smartlinks/0.1/test/test-amp-smartlinks.js new file mode 100644 index 000000000000..1ef15100c64f --- /dev/null +++ b/extensions/amp-smartlinks/0.1/test/test-amp-smartlinks.js @@ -0,0 +1,246 @@ +/** + * Copyright 2019 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as DocumentReady from '../../../../src/document-ready'; +import * as LinkmateOptions from '../linkmate-options'; +import {AmpSmartlinks} from '../amp-smartlinks'; +import {LinkRewriterManager} from + '../../../amp-skimlinks/0.1/link-rewriter/link-rewriter-manager'; +import {Services} from '../../../../src/services'; + +const helpersFactory = env => { + return { + createAmpSmartlinks(extensionAttrs) { + const ampTag = document.createElement('amp-smartlinks'); + + for (const attr in extensionAttrs) { + ampTag.setAttribute(attr, extensionAttrs[attr]); + } + ampTag.getAmpDoc = () => env.ampdoc; + return new AmpSmartlinks(ampTag); + }, + }; +}; + +describes.fakeWin('amp-smartlinks', + {amp: {extensions: ['amp-smartlinks']}}, + env => { + let ampSmartlinks, helpers, xhr; + + beforeEach(() => { + xhr = Services.xhrFor(env.win); + helpers = helpersFactory(env); + env.sandbox + .stub(DocumentReady, 'whenDocumentReady') + .returns(Promise.reject()); + }); + + afterEach(() => { + env.sandbox.restore(); + }); + + describe('getConfigOptions', () => { + it('Should parse options', () => { + env.sandbox.spy(LinkmateOptions, 'getConfigOptions'); + + const smartlinkOptions = { + 'nrtv-account-name': 'thisisnotapublisher', + 'linkmate': '', + 'exclusive-links': '', + 'link-attribute': 'href', + 'link-selector': 'a', + }; + ampSmartlinks = helpers.createAmpSmartlinks(smartlinkOptions); + env.sandbox.stub(ampSmartlinks, 'runSmartlinks_'); + + return ampSmartlinks.buildCallback().then(() => { + expect(LinkmateOptions.getConfigOptions.calledOnce).to.be + .true; + expect(ampSmartlinks.linkmateOptions_).to.deep.equal({ + nrtvSlug: smartlinkOptions['nrtv-account-name'], + linkmateEnabled: true, + exclusiveLinks: true, + linkAttribute: smartlinkOptions['link-attribute'], + linkSelector: smartlinkOptions['link-selector'], + }); + }); + }); + + it('Should return handle bad options', () => { + env.sandbox.spy(LinkmateOptions, 'getConfigOptions'); + + const smartlinkOptions = { + 'nrtv-account-name': 'alwaysastring', + 'linkmate': 1234, + 'exclusive-links': 'monkeysatatypewriter', + }; + ampSmartlinks = helpers.createAmpSmartlinks(smartlinkOptions); + env.sandbox.stub(ampSmartlinks, 'runSmartlinks_'); + + return ampSmartlinks.buildCallback().then(() => { + expect(LinkmateOptions.getConfigOptions.calledOnce).to.be.true; + expect(ampSmartlinks.linkmateOptions_).to.deep.equal({ + nrtvSlug: 'alwaysastring', + linkmateEnabled: true, + exclusiveLinks: true, + linkAttribute: 'href', + linkSelector: 'a', + }); + }); + }); + }); + + describe('getLinkmateOptions_', () => { + it('Should fetch Linkmate Options from API', () => { + const options = { + 'nrtv-account-name': 'testingconfigpub', + 'linkmate': '', + 'exclusive-links': '', + }; + ampSmartlinks = helpers.createAmpSmartlinks(options); + + env.sandbox.spy(ampSmartlinks, 'getLinkmateOptions_'); + env.sandbox.stub(xhr, 'fetchJson'); + + return ampSmartlinks.buildCallback().then(() => { + expect(ampSmartlinks.getLinkmateOptions_.calledOnce).to.be.true; + }); + }); + }); + + describe('runSmartlinks_', () => { + let fakeViewer; + + beforeEach(() => { + const options = { + 'nrtv-account-name': 'thisisnotapublisher', + 'linkmate': '', + 'exclusive-links': '', + }; + + ampSmartlinks = helpers.createAmpSmartlinks(options); + fakeViewer = Services.viewerForDoc(env.ampdoc); + + env.sandbox + .stub(ampSmartlinks, 'getLinkmateOptions_') + .returns(Promise.resolve({'publisher_id': 999})); + env.sandbox.stub(xhr, 'fetchJson'); + }); + + it('Should call postPageImpression_', () => { + env.sandbox.spy(ampSmartlinks, 'postPageImpression_'); + + return ampSmartlinks.buildCallback().then(() => { + fakeViewer.whenFirstVisible().then(() => { + expect(ampSmartlinks.postPageImpression_.calledOnce).to.be.true; + }); + }); + }); + + it('Should call initLinkRewriter_', () => { + env.sandbox.spy(ampSmartlinks, 'initLinkRewriter_'); + + return ampSmartlinks.buildCallback().then(() => { + fakeViewer.whenFirstVisible().then(() => { + expect(ampSmartlinks.initLinkRewriter_.calledOnce).to.be.true; + }); + }); + }); + }); + + describe('buildPageImpressionPayload_', () => { + beforeEach(() => { + const options = { + 'nrtv-account-name': 'thisisnotapublisher', + 'linkmate': '', + 'exclusive-links': '', + }; + + ampSmartlinks = helpers.createAmpSmartlinks(options); + }); + + it('Should build body correctly', () => { + env.sandbox.spy(ampSmartlinks, 'buildPageImpressionPayload_'); + env.sandbox.stub(ampSmartlinks, 'postPageImpression_'); + env.sandbox + .stub(ampSmartlinks, 'generateUUID_') + .returns('acbacc4b-e171-4869-b32a-921f48659624'); + env.sandbox + .stub(env.ampdoc, 'getUrl') + .returns('http://fakewebsite.example/'); + + const mockPub = 999; + const mockUA = 'thisisnotauseragent'; + const expectedPayload = { + 'events': [{'is_amp': true}], + 'organization_id': mockPub, + 'organization_type': 'publisher', + 'user': { + 'page_session_uuid': 'acbacc4b-e171-4869-b32a-921f48659624', + 'source_url': 'http://fakewebsite.example/', + 'previous_url': '', + 'user_agent': mockUA, + }, + }; + + return ampSmartlinks.buildCallback().then(() => { + ampSmartlinks.linkmateOptions_.publisherID = mockPub; + ampSmartlinks.ampDoc_.win.navigator.userAgent = mockUA; + + const payload = ampSmartlinks.buildPageImpressionPayload_(); + expect(payload).to.deep.equals(expectedPayload); + }); + }); + }); + + describe('initSmartlinkRewriter_', () => { + beforeEach(() => { + const options = { + 'nrtv-account-name': 'thisisnotapublisher', + 'linkmate': '', + 'exclusive-links': '', + }; + + ampSmartlinks = helpers.createAmpSmartlinks(options); + env.sandbox + .stub(ampSmartlinks, 'getLinkmateOptions_') + .returns(Promise.resolve({'publisher_id': 999})); + }); + + it('Should register link rewriter', () => { + ampSmartlinks.linkRewriterService_ = new LinkRewriterManager( + env.ampdoc + ); + ampSmartlinks.linkmateOptions_ = { + linkSelector: 'a', + }; + env.sandbox.spy(ampSmartlinks.linkRewriterService_, + 'registerLinkRewriter'); + + ampSmartlinks.initLinkRewriter_(); + const args = ampSmartlinks.linkRewriterService_.registerLinkRewriter + .args[0]; + + expect(ampSmartlinks.linkRewriterService_.registerLinkRewriter + .calledOnce).to.be.true; + // This is a constant value in amp-smartlinks.js + expect(args[0]).to.equal('amp-smartlinks'); + expect(args[1]).to.be.a('function'); + expect(args[2].linkSelector).to.equal('a'); + }); + }); + } +); diff --git a/extensions/amp-smartlinks/0.1/test/test-linkmate.js b/extensions/amp-smartlinks/0.1/test/test-linkmate.js new file mode 100644 index 000000000000..2f6d3482945f --- /dev/null +++ b/extensions/amp-smartlinks/0.1/test/test-linkmate.js @@ -0,0 +1,418 @@ +/** + * Copyright 2019 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as DocumentReady from '../../../../src/document-ready'; +import {AmpSmartlinks} from '../amp-smartlinks'; +import {Linkmate} from '../linkmate'; +import {Services} from '../../../../src/services'; +import {TwoStepsResponse} from + '../../../amp-skimlinks/0.1/link-rewriter/two-steps-response'; + +const helpersFactory = env => { + const {win} = env; + + return { + createAmpSmartlinks(extensionAttrs) { + const ampTag = document.createElement('amp-smartlinks'); + + for (const attr in extensionAttrs) { + ampTag.setAttribute(attr, extensionAttrs[attr]); + } + ampTag.getAmpDoc = () => env.ampdoc; + + return new AmpSmartlinks(ampTag); + }, + createAnchor(href) { + const anchor = win.document.createElement('a'); + anchor.href = href; + + return anchor; + }, + }; +}; + +describes.fakeWin('amp-smartlinks', + {amp: {extensions: ['amp-smartlinks']}}, + env => { + let helpers, xhr, linkmate; + + beforeEach(() => { + xhr = Services.xhrFor(env.win); + helpers = helpersFactory(env); + }); + + beforeEach(() => { + env.sandbox + .stub(DocumentReady, 'whenDocumentReady') + .returns(Promise.reject()); + }); + + afterEach(() => { + env.sandbox.restore(); + }); + + describe('runLinkmate', () => { + let anchorList, mockFetch, response; + + beforeEach(() => { + const linkmateOptions = { + exclusiveLinks: false, + publisherID: 999, + linkAttribute: 'href', + }; + linkmate = new Linkmate( + env.ampdoc, + xhr, + linkmateOptions, + ); + anchorList = [ + 'http://fakelink.example', + 'http://fakelink2.example', + 'https://examplelocklink.example/#locklink', + ].map(helpers.createAnchor); + }); + + beforeEach(() => { + mockFetch = env.sandbox.mock(xhr); + + response = { + json: () => Promise.resolve({}), + }; + }); + + afterEach(() => { + mockFetch.verify(); + }); + + it('Should fire an API call if none exists', () => { + env.sandbox.spy(linkmate, 'postToLinkmate_'); + env.sandbox.stub(linkmate, 'mapLinks_'); + + mockFetch + .expects('fetchJson') + .once() + .returns(Promise.resolve(response)); + + const linkmateResponse = linkmate.runLinkmate(anchorList); + + expect(linkmate.postToLinkmate_.calledOnce).to.be.true; + expect(linkmateResponse).to.be.instanceof(TwoStepsResponse); + }); + + it('Should fire an API call if anchorList changed', () => { + env.sandbox.spy(linkmate, 'postToLinkmate_'); + env.sandbox.stub(linkmate, 'mapLinks_'); + + mockFetch + .expects('fetchJson') + .once() + .returns(Promise.resolve(response)); + const linkmateResponse = linkmate.runLinkmate(anchorList); + + linkmate.anchorList_ = anchorList; + const newAnchorList = [ + 'http://totallynewlink.example', + 'http://fakelink2.example', + 'https://examplelocklink.example/#locklink', + ].map(helpers.createAnchor); + + mockFetch + .expects('fetchJson') + .once() + .returns(Promise.resolve(response)); + const linkmateResponse2 = linkmate.runLinkmate(newAnchorList); + + expect(linkmate.postToLinkmate_.calledTwice).to.be.true; + expect(linkmateResponse).to.be.instanceof(TwoStepsResponse); + expect(linkmateResponse2).to.be.instanceof(TwoStepsResponse); + }); + + it('Should map new anchors', () => { + env.sandbox.spy(linkmate, 'postToLinkmate_'); + env.sandbox.spy(linkmate, 'mapLinks_'); + + mockFetch + .expects('fetchJson') + .once() + .returns(Promise.resolve(response)); + const linkmateResponse = linkmate.runLinkmate(anchorList); + + linkmate.anchorList_ = anchorList; + linkmate.linkmateResponse_ = [{a: 'b'}]; + const newAnchorList = [ + 'http://totallynewlink.example', + 'http://fakelink2.example', + 'https://examplelocklink.example/#locklink', + ].map(helpers.createAnchor); + + mockFetch + .expects('fetchJson') + .once() + .returns(Promise.resolve(response)); + const linkmateResponse2 = linkmate.runLinkmate(newAnchorList); + + expect(linkmate.postToLinkmate_.calledTwice).to.be.true; + expect(linkmate.mapLinks_.calledOnce).to.be.true; + expect(linkmateResponse).to.be.instanceof(TwoStepsResponse); + expect(linkmateResponse2).to.be.instanceof(TwoStepsResponse); + }); + + it('Should do nothing if no new anchors', () => { + env.sandbox.spy(linkmate, 'postToLinkmate_'); + env.sandbox.stub(linkmate, 'mapLinks_'); + + mockFetch + .expects('fetchJson') + .once() + .returns(Promise.resolve(response)); + const linkmateResponse = linkmate.runLinkmate(anchorList); + + linkmate.anchorList_ = anchorList; + linkmate.linkmateResponse_ = [{a: 'b'}]; + const syncResponse = linkmate.runLinkmate(anchorList); + + expect(linkmate.postToLinkmate_.calledOnce).to.be.true; + expect(linkmate.postToLinkmate_.calledTwice).to.be.false; + expect(syncResponse).to.not.be.null; + expect(linkmateResponse).to.be.instanceof(TwoStepsResponse); + }); + }); + + describe('postToLinkmate_', () => { + let mockFetch; + + beforeEach(() => { + mockFetch = env.sandbox.mock(xhr); + }); + + afterEach(() => { + mockFetch.verify(); + }); + + it('Should build payload', () => { + const linkmateOptions = { + exclusiveLinks: false, + publisherID: 999, + linkAttribute: 'href', + }; + linkmate = new Linkmate( + env.ampdoc, + xhr, + linkmateOptions, + ); + const response = { + json: () => Promise.resolve({}), + }; + + env.sandbox.spy(linkmate, 'postToLinkmate_'); + env.sandbox + .stub(linkmate, 'buildLinksPayload_') + .returns({}); + env.sandbox + .stub(linkmate, 'getEditInfo_') + .returns({}); + mockFetch + .expects('fetchJson') + .once() + .returns(Promise.resolve(response)); + linkmate.postToLinkmate_(); + + expect(linkmate.buildLinksPayload_.calledOnce).to.be.true; + expect(linkmate.getEditInfo_.calledOnce).to.be.true; + }); + }); + + describe('buildLinksPayload_', () => { + let anchorList; + + beforeEach(() => { + const linkmateOptions = { + exclusiveLinks: false, + publisherID: 999, + linkAttribute: 'href', + }; + linkmate = new Linkmate( + env.ampdoc, + xhr, + linkmateOptions, + ); + + anchorList = [ + 'http://fakelink.example', + 'http://fakelink2.example', + 'https://examplelocklink.example/#locklink', + ].map(helpers.createAnchor); + }); + + it('Should build payload from anchorList', () => { + env.sandbox.spy(linkmate, 'buildLinksPayload_'); + + const expectedPayload = [{ + 'raw_url': 'http://fakelink.example/', + 'exclusive_match_requested': false, + }, { + 'raw_url': 'http://fakelink2.example/', + 'exclusive_match_requested': false, + }, { + 'raw_url': 'https://examplelocklink.example/#locklink', + 'exclusive_match_requested': true, + }]; + + const linkPayload = linkmate.buildLinksPayload_(anchorList); + + expect(linkPayload).to.deep.equal(expectedPayload); + }); + + it('Should build all exclusive links if requested', () => { + env.sandbox.spy(linkmate, 'buildLinksPayload_'); + + linkmate.requestExclusiveLinks_ = true; + const expectedPayload = [{ + 'raw_url': 'http://fakelink.example/', + 'exclusive_match_requested': true, + }, { + 'raw_url': 'http://fakelink2.example/', + 'exclusive_match_requested': true, + }, { + 'raw_url': 'https://examplelocklink.example/#locklink', + 'exclusive_match_requested': true, + }]; + + const linkPayload = linkmate.buildLinksPayload_(anchorList); + + expect(linkPayload).to.deep.equal(expectedPayload); + }); + + it('Should skip existing shop-links', () => { + env.sandbox.spy(linkmate, 'buildLinksPayload_'); + + anchorList = [ + 'http://fakelink.example', + 'http://http://shop-links.co/999', + 'https://examplelocklink.example/#locklink', + ].map(helpers.createAnchor); + + const expectedPayload = [{ + 'raw_url': 'http://fakelink.example/', + 'exclusive_match_requested': false, + }, { + 'raw_url': 'https://examplelocklink.example/#locklink', + 'exclusive_match_requested': true, + }]; + + const linkPayload = linkmate.buildLinksPayload_(anchorList); + + expect(linkPayload).to.deep.equal(expectedPayload); + }); + + it('Should add amp flag to existing shop-links', () => { + env.sandbox.spy(linkmate, 'buildLinksPayload_'); + + anchorList = [ + 'http://http://shop-links.co/999', + ].map(helpers.createAnchor); + + const expectedAnchor = 'http://http//shop-links.co/999?amp=true'; + + const linkPayload = linkmate.buildLinksPayload_(anchorList); + + expect(linkPayload).to.deep.equal([]); + expect(anchorList[0].href).to.equal(expectedAnchor); + }); + }); + + describe('getEditInfo_', () => { + it('Should build edit info payload', () => { + const linkmateOptions = { + exclusiveLinks: false, + publisherID: 999, + linkAttribute: 'href', + }; + linkmate = new Linkmate( + env.ampdoc, + xhr, + linkmateOptions, + ); + const envRoot = env.ampdoc.getRootNode(); + envRoot.title = 'Fake Website Title'; + + env.sandbox + .stub(env.ampdoc, 'getUrl') + .returns('http://fakewebsite.example/'); + env.sandbox.spy(linkmate, 'getEditInfo_'); + + const expectedPayload = { + 'name': 'Fake Website Title', + 'url': 'http://fakewebsite.example/', + }; + + const editPayload = linkmate.getEditInfo_(); + + expect(editPayload).to.deep.equal(expectedPayload); + }); + }); + + describe('mapLinks_', () => { + let anchorList; + + beforeEach(() => { + const linkmateOptions = { + exclusiveLinks: false, + publisherID: 999, + linkAttribute: 'href', + }; + linkmate = new Linkmate( + env.ampdoc, + xhr, + linkmateOptions, + ); + + anchorList = [ + 'http://fakelink.example/', + 'http://fakelink2.example/', + 'https://examplelocklink.example/#locklink', + ].map(helpers.createAnchor); + }); + + it('Should map API response to anchorList', () => { + env.sandbox.spy(linkmate, 'mapLinks_'); + const linkmateResponse = [{ + 'auction_id': '1661245605416735203', + 'exclusive_match_requested': false, + 'pub_id': 999, + 'url': 'http://fakelink.example/', + }]; + linkmate.anchorList_ = anchorList; + linkmate.linkmateResponse_ = linkmateResponse; + + const expectedMapping = [{ + anchor: anchorList[0], + replacementUrl: `https://shop-links.co/${linkmateResponse[0]['auction_id']}/?amp=true`, + }, { + anchor: anchorList[1], + replacementUrl: null, + }, { + anchor: anchorList[2], + replacementUrl: null, + }]; + + const actualMapping = linkmate.mapLinks_(); + + expect(actualMapping).to.deep.equal(expectedMapping); + }); + }); + } +); diff --git a/extensions/amp-smartlinks/0.1/test/validator-amp-smartlinks.html b/extensions/amp-smartlinks/0.1/test/validator-amp-smartlinks.html new file mode 100644 index 000000000000..d95c642bb34a --- /dev/null +++ b/extensions/amp-smartlinks/0.1/test/validator-amp-smartlinks.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + diff --git a/extensions/amp-smartlinks/0.1/test/validator-amp-smartlinks.out b/extensions/amp-smartlinks/0.1/test/validator-amp-smartlinks.out new file mode 100644 index 000000000000..6a578a938f26 --- /dev/null +++ b/extensions/amp-smartlinks/0.1/test/validator-amp-smartlinks.out @@ -0,0 +1,39 @@ +PASS +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| \ No newline at end of file diff --git a/extensions/amp-smartlinks/amp-smartlinks.md b/extensions/amp-smartlinks/amp-smartlinks.md new file mode 100644 index 000000000000..1d69c82d862d --- /dev/null +++ b/extensions/amp-smartlinks/amp-smartlinks.md @@ -0,0 +1,102 @@ + + +# `amp-smartlinks` + + + + + + + + + + + + + + +
DescriptionRun Narrativ's Linkmate process inside your AMP page
Required Script<script async custom-element="amp-smartlinks" src="https://cdn.ampproject.org/v0/amp-smartlinks-0.1.js"></script>
Supported Layoutsnodisplay
+ +## Overview + + At [Narrativ](https://narrativ.com/), we transform static commerce links into dynamic, multimerchant nodes. With a library of millions of products matched to expert reviews from top commerce publishers, we lift publisher revenue through real-time bidding and data solutions. + +This AMP extension is our Linkmate service in AMP. See the full documentation for Linkmate [here](http://docs.narrativ.com/en/stable/linkmate.html). + +## Getting started + +Your account must be a member of our Linkmate program to use this feature. For more information about this program, feel free to contact your account manager or [hello@narrativ.com](mailto:hello@narrativ.com). + +NOTE: If you plan to use `amp-smartlinks` alongside other affiliate partners you will need to specify the meta tag shown below. The tag will specify the order in which the affiliate tags fire. + +In your AMP page you will have to add the following snippets: + +```html + + + + ... + + + ... + + + + ... + + + ... + + +``` + +## Attributes + + + + + + + + + + + + + + + + + + + + + + + + + + + +
nrtv-account-nameRequiredYour Narrativ account name given to you by your account manager. Need to know your Narrativ account name? Log into dashboard.narrativ.com and go to setup to see your account name in the snippet, or reach out to your account manager for support as needed.
linkmateOptionalFlag to run our Linkmate service on an article. Inserting the attribute linkmate in the amp-smartlinks element will run our linkmate service.
exclusive-linksOptionalFlag to mark links as exclusive. Inserting the attribute exclusive-links in the amp-smartlinks element will generate exclusive links for the article.
link-attributeOptionalIf you store the "plain" url for a link in a different element attribute than href you can specify so here. Default value: href.
link-selectorOptionalA CSS selector to get all links you want monetized from an article. Default value: a.
+ +## Validation + +See [amp-smartlinks rules](validator-amp-smartlinks.protoascii) in the AMP validator specification. diff --git a/extensions/amp-smartlinks/validator-amp-smartlinks.protoascii b/extensions/amp-smartlinks/validator-amp-smartlinks.protoascii new file mode 100644 index 000000000000..26b5c9d893aa --- /dev/null +++ b/extensions/amp-smartlinks/validator-amp-smartlinks.protoascii @@ -0,0 +1,54 @@ +# +# Copyright 2019 The AMP HTML Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS-IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the license. +# + +tags: { # amp-smartlinks + html_format: AMP + tag_name: "SCRIPT" + extension_spec: { + name: "amp-smartlinks" + version: "0.1" + version: "latest" + } + attr_lists: "common-extension-attrs" +} + +tags: { # + html_format: AMP + tag_name: "AMP-SMARTLINKS" + requires_extension: "amp-smartlinks" + attrs: { + name: "exclusive-links" + value: "" + } + attrs: { + name: "link-attribute" + } + attrs: { + name: "link-selector" + } + attrs: { + name: "linkmate" + value: "" + } + attrs: { + name: "nrtv-account-name" + mandatory: true + } + attr_lists: "extended-amp-global" + amp_layout: { + supported_layouts: NODISPLAY + } +} diff --git a/src/extension-analytics.js b/src/extension-analytics.js index e9f9d9f66208..c2b29c23daeb 100644 --- a/src/extension-analytics.js +++ b/src/extension-analytics.js @@ -150,6 +150,13 @@ export class CustomEventReporterBuilder { this.config_['transport'] = transportConfig; } + /** + * @param {!JsonObject} extraUrlParamsConfig + */ + setExtraUrlParams(extraUrlParamsConfig) { + this.config_['extraUrlParams'] = extraUrlParamsConfig; + } + /** * The #track() method takes in a unique custom-event name, and the * corresponding request url (or an array of request urls). One can call diff --git a/test/unit/test-extension-analytics.js b/test/unit/test-extension-analytics.js index 0fa19e93e558..5122e0ea8049 100644 --- a/test/unit/test-extension-analytics.js +++ b/test/unit/test-extension-analytics.js @@ -196,6 +196,23 @@ describes.realWin('extension-analytics', { 'xhrpost': false, }); }); + + it('Should allow to specify extraUrlParams config', () => { + parent.getResourceId = () => { return 1; }; + parent.signals = () => { + return { + whenSignal: () => { return Promise.resolve(); }, + }; + }; + builder.setExtraUrlParams({ + 'a': 'b', + }); + + const reporter = builder.build(); + expect(reporter.config_.extraUrlParams).to.jsonEqual({ + 'a': 'b', + }); + }); }); describe('CustomEventReporter test', () => {