广告点击接口添加

This commit is contained in:
谢宇宁
2026-10-29 16:25:57 +07:00
parent 3c9ab2630f
commit 36c5f6c48a
8 changed files with 68 additions and 7 deletions
+31
View File
@@ -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;
}
+1 -1
View File
@@ -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);
+8 -1
View File
@@ -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;
+2 -2
View File
@@ -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>`;
}
+2 -2
View File
@@ -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 => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[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("");
}
+3 -1
View File
@@ -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
View File
@@ -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"
}
}
}
+6
View File
@@ -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);
});