diff --git a/modules/appnerveBidAdapter.md b/modules/appnerveBidAdapter.md new file mode 100644 index 00000000000..baa3d66d58b --- /dev/null +++ b/modules/appnerveBidAdapter.md @@ -0,0 +1,30 @@ +# Overview + +Module Name: Appnerve Bidder Adapter +Module Type: Bidder Adapter +Maintainer: prebid@appnerve.com + +# Description + +Connects Prebid.js publisher inventory to Appnerve Exchange. The production +endpoint is fixed by the adapter; publishers provide only their Appnerve +`sourceId`. + +# Test Parameters + +```javascript +var adUnits = [{ + code: 'appnerve-banner-test', + mediaTypes: { + banner: {sizes: [[300, 250]]} + }, + bids: [{ + bidder: 'appnerve', + params: {sourceId: '74000976'} + }] +}]; +``` + +Replace the sample ID with the permanent community-review source selected in +the Appnerve Prebid onboarding panel. Keep separate active sources and safe +creatives for video, native, and audio when declaring those formats. diff --git a/modules/appnerveBidAdapter.ts b/modules/appnerveBidAdapter.ts new file mode 100644 index 00000000000..1e8314d5dd5 --- /dev/null +++ b/modules/appnerveBidAdapter.ts @@ -0,0 +1,197 @@ +import {registerBidder} from '../src/adapters/bidderFactory.js'; +import {BANNER, VIDEO, NATIVE, AUDIO} from '../src/mediaTypes.js'; + +export const BIDDER_CODE = 'appnerve'; +export const ENDPOINT = 'https://exchange.appnerve.net/openrtb2/auction'; + +function sourceId(bid: any): string { + return String(bid?.params?.sourceId || bid?.params?.placementId || '').trim(); +} + +function firstSize(sizes: any): number[] | undefined { + if (!Array.isArray(sizes)) return undefined; + const size = Array.isArray(sizes[0]) ? sizes[0] : sizes; + if (Number(size?.[0]) > 0 && Number(size?.[1]) > 0) return [Number(size[0]), Number(size[1])]; +} + +function bannerFormat(sizes: any): any[] { + if (!Array.isArray(sizes)) return []; + const rows = Array.isArray(sizes[0]) ? sizes : [sizes]; + return rows.filter((size: any) => Number(size?.[0]) > 0 && Number(size?.[1]) > 0) + .map((size: any) => ({w: Number(size[0]), h: Number(size[1])})); +} + +function requestedMediaTypes(bid: any): string[] { + return [BANNER, VIDEO, NATIVE, AUDIO].filter((type) => bid?.mediaTypes?.[type]); +} + +function floorMediaType(bid: any): string { + return requestedMediaTypes(bid)[0] || BANNER; +} + +function nativeRequest(native: any): any { + const assets: any[] = []; + let id = 1; + if (native?.title) assets.push({id: id++, required: native.title.required ? 1 : 0, title: {len: Number(native.title.len || 90)}}); + if (native?.image) assets.push({id: id++, required: native.image.required ? 1 : 0, img: {type: 3, w: Number(native.image.sizes?.[0]), h: Number(native.image.sizes?.[1])}}); + if (native?.icon) assets.push({id: id++, required: native.icon.required ? 1 : 0, img: {type: 1, w: Number(native.icon.sizes?.[0]), h: Number(native.icon.sizes?.[1])}}); + if (native?.sponsoredBy) assets.push({id: id++, required: native.sponsoredBy.required ? 1 : 0, data: {type: 1, len: Number(native.sponsoredBy.len || 50)}}); + if (native?.body) assets.push({id: id++, required: native.body.required ? 1 : 0, data: {type: 2, len: Number(native.body.len || 140)}}); + if (native?.cta) assets.push({id: id++, required: native.cta.required ? 1 : 0, data: {type: 12, len: Number(native.cta.len || 30)}}); + return {ver: '1.2', context: 1, plcmttype: 1, assets, eventtrackers: [{event: 1, methods: [1, 2]}]}; +} + +function impression(bid: any): any { + const imp: any = { + id: bid.bidId, + tagid: sourceId(bid), + secure: 1, + ext: {tid: bid.transactionId, prebid: {storedrequest: {id: sourceId(bid)}}} + }; + if (bid.adUnitCode) imp.ext.adunitcode = bid.adUnitCode; + if (bid.mediaTypes?.banner) { + const format = bannerFormat(bid.mediaTypes.banner.sizes || bid.sizes); + imp.banner = {format}; + const size = firstSize(format.map((row) => [row.w, row.h])); + if (size) [imp.banner.w, imp.banner.h] = size; + } + if (bid.mediaTypes?.video) { + const video = {...bid.mediaTypes.video}; + const size = firstSize(video.playerSize); + if (size) { + video.w = video.w || size[0]; + video.h = video.h || size[1]; + } + delete video.playerSize; + imp.video = video; + } + if (bid.mediaTypes?.native) imp.native = {request: JSON.stringify(nativeRequest(bid.mediaTypes.native)), ver: '1.2'}; + if (bid.mediaTypes?.audio) imp.audio = {...bid.mediaTypes.audio}; + if (typeof bid.getFloor === 'function') { + const floor = bid.getFloor({currency: 'USD', mediaType: floorMediaType(bid), size: '*'}); + if (Number(floor?.floor) > 0) { + imp.bidfloor = Number(floor.floor); + imp.bidfloorcur = floor.currency || 'USD'; + } + } + return imp; +} + +function applyPrivacy(request: any, bidderRequest: any): void { + request.regs = {...(request.regs || {}), ext: {...(request.regs?.ext || {})}}; + request.user = {...(request.user || {}), ext: {...(request.user?.ext || {})}}; + if (bidderRequest?.gdprConsent) { + request.regs.ext.gdpr = bidderRequest.gdprConsent.gdprApplies ? 1 : 0; + if (bidderRequest.gdprConsent.consentString) request.user.ext.consent = bidderRequest.gdprConsent.consentString; + } + if (bidderRequest?.uspConsent) request.regs.ext.us_privacy = bidderRequest.uspConsent; + if (bidderRequest?.gppConsent?.gppString) request.regs.ext.gpp = bidderRequest.gppConsent.gppString; + if (Array.isArray(bidderRequest?.gppConsent?.applicableSections)) request.regs.ext.gpp_sid = bidderRequest.gppConsent.applicableSections; +} + +function responseMediaType(bid: any, original: any): string { + const declared = bid?.ext?.prebid?.type; + if ([BANNER, VIDEO, NATIVE, AUDIO].includes(declared)) return declared; + const adm = typeof bid?.adm === 'string' ? bid.adm.trim() : ''; + if (adm.startsWith('{') && /"native"\s*:/.test(adm)) return NATIVE; + if (/]/i.test(adm)) { + if (original?.mediaTypes?.audio && !original?.mediaTypes?.video) return AUDIO; + return VIDEO; + } + return original?.mediaTypes?.banner ? BANNER : requestedMediaTypes(original)[0] || BANNER; +} + +function nativeResponse(adm: any): any { + let payload = adm; + try { payload = typeof adm === 'string' ? JSON.parse(adm) : adm; } catch { return null; } + const native = payload?.native || payload; + if (!native || !Array.isArray(native.assets) || !native.link?.url) return null; + const result: any = { + clickUrl: native.link.url, + clickTrackers: native.link.clicktrackers || [], + impressionTrackers: native.imptrackers || [], + javascriptTrackers: native.jstracker ? [native.jstracker] : [] + }; + native.assets.forEach((asset: any) => { + if (asset.title?.text) result.title = asset.title.text; + if (asset.img?.url && asset.img.type === 1) result.icon = {url: asset.img.url, width: asset.img.w, height: asset.img.h}; + if (asset.img?.url && asset.img.type !== 1) result.image = {url: asset.img.url, width: asset.img.w, height: asset.img.h}; + if (asset.data?.type === 1) result.sponsoredBy = asset.data.value; + if (asset.data?.type === 2) result.body = asset.data.value; + if (asset.data?.type === 12) result.cta = asset.data.value; + }); + return result; +} + +export const spec = { + code: BIDDER_CODE, + supportedMediaTypes: [BANNER, VIDEO, NATIVE, AUDIO], + isBidRequestValid(bid: any): boolean { + return Boolean(sourceId(bid) && bid?.bidId && requestedMediaTypes(bid).length); + }, + buildRequests(validBidRequests: any[], bidderRequest: any): any[] { + const grouped = validBidRequests.reduce((groups: Map, bid: any) => { + const id = sourceId(bid); + groups.set(id, [...(groups.get(id) || []), bid]); + return groups; + }, new Map()); + return [...grouped.entries()].map(([id, bids]) => { + const ortb2 = bidderRequest?.ortb2 || {}; + const request: any = { + ...ortb2, + id: bidderRequest?.bidderRequestId || bidderRequest?.auctionId, + imp: bids.map(impression), + at: Number(ortb2.at || 1), + tmax: bidderRequest?.timeout, + cur: Array.isArray(ortb2.cur) && ortb2.cur.length ? ortb2.cur : ['USD'], + source: {...(ortb2.source || {}), tid: ortb2.source?.tid || bidderRequest?.auctionId} + }; + applyPrivacy(request, bidderRequest); + return { + method: 'POST', + url: `${ENDPOINT}?ssp_id=${encodeURIComponent(id)}`, + data: JSON.stringify(request), + bidMap: Object.fromEntries(bids.map((bid: any) => [bid.bidId, bid])), + options: {contentType: 'text/plain', withCredentials: false} + }; + }); + }, + interpretResponse(serverResponse: any, request: any): any[] { + let body = serverResponse?.body || {}; + if (typeof body === 'string') { + try { body = JSON.parse(body); } catch { return []; } + } + if (!Array.isArray(body?.seatbid)) return []; + const currency = String(body.cur || 'USD'); + return body.seatbid.flatMap((seat: any) => Array.isArray(seat?.bid) ? seat.bid : []).flatMap((bid: any) => { + const original = request?.bidMap?.[bid?.impid]; + const price = Number(bid?.price); + const creativeId = String(bid?.crid || bid?.id || '').trim(); + if (!original || !bid?.impid || !Number.isFinite(price) || price <= 0 || !creativeId || !bid?.adm) return []; + const type = responseMediaType(bid, original); + const response: any = { + requestId: bid.impid, cpm: price, creativeId, currency, + netRevenue: true, ttl: Number(bid.exp || 300), mediaType: type, + dealId: bid.dealid, meta: {advertiserDomains: Array.isArray(bid.adomain) ? bid.adomain : []} + }; + if (type === BANNER) { + const fallback = firstSize(original.mediaTypes?.banner?.sizes || original.sizes); + response.width = Number(bid.w || fallback?.[0]); + response.height = Number(bid.h || fallback?.[1]); + if (!response.width || !response.height) return []; + response.ad = bid.adm; + } else if (type === NATIVE) { + response.native = nativeResponse(bid.adm); + if (!response.native) return []; + } else { + response.vastXml = bid.adm; + } + return [response]; + }); + }, + getUserSyncs(): any[] { + return []; + } +}; + +registerBidder(spec); diff --git a/test/spec/modules/appnerveBidAdapter_spec.js b/test/spec/modules/appnerveBidAdapter_spec.js new file mode 100644 index 00000000000..001e7171cbb --- /dev/null +++ b/test/spec/modules/appnerveBidAdapter_spec.js @@ -0,0 +1,126 @@ +import {expect} from 'chai'; +import {ENDPOINT, spec} from 'modules/appnerveBidAdapter.js'; + +describe('appnerveBidAdapter', function () { + const bannerBid = { + bidId: 'bid-1', transactionId: 'transaction-1', adUnitCode: 'slot-1', + params: {sourceId: '74000976'}, + mediaTypes: {banner: {sizes: [[300, 250], [320, 50]]}} + }; + const requestContext = { + bidderRequestId: 'request-1', auctionId: 'auction-1', timeout: 1000, + ortb2: {site: {domain: 'publisher.example'}, source: {ext: {schain: {ver: '1.0', complete: 1, nodes: []}}}} + }; + + it('validates required parameters and media types', function () { + expect(spec.isBidRequestValid(bannerBid)).to.equal(true); + expect(spec.isBidRequestValid({...bannerBid, params: {placementId: '74000976'}})).to.equal(true); + expect(spec.isBidRequestValid({...bannerBid, params: {}})).to.equal(false); + expect(spec.isBidRequestValid({...bannerBid, mediaTypes: {}})).to.equal(false); + }); + + it('builds multi-size banner requests without a CORS preflight content type', function () { + const [request] = spec.buildRequests([bannerBid], requestContext); + const payload = JSON.parse(request.data); + expect(request.url).to.equal(`${ENDPOINT}?ssp_id=74000976`); + expect(request.options).to.deep.equal({contentType: 'text/plain', withCredentials: false}); + expect(payload.site.domain).to.equal('publisher.example'); + expect(payload.source.ext.schain.complete).to.equal(1); + expect(payload.imp[0].banner.format).to.deep.equal([{w: 300, h: 250}, {w: 320, h: 50}]); + }); + + it('groups multiple impressions by source ID', function () { + const requests = spec.buildRequests([ + bannerBid, + {...bannerBid, bidId: 'bid-2', params: {sourceId: '74000976'}}, + {...bannerBid, bidId: 'bid-3', params: {sourceId: '74000977'}} + ], requestContext); + expect(requests).to.have.length(2); + expect(JSON.parse(requests[0].data).imp).to.have.length(2); + }); + + it('forwards GDPR, USP, GPP and COPPA signals', function () { + const [request] = spec.buildRequests([bannerBid], { + ...requestContext, + ortb2: {...requestContext.ortb2, regs: {coppa: 1}}, + gdprConsent: {gdprApplies: true, consentString: 'TC_STRING'}, + uspConsent: '1YNN', + gppConsent: {gppString: 'DBABMA~CPXxRfAPXxRfAAfKABENB-CgAAAAAAAAAAYgAAAAAAAA', applicableSections: [7, 8]} + }); + const payload = JSON.parse(request.data); + expect(payload.regs.coppa).to.equal(1); + expect(payload.regs.ext.gdpr).to.equal(1); + expect(payload.regs.ext.us_privacy).to.equal('1YNN'); + expect(payload.regs.ext.gpp_sid).to.deep.equal([7, 8]); + expect(payload.user.ext.consent).to.equal('TC_STRING'); + }); + + it('adds floors and preserves currency', function () { + const bid = {...bannerBid, getFloor: () => ({floor: 1.25, currency: 'EUR'})}; + const [request] = spec.buildRequests([bid], requestContext); + const imp = JSON.parse(request.data).imp[0]; + expect(imp.bidfloor).to.equal(1.25); + expect(imp.bidfloorcur).to.equal('EUR'); + }); + + it('builds video, native and audio impressions', function () { + const video = {...bannerBid, bidId: 'video', mediaTypes: {video: {context: 'outstream', playerSize: [[640, 360]], mimes: ['video/mp4'], protocols: [2, 3, 5, 6]}}}; + const native = {...bannerBid, bidId: 'native', mediaTypes: {native: {title: {required: true, len: 90}, image: {required: true, sizes: [1200, 627]}, body: {required: true, len: 140}}}}; + const audio = {...bannerBid, bidId: 'audio', mediaTypes: {audio: {mimes: ['audio/mpeg'], minduration: 5, maxduration: 30, protocols: [2, 3, 5, 6]}}}; + const payload = JSON.parse(spec.buildRequests([video, native, audio], requestContext)[0].data); + expect(payload.imp[0].video.w).to.equal(640); + expect(JSON.parse(payload.imp[1].native.request).assets).to.have.length(3); + expect(payload.imp[2].audio.mimes).to.deep.equal(['audio/mpeg']); + }); + + it('uses the returned Prebid media type for multi-format bids', function () { + const multi = {...bannerBid, mediaTypes: {...bannerBid.mediaTypes, video: {context: 'outstream', playerSize: [[640, 360]]}}}; + const response = spec.interpretResponse({body: {seatbid: [{bid: [{ + id: 'bid-response', crid: 'creative-1', impid: 'bid-1', price: 0.2, + adm: '
banner
', w: 300, h: 250, ext: {prebid: {type: 'banner'}} + }]}]}}, {bidMap: {'bid-1': multi}}); + expect(response[0].mediaType).to.equal('banner'); + expect(response[0].ad).to.equal('
banner
'); + }); + + it('maps OpenRTB Native 1.2 responses', function () { + const original = {...bannerBid, mediaTypes: {native: {title: {required: true}}}}; + const adm = JSON.stringify({native: { + link: {url: 'https://brand.example', clicktrackers: ['https://tracker.example/click']}, + imptrackers: ['https://tracker.example/imp'], + assets: [ + {id: 1, title: {text: 'Native title'}}, + {id: 2, img: {type: 3, url: 'https://cdn.example/main.jpg', w: 1200, h: 627}}, + {id: 3, data: {type: 2, value: 'Native body'}} + ] + }}); + const response = spec.interpretResponse({body: {seatbid: [{bid: [{id: 'b', crid: 'c', impid: 'bid-1', price: 0.4, adm, ext: {prebid: {type: 'native'}}}]}]}}, {bidMap: {'bid-1': original}}); + expect(response[0].native.title).to.equal('Native title'); + expect(response[0].native.image.url).to.equal('https://cdn.example/main.jpg'); + expect(response[0].native.clickUrl).to.equal('https://brand.example'); + }); + + it('returns deals, currency and multiple valid bids', function () { + const body = {cur: 'EUR', seatbid: [ + {seat: 'a', bid: [{id: 'b1', crid: 'c1', impid: 'bid-1', price: 0.2, adm: '
A
', w: 300, h: 250, dealid: 'deal-1'}]}, + {seat: 'b', bid: [{id: 'b2', crid: 'c2', impid: 'bid-2', price: 0.3, adm: '
B
', w: 300, h: 250}]} + ]}; + const responses = spec.interpretResponse({body}, {bidMap: {'bid-1': bannerBid, 'bid-2': {...bannerBid, bidId: 'bid-2'}}}); + expect(responses).to.have.length(2); + expect(responses[0].dealId).to.equal('deal-1'); + expect(responses[0].currency).to.equal('EUR'); + }); + + it('rejects empty, malformed, zero-price and incomplete bids', function () { + expect(spec.interpretResponse({body: 'not-json'}, {bidMap: {}})).to.deep.equal([]); + expect(spec.interpretResponse({body: {seatbid: []}}, {bidMap: {}})).to.deep.equal([]); + const invalid = (bid) => spec.interpretResponse({body: {seatbid: [{bid: [bid]}]}}, {bidMap: {'bid-1': bannerBid}}); + expect(invalid({id: 'b', crid: 'c', impid: 'bid-1', price: 0, adm: '
', w: 300, h: 250})).to.deep.equal([]); + expect(invalid({id: 'b', crid: 'c', impid: 'bid-1', price: 1, w: 300, h: 250})).to.deep.equal([]); + expect(invalid({impid: 'bid-1', price: 1, adm: '
', w: 300, h: 250})).to.deep.equal([]); + }); + + it('does not perform user syncing', function () { + expect(spec.getUserSyncs()).to.deep.equal([]); + }); +});