diff --git a/modules/adbutlerBidAdapter.js b/modules/adbutlerBidAdapter.js
new file mode 100644
index 00000000000..de430a5c916
--- /dev/null
+++ b/modules/adbutlerBidAdapter.js
@@ -0,0 +1,113 @@
+import * as utils from '../src/utils.js';
+import { registerBidder } from '../src/adapters/bidderFactory.js';
+import { BANNER } from '../src/mediaTypes.js';
+
+const BIDDER_CODE = 'adbutler';
+
+function getTrackingPixelsMarkup(pixelURLs) {
+ return pixelURLs
+ .map(pixelURL => ``)
+ .join();
+}
+
+export const spec = {
+ code: BIDDER_CODE,
+ pageID: Math.floor(Math.random() * 10e6),
+ aliases: ['divreach', 'doceree'],
+ supportedMediaTypes: [BANNER],
+
+ isBidRequestValid(bid) {
+ return !!(bid.params.accountID && bid.params.zoneID);
+ },
+
+ buildRequests(validBidRequests) {
+ const zoneCounters = {};
+
+ return utils._map(validBidRequests, function (bidRequest) {
+ const zoneID = bidRequest.params?.zoneID;
+
+ zoneCounters[zoneID] ??= 0;
+
+ const domain = bidRequest.params?.domain ?? 'servedbyadbutler.com';
+ const adserveBase = `https://${domain}/adserve`;
+ const params = {
+ ...(bidRequest.params?.extra ?? {}),
+ ID: bidRequest.params?.accountID,
+ type: 'hbr',
+ setID: zoneID,
+ pid: spec.pageID,
+ place: zoneCounters[zoneID],
+ kw: bidRequest.params?.keyword,
+ };
+
+ const paramsString = Object.entries(params).map(([key, value]) => `${key}=${value}`).join(';');
+ const requestURI = `${adserveBase}/;${paramsString};`;
+
+ zoneCounters[zoneID]++;
+
+ return {
+ method: 'GET',
+ url: requestURI,
+ data: {},
+ bidRequest,
+ };
+ });
+ },
+
+ interpretResponse(serverResponse, serverRequest) {
+ const bidObj = serverRequest.bidRequest;
+ const response = serverResponse.body ?? {};
+
+ if (!bidObj || response.status !== 'SUCCESS') {
+ return [];
+ }
+
+ const width = parseInt(response.width);
+ const height = parseInt(response.height);
+
+ const sizeValid = (bidObj.mediaTypes?.banner?.sizes ?? []).some(([w, h]) => w === width && h === height);
+
+ if (!sizeValid) {
+ return [];
+ }
+
+ const cpm = response.cpm;
+ const minCPM = bidObj.params?.minCPM ?? null;
+ const maxCPM = bidObj.params?.maxCPM ?? null;
+
+ if (minCPM !== null && cpm < minCPM) {
+ return [];
+ }
+
+ if (maxCPM !== null && cpm > maxCPM) {
+ return [];
+ }
+
+ let advertiserDomains = [];
+
+ if (response.advertiser?.domain) {
+ advertiserDomains.push(response.advertiser.domain);
+ }
+
+ const bidResponse = {
+ requestId: bidObj.bidId,
+ cpm,
+ currency: 'USD',
+ width,
+ height,
+ ad: response.ad_code + getTrackingPixelsMarkup(response.tracking_pixels),
+ ttl: 360,
+ creativeId: response.placement_id,
+ netRevenue: true,
+ meta: {
+ advertiserId: response.advertiser?.id,
+ advertiserName: response.advertiser?.name,
+ advertiserDomains,
+ },
+ };
+
+ return [bidResponse];
+ },
+};
+
+registerBidder(spec);
diff --git a/modules/adbutlerBidAdapter.md b/modules/adbutlerBidAdapter.md
new file mode 100644
index 00000000000..88b5cf64475
--- /dev/null
+++ b/modules/adbutlerBidAdapter.md
@@ -0,0 +1,31 @@
+# Overview
+
+**Module Name**: AdButler Bidder Adapter
+**Module Type**: Bidder Adapter
+**Maintainer**: trevor@sparklit.com
+
+# Description
+
+Bid Adapter for creating a bid from an AdButler zone.
+
+# Test Parameters
+```
+ var adUnits = [
+ {
+ code: 'display-div',
+ sizes: [[300, 250]], // a display size
+ bids: [
+ {
+ bidder: "adbutler",
+ params: {
+ accountID: '181556',
+ zoneID: '705374',
+ keyword: 'red', //optional
+ minCPM: '1.00', //optional
+ maxCPM: '5.00' //optional
+ }
+ }
+ ]
+ }
+ ];
+```
diff --git a/test/spec/modules/adbutlerBidAdapter_spec.js b/test/spec/modules/adbutlerBidAdapter_spec.js
new file mode 100644
index 00000000000..6c38de717a3
--- /dev/null
+++ b/test/spec/modules/adbutlerBidAdapter_spec.js
@@ -0,0 +1,329 @@
+import { expect } from 'chai';
+import { spec } from 'modules/adbutlerBidAdapter.js';
+
+describe('AdButler adapter', function () {
+ let validBidRequests;
+
+ beforeEach(function () {
+ validBidRequests = [
+ {
+ bidder: 'adbutler',
+ params: {
+ accountID: '181556',
+ zoneID: '705374',
+ keyword: 'red',
+ minCPM: '1.00',
+ maxCPM: '5.00',
+ },
+ placementCode: '/19968336/header-bid-tag-1',
+ mediaTypes: {
+ banner: {
+ sizes: [[300, 250], [300, 600]],
+ },
+ },
+ bidId: '23acc48ad47af5',
+ auctionId: '0fb4905b-9456-4152-86be-c6f6d259ba99',
+ bidderRequestId: '1c56ad30b9b8ca8',
+ transactionId: '92489f71-1bf2-49a0-adf9-000cea934729',
+ },
+ ];
+ });
+
+ describe('for requests', function () {
+ describe('without account ID', function () {
+ it('rejects the bid', function () {
+ const invalidBid = {
+ bidder: 'adbutler',
+ params: {
+ zoneID: '210093',
+ },
+ };
+ const isValid = spec.isBidRequestValid(invalidBid);
+
+ expect(isValid).to.equal(false);
+ });
+ });
+
+ describe('without a zone ID', function () {
+ it('rejects the bid', function () {
+ const invalidBid = {
+ bidder: 'adbutler',
+ params: {
+ accountID: '167283',
+ },
+ };
+ const isValid = spec.isBidRequestValid(invalidBid);
+
+ expect(isValid).to.equal(false);
+ });
+ });
+
+ describe('with a valid bid', function () {
+ describe('with a custom domain', function () {
+ it('uses the custom domain', function () {
+ validBidRequests[0].params.domain = 'customadbutlerdomain.com';
+
+ const requests = spec.buildRequests(validBidRequests);
+ const requestURL = requests[0].url;
+
+ expect(requestURL).to.have.string('customadbutlerdomain.com');
+ });
+ });
+
+ it('accepts the bid', function () {
+ const validBid = {
+ bidder: 'adbutler',
+ params: {
+ accountID: '167283',
+ zoneID: '210093',
+ },
+ };
+ const isValid = spec.isBidRequestValid(validBid);
+
+ expect(isValid).to.equal(true);
+ });
+
+ it('sets default domain', function () {
+ const requests = spec.buildRequests(validBidRequests);
+ const request = requests[0];
+
+ let [domain] = request.url.split('/adserve/');
+
+ expect(domain).to.equal('https://servedbyadbutler.com');
+ });
+
+ it('sets the keyword parameter', function () {
+ const requests = spec.buildRequests(validBidRequests);
+ const requestURL = requests[0].url;
+
+ expect(requestURL).to.have.string(';kw=red;');
+ });
+
+ describe('with extra params', function () {
+ beforeEach(function() {
+ validBidRequests[0].params.extra = {
+ foo: 'bar',
+ };
+ });
+
+ it('sets the extra parameter', function () {
+ const requests = spec.buildRequests(validBidRequests);
+ const requestURL = requests[0].url;
+
+ expect(requestURL).to.have.string(';foo=bar;');
+ });
+ });
+
+ describe('with multiple bids to the same zone', function () {
+ it('increments the place count', function () {
+ const requests = spec.buildRequests([validBidRequests[0], validBidRequests[0]]);
+ const firstRequest = requests[0].url;
+ const secondRequest = requests[1].url;
+
+ expect(firstRequest).to.have.string(';place=0;');
+ expect(secondRequest).to.have.string(';place=1;');
+ });
+ });
+ });
+ });
+
+ describe('for server responses', function () {
+ let serverResponse;
+
+ describe('with no body', function () {
+ beforeEach(function() {
+ serverResponse = {
+ body: null,
+ };
+ });
+
+ it('does not return any bids', function () {
+ const bids = spec.interpretResponse(serverResponse, { bidRequest: validBidRequests[0] });
+
+ expect(bids).to.be.length(0);
+ });
+ });
+
+ describe('with an incorrect size', function () {
+ beforeEach(function() {
+ serverResponse = {
+ body: {
+ status: 'SUCCESS',
+ account_id: 167283,
+ zone_id: 210083,
+ cpm: 1.5,
+ width: 728,
+ height: 90,
+ place: 0,
+ },
+ };
+ });
+
+ it('does not return any bids', function () {
+ const bids = spec.interpretResponse(serverResponse, { bidRequest: validBidRequests[0] });
+
+ expect(bids).to.be.length(0);
+ });
+ });
+
+ describe('with a failed status', function () {
+ beforeEach(function() {
+ serverResponse = {
+ body: {
+ status: 'NO_ELIGIBLE_ADS',
+ zone_id: 210083,
+ width: 300,
+ height: 250,
+ place: 0,
+ },
+ };
+ });
+
+ it('does not return any bids', function () {
+ const bids = spec.interpretResponse(serverResponse, { bidRequest: validBidRequests[0] });
+
+ expect(bids).to.be.length(0);
+ });
+ });
+
+ describe('with low CPM', function () {
+ beforeEach(function() {
+ serverResponse = {
+ body: {
+ status: 'SUCCESS',
+ account_id: 167283,
+ zone_id: 210093,
+ cpm: 0.75,
+ width: 300,
+ height: 250,
+ place: 0,
+ ad_code: '',
+ tracking_pixels: [],
+ },
+ }
+ });
+
+ describe('with a minimum CPM', function () {
+ it('does not return any bids', function () {
+ const bids = spec.interpretResponse(serverResponse, { bidRequest: validBidRequests[0] });
+ expect(bids).to.be.length(0);
+ });
+ });
+
+ describe('with no minimum CPM', function () {
+ beforeEach(function() {
+ delete validBidRequests[0].params.minCPM;
+ });
+
+ it('returns a bid', function() {
+ const bids = spec.interpretResponse(serverResponse, { bidRequest: validBidRequests[0] });
+
+ expect(bids).to.be.length(1);
+ });
+ });
+ });
+
+ describe('with high CPM', function () {
+ beforeEach(function() {
+ serverResponse = {
+ body: {
+ status: 'SUCCESS',
+ account_id: 167283,
+ zone_id: 210093,
+ cpm: 999,
+ width: 300,
+ height: 250,
+ place: 0,
+ ad_code: '',
+ tracking_pixels: [],
+ },
+ }
+ });
+
+ describe('with a maximum CPM', function () {
+ it('does not return any bids', function () {
+ const bids = spec.interpretResponse(serverResponse, { bidRequest: validBidRequests[0] });
+
+ expect(bids).to.be.length(0);
+ });
+ });
+
+ describe('with no maximum CPM', function () {
+ beforeEach(function() {
+ delete validBidRequests[0].params.maxCPM;
+ });
+
+ it('returns a bid', function() {
+ const bids = spec.interpretResponse(serverResponse, { bidRequest: validBidRequests[0] });
+
+ expect(bids).to.be.length(1);
+ });
+ });
+ });
+
+ describe('with a valid ad', function () {
+ beforeEach(function() {
+ serverResponse = {
+ body: {
+ status: 'SUCCESS',
+ account_id: 167283,
+ zone_id: 210093,
+ cpm: 1.5,
+ width: 300,
+ height: 250,
+ place: 0,
+ ad_code: '',
+ tracking_pixels: [
+ 'http://tracking.pixel.com/params=info',
+ ],
+ },
+ };
+ });
+
+ it('returns a complete bid', function () {
+ const bids = spec.interpretResponse(serverResponse, { bidRequest: validBidRequests[0] });
+
+ expect(bids).to.be.length(1);
+ expect(bids[0].cpm).to.equal(1.5);
+ expect(bids[0].width).to.equal(300);
+ expect(bids[0].height).to.equal(250);
+ expect(bids[0].currency).to.equal('USD');
+ expect(bids[0].netRevenue).to.equal(true);
+ expect(bids[0].ad).to.have.length.above(1);
+ expect(bids[0].ad).to.have.string('http://tracking.pixel.com/params=info');
+ });
+
+ describe('for a bid request without banner media type', function () {
+ beforeEach(function() {
+ delete validBidRequests[0].mediaTypes.banner;
+ });
+
+ it('does not return any bids', function () {
+ const bids = spec.interpretResponse(serverResponse, { bidRequest: validBidRequests[0] });
+
+ expect(bids).to.be.length(0);
+ });
+ });
+
+ describe('with advertiser meta', function () {
+ beforeEach(function() {
+ serverResponse.body.advertiser = {
+ id: 123,
+ name: 'Advertiser Name',
+ domain: 'advertiser.com',
+ };
+ });
+
+ it('returns a bid including advertiser meta', function () {
+ const bids = spec.interpretResponse(serverResponse, { bidRequest: validBidRequests[0] });
+
+ expect(bids).to.be.length(1);
+ expect(bids[0]).to.have.property('meta');
+ expect(bids[0].meta.advertiserId).to.equal(123);
+ expect(bids[0].meta.advertiserName).to.equal('Advertiser Name');
+ expect(bids[0].meta.advertiserDomains).to.contain('advertiser.com');
+ });
+ });
+ });
+ });
+});