-
Notifications
You must be signed in to change notification settings - Fork 2.4k
Appnerve Bid Adapter #15254
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
base: master
Are you sure you want to change the base?
Appnerve Bid Adapter #15254
Changes from 5 commits
7aac126
da9d85f
c494908
9a525f3
bbf461d
816d7b2
6f0b6b2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,197 @@ | ||
| import {registerBidder} from '../src/adapters/bidderFactory.js'; | ||
|
Check failure on line 1 in modules/appnerveBidAdapter.ts
|
||
| import {BANNER, VIDEO, NATIVE, AUDIO} from '../src/mediaTypes.js'; | ||
|
Check failure on line 2 in modules/appnerveBidAdapter.ts
|
||
|
|
||
| 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])})); | ||
|
Check failure on line 21 in modules/appnerveBidAdapter.ts
|
||
| } | ||
|
|
||
| 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)}}); | ||
|
Check failure on line 35 in modules/appnerveBidAdapter.ts
|
||
| 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'}; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a publisher configures native with Useful? React with 👍 / 👎. |
||
| 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 (/<VAST[\s>]/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<string, any[]>, bid: any) => { | ||
| const id = sourceId(bid); | ||
| groups.set(id, [...(groups.get(id) || []), bid]); | ||
| return groups; | ||
| }, new Map<string, any[]>()); | ||
| 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); | ||
|
Check failure on line 197 in modules/appnerveBidAdapter.ts
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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: '<div>banner</div>', 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('<div>banner</div>'); | ||
| }); | ||
|
|
||
| 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: '<div>A</div>', w: 300, h: 250, dealid: 'deal-1'}]}, | ||
| {seat: 'b', bid: [{id: 'b2', crid: 'c2', impid: 'bid-2', price: 0.3, adm: '<div>B</div>', 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: '<div/>', 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: '<div/>', w: 300, h: 250})).to.deep.equal([]); | ||
| }); | ||
|
|
||
| it('does not perform user syncing', function () { | ||
| expect(spec.getUserSyncs()).to.deep.equal([]); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For ad units with
ortb2Impvalues such as GPID/adserver data,instl, or publisher/prebidext.tid, this builds a fresh OpenRTB impression and writesext.tidfrom the legacybid.transactionId, so those impression fields are dropped or overridden before the request is sent. Prebid carries the authoritative impression TID onbid.ortb2Imp.ext.tid, including publisher-supplied TIDs, so Appnerve can receive the wrong TID and miss GPID/FPD in those auctions; mergebid.ortb2Impintoimpand layer adapter-specificextfields on top.Useful? React with 👍 / 👎.