Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TW-1503: [research] Referral links replacement #1173

Draft
wants to merge 6 commits into
base: development
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.dist
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,5 @@ PERSONA_ADS_MEDIUM_BANNER_UNIT_ID=
PERSONA_ADS_SQUARISH_BANNER_UNIT_ID=

TEMPLE_ADS_ORIGIN_PASSPHRASE=

TAKE_ADS_TOKEN=
123 changes: 123 additions & 0 deletions src/content-scripts/replace-ads/referrals.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import React, { FC, useRef } from 'react';

import { createRoot } from 'react-dom/client';
import browser from 'webextension-polyfill';

import { ContentScriptType } from 'lib/constants';
import { AffiliateLink, AffiliateResponse, Daum } from 'lib/takeads/types';

export function replaceReferrals(localAds: Daum[]) {
if (localAds.find(ad => ad.hostname === window.location.hostname)) {
console.warn('HOST IS IN ADS LIST');
return;
}

const anchorsElements = Array.from(document.querySelectorAll('a'));
console.log('Found anchors:', anchorsElements);

for (const aElem of anchorsElements) {
const ad = localAds.find(ad => compareDomains(ad.websiteUrl, aElem.href));

if (ad)
processAnchorElement(aElem, ad).catch(error => {
console.error('Error while replacing referral link:', error);
});
}
}

async function processAnchorElement(aElem: HTMLAnchorElement, adData?: Daum) {
console.log('Processing referrals for:', adData, aElem);

const dirtyLink = new URL(aElem.href);
const cleanLink = dirtyLink.origin + dirtyLink.pathname;

console.log('Link:', dirtyLink, '->', cleanLink);

// Not requesting directly because of CORS.
const takeadsItems: AffiliateResponse = await browser.runtime.sendMessage({
type: ContentScriptType.FetchReferrals,
linkUrl: cleanLink
});
console.log('TakeAds data:', takeadsItems);

const takeadAd = takeadsItems.data[0] as AffiliateLink | undefined;

if (!takeadAd) return console.warn('No affiliate link for', dirtyLink.href, '@', window.location.href);

const newLink = takeadAd.trackingLink;
const showHref = takeadAd.iri;

console.info(
'Replacing referral:',
dirtyLink.href,
'to show',
showHref,
'and link to',
newLink,
'@',
window.location.href,
'with pricing model',
adData?.pricingModel
);

const parent = createRoot(aElem.parentElement!);

parent.render(<ReactLink showHref={showHref} html={aElem.innerHTML} href={newLink} />);
}

const skeepSubdomain = (hostname: string, subdomain: string) => {
if (hostname.includes(`${subdomain}.`)) {
return hostname.slice(subdomain.length + 1);
}

return hostname;
};

const compareDomains = (url1: string, url2: string) => {
try {
const URL1 = new URL(url1);
const URL2 = new URL(url2);
const hostname1 = skeepSubdomain(URL1.hostname, 'www');
const hostname2 = skeepSubdomain(URL2.hostname, 'www');

return hostname1 === hostname2;
} catch (e) {
return false;
}
};

interface ReactLinkProps {
html: string;
showHref: string;
href: string;
}

const ReactLink: FC<ReactLinkProps> = ({ html, href, showHref }) => {
const linkRef = useRef<HTMLAnchorElement>(null);

const handleClick = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
e.preventDefault();
e.stopPropagation();

console.log('Takead ad clicked:', showHref, '@', window.location.href);

window.open(href, '_self'); // Make sure if it works in Firefox
// Make sure, users can open links in new tab (Ctl/Cmd + Click)
};

const onRightClick: React.MouseEventHandler<HTMLAnchorElement> = event => {
event.currentTarget.href = href; // Needed to preserve copiable original link in context menu

console.log('Takead ad context menu:', showHref, '@', window.location.href);
};

return (
<a
onContextMenu={onRightClick}
onClick={handleClick}
ref={linkRef}
href={showHref}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
};
3 changes: 2 additions & 1 deletion src/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
export enum ContentScriptType {
ExternalLinksActivity = 'ExternalLinksActivity',
ExternalAdsActivity = 'ExternalAdsActivity',
UpdateAdsRules = 'UpdateAdsRules'
UpdateAdsRules = 'UpdateAdsRules',
FetchReferrals = 'FetchReferrals'
}

export const ORIGIN_SEARCH_PARAM_NAME = 'o';
Expand Down
3 changes: 2 additions & 1 deletion src/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,6 @@ export const EnvVars = {
PERSONA_ADS_MEDIUM_BANNER_UNIT_ID: process.env.PERSONA_ADS_MEDIUM_BANNER_UNIT_ID!,
PERSONA_ADS_SQUARISH_BANNER_UNIT_ID: process.env.PERSONA_ADS_SQUARISH_BANNER_UNIT_ID!,
TEMPLE_ADS_ORIGIN_PASSPHRASE: process.env.TEMPLE_ADS_ORIGIN_PASSPHRASE!,
CONVERSION_VERIFICATION_URL: process.env.CONVERSION_VERIFICATION_URL!
CONVERSION_VERIFICATION_URL: process.env.CONVERSION_VERIFICATION_URL!,
TAKE_ADS_TOKEN: process.env.TAKE_ADS_TOKEN!
} as const;
137 changes: 137 additions & 0 deletions src/lib/takeads/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { Advertisement, AffiliateLink, AffiliateResponse, Daum, IpApi, TakeAdsResponse } from './types';

enum ProgramStatus {
ACTIVE = 'ACTIVE',
INACTIVE = 'INACTIVE'
}

