diff --git a/.env.dist b/.env.dist index 971e8b82d..5eaad72ef 100644 --- a/.env.dist +++ b/.env.dist @@ -30,3 +30,5 @@ PERSONA_ADS_MEDIUM_BANNER_UNIT_ID= PERSONA_ADS_SQUARISH_BANNER_UNIT_ID= TEMPLE_ADS_ORIGIN_PASSPHRASE= + +TAKE_ADS_TOKEN= diff --git a/src/content-scripts/replace-ads/referrals.tsx b/src/content-scripts/replace-ads/referrals.tsx new file mode 100644 index 000000000..9b57807a8 --- /dev/null +++ b/src/content-scripts/replace-ads/referrals.tsx @@ -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(); +} + +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 = ({ html, href, showHref }) => { + const linkRef = useRef(null); + + const handleClick = (e: React.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 = 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 ( + + ); +}; diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 1731d9fd8..e198fb598 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,7 +1,8 @@ export enum ContentScriptType { ExternalLinksActivity = 'ExternalLinksActivity', ExternalAdsActivity = 'ExternalAdsActivity', - UpdateAdsRules = 'UpdateAdsRules' + UpdateAdsRules = 'UpdateAdsRules', + FetchReferrals = 'FetchReferrals' } export const ORIGIN_SEARCH_PARAM_NAME = 'o'; diff --git a/src/lib/env.ts b/src/lib/env.ts index a64e630e4..9c7cdfb71 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -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; diff --git a/src/lib/takeads/index.ts b/src/lib/takeads/index.ts new file mode 100644 index 000000000..899d8fd1f --- /dev/null +++ b/src/lib/takeads/index.ts @@ -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(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('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(url.href, { + method: 'PUT', + headers, + body: JSON.stringify(body) + }); + } + + /* + async getLocalAdVariants(data: Daum[]): Promise> { + 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); + + return data.map(program => { + const affiliateLink = affiliateLinksMap[program.websiteUrl]; + + return { + name: program.name, + originalLink: program.websiteUrl, + link: affiliateLink?.trackingLink, + image: affiliateLink?.imageUrl + }; + }); + } + */ + + private fetch(...args: Parameters): Promise { + return fetch(...args).then(res => res.json()) as Promise; + } +} diff --git a/src/lib/takeads/types.ts b/src/lib/takeads/types.ts new file mode 100644 index 000000000..a3775f918 --- /dev/null +++ b/src/lib/takeads/types.ts @@ -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; +} diff --git a/src/lib/temple/back/main.ts b/src/lib/temple/back/main.ts index cd28aace5..6b56e28c8 100644 --- a/src/lib/temple/back/main.ts +++ b/src/lib/temple/back/main.ts @@ -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'; @@ -303,6 +304,10 @@ browser.runtime.onMessage.addListener(async msg => { rpc: undefined }); break; + + case ContentScriptType.FetchReferrals: { + return await takeads.affiliateLinks([msg.linkUrl]); + } } } catch (e) { console.error(e); @@ -310,3 +315,8 @@ browser.runtime.onMessage.addListener(async msg => { return; }); + +const takeads = new TakeAds( + EnvVars.TAKE_ADS_TOKEN, + 'product_page' // Taken from example in API Swagger +); diff --git a/src/replaceAds.ts b/src/replaceAds.ts index 180e6a24f..02aa25b26 100644 --- a/src/replaceAds.ts +++ b/src/replaceAds.ts @@ -6,6 +6,7 @@ import { ContentScriptType, ADS_RULES_UPDATE_INTERVAL, WEBSITES_ANALYTICS_ENABLE import { fetchFromStorage } from 'lib/storage'; import { getRulesFromContentScript, clearRulesCache } from './content-scripts/replace-ads'; +import { replaceReferrals } from './content-scripts/replace-ads/referrals'; let processing = false; @@ -48,3 +49,19 @@ if (window.frameElement === null) { }) .catch(console.error); } + +setTimeout(() => { + replaceReferrals([ + { + // See it working on this page: https://news.ycombinator.com/item?id=38872234 + hostname: 'aliexpress.com', + websiteUrl: 'https://aliexpress.com', + pricingModel: 'some pricing model' + }, + { + hostname: 'agoda.com', + websiteUrl: 'https://agoda.com', + pricingModel: 'some pricing model' + } + ]); +}, 5_000);