From 36c5f6c48a58d45e276cf89dd1b815d848e578e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B0=A2=E5=AE=87=E5=AE=81?= Date: Thu, 29 Oct 2026 16:25:57 +0700 Subject: [PATCH] =?UTF-8?q?=E5=B9=BF=E5=91=8A=E7=82=B9=E5=87=BB=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E6=B7=BB=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/api/protocol.ts | 31 +++++++++++++++++++++++++++ src/lib/api/server.ts | 2 +- src/lib/client/application.ts | 9 +++++++- src/lib/client/apps.ts | 4 ++-- src/lib/client/render.ts | 4 ++-- src/lib/client/startup.ts | 4 +++- tests/fixtures/91av-app-contract.json | 15 +++++++++++++ tests/render.test.mjs | 6 ++++++ 8 files changed, 68 insertions(+), 7 deletions(-) diff --git a/src/lib/api/protocol.ts b/src/lib/api/protocol.ts index e6c73b0..2153a4e 100644 --- a/src/lib/api/protocol.ts +++ b/src/lib/api/protocol.ts @@ -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('[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; +} diff --git a/src/lib/api/server.ts b/src/lib/api/server.ts index ae9efef..c820291 100644 --- a/src/lib/api/server.ts +++ b/src/lib/api/server.ts @@ -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); diff --git a/src/lib/client/application.ts b/src/lib/client/application.ts index d0a62f2..ce41daa 100644 --- a/src/lib/client/application.ts +++ b/src/lib/client/application.ts @@ -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('button,a');if(!el)return; diff --git a/src/lib/client/apps.ts b/src/lib/client/apps.ts index 38b4d3d..bce5671 100644 --- a/src/lib/client/apps.ts +++ b/src/lib/client/apps.ts @@ -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,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 `
${visible.map(category=>`

${escape(category.title)}

${category.intro?`

${escape(category.intro)}

`:''}
${category.appList.map(app=>`${escape(app.name.trim())}应用图标${escape(app.name)}${escape(app.intro||app.description)}`).join('')}
`).join('')}
`; + return `
${visible.map(category=>`

${escape(category.title)}

${category.intro?`

${escape(category.intro)}

`:''}
${category.appList.map(app=>`${escape(app.name.trim())}应用图标${escape(app.name)}${escape(app.intro||app.description)}`).join('')}
`).join('')}
`; } diff --git a/src/lib/client/render.ts b/src/lib/client/render.ts index 9506d81..14060f6 100644 --- a/src/lib/client/render.ts +++ b/src/lib/client/render.ts @@ -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(items).filter(item=>safeUrl(item.images)).map(item=>{ const label=escape(String(item.title || "").trim() || "推广广告"); const image=`${label}`; - return ``; + return `${tile?`${image}${escape(item.title)}`:`${image}${escape(item.title)}`}`; }).join(""); } diff --git a/src/lib/client/startup.ts b/src/lib/client/startup.ts index e659d19..e571421 100644 --- a/src/lib/client/startup.ts +++ b/src/lib/client/startup.ts @@ -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 || '推荐应用'; diff --git a/tests/fixtures/91av-app-contract.json b/tests/fixtures/91av-app-contract.json index e0faabf..4b1cd11 100644 --- a/tests/fixtures/91av-app-contract.json +++ b/tests/fixtures/91av-app-contract.json @@ -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" } } } diff --git a/tests/render.test.mjs b/tests/render.test.mjs index 1a72ff2..580bf40 100644 --- a/tests/render.test.mjs +++ b/tests/render.test.mjs @@ -119,3 +119,9 @@ test('carousel frames keep their own titles below the stable page heading, inclu } assert.ok(!/{ + 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); +});