Skip to content

Commit

Permalink
AdHash bid adapter: update to support latest version (#9286)
Browse files Browse the repository at this point in the history
* AdHash Bidder Adapter: minor changes

We're operating on a com TLD now.
Added publisher in URL for easier routing.

* Implemented brand safety

Implemented brand safety checks

* Fix for GDPR consent

Removing the extra information as request data becomes too big and is sometimes truncated

* Ad fraud prevention formula changed

Ad fraud prevention formula changed to support negative values as well as linear distribution of article length

* AdHash brand safety additions

Adding starts-with and ends-with rules that will help us with languages such as German where a single word can be written in multiple ways depending on the gender and grammatical case.

* AdHash brand safety updates

Added support for Cyrillic characters.
Added support for bidderURL parameter.
Fixed score multiplier from 500 to 1000.

* AdHash Analytics adapter

* Support for recent ads

Support for recent ads which gives us the option to do frequency and recency capping.

* Fix for timestamp

* PUB-222

Added logic for measuring the fill rate (fallbacks) for Prebid impressions

* Unit tests for the analytics adapter

Added unit tests for the analytics adapter

* Removed export causing errors

Removed an unneeded export of a const that was causing errors with the analytics adapter

* Added globalScript parameter

* PUB-227

Support for non-latin and non-cyrillic symbols

* GEN-964

- Brand safety now checks the page URL for bad words. No ad is shown if there is at least one match.
- Repeating code is optimized and moved to helper function
- Multi-language support for brand safety

* GEN-1025

Sending the needed ad density data to the bidder

* Removing the analytics adaptor

* Fix for regexp match

* Version change

* MINOR

Code review changes

Co-authored-by: NikolayMGeorgiev <nikolay@adhash.org>
Co-authored-by: Ventsislav Saraminev <v.saraminev@abv.bg>
Co-authored-by: Dimitar Kalenderov <mitko.kalenderov@gmail.com>
  • Loading branch information
4 people authored Dec 8, 2022
1 parent a247abc commit 2a90f51
Show file tree
Hide file tree
Showing 3 changed files with 181 additions and 44 deletions.
145 changes: 115 additions & 30 deletions modules/adhashBidAdapter.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import {registerBidder} from '../src/adapters/bidderFactory.js';
import {includes} from '../src/polyfill.js';
import {BANNER} from '../src/mediaTypes.js';
import { getStorageManager } from '../src/storageManager.js';
import { includes } from '../src/polyfill.js';
import { BANNER } from '../src/mediaTypes.js';

const VERSION = '1.0';
const VERSION = '3.2';
const BAD_WORD_STEP = 0.1;
const BAD_WORD_MIN = 0.2;
const ADHASH_BIDDER_CODE = 'adhash';

/**
* Function that checks the page where the ads are being served for brand safety.
Expand Down Expand Up @@ -54,68 +56,144 @@ function brandSafety(badWords, maxScore) {
return positive ? result : -result;
};

/**
* Checks what rule will match in the given array with words
* @param {string} rule rule type (full, partial, starts, ends, regexp)
* @param {string} decodedWord decoded word
* @param {array} wordsToMatch array to find a match
* @returns {object|boolean} matched rule and occurances. If nothing is matched returns false
*/
const wordsMatchedWithRule = function (rule, decodedWord, wordsToMatch) {
if (rule === 'full' && wordsToMatch && wordsToMatch.includes(decodedWord)) {
return { rule, occurances: wordsToMatch.filter(element => element === decodedWord).length };
} else if (rule === 'partial' && wordsToMatch && wordsToMatch.some(element => element.indexOf(decodedWord) > -1)) {
return { rule, occurances: wordsToMatch.filter(element => element.indexOf(decodedWord) > -1).length };
} else if (rule === 'starts' && wordsToMatch && wordsToMatch.some(word => word.startsWith(decodedWord))) {
return { rule, occurances: wordsToMatch.filter(element => element.startsWith(decodedWord)).length };
} else if (rule === 'ends' && wordsToMatch && wordsToMatch.some(word => word.endsWith(decodedWord))) {
return { rule, occurances: wordsToMatch.filter(element => element.endsWith(decodedWord)).length };
} else if (rule === 'regexp' && wordsToMatch && wordsToMatch.some(element => element.match(new RegExp(decodedWord, 'i')))) {
return { rule, occurances: wordsToMatch.filter(element => element.match(new RegExp(decodedWord, 'i'))).length };
}
return false;
};

// Default parameters if the bidder is unable to send some of them
badWords = badWords || [];
maxScore = parseInt(maxScore) || 10;

try {
let score = 0;
const decodedUrl = decodeURI(window.top.location.href.substring(window.top.location.origin.length));
const wordsAndNumbersInUrl = decodedUrl
.replaceAll(/[-,\._/\?=&#%]/g, ' ')
.replaceAll(/\s\s+/g, ' ')
.toLowerCase()
.trim();
const content = window.top.document.body.innerText.toLowerCase();
const words = content.trim().split(/\s+/).length;
const contentWords = content.trim().split(/\s+/).length;
// \p{L} matches a single unicode code point in the category 'letter'. Matches any kind of letter from any language.
const regexp = new RegExp('[\\p{L}]+', 'gu');
const words = content.match(regexp);
const wordsInUrl = wordsAndNumbersInUrl.match(regexp);

for (const [word, rule, points] of badWords) {
if (rule === 'full' && new RegExp('\\b' + rot13(word) + '\\b', 'i').test(content)) {
const occurances = content.match(new RegExp('\\b' + rot13(word) + '\\b', 'g')).length;
score += scoreCalculator(points, occurances);
} else if (rule === 'partial' && content.indexOf(rot13(word.toLowerCase())) > -1) {
const occurances = content.match(new RegExp(rot13(word), 'g')).length;
score += scoreCalculator(points, occurances);
const decodedWord = rot13(word.toLowerCase());

// Checks the words in the url of the page only for negative words. Don't serve any ad when at least one match is found
if (points > 0) {
const matchedRuleInUrl = wordsMatchedWithRule(rule, decodedWord, wordsInUrl);
if (matchedRuleInUrl.rule) {
return false;
}
}

// Check if site content's words match any of our brand safety rules
const matchedRule = wordsMatchedWithRule(rule, decodedWord, words);
if (matchedRule.rule === 'full') {
score += scoreCalculator(points, matchedRule.occurances);
} else if (matchedRule.rule === 'partial') {
score += scoreCalculator(points, matchedRule.occurances);
} else if (matchedRule.rule === 'starts') {
score += scoreCalculator(points, matchedRule.occurances);
} else if (matchedRule.rule === 'ends') {
score += scoreCalculator(points, matchedRule.occurances);
} else if (matchedRule.rule === 'regexp') {
score += scoreCalculator(points, matchedRule.occurances);
}
}
return score < maxScore * words / 500;
return score < (maxScore * contentWords) / 1000;
} catch (e) {
return true;
}
}

export const spec = {
code: 'adhash',
url: 'https://bidder.adhash.com/rtb?version=' + VERSION + '&prebid=true',
code: ADHASH_BIDDER_CODE,
supportedMediaTypes: [ BANNER ],

isBidRequestValid: (bid) => {
try {
const { publisherId, platformURL } = bid.params;
const { publisherId, platformURL, bidderURL } = bid.params;
return (
includes(Object.keys(bid.mediaTypes), BANNER) &&
typeof publisherId === 'string' &&
publisherId.length === 42 &&
typeof platformURL === 'string' &&
platformURL.length >= 13
platformURL.length >= 13 &&
(!bidderURL || bidderURL.indexOf('https://') === 0)
);
} catch (error) {
return false;
}
},

buildRequests: (validBidRequests, bidderRequest) => {
const storage = getStorageManager({ bidderCode: ADHASH_BIDDER_CODE });
const { gdprConsent } = bidderRequest;
const { url } = spec;
const bidRequests = [];
let referrer = '';
if (bidderRequest && bidderRequest.refererInfo) {
// TODO: is 'page' the right value here?
referrer = bidderRequest.refererInfo.page;
}
for (var i = 0; i < validBidRequests.length; i++) {
var index = Math.floor(Math.random() * validBidRequests[i].sizes.length);
var size = validBidRequests[i].sizes[index].join('x');
const body = document.body;
const html = document.documentElement;
const pageHeight = Math.max(
body.scrollHeight,
body.offsetHeight,
html.clientHeight,
html.scrollHeight,
html.offsetHeight
);
const pageWidth = Math.max(body.scrollWidth, body.offsetWidth, html.clientWidth, html.scrollWidth, html.offsetWidth);

for (let i = 0; i < validBidRequests.length; i++) {
const bidderURL = validBidRequests[i].params.bidderURL || 'https://bidder.adhash.com';
const url = `${bidderURL}/rtb?version=${VERSION}&prebid=true`;
const index = Math.floor(Math.random() * validBidRequests[i].sizes.length);
const size = validBidRequests[i].sizes[index].join('x');

let recentAds = [];
if (storage.localStorageIsEnabled()) {
const prefix = validBidRequests[i].params.prefix || 'adHash';
recentAds = JSON.parse(storage.getDataFromLocalStorage(prefix + 'recentAds') || '[]');
}

// Needed for the ad density calculation
var adHeight = validBidRequests[i].sizes[index][1];
var adWidth = validBidRequests[i].sizes[index][0];
if (!window.adsCount) {
window.adsCount = 0;
}
if (!window.adsTotalSurface) {
window.adsTotalSurface = 0;
}
window.adsTotalSurface += adHeight * adWidth;
window.adsCount++;

bidRequests.push({
method: 'POST',
url: url + '&publisher=' + validBidRequests[i].params.publisherId,
bidRequest: validBidRequests[i],
data: {
timezone: new Date().getTimezoneOffset() / 60,
location: referrer,
location: bidderRequest.refererInfo ? bidderRequest.refererInfo.topmostLocation : '',
publisherId: validBidRequests[i].params.publisherId,
size: {
screenWidth: window.screen.width,
Expand All @@ -131,10 +209,14 @@ export const spec = {
position: validBidRequests[i].adUnitCode
}],
blockedCreatives: [],
currentTimestamp: new Date().getTime(),
recentAds: [],
currentTimestamp: (new Date().getTime() / 1000) | 0,
recentAds: recentAds,
GDPRApplies: gdprConsent ? gdprConsent.gdprApplies : null,
GDPR: gdprConsent ? gdprConsent.consentString : null
GDPR: gdprConsent ? gdprConsent.consentString : null,
servedAdsCount: window.adsCount,
adsTotalSurface: window.adsTotalSurface,
pageHeight: pageHeight,
pageWidth: pageWidth
},
options: {
withCredentials: false,
Expand All @@ -157,16 +239,19 @@ export const spec = {
}

const publisherURL = JSON.stringify(request.bidRequest.params.platformURL);
const bidderURL = request.bidRequest.params.bidderURL || 'https://bidder.adhash.com';
const oneTimeId = request.bidRequest.adUnitCode + Math.random().toFixed(16).replace('0.', '.');
const globalScript = !request.bidRequest.params.globalScript
? `<script src="${bidderURL}/static/scripts/creative.min.js"></script>`
: '';
const bidderResponse = JSON.stringify({ responseText: JSON.stringify(responseBody) });
const requestData = JSON.stringify(request.data);

return [{
requestId: request.bidRequest.bidId,
cpm: responseBody.creatives[0].costEUR,
ad:
`<div id="${oneTimeId}"></div>
<script src="https://bidder.adhash.com/static/scripts/creative.min.js"></script>
`<div id="${oneTimeId}"></div>${globalScript}
<script>callAdvertiser(${bidderResponse},['${oneTimeId}'],${requestData},${publisherURL})</script>`,
width: request.bidRequest.sizes[0][0],
height: request.bidRequest.sizes[0][1],
Expand Down
4 changes: 1 addition & 3 deletions modules/adhashBidAdapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
```
Module Name: AdHash Bidder Adapter
Module Type: Bidder Adapter
Maintainer: damyan@adhash.org
Maintainer: damyan@adhash.com
```

# Description
Expand All @@ -14,8 +14,6 @@ Here is what you need for Prebid integration with AdHash:
3. Use the Publisher ID and Platform URL as parameters in params.

Please note that a number of AdHash functionalities are not supported in the Prebid.js integration:
* Cookie-less frequency and recency capping;
* Audience segments;
* Price floors and passback tags, as they are not needed in the Prebid.js setup;
* Reservation for direct deals only, as bids are evaluated based on their price.

Expand Down
76 changes: 65 additions & 11 deletions test/spec/modules/adhashBidAdapter_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ describe('adhashBidAdapter', function () {
bid.params.platformURL = 'https://';
expect(spec.isBidRequestValid(bid)).to.equal(false);
});

it('should return false when bidderURL is present but not https://', function () {
const bid = { ...validBid };
bid.params.bidderURL = 'http://example.com/';
expect(spec.isBidRequestValid(bid)).to.equal(false);
});
});

describe('buildRequests', function () {
Expand All @@ -73,11 +79,11 @@ describe('adhashBidAdapter', function () {
it('should build the request correctly', function () {
const result = spec.buildRequests(
[ bidRequest ],
{ gdprConsent: { gdprApplies: true, consentString: 'example' }, refererInfo: { referer: 'http://example.com/' } }
{ gdprConsent: { gdprApplies: true, consentString: 'example' }, refererInfo: { topmostLocation: 'https://example.com/path.html' } }
);
expect(result.length).to.equal(1);
expect(result[0].method).to.equal('POST');
expect(result[0].url).to.equal('https://bidder.adhash.com/rtb?version=1.0&prebid=true&publisher=0xc3b09b27e9c6ef73957901aa729b9e69e5bbfbfb');
expect(result[0].url).to.equal('https://bidder.adhash.com/rtb?version=3.2&prebid=true&publisher=0xc3b09b27e9c6ef73957901aa729b9e69e5bbfbfb');
expect(result[0].bidRequest).to.equal(bidRequest);
expect(result[0].data).to.have.property('timezone');
expect(result[0].data).to.have.property('location');
Expand All @@ -93,7 +99,7 @@ describe('adhashBidAdapter', function () {
const result = spec.buildRequests([ bidRequest ], { gdprConsent: { gdprApplies: true, consentString: 'example' } });
expect(result.length).to.equal(1);
expect(result[0].method).to.equal('POST');
expect(result[0].url).to.equal('https://bidder.adhash.com/rtb?version=1.0&prebid=true&publisher=0xc3b09b27e9c6ef73957901aa729b9e69e5bbfbfb');
expect(result[0].url).to.equal('https://bidder.adhash.com/rtb?version=3.2&prebid=true&publisher=0xc3b09b27e9c6ef73957901aa729b9e69e5bbfbfb');
expect(result[0].bidRequest).to.equal(bidRequest);
expect(result[0].data).to.have.property('timezone');
expect(result[0].data).to.have.property('location');
Expand Down Expand Up @@ -127,9 +133,15 @@ describe('adhashBidAdapter', function () {
creatives: [{ costEUR: 1.234 }],
advertiserDomains: 'adhash.com',
badWords: [
['onqjbeq1', 'full', 1],
['onqjbeq2', 'partial', 1],
['onqjbeq', 'full', 1],
['onqjbeqo', 'partial', 1],
['tbbqjbeq', 'full', -1],
['fgnegf', 'starts', 1],
['raqf', 'ends', 1],
['kkk[no]lll', 'regexp', 1],
['дума', 'full', 1],
['старт', 'starts', 1],
['край', 'ends', 1],
],
maxScore: 2
}
Expand All @@ -155,42 +167,84 @@ describe('adhashBidAdapter', function () {

it('should return empty array when there are bad words (full)', function () {
bodyStub = sinon.stub(window.top.document.body, 'innerText').get(function() {
return 'example text badWord1 badWord1 example badWord1 text' + ' word'.repeat(493);
return 'example text badword badword example badword text' + ' word'.repeat(993);
});
expect(spec.interpretResponse(serverResponse, request).length).to.equal(0);
});

it('should return empty array when there are bad words (full cyrillic)', function () {
bodyStub = sinon.stub(window.top.document.body, 'innerText').get(function() {
return 'example text дума дума example дума text' + ' текст'.repeat(993);
});
expect(spec.interpretResponse(serverResponse, request).length).to.equal(0);
});

it('should return empty array when there are bad words (partial)', function () {
bodyStub = sinon.stub(window.top.document.body, 'innerText').get(function() {
return 'example text partialBadWord2 badword2 example BadWord2text' + ' word'.repeat(494);
return 'example text partialbadwordb badwordb example badwordbtext' + ' word'.repeat(994);
});
expect(spec.interpretResponse(serverResponse, request).length).to.equal(0);
});

it('should return empty array when there are bad words (starts)', function () {
bodyStub = sinon.stub(window.top.document.body, 'innerText').get(function() {
return 'example text startsWith starts text startsAgain' + ' word'.repeat(994);
});
expect(spec.interpretResponse(serverResponse, request).length).to.equal(0);
});

it('should return empty array when there are bad words (starts cyrillic)', function () {
bodyStub = sinon.stub(window.top.document.body, 'innerText').get(function() {
return 'example text стартТекст старт text стартТекст' + ' дума'.repeat(994);
});
expect(spec.interpretResponse(serverResponse, request).length).to.equal(0);
});

it('should return empty array when there are bad words (ends)', function () {
bodyStub = sinon.stub(window.top.document.body, 'innerText').get(function() {
return 'example text wordEnds ends text anotherends' + ' word'.repeat(994);
});
expect(spec.interpretResponse(serverResponse, request).length).to.equal(0);
});

it('should return empty array when there are bad words (ends cyrillic)', function () {
bodyStub = sinon.stub(window.top.document.body, 'innerText').get(function() {
return 'example text ДругКрай край text ощеединкрай' + ' дума'.repeat(994);
});
expect(spec.interpretResponse(serverResponse, request).length).to.equal(0);
});

it('should return empty array when there are bad words (regexp)', function () {
bodyStub = sinon.stub(window.top.document.body, 'innerText').get(function() {
return 'example text xxxayyy zzxxxAyyyzz text xxxbyyy' + ' word'.repeat(994);
});
expect(spec.interpretResponse(serverResponse, request).length).to.equal(0);
});

it('should return non-empty array when there are not enough bad words (full)', function () {
bodyStub = sinon.stub(window.top.document.body, 'innerText').get(function() {
return 'example text badWord1 badWord1 example text' + ' word'.repeat(494);
return 'example text badword badword example text' + ' word'.repeat(994);
});
expect(spec.interpretResponse(serverResponse, request).length).to.equal(1);
});

it('should return non-empty array when there are not enough bad words (partial)', function () {
bodyStub = sinon.stub(window.top.document.body, 'innerText').get(function() {
return 'example text partialBadWord2 example' + ' word'.repeat(496);
return 'example text partialbadwordb example' + ' word'.repeat(996);
});
expect(spec.interpretResponse(serverResponse, request).length).to.equal(1);
});

it('should return non-empty array when there are no-bad word matches', function () {
bodyStub = sinon.stub(window.top.document.body, 'innerText').get(function() {
return 'example text partialBadWord1 example text' + ' word'.repeat(495);
return 'example text partialbadword example text' + ' word'.repeat(995);
});
expect(spec.interpretResponse(serverResponse, request).length).to.equal(1);
});

it('should return non-empty array when there are bad words and good words', function () {
bodyStub = sinon.stub(window.top.document.body, 'innerText').get(function() {
return 'example text badWord1 badWord1 example badWord1 goodWord goodWord ' + ' word'.repeat(492);
return 'example text badword badword example badword goodWord goodWord ' + ' word'.repeat(992);
});
expect(spec.interpretResponse(serverResponse, request).length).to.equal(1);
});
Expand Down

0 comments on commit 2a90f51

Please sign in to comment.