chore: import WonderQ project

This commit is contained in:
duanshuwen
2026-08-11 19:19:11 +08:00
commit f5a9e4403a
452 changed files with 34173 additions and 0 deletions

View File

@@ -0,0 +1,308 @@
import fs from "node:fs/promises";
import path from "node:path";
import { chromium, devices } from "playwright";
const targetUrl =
"https://m.ctrip.com/tangram/OTQ1Mg==?ctm_ref=vactang_page_9452&isHideNavBar=YES&ctm_ref=vactang_page_159930";
const root = process.cwd();
const researchDir = path.join(root, "docs", "research");
const referenceDir = path.join(root, "docs", "design-references");
await fs.mkdir(researchDir, { recursive: true });
await fs.mkdir(referenceDir, { recursive: true });
const browser = await chromium.launch({ headless: true });
async function captureViewport(name, contextOptions) {
const context = await browser.newContext({
...contextOptions,
locale: "zh-CN",
timezoneId: "Asia/Shanghai",
extraHTTPHeaders: {
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
},
});
const page = await context.newPage();
const consoleMessages = [];
const failedRequests = [];
page.on("console", (msg) => {
const text = msg.text();
if (consoleMessages.length < 50) consoleMessages.push({ type: msg.type(), text });
});
page.on("requestfailed", (request) => {
if (failedRequests.length < 50) {
failedRequests.push({
url: request.url(),
failure: request.failure()?.errorText,
});
}
});
await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
await page.waitForTimeout(8000);
const pageInfoBeforeScroll = await page.evaluate(() => ({
title: document.title,
url: location.href,
readyState: document.readyState,
scrollHeight: document.documentElement.scrollHeight,
viewportHeight: innerHeight,
bodyTextLength: document.body?.innerText?.length ?? 0,
bodyTextStart: document.body?.innerText?.slice(0, 1000) ?? "",
}));
await page.screenshot({
path: path.join(referenceDir, `source-${name}-top.png`),
fullPage: false,
});
await page.screenshot({
path: path.join(referenceDir, `source-${name}-full.png`),
fullPage: true,
});
const scrollSamples = [];
let i = 0;
let y = 0;
let previousHeight = 0;
while (i < 24) {
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), y);
await page.waitForTimeout(1200);
await page.screenshot({
path: path.join(referenceDir, `source-${name}-segment-${String(i).padStart(2, "0")}.png`),
fullPage: false,
});
scrollSamples.push(
await page.evaluate(() => ({
scrollY,
visibleText: document.body.innerText.slice(0, 1600),
activeElements: [...document.querySelectorAll("button, a, [role='button'], [class*='active'], [class*='selected']")]
.slice(0, 80)
.map((el) => ({
tag: el.tagName.toLowerCase(),
text: el.textContent?.trim().slice(0, 120) ?? "",
className: String(el.className ?? "").slice(0, 200),
href: el instanceof HTMLAnchorElement ? el.href : "",
})),
})),
);
const metrics = await page.evaluate(() => ({
scrollHeight: document.documentElement.scrollHeight,
viewportHeight: innerHeight,
}));
const maxScroll = Math.max(0, metrics.scrollHeight - metrics.viewportHeight);
if (y >= maxScroll - 4 && metrics.scrollHeight === previousHeight) break;
previousHeight = metrics.scrollHeight;
y = Math.min(maxScroll, y + 680);
i += 1;
}
await page.evaluate(() => window.scrollTo(0, 0));
await page.waitForTimeout(500);
await page.screenshot({
path: path.join(referenceDir, `source-${name}-full-after-scroll.png`),
fullPage: true,
});
const extract = await page.evaluate(() => {
const styleProps = [
"fontSize",
"fontWeight",
"fontFamily",
"lineHeight",
"letterSpacing",
"color",
"backgroundColor",
"backgroundImage",
"padding",
"margin",
"display",
"position",
"top",
"left",
"right",
"bottom",
"width",
"height",
"borderRadius",
"boxShadow",
"transform",
"opacity",
"zIndex",
];
function compactStyles(el) {
const cs = getComputedStyle(el);
const out = {};
for (const prop of styleProps) {
const value = cs[prop];
if (value && value !== "none" && value !== "normal" && value !== "auto" && value !== "0px" && value !== "rgba(0, 0, 0, 0)") {
out[prop] = value;
}
}
return out;
}
const visibleElements = [...document.querySelectorAll("body *")]
.map((el) => {
const rect = el.getBoundingClientRect();
const area = Math.round(rect.width * rect.height);
return { el, rect, area };
})
.filter(({ rect, area }) => area > 5000 && rect.width > 40 && rect.height > 20)
.slice(0, 220)
.map(({ el, rect, area }) => ({
tag: el.tagName.toLowerCase(),
id: el.id,
className: String(el.className ?? "").slice(0, 240),
text: el.textContent?.trim().replace(/\s+/g, " ").slice(0, 260) ?? "",
rect: {
x: Math.round(rect.x),
y: Math.round(rect.y + scrollY),
width: Math.round(rect.width),
height: Math.round(rect.height),
},
area,
styles: compactStyles(el),
}));
const images = [...document.images].map((img) => ({
src: img.currentSrc || img.src,
alt: img.alt,
naturalWidth: img.naturalWidth,
naturalHeight: img.naturalHeight,
className: String(img.className ?? "").slice(0, 160),
parentClassName: String(img.parentElement?.className ?? "").slice(0, 160),
rect: (() => {
const rect = img.getBoundingClientRect();
return {
x: Math.round(rect.x),
y: Math.round(rect.y + scrollY),
width: Math.round(rect.width),
height: Math.round(rect.height),
};
})(),
}));
const backgroundImages = [...document.querySelectorAll("*")]
.map((el) => ({
tag: el.tagName.toLowerCase(),
className: String(el.className ?? "").slice(0, 180),
backgroundImage: getComputedStyle(el).backgroundImage,
}))
.filter((item) => item.backgroundImage && item.backgroundImage !== "none")
.slice(0, 260);
const videos = [...document.querySelectorAll("video")].map((video) => ({
src: video.currentSrc || video.src,
poster: video.poster,
autoplay: video.autoplay,
loop: video.loop,
muted: video.muted,
}));
const links = [...document.querySelectorAll("a")].map((a) => ({
text: a.textContent?.trim().replace(/\s+/g, " ").slice(0, 160) ?? "",
href: a.href,
className: String(a.className ?? "").slice(0, 120),
}));
const buttons = [...document.querySelectorAll("button, [role='button']")].map((button) => ({
text: button.textContent?.trim().replace(/\s+/g, " ").slice(0, 160) ?? "",
className: String(button.className ?? "").slice(0, 160),
styles: compactStyles(button),
}));
const fontFamilies = [...new Set([...document.querySelectorAll("body *")].slice(0, 500).map((el) => getComputedStyle(el).fontFamily))];
const colors = [...new Set([...document.querySelectorAll("body *")].slice(0, 500).flatMap((el) => {
const cs = getComputedStyle(el);
return [cs.color, cs.backgroundColor].filter(Boolean);
}))].filter((color) => color !== "rgba(0, 0, 0, 0)").slice(0, 100);
return {
title: document.title,
location: location.href,
htmlLang: document.documentElement.lang,
bodyText: document.body.innerText,
meta: [...document.querySelectorAll("meta")].map((meta) => ({
name: meta.getAttribute("name"),
property: meta.getAttribute("property"),
content: meta.getAttribute("content"),
})),
styleSheets: [...document.styleSheets].map((sheet) => sheet.href).filter(Boolean),
scripts: [...document.scripts].map((script) => script.src).filter(Boolean),
links: [...document.querySelectorAll("link")].map((link) => ({
rel: link.rel,
href: link.href,
as: link.as,
})),
visibleElements,
images,
backgroundImages,
videos,
anchors: links,
buttons,
fontFamilies,
colors,
viewport: {
width: innerWidth,
height: innerHeight,
scrollHeight: document.documentElement.scrollHeight,
},
};
});
await fs.writeFile(path.join(researchDir, `source-${name}.html`), await page.content());
await fs.writeFile(
path.join(researchDir, `source-${name}-extract.json`),
JSON.stringify({ pageInfoBeforeScroll, extract, scrollSamples, consoleMessages, failedRequests }, null, 2),
);
await context.close();
return { pageInfoBeforeScroll, extract, scrollSamples, consoleMessages, failedRequests };
}
const iphone = devices["iPhone 13 Pro"];
const mobile = await captureViewport("mobile", {
...iphone,
viewport: { width: 390, height: 844 },
});
const desktop = await captureViewport("desktop", {
viewport: { width: 1440, height: 1200 },
userAgent:
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
deviceScaleFactor: 1,
isMobile: false,
hasTouch: false,
});
await browser.close();
await fs.writeFile(
path.join(researchDir, "source-summary.json"),
JSON.stringify(
{
targetUrl,
mobile: mobile.pageInfoBeforeScroll,
desktop: desktop.pageInfoBeforeScroll,
mobileAssetCounts: {
images: mobile.extract.images.length,
backgrounds: mobile.extract.backgroundImages.length,
videos: mobile.extract.videos.length,
},
desktopAssetCounts: {
images: desktop.extract.images.length,
backgrounds: desktop.extract.backgroundImages.length,
videos: desktop.extract.videos.length,
},
},
null,
2,
),
);
console.log("Source capture complete.");

