广告点击接口添加
This commit is contained in:
@@ -37,3 +37,34 @@ export function safeUrl(value: unknown, base?: string): string {
|
||||
export function formatCount(value = 0): string {
|
||||
return value >= 10000 ? `${(value / 10000).toFixed(1).replace(/\.0$/, "")}万` : Math.max(0, value).toLocaleString("zh-CN");
|
||||
}
|
||||
|
||||
/* ---- Ad click reporting (POST ads/click) ---- */
|
||||
/** Application ID the ad service expects for this site. */
|
||||
export const AD_APP_ID = 12;
|
||||
|
||||
export interface AdClickPayload { appId: number; adsId: number; location: number }
|
||||
|
||||
const positiveId = (value: unknown): number => {
|
||||
const number = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value.trim()) ? Number(value.trim()) : NaN;
|
||||
return Number.isSafeInteger(number) && number > 0 ? number : 0;
|
||||
};
|
||||
|
||||
/** Markup attributes carried by every ad link; omitted unless both the ad ID and its slot are valid. */
|
||||
export function adClickAttributes(id: unknown, location: unknown): string {
|
||||
const adsId = positiveId(id), slot = positiveId(location);
|
||||
return adsId && slot ? ` data-ads-id="${adsId}" data-ads-location="${slot}"` : '';
|
||||
}
|
||||
|
||||
/** Same data for ad elements created in the browser (startup dialogs). */
|
||||
export function setAdClickData(element: HTMLElement, id: unknown, location: unknown) {
|
||||
const adsId = positiveId(id), slot = positiveId(location);
|
||||
if (adsId && slot) { element.dataset.adsId = String(adsId); element.dataset.adsLocation = String(slot); }
|
||||
else { delete element.dataset.adsId; delete element.dataset.adsLocation; }
|
||||
}
|
||||
|
||||
export function adClickPayload(element: Element | null | undefined): AdClickPayload | null {
|
||||
const link = element?.closest<HTMLElement>('[data-ads-id]');
|
||||
if (!link) return null;
|
||||
const adsId = positiveId(link.dataset.adsId), location = positiveId(link.dataset.adsLocation);
|
||||
return adsId && location ? {appId: AD_APP_ID, adsId, location} : null;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const GET_PATHS = [
|
||||
const POST_PATHS = [
|
||||
"auth/guest",
|
||||
"playlet/collect/set", "playlet/like/set", "playlet/chapter/collect/set", "playlet/chapter/like/set",
|
||||
"playlet/chapter/watch/report", "playlet/forward/heat",
|
||||
"playlet/chapter/watch/report", "playlet/forward/heat", "ads/click",
|
||||
];
|
||||
export function allowedRequest(path: string, method: string): boolean {
|
||||
return (method === "GET" ? GET_PATHS : method === "POST" ? POST_PATHS : []).includes(path);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { navigationIsActive } from '../site-navigation';
|
||||
import { ApiClient } from "./api";
|
||||
import type { Config, Drama, FooterInfoResponse, HomeData, PageData } from "../api/types";
|
||||
import type { SiteContext } from "./context";
|
||||
import { recordList, safeUrl } from "../api/protocol";
|
||||
import { adClickPayload, recordList, safeUrl } from "../api/protocol";
|
||||
import { activeBanners, card, empty, escape, hero } from "./render";
|
||||
import { configureSite, copyRoadAddress, downloadUrl } from "./config";
|
||||
import { renderPlayer } from "./player";
|
||||
@@ -173,6 +173,13 @@ export class Application implements SiteContext {
|
||||
|
||||
private bindEvents(){
|
||||
bindPaginationJumps();
|
||||
// Ad clicks are reported without delaying the ad link itself; middle clicks open ads too.
|
||||
const reportAd=(event:MouseEvent)=>{
|
||||
if(event.type==='auxclick'&&event.button!==1)return;
|
||||
const payload=adClickPayload(event.target as Element);
|
||||
if(payload)void this.api.post('ads/click',payload).catch(()=>{});
|
||||
};
|
||||
document.addEventListener('click',reportAd,true);document.addEventListener('auxclick',reportAd,true);
|
||||
document.addEventListener('visibilitychange',()=>this.restartHeroAutoplay());
|
||||
document.addEventListener('click',event=>{void (async()=>{
|
||||
const el=(event.target as Element).closest<HTMLElement>('button,a');if(!el)return;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AppCategory, AppData, AppItem } from '../api/types';
|
||||
import { recordList, safeUrl } from '../api/protocol';
|
||||
import { adClickAttributes, recordList, safeUrl } from '../api/protocol';
|
||||
import type { ApiClient } from './api';
|
||||
import { empty, escape, imageAttributes } from './render';
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function loadAppCategories(api:Pick<ApiClient,'get'>,signal:AbortSi
|
||||
export function renderAppDirectory(categories:AppCategory[]):string{
|
||||
const visible=categories.map(category=>({...category,appList:appItems(category.appList)})).filter(category=>category.appList.length);
|
||||
if(!visible.length)return empty('暂无福利应用');
|
||||
return `<div class="benefits-directory"><nav class="channel-tabs" aria-label="福利应用分类">${visible.map((category,index)=>`<button type="button" data-app-category="${category.id}" aria-controls="apps-${category.id}" aria-pressed="${index===0}">${escape(category.title)}</button>`).join('')}</nav>${visible.map(category=>`<section class="benefit-section" id="apps-${category.id}" aria-labelledby="apps-title-${category.id}"><header class="benefit-heading"><div><h2 id="apps-title-${category.id}">${escape(category.title)}</h2>${category.intro?`<p>${escape(category.intro)}</p>`:''}</div></header><div class="benefit-grid" style="--compact-app-columns:${Math.max(1,Math.ceil(Math.min(category.appList.length,20)/2))}">${category.appList.map(app=>`<a class="benefit-card"${app.advitiseId?' data-ad':''} href="${escape(safeUrl(app.url)||'/apps/')}" target="_blank" rel="${safeUrl(app.url)?"sponsored nofollow noopener noreferrer":"noopener noreferrer"}"><span class="benefit-icon"><img ${imageAttributes(app.logo)} alt="${escape(app.name.trim())}应用图标" loading="lazy"></span><span class="benefit-copy"><strong>${escape(app.name)}</strong><small>${escape(app.intro||app.description)}</small></span></a>`).join('')}</div></section>`).join('')}</div>`;
|
||||
return `<div class="benefits-directory"><nav class="channel-tabs" aria-label="福利应用分类">${visible.map((category,index)=>`<button type="button" data-app-category="${category.id}" aria-controls="apps-${category.id}" aria-pressed="${index===0}">${escape(category.title)}</button>`).join('')}</nav>${visible.map(category=>`<section class="benefit-section" id="apps-${category.id}" aria-labelledby="apps-title-${category.id}"><header class="benefit-heading"><div><h2 id="apps-title-${category.id}">${escape(category.title)}</h2>${category.intro?`<p>${escape(category.intro)}</p>`:''}</div></header><div class="benefit-grid" style="--compact-app-columns:${Math.max(1,Math.ceil(Math.min(category.appList.length,20)/2))}">${category.appList.map(app=>`<a class="benefit-card"${app.advitiseId?' data-ad'+adClickAttributes(app.advitiseId,app.location):''} href="${escape(safeUrl(app.url)||'/apps/')}" target="_blank" rel="${safeUrl(app.url)?"sponsored nofollow noopener noreferrer":"noopener noreferrer"}"><span class="benefit-icon"><img ${imageAttributes(app.logo)} alt="${escape(app.name.trim())}应用图标" loading="lazy"></span><span class="benefit-copy"><strong>${escape(app.name)}</strong><small>${escape(app.intro||app.description)}</small></span></a>`).join('')}</div></section>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { contentPath, searchPath } from '../routing';
|
||||
import type { Advertisement, Drama, HomeBlock, HomeData, Topic } from "../api/types";
|
||||
import { formatCount, recordList, safeUrl } from "../api/protocol";
|
||||
import { adClickAttributes, formatCount, recordList, safeUrl } from "../api/protocol";
|
||||
|
||||
export const PLACEHOLDER = "/assets/poster-placeholder.svg";
|
||||
export const escape = (value: unknown): string => String(value ?? "").replace(/[&<>"']/g, char => ({"&":"&","<":"<",">":">",'"':""","'":"'"})[char]!);
|
||||
@@ -75,7 +75,7 @@ export function ads(items: unknown = [], className = "promo-app"): string {
|
||||
return recordList<Advertisement>(items).filter(item=>safeUrl(item.images)).map(item=>{
|
||||
const label=escape(String(item.title || "").trim() || "推广广告");
|
||||
const image=`<img ${imageAttributes(item.images)} alt="${label}" loading="lazy"${tile?' width="132" height="132" decoding="async"':''}>`;
|
||||
return `<a class="${escape(className)}" data-ad href="${escape(safeUrl(item.link)||"/apps/")}" target="_blank" rel="sponsored nofollow noopener noreferrer">${tile?`<span class="promo-tile-image">${image}</span><strong>${escape(item.title)}</strong>`:`${image}<span>${escape(item.title)}</span>`}</a>`;
|
||||
return `<a class="${escape(className)}" data-ad${adClickAttributes(item.id,item.location)} href="${escape(safeUrl(item.link)||"/apps/")}" target="_blank" rel="sponsored nofollow noopener noreferrer">${tile?`<span class="promo-tile-image">${image}</span><strong>${escape(item.title)}</strong>`:`${image}<span>${escape(item.title)}</span>`}</a>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Advertisement } from '../api/types';
|
||||
import { recordList, safeUrl } from '../api/protocol';
|
||||
import { recordList, safeUrl, setAdClickData } from '../api/protocol';
|
||||
|
||||
const AGE_KEY = 'hg_age_ok';
|
||||
const DISMISSED_KEY = 'dialogAdDismissed';
|
||||
@@ -227,6 +227,7 @@ export function startStartupDialogs(): StartupDialogs {
|
||||
image.alt = image.title = item.title?.trim() || '推荐广告';
|
||||
media.setAttribute('aria-label', item.title || '查看广告');
|
||||
if (item.link) media.href = item.link; else media.removeAttribute('href');
|
||||
setAdClickData(media, item.id, item.location);
|
||||
media.hidden = false; loading.hidden = true; close.disabled = false;
|
||||
ad.removeAttribute('aria-busy');
|
||||
ad.setAttribute('aria-label', item.title || '广告推荐');
|
||||
@@ -242,6 +243,7 @@ export function startStartupDialogs(): StartupDialogs {
|
||||
link.rel = 'sponsored nofollow noopener noreferrer';
|
||||
link.setAttribute('aria-label', item.title || '查看推荐应用');
|
||||
if (item.link) link.href = item.link;
|
||||
setAdClickData(link, item.id, item.location);
|
||||
const icon = document.createElement('img');
|
||||
icon.width = icon.height = 80;
|
||||
icon.alt = item.title || '推荐应用';
|
||||
|
||||
+15
@@ -576,6 +576,21 @@
|
||||
"body": [],
|
||||
"requiredBody": [],
|
||||
"confirmedBy": "用户提供接口,2026-10-24"
|
||||
},
|
||||
"POST ads/click": {
|
||||
"query": [],
|
||||
"requiredQuery": [],
|
||||
"body": [
|
||||
"adsId",
|
||||
"appId",
|
||||
"location"
|
||||
],
|
||||
"requiredBody": [
|
||||
"adsId",
|
||||
"appId",
|
||||
"location"
|
||||
],
|
||||
"confirmedBy": "用户提供接口,2026-10-29"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,3 +119,9 @@ test('carousel frames keep their own titles below the stable page heading, inclu
|
||||
}
|
||||
assert.ok(!/<h1\b/.test(hero(null)));
|
||||
});
|
||||
|
||||
test('ad links carry the numeric ad ID and slot for click reporting, and invalid identities are omitted',()=>{
|
||||
const html=promotionRow([{id:'1138200002',location:2,title:'广告',images:'https://example.com/ad.png',link:'https://example.com/landing'},{id:'bad-id',location:2,title:'无效',images:'https://example.com/b.png',link:'https://example.com/b'}]);
|
||||
assert.ok(html.includes('data-ad data-ads-id="1138200002" data-ads-location="2" href="https://example.com/landing"'));
|
||||
assert.equal((html.match(/data-ads-id=/g)||[]).length,1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user