- 原生 Next.js App Router + 严格 TypeScript 实现花魁短剧 PC/H5 页面,按 src/features/{catalog,content,playback} 组织,保留完整 SEO(metadata、JSON-LD、canonical、robots、llms、sitemap、IndexNow)。
- 主域名通过 SITE_URL=https://huakuidj.com 强制覆盖后端 foreverUrlAddr(config/*.env、src/server/config.ts、src/server/seo/shared.ts、src/server/services/shared.ts),支持邮箱改为 support@huakuidj.com。
- 移除登录与账户体系:删除 /account 全部页面、登录/注册/充值弹窗、收藏点赞、评论、金币解锁及对应 BFF 接口;旧账户地址 GET/HEAD 统一 301 到首页(src/lib/routes.ts REMOVED_ACCOUNT_PATHS),保留游客会话、反馈、图片上传、观看上报与搜索联想。
- 播放页:评论位改为同类推荐 + 热播榜单,锁定集改为 App 观看遮罩与底部弹层,新增简介标签、分集图例与独立视觉皮肤(src/features/playback、src/styles/pages/playback.css)。
- 卡片:短剧/视频卡状态与时长移至信息行胶囊标签,封面角标移至左上角(src/components/catalog/Cards.tsx、src/styles/foundations/base.css)。
- 同步更新单元测试、浏览器冒烟脚本、路由清单与 README。
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
171 lines
7.8 KiB
TypeScript
171 lines
7.8 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { createCipheriv, randomBytes } from "node:crypto";
|
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
import { createServer } from "node:http";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { build } from "esbuild";
|
|
import { chromium } from "@playwright/test";
|
|
|
|
// Exercise the shipped SDK and React provider. Every report terminates in a
|
|
// browser route; synthetic configuration never reaches an analytics service.
|
|
const root = process.cwd();
|
|
const temp = await mkdtemp(join(tmpdir(), "huakui-tracker-"));
|
|
const sdk = await readFile(join(root, "public/static/common/web-sdk-v1.2.0.js"), "utf8");
|
|
const configKey = sdk.match(/_secondGcmKeyBase64="([^"\n]+)"/)?.[1];
|
|
assert.ok(configKey, "The SDK configuration encryption contract is available");
|
|
const iv = randomBytes(12);
|
|
const cipher = createCipheriv("aes-256-gcm", Buffer.from(configKey, "base64"), iv);
|
|
const encryptedConfig = Buffer.concat([
|
|
iv,
|
|
cipher.update(JSON.stringify({
|
|
batchUrls: ["https://analytics.fixture.invalid"],
|
|
eventCodes: ["app_page_view", "ad_impression", "page_click", "ad_click"],
|
|
signKey: "synthetic-tracker-fixture-key",
|
|
securityKeyVersion: "fixture",
|
|
})),
|
|
cipher.final(),
|
|
cipher.getAuthTag(),
|
|
]).toString("base64");
|
|
const appFile = join(temp, "app.js");
|
|
let browser: Awaited<ReturnType<typeof chromium.launch>> | undefined;
|
|
let server: ReturnType<typeof createServer> | undefined;
|
|
|
|
try {
|
|
await build({
|
|
stdin: {
|
|
contents: `import {createRoot} from 'react-dom/client';
|
|
import {TrackerProvider} from './src/components/analytics/TrackerProvider';
|
|
createRoot(document.getElementById('root')).render(
|
|
<TrackerProvider boot={window.__fixtureBoot}>
|
|
<a href="#fixture" data-track-ad="external" data-ad-id="fixture-ad"
|
|
data-ad-clickable="1" data-ad-type="banner"
|
|
style={{display:'block',width:300,height:100}}>广告验证</a>
|
|
</TrackerProvider>);`,
|
|
loader: "tsx",
|
|
resolveDir: root,
|
|
},
|
|
outfile: appFile,
|
|
bundle: true,
|
|
platform: "browser",
|
|
format: "iife",
|
|
tsconfig: join(root, "tsconfig.json"),
|
|
define: { "process.env.NODE_ENV": '"production"' },
|
|
});
|
|
const app = await readFile(appFile);
|
|
server = createServer((request, response) => {
|
|
const path = new URL(request.url || "/", "http://fixture").pathname;
|
|
if (path === "/app.js") {
|
|
response.setHeader("Content-Type", "application/javascript").end(app);
|
|
} else if (path === "/static/common/web-sdk-v1.2.0.js") {
|
|
response.setHeader("Content-Type", "application/javascript").end(sdk);
|
|
} else if (path === "/") {
|
|
response.setHeader("Content-Type", "text/html; charset=utf-8").end(
|
|
'<!doctype html><html><head><meta charset="utf-8"><title>Tracker fixture</title></head><body><div id="root"></div><script>window.__fixtureBoot=' +
|
|
JSON.stringify({
|
|
appId: "JHA-4001",
|
|
encryptedConfig,
|
|
appVersion: "1.0.0",
|
|
pageKey: "home",
|
|
pageName: "首页",
|
|
uid: "fixture_user",
|
|
}).replace(/</g, "\\u003c") +
|
|
'</script><script src="/app.js"></script></body></html>',
|
|
);
|
|
} else response.writeHead(404).end();
|
|
});
|
|
await new Promise<void>((done) => server!.listen(0, "127.0.0.1", done));
|
|
const address = server.address();
|
|
assert.ok(address && typeof address !== "string");
|
|
const origin = `http://127.0.0.1:${address.port}`;
|
|
browser = await chromium.launch({
|
|
executablePath: process.env.CHROME_PATH || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
headless: true,
|
|
});
|
|
const commonKeys = ["sid", "device_id", "device", "device_fingerprint", "fp_version"].sort();
|
|
const results: Record<string, unknown>[] = [];
|
|
for (const fixture of [
|
|
{
|
|
device: "iOS", system: "iOS", version: "18.0",
|
|
ua: "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Version/18.0 Mobile/15E148 Safari/604.1",
|
|
},
|
|
{
|
|
device: "Android", system: "Android", version: "14",
|
|
ua: "Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/130.0.0.0 Mobile Safari/537.36",
|
|
},
|
|
{
|
|
device: "PC", system: "Windows", version: "10.0",
|
|
ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/130.0.0.0 Safari/537.36",
|
|
},
|
|
{
|
|
device: "PC", system: "macOS", version: "14.6",
|
|
ua: "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6) AppleWebKit/605.1.15 Version/18.0 Safari/605.1.15",
|
|
},
|
|
]) {
|
|
const context = await browser.newContext({ userAgent: fixture.ua });
|
|
const reports: Record<string, unknown>[] = [];
|
|
const errors: string[] = [];
|
|
await context.route("**/*", async (route) => {
|
|
const request = route.request();
|
|
const url = new URL(request.url());
|
|
if (url.origin === origin) return route.continue();
|
|
if (url.hostname === "analytics.fixture.invalid") {
|
|
if (request.method() === "POST") {
|
|
const body: unknown = JSON.parse(request.postData() || "[]");
|
|
assert.ok(Array.isArray(body), "SDK reports use a top-level batch array");
|
|
for (const record of body) {
|
|
assert.ok(record && typeof record === "object");
|
|
reports.push(record as Record<string, unknown>);
|
|
}
|
|
}
|
|
return route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
|
|
}
|
|
return route.abort();
|
|
});
|
|
const page = await context.newPage();
|
|
page.on("pageerror", (error) => errors.push(error.message));
|
|
await page.goto(origin, { waitUntil: "load" });
|
|
await page.waitForFunction(() => !!window.__trackerCommonFields, null, { timeout: 15000 });
|
|
await page.waitForFunction(() => !!window.tracker, null, { timeout: 15000 });
|
|
const common = await page.evaluate(async () => {
|
|
const fields = await window.WebSDK!.getCommonFields!(["device", "system_name", "system_version"]);
|
|
const header = await window.tracker!.getSDKCommonHeader();
|
|
return { fields, headerKeys: Object.keys(JSON.parse(atob(header))).sort() };
|
|
});
|
|
assert.equal(common.fields.device, fixture.device);
|
|
assert.equal(common.fields.system_name, fixture.system);
|
|
assert.equal(common.fields.system_version, fixture.version);
|
|
assert.deepEqual(common.headerKeys, commonKeys, "X-SDK-Common retains its five-field contract");
|
|
await page.waitForFunction(async () => {
|
|
await window.tracker!.flush();
|
|
return true;
|
|
});
|
|
await assertEventually(() => reports.some((r) => r.event === "app_page_view") &&
|
|
reports.some((r) => r.event === "ad_impression"));
|
|
for (const event of ["app_page_view", "ad_impression"]) {
|
|
const report = reports.find((record) => record.event === event);
|
|
assert.ok(report, `${fixture.system}: ${event} was sent through the real SDK`);
|
|
assert.equal(report.device, fixture.device);
|
|
assert.equal(report.system_name, fixture.system);
|
|
assert.equal(report.system_version, fixture.version);
|
|
assert.equal(report.app_id, "JHA-4001");
|
|
assert.ok(typeof report.event_sig === "string" && report.event_sig.length > 0, "Real SDK signs the report");
|
|
}
|
|
assert.deepEqual(errors, []);
|
|
results.push({ system: fixture.system, version: fixture.version, events: ["app_page_view", "ad_impression"], headerKeys: common.headerKeys.length });
|
|
await context.close();
|
|
}
|
|
console.log(JSON.stringify({ passed: true, cases: results }, null, 2));
|
|
} finally {
|
|
await browser?.close();
|
|
if (server) await new Promise<void>((done) => server!.close(() => done()));
|
|
await rm(temp, { recursive: true, force: true });
|
|
}
|
|
|
|
async function assertEventually(predicate: () => boolean) {
|
|
const deadline = Date.now() + 15000;
|
|
while (!predicate() && Date.now() < deadline)
|
|
await new Promise((done) => setTimeout(done, 50));
|
|
assert.ok(predicate(), "The SDK sent both the page view and visible ad impression");
|
|
}
|