View File

@@ -0,0 +1,102 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
const root = process.cwd();
const extractPath = path.join(root, "docs", "research", "source-mobile-extract.json");
const assetDir = path.join(root, "public", "assets", "source");
const manifestPath = path.join(assetDir, "manifest.json");
function extractCssUrls(value) {
if (!value) return [];
return [...String(value).matchAll(/url\((?:"|')?([^"')]+)(?:"|')?\)/g)].map((match) => match[1]);
}
function extensionFromContentType(contentType) {
if (contentType.includes("webp")) return ".webp";
if (contentType.includes("png")) return ".png";
if (contentType.includes("gif")) return ".gif";
if (contentType.includes("svg")) return ".svg";
if (contentType.includes("jpeg") || contentType.includes("jpg")) return ".jpg";
return ".bin";
}
function extensionFromUrl(url) {
try {
const parsed = new URL(url);
const ext = path.extname(parsed.pathname).toLowerCase();
if ([".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif"].includes(ext)) {
return ext === ".jpeg" ? ".jpg" : ext;
}
} catch {
return "";
}
return "";
}
const data = JSON.parse(await fs.readFile(extractPath, "utf8"));
const rawUrls = [
...data.extract.images.map((item) => item.src),
...data.extract.images.map((item) => item.src?.replace(/&amp;/g, "&")),
...data.extract.backgroundImages.flatMap((item) => extractCssUrls(item.backgroundImage)),
...data.extract.links.filter((item) => item.as === "image").map((item) => item.href),
].filter(Boolean);
const uniqueUrls = [...new Set(rawUrls)]
.filter((url) => /^https?:\/\//.test(url))
.filter((url) => !url.includes("data:image"));
await fs.mkdir(assetDir, { recursive: true });
const manifest = [];
let cursor = 0;
async function downloadOne(originalUrl, index) {
const hash = crypto.createHash("sha1").update(originalUrl).digest("hex").slice(0, 10);
try {
const response = await fetch(originalUrl, {
headers: {
"User-Agent":
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1",
Referer: "https://m.ctrip.com/",
},
});
if (!response.ok) {
manifest.push({ originalUrl, ok: false, status: response.status });
return;
}
const contentType = response.headers.get("content-type") ?? "";
const ext = extensionFromUrl(originalUrl) || extensionFromContentType(contentType);
const filename = `asset-${String(index).padStart(3, "0")}-${hash}${ext}`;
const filePath = path.join(assetDir, filename);
const arrayBuffer = await response.arrayBuffer();
await fs.writeFile(filePath, Buffer.from(arrayBuffer));
manifest.push({
originalUrl,
ok: true,
status: response.status,
contentType,
bytes: arrayBuffer.byteLength,
filename,
publicPath: `/assets/source/${filename}`,
});
} catch (error) {
manifest.push({ originalUrl, ok: false, error: error instanceof Error ? error.message : String(error) });
}
}
async function worker() {
while (cursor < uniqueUrls.length) {
const index = cursor;
cursor += 1;
await downloadOne(uniqueUrls[index], index);
}
}
await Promise.all(Array.from({ length: 5 }, () => worker()));
manifest.sort((a, b) => (a.filename ?? "").localeCompare(b.filename ?? ""));
await fs.writeFile(manifestPath, JSON.stringify({ generatedAt: new Date().toISOString(), assets: manifest }, null, 2));
const ok = manifest.filter((item) => item.ok).length;
const failed = manifest.length - ok;
console.log(`Downloaded ${ok}/${manifest.length} assets. Failed: ${failed}.`);

View File

@@ -0,0 +1,129 @@
import fs from "node:fs/promises";
import path from "node:path";
import { chromium, devices } from "playwright";
const targetUrl =
"https://m.ctrip.com/tangram/OTQ1Mg==?ctm_ref=vactang_page_9452&isHideNavBar=YES&ctm_ref=vactang_page_159930";
const outDir = path.join(process.cwd(), "docs", "research", "secondary");
const shotDir = path.join(process.cwd(), "docs", "design-references", "secondary");
await fs.mkdir(outDir, { recursive: true });
await fs.mkdir(shotDir, { recursive: true });
const browser = await chromium.launch({ headless: true });
async function newPage() {
const context = await browser.newContext({
...devices["iPhone 13 Pro"],
viewport: { width: 390, height: 844 },
locale: "zh-CN",
timezoneId: "Asia/Shanghai",
extraHTTPHeaders: { "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8" },
});
const page = await context.newPage();
const events = [];
page.on("console", (msg) => {
if (msg.type() === "error") events.push({ type: "console-error", text: msg.text().slice(0, 300) });
});
page.on("pageerror", (err) => events.push({ type: "page-error", text: err.message.slice(0, 300) }));
await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
await page.waitForTimeout(6000);
return { context, page, events };
}
async function snap(name, page, events, extra = {}) {
await page.screenshot({ path: path.join(shotDir, `${name}.png`), fullPage: false });
const data = await page.evaluate(() => ({
title: document.title,
url: location.href,
scrollY,
bodyText: document.body.innerText.slice(0, 2500),
buttons: [...document.querySelectorAll('button, a, [role="button"], .nav_bottom_item')].slice(0, 100).map((el) => ({
tag: el.tagName.toLowerCase(),
text: el.textContent?.trim().replace(/\s+/g, " ").slice(0, 160) ?? "",
className: String(el.className ?? "").slice(0, 160),
href: el instanceof HTMLAnchorElement ? el.href : "",
rect: (() => {
const r = el.getBoundingClientRect();
return { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) };
})(),
})),
}));
await fs.writeFile(path.join(outDir, `${name}.json`), JSON.stringify({ ...data, events, extra }, null, 2));
return data;
}
async function probe(name, action) {
const { context, page, events } = await newPage();
let error = null;
try {
await action(page);
await page.waitForTimeout(5000);
} catch (err) {
error = err instanceof Error ? err.message : String(err);
}
const data = await snap(name, page, events, { error });
await context.close();
return {
name,
error,
title: data.title,
url: data.url,
text: data.bodyText.slice(0, 200).replace(/\n/g, "|"),
};
}
const results = [];
results.push(
await probe("search-click", async (page) => {
await page.locator(".icon-wrap").filter({ hasText: "搜索" }).first().click({ timeout: 10000 });
}),
);
results.push(
await probe("support-click", async (page) => {
await page.locator(".icon-wrap").filter({ hasText: "客服" }).first().click({ timeout: 10000 });
}),
);
results.push(
await probe("product-first-click", async (page) => {
for (let y = 0; y < 2400; y += 600) {
await page.evaluate((scrollY) => scrollTo(0, scrollY), y);
await page.waitForTimeout(500);
}
const item = page.locator(".diy_product_flex_item.expose_dom").filter({ hasText: "¥" }).first();
await item.scrollIntoViewIfNeeded();
await page.waitForTimeout(500);
await item.click({ timeout: 10000 });
}),
);
results.push(
await probe("bottom-destination-click", async (page) => {
await page.locator(".nav_bottom_item").filter({ hasText: "目的地" }).first().click({ timeout: 10000 });
}),
);
results.push(
await probe("bottom-manager-click", async (page) => {
await page.locator(".nav_bottom_item").filter({ hasText: "客户经理" }).first().click({ timeout: 10000 });
}),
);
results.push(
await probe("bottom-message-click", async (page) => {
await page.locator(".nav_bottom_item").filter({ hasText: "消息" }).first().click({ timeout: 10000 });
}),
);
results.push(
await probe("bottom-order-click", async (page) => {
await page.locator(".nav_bottom_item").filter({ hasText: "订单" }).first().click({ timeout: 10000 });
}),
);
await fs.writeFile(path.join(outDir, "probe-summary.json"), JSON.stringify(results, null, 2));
console.log(JSON.stringify(results, null, 2));
await browser.close();