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

AdHash bid adapter: update to support latest version #9286

Merged
merged 25 commits into from
Dec 8, 2022
Merged
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
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';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please ensure that the url passed through params.bidderURL is using https:// and not http://.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added a check in isBidRequestValid as well as unit test for that.

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