- 原生 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>
255 lines
8.5 KiB
TypeScript
255 lines
8.5 KiB
TypeScript
import { chromium } from "@playwright/test";
|
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
import { resolve } from "node:path";
|
|
|
|
const origin = process.env.SMOKE_URL || "http://localhost:25131";
|
|
const output = process.env.UI_OUTPUT_DIR || "/tmp/hkdj-figma/implementation";
|
|
const allowEmptyContent = process.env.UI_ALLOW_EMPTY_CONTENT === "1";
|
|
await mkdir(output, { recursive: true });
|
|
const browser = await chromium.launch({
|
|
executablePath:
|
|
process.env.CHROME_PATH ||
|
|
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
headless: true,
|
|
});
|
|
const results: Record<string, unknown>[] = [];
|
|
const failures: string[] = [];
|
|
const paths = [
|
|
"/",
|
|
"/dramas/",
|
|
"/series/",
|
|
"/collections/",
|
|
"/collection/dushi/",
|
|
"/rankings/",
|
|
"/tags/chitie/",
|
|
"/latest/",
|
|
"/featured/",
|
|
"/search/",
|
|
"/search/测试/",
|
|
"/discover/search/",
|
|
"/about/",
|
|
"/help/",
|
|
"/terms/",
|
|
"/privacy/",
|
|
"/feedback/",
|
|
"/2257/",
|
|
"/dmca/",
|
|
"/navigation/",
|
|
"/__ui_missing_page__/",
|
|
];
|
|
// The account system was removed; every former account page permanently returns home.
|
|
const removedAccountPaths = [
|
|
"/account/",
|
|
"/account/history/",
|
|
"/account/favorites/",
|
|
"/account/purchases/",
|
|
"/account/settings/",
|
|
"/account/recharge/",
|
|
"/account/login/",
|
|
"/account/register/",
|
|
"/login/",
|
|
"/profile/",
|
|
];
|
|
try {
|
|
for (const [device, viewport] of [
|
|
["desktop", { width: 1440, height: 1000 }],
|
|
["mobile", { width: 375, height: 812 }],
|
|
] as const) {
|
|
const context = await browser.newContext({
|
|
viewport,
|
|
isMobile: device === "mobile",
|
|
deviceScaleFactor: 1,
|
|
});
|
|
await context.addInitScript(() => localStorage.setItem("hg_age_ok", "1"));
|
|
await context.route("**/*", async (route) => {
|
|
const request = route.request();
|
|
if (
|
|
new URL(request.url()).origin !== origin &&
|
|
(request.method() !== "GET" || request.resourceType() === "script")
|
|
)
|
|
return route.abort();
|
|
if (
|
|
request.url().includes("/api/statistics/") ||
|
|
request.url().includes("/api/report/")
|
|
)
|
|
return route.fulfill({ json: { code: 200, data: {} } });
|
|
return route.continue();
|
|
});
|
|
for (const path of removedAccountPaths) {
|
|
const response = await context.request.get(origin + path, { maxRedirects: 0 });
|
|
if (response.status() !== 301 || response.headers().location !== "/")
|
|
failures.push(
|
|
`${device} ${path}: expected 301 to /, got ${response.status()} ${response.headers().location || ""}`,
|
|
);
|
|
}
|
|
const page = await context.newPage();
|
|
let play = "";
|
|
for (const path of paths) {
|
|
const errors: string[] = [];
|
|
const missing: string[] = [];
|
|
const onError = (error: Error): void => {
|
|
errors.push(error.message);
|
|
};
|
|
const onResponse = (
|
|
response: import("@playwright/test").Response,
|
|
): void => {
|
|
if (
|
|
response.status() >= 400 &&
|
|
response.url().startsWith(origin + "/static/")
|
|
)
|
|
missing.push(new URL(response.url()).pathname);
|
|
};
|
|
page.on("pageerror", onError);
|
|
page.on("response", onResponse);
|
|
try {
|
|
const response = await page.goto(origin + path, {
|
|
waitUntil: "domcontentloaded",
|
|
timeout: 40000,
|
|
});
|
|
await page.waitForTimeout(
|
|
path === "/" || path === "/dramas/" ? 1800 : 250,
|
|
);
|
|
for (let index = 0; index < 12; index++) {
|
|
const close = page.locator(
|
|
"[data-dialog-ad].hk-is-open [data-dialog-ad-item]:not([hidden]) [data-dialog-ad-close]",
|
|
);
|
|
if (!(await close.count()) || !(await close.first().isVisible()))
|
|
break;
|
|
await close.first().click({ force: true });
|
|
}
|
|
if (path === "/") {
|
|
const playLink = page.locator('a[href^="/drama/"]').first();
|
|
play = (await playLink.count())
|
|
? (await playLink.getAttribute("href")) || ""
|
|
: "";
|
|
if (!play && !allowEmptyContent)
|
|
failures.push(
|
|
`${device} ${path}: no playable content; populate the upstream or use UI_ALLOW_EMPTY_CONTENT=1 for empty-state checks`,
|
|
);
|
|
}
|
|
const layout = await page.evaluate(() => {
|
|
const boxes = Object.fromEntries(
|
|
Object.entries({
|
|
header: ".site-header",
|
|
logo: ".site-header .brand__logo",
|
|
footer: ".site-footer",
|
|
hero: ".hero",
|
|
listHead: ".list-hero",
|
|
listFilters: ".list-filters",
|
|
firstCard: ".grid--list > :first-child",
|
|
tabbar: ".mobile-tabbar",
|
|
}).map(([key, selector]) => {
|
|
const element = document.querySelector(selector);
|
|
const rect = element?.getBoundingClientRect();
|
|
return [
|
|
key,
|
|
rect
|
|
? {
|
|
x: rect.x,
|
|
y: rect.y,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
}
|
|
: null,
|
|
];
|
|
}),
|
|
);
|
|
return {
|
|
width: innerWidth,
|
|
scrollWidth: document.documentElement.scrollWidth,
|
|
title: document.title,
|
|
noindex: /(?:^|[,\s])noindex(?:$|[,\s])/.test(
|
|
document.querySelector<HTMLMetaElement>('meta[name="robots"]')
|
|
?.content || "",
|
|
),
|
|
background: getComputedStyle(document.body).backgroundColor,
|
|
accent: getComputedStyle(document.documentElement)
|
|
.getPropertyValue("--accent")
|
|
.trim(),
|
|
oldBrand: document.body.innerText.includes("黄果短剧"),
|
|
accountEntries: document.querySelectorAll(
|
|
"[data-login-open], [data-register-open], .hk-user-menu, a[href^='/account']",
|
|
).length,
|
|
...boxes,
|
|
};
|
|
});
|
|
const unavailableEntity =
|
|
allowEmptyContent &&
|
|
response?.status() === 404 &&
|
|
(path.startsWith("/collection/") || path.startsWith("/tags/"));
|
|
const expected =
|
|
path === "/__ui_missing_page__/" || unavailableEntity ? 404 : 200;
|
|
if (response?.status() !== expected)
|
|
failures.push(
|
|
`${device} ${path}: status ${response?.status()}, expected ${expected}`,
|
|
);
|
|
if (unavailableEntity && !layout.noindex)
|
|
failures.push(
|
|
`${device} ${path}: missing entity must remain noindex`,
|
|
);
|
|
if (layout.scrollWidth > layout.width + 1)
|
|
failures.push(
|
|
`${device} ${path}: horizontal overflow ${layout.scrollWidth}`,
|
|
);
|
|
if (layout.accountEntries)
|
|
failures.push(`${device} ${path}: removed account entries remain`);
|
|
if (layout.oldBrand)
|
|
failures.push(`${device} ${path}: former brand remains visible`);
|
|
if (errors.length || missing.length)
|
|
failures.push(
|
|
`${device} ${path}: ${JSON.stringify({ errors, missing })}`,
|
|
);
|
|
results.push({
|
|
device,
|
|
path,
|
|
status: response?.status(),
|
|
unavailableEntity,
|
|
...layout,
|
|
errors,
|
|
missing,
|
|
});
|
|
await page.screenshot({
|
|
path: resolve(
|
|
output,
|
|
`${device}-${path === "/" ? "home" : path.split("/").filter(Boolean).join("-")}.png`,
|
|
),
|
|
fullPage: true,
|
|
animations: "disabled",
|
|
});
|
|
} catch (error) {
|
|
failures.push(`${device} ${path}: ${String(error)}`);
|
|
} finally {
|
|
page.off("pageerror", onError);
|
|
page.off("response", onResponse);
|
|
}
|
|
}
|
|
if (play) {
|
|
await page.goto(origin + play, { waitUntil: "domcontentloaded" });
|
|
await page.waitForTimeout(300);
|
|
results.push({
|
|
device,
|
|
path: play,
|
|
player: await page.locator("[data-play-player]").count(),
|
|
xgplayer: await page.locator(".xgplayer").count(),
|
|
video: await page.locator("video").count(),
|
|
});
|
|
await page.screenshot({
|
|
path: resolve(output, device + "-play.png"),
|
|
fullPage: true,
|
|
animations: "disabled",
|
|
});
|
|
}
|
|
await context.close();
|
|
}
|
|
await writeFile(
|
|
resolve(output, "results.json"),
|
|
JSON.stringify({ results, failures }, null, 2),
|
|
);
|
|
console.log(
|
|
JSON.stringify({ pages: results.length, failures, output }, null, 2),
|
|
);
|
|
if (failures.length) process.exitCode = 1;
|
|
} finally {
|
|
await browser.close();
|
|
}
|