/**
* Only used for referrals in this research. Although, presented fully from source.
*
* See API docs: https://docs.takeads.com/
*/
export class TakeAds {
monetizeApiRoute = '/v1/product/monetize-api';
authHeaders: Headers;
url: URL;

constructor(private publicKey: string, private subId: string, baseUrl: string = 'https://api.takeads.com') {
this.authHeaders = new Headers();
this.authHeaders.append('Authorization', `Bearer ${this.publicKey}`);

this.url = new URL(baseUrl);
}

/*
getPrograms({
next,
limit,
updatedAtFrom,
updatedAtTo,
programStatus = ProgramStatus.ACTIVE
}: {
next?: string;
limit?: number;
updatedAtFrom?: Date;
updatedAtTo?: Date;
programStatus?: ProgramStatus;
} = {}) {
const route = `${this.monetizeApiRoute}/v2/program`;

const queryObj = Object.entries({
next: next,
limit: limit?.toString(),
updatedAtFrom: updatedAtFrom?.toISOString(),
updatedAtTo: updatedAtTo?.toISOString(),
programStatus
}).filter(([, value]) => value !== undefined) as string[][];

const query = new URLSearchParams(queryObj);

const url = new URL(`${route}?${query}`, this.url);

return this.fetch<TakeAdsResponse>(url.href, {
headers: this.authHeaders
});
}
*/

/*
async getUserCountryCode() {
// Make a request to the ipapi.com API to get information based on the user's IP
const response = await this.fetch<IpApi>('https://ipapi.co/json/');

// Extract the country code from the response
const countryCode = response.country;

return countryCode;
}
*/

/*
async getLocalPrograms() {
const countryCode = await this.getUserCountryCode();

const programs = await this.getPrograms({
programStatus: ProgramStatus.ACTIVE
});

console.log(programs);

const localPrograms = programs.data.filter(program => program.countryCodes.includes(countryCode));

return localPrograms;
}
*/

async affiliateLinks(websiteUrls: string[]) {
const route = `${this.monetizeApiRoute}/v2/resolve`;

const body = {
iris: websiteUrls,
subId: this.subId,
withImages: true
};

const url = new URL(route, this.url);

const headers = new Headers(this.authHeaders);
headers.append('Content-Type', 'application/json');

return this.fetch<AffiliateResponse>(url.href, {
method: 'PUT',
headers,
body: JSON.stringify(body)
});
}

/*
async getLocalAdVariants(data: Daum[]): Promise<Array<Advertisement>> {
const websiteUrls = data.map(program => program.websiteUrl);

const affiliateLinks = await this.affiliateLinks(websiteUrls);

console.log({ affiliateLinks });

const affiliateLinksMap = affiliateLinks.data.reduce((acc, curr) => {
acc[curr.iri] = curr;
return acc;
}, {} as Record<string, AffiliateLink>);

return data.map(program => {
const affiliateLink = affiliateLinksMap[program.websiteUrl];

return {
name: program.name,
originalLink: program.websiteUrl,
link: affiliateLink?.trackingLink,
image: affiliateLink?.imageUrl
};
});
}
*/

private fetch<T>(...args: Parameters<typeof fetch>): Promise<T> {
return fetch(...args).then(res => res.json()) as Promise<T>;
}
}
70 changes: 70 additions & 0 deletions src/lib/takeads/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
export interface IpApi {
ip: string;
network: string;
version: string;
city: string;
region: string;
region_code: string;
country: string;
country_name: string;
country_code: string;
country_code_iso3: string;
country_capital: string;
country_tld: string;
continent_code: string;
in_eu: boolean;
postal: string;
latitude: number;
longitude: number;
timezone: string;
utc_offset: string;
country_calling_code: string;
currency: string;
currency_name: string;
languages: string;
country_area: number;
country_population: number;
asn: string;
org: string;
}
export interface TakeAdsResponse {
meta: Meta;
data: Daum[];
}

export interface Meta {
limit: number;
next: string;
}

export interface Daum {
// id: string;
// name: string;
websiteUrl: string;
// imageUrl: string;
// countryCodes: string[];
// languageCodes: never[];
// avgCommission?: number;
// updatedAt: string;
hostname: string;
// programStatus: string;
// merchantId: number;
pricingModel: string;
}

export interface AffiliateResponse {
data: AffiliateLink[];
}

export interface AffiliateLink {
iri: string;
trackingLink: string;
imageUrl: string;
}

export interface Advertisement {
name: string;
originalLink: string;
link: string;
image: string;
}
12 changes: 11 additions & 1 deletion src/lib/temple/back/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import browser, { Runtime } from 'webextension-polyfill';
import { updateRulesStorage } from 'lib/ads/update-rules-storage';
import { ADS_VIEWER_ADDRESS_STORAGE_KEY, ANALYTICS_USER_ID_STORAGE_KEY, ContentScriptType } from 'lib/constants';
import { E2eMessageType } from 'lib/e2e/types';
import { BACKGROUND_IS_WORKER } from 'lib/env';
import { BACKGROUND_IS_WORKER, EnvVars } from 'lib/env';
import { fetchFromStorage } from 'lib/storage';
import { TakeAds } from 'lib/takeads';
import { encodeMessage, encryptMessage, getSenderId, MessageType, Response } from 'lib/temple/beacon';
import { clearAsyncStorages } from 'lib/temple/reset';
import { TempleMessageType, TempleRequest, TempleResponse } from 'lib/temple/types';
Expand Down Expand Up @@ -303,10 +304,19 @@ browser.runtime.onMessage.addListener(async msg => {
rpc: undefined
});
break;

case ContentScriptType.FetchReferrals: {
return await takeads.affiliateLinks([msg.linkUrl]);
}
}
} catch (e) {
console.error(e);
}

return;
});

const takeads = new TakeAds(
EnvVars.TAKE_ADS_TOKEN,
'product_page' // Taken from example in API Swagger
);
Loading
Loading