chore: initialize WanderQ MiniAPP 2.0 repository
This commit is contained in:
308
scripts/capture-source.mjs
Normal file
308
scripts/capture-source.mjs
Normal 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.");
|
||||
125
scripts/download-assets.mjs
Normal file
125
scripts/download-assets.mjs
Normal file
@@ -0,0 +1,125 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { config as loadEnv } from "dotenv";
|
||||
import OSS from "ali-oss";
|
||||
import sharp from "sharp";
|
||||
|
||||
const root = process.cwd();
|
||||
const extractPath = path.join(root, "docs", "research", "source-mobile-extract.json");
|
||||
const manifestPath = path.join(root, "docs", "research", "source-assets.json");
|
||||
|
||||
loadEnv({ path: path.join(root, ".env") });
|
||||
|
||||
function endpointHost(endpoint) {
|
||||
return endpoint.replace(/^https?:\/\//i, "").replace(/\/$/, "").split("/")[0];
|
||||
}
|
||||
|
||||
function urlForKey(key) {
|
||||
const configured = String(process.env.MEDIA_PUBLIC_BASE_URL ?? "").replace(/\/$/, "");
|
||||
const endpoint = endpointHost(String(process.env.OSS_ENDPOINT ?? ""));
|
||||
const baseUrl = configured || `https://${process.env.OSS_BUCKET_NAME}.${endpoint}`;
|
||||
return `${baseUrl}/${key.split("/").map((part) => encodeURIComponent(part)).join("/")}`;
|
||||
}
|
||||
|
||||
function buildClient() {
|
||||
const endpoint = process.env.OSS_ENDPOINT;
|
||||
const bucket = process.env.OSS_BUCKET_NAME;
|
||||
const accessKeyId = process.env.OSS_ACCESS_KEY_ID;
|
||||
const accessKeySecret = process.env.OSS_ACCESS_KEY_SECRET;
|
||||
if (!endpoint || !bucket || !accessKeyId || !accessKeySecret) {
|
||||
throw new Error("OSS 存储需要配置 OSS_ENDPOINT、OSS_BUCKET_NAME、OSS_ACCESS_KEY_ID 和 OSS_ACCESS_KEY_SECRET");
|
||||
}
|
||||
const host = endpointHost(endpoint);
|
||||
return new OSS({
|
||||
region: process.env.OSS_REGION ?? host.split(".")[0],
|
||||
endpoint: /^https?:\/\//i.test(endpoint) ? endpoint : `https://${endpoint}`,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
bucket,
|
||||
secure: true,
|
||||
});
|
||||
}
|
||||
|
||||
function extractCssUrls(value) {
|
||||
if (!value) return [];
|
||||
return [...String(value).matchAll(/url\((?:"|')?([^"')]+)(?:"|')?\)/g)].map((match) => match[1]);
|
||||
}
|
||||
|
||||
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(/&/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"));
|
||||
|
||||
const manifest = [];
|
||||
let cursor = 0;
|
||||
const client = buildClient();
|
||||
|
||||
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 arrayBuffer = await response.arrayBuffer();
|
||||
const sourceBuffer = Buffer.from(arrayBuffer);
|
||||
const lossless = contentType.includes("png") || contentType.includes("svg");
|
||||
const webpBuffer = await sharp(sourceBuffer)
|
||||
.webp({ quality: 84, lossless, effort: 6 })
|
||||
.toBuffer();
|
||||
const filename = `asset-${String(index).padStart(3, "0")}-${hash}.webp`;
|
||||
const objectKey = `wanqu/miniapp/static/source/${filename}`;
|
||||
await client.put(objectKey, webpBuffer, {
|
||||
headers: {
|
||||
"Content-Type": "image/webp",
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
manifest.push({
|
||||
originalUrl,
|
||||
ok: true,
|
||||
status: response.status,
|
||||
contentType,
|
||||
sourceBytes: arrayBuffer.byteLength,
|
||||
bytes: webpBuffer.byteLength,
|
||||
filename,
|
||||
objectKey,
|
||||
publicPath: urlForKey(objectKey),
|
||||
});
|
||||
} 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.mkdir(path.dirname(manifestPath), { recursive: true });
|
||||
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}.`);
|
||||
162
scripts/migrate-static-assets.mjs
Normal file
162
scripts/migrate-static-assets.mjs
Normal file
@@ -0,0 +1,162 @@
|
||||
import { config as loadEnv } from "dotenv";
|
||||
import OSS from "ali-oss";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = resolve(scriptDir, "..");
|
||||
const pythonScript = join(scriptDir, "prepare-static-webp.py");
|
||||
const targetFiles = [
|
||||
"src/App.tsx",
|
||||
"src/content.ts",
|
||||
"src/generated-products.json",
|
||||
"src/search-page-defaults.ts",
|
||||
"src/train-activity.ts",
|
||||
"apps/admin/src/App.tsx",
|
||||
"apps/api/src/home-module-defaults.ts",
|
||||
"apps/api/src/routes/admin.ts",
|
||||
"apps/api/src/routes/memory.ts",
|
||||
"apps/api/src/search-page.ts",
|
||||
];
|
||||
|
||||
loadEnv({ path: resolve(projectRoot, ".env") });
|
||||
|
||||
const defaultPublicBaseUrl = (() => {
|
||||
const endpoint = String(process.env.OSS_ENDPOINT ?? "")
|
||||
.replace(/^https?:\/\//i, "")
|
||||
.replace(/\/$/, "")
|
||||
.split("/")[0];
|
||||
return process.env.OSS_BUCKET_NAME && endpoint ? `https://${process.env.OSS_BUCKET_NAME}.${endpoint}` : "";
|
||||
})();
|
||||
const configuredPublicBaseUrl = String(process.env.MEDIA_PUBLIC_BASE_URL ?? "").replace(/\/$/, "");
|
||||
const publicBaseUrl = configuredPublicBaseUrl || defaultPublicBaseUrl;
|
||||
if (!publicBaseUrl) throw new Error("无法推导 OSS 公共地址,请配置 MEDIA_PUBLIC_BASE_URL 或 OSS_BUCKET_NAME/OSS_ENDPOINT");
|
||||
|
||||
function endpointHost(endpoint) {
|
||||
return endpoint.replace(/^https?:\/\//i, "").replace(/\/$/, "").split("/")[0];
|
||||
}
|
||||
|
||||
function urlForKey(key) {
|
||||
return `${publicBaseUrl}/${key.split("/").map((part) => encodeURIComponent(part)).join("/")}`;
|
||||
}
|
||||
|
||||
function run(command, args) {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const child = spawn(command, args, { cwd: projectRoot, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => {
|
||||
if (code === 0) {
|
||||
resolvePromise(stdout.trim());
|
||||
return;
|
||||
}
|
||||
reject(new Error(`${command} 退出码 ${code}: ${stderr || stdout}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildClient() {
|
||||
const accessKeyId = process.env.OSS_ACCESS_KEY_ID;
|
||||
const accessKeySecret = process.env.OSS_ACCESS_KEY_SECRET;
|
||||
const endpoint = process.env.OSS_ENDPOINT;
|
||||
const bucket = process.env.OSS_BUCKET_NAME;
|
||||
if (!accessKeyId || !accessKeySecret || !endpoint || !bucket) {
|
||||
throw new Error("OSS 存储需要配置 OSS_ACCESS_KEY_ID、OSS_ACCESS_KEY_SECRET、OSS_ENDPOINT 和 OSS_BUCKET_NAME");
|
||||
}
|
||||
const host = endpointHost(endpoint);
|
||||
return {
|
||||
bucket,
|
||||
client: new OSS({
|
||||
region: process.env.OSS_REGION ?? host.split(".")[0],
|
||||
endpoint: /^https?:\/\//i.test(endpoint) ? endpoint : `https://${endpoint}`,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
bucket,
|
||||
secure: true,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadAll(manifest, outputRoot) {
|
||||
const { client } = buildClient();
|
||||
let cursor = 0;
|
||||
let uploaded = 0;
|
||||
const concurrency = 4;
|
||||
async function worker() {
|
||||
while (cursor < manifest.length) {
|
||||
const item = manifest[cursor++];
|
||||
const buffer = await readFile(join(outputRoot, item.outputPath));
|
||||
await client.put(item.objectKey, buffer, {
|
||||
headers: {
|
||||
"Content-Type": "image/webp",
|
||||
"Cache-Control": "public, max-age=31536000, immutable",
|
||||
},
|
||||
});
|
||||
uploaded += 1;
|
||||
if (uploaded % 20 === 0 || uploaded === manifest.length) {
|
||||
console.log(`OSS 上传进度 ${uploaded}/${manifest.length}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, manifest.length) }, () => worker()));
|
||||
}
|
||||
|
||||
async function rewriteReferences(manifest) {
|
||||
const replacements = new Map();
|
||||
for (const item of manifest) {
|
||||
if (item.reference) replacements.set(item.reference, urlForKey(item.objectKey));
|
||||
}
|
||||
|
||||
for (const relativePath of targetFiles) {
|
||||
const absolutePath = resolve(projectRoot, relativePath);
|
||||
let source = await readFile(absolutePath, "utf8");
|
||||
for (const [from, to] of replacements) source = source.split(from).join(to);
|
||||
await writeFile(absolutePath, source);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const workDir = await mkdtemp(join(tmpdir(), "miniapp-static-webp-"));
|
||||
const outputRoot = join(workDir, "webp");
|
||||
const manifestPath = join(workDir, "manifest.json");
|
||||
try {
|
||||
const python = process.env.MINIAPP_IMAGE_PYTHON || process.env.PYTHON || "python3";
|
||||
const prepareSummary = await run(python, [pythonScript, "--root", projectRoot, "--output", outputRoot, "--manifest", manifestPath]);
|
||||
console.log(`WebP 转码完成:${prepareSummary}`);
|
||||
const manifest = JSON.parse(await readFile(manifestPath, "utf8")).assets;
|
||||
await uploadAll(manifest, outputRoot);
|
||||
await rewriteReferences(manifest);
|
||||
await writeFile(resolve(projectRoot, "scripts/static-asset-manifest.json"), JSON.stringify({
|
||||
generatedAt: new Date().toISOString(),
|
||||
publicBaseUrl,
|
||||
assets: manifest.map((item) => ({
|
||||
sourcePath: item.sourcePath,
|
||||
reference: item.reference,
|
||||
objectKey: item.objectKey,
|
||||
url: urlForKey(item.objectKey),
|
||||
lossless: item.lossless,
|
||||
sourceBytes: item.sourceBytes,
|
||||
outputBytes: item.outputBytes,
|
||||
sha256: item.sha256,
|
||||
})),
|
||||
}, null, 2) + "\n");
|
||||
console.log(JSON.stringify({ message: "静态图片已转 WebP 并上传 OSS", assets: manifest.length, publicBaseUrl }, null, 2));
|
||||
} finally {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
129
scripts/prepare-static-webp.py
Normal file
129
scripts/prepare-static-webp.py
Normal file
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert the repository's runtime image assets to WebP.
|
||||
|
||||
This is intentionally a one-time migration helper. It keeps the source files
|
||||
untouched and writes converted files into a caller-provided temporary folder.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
|
||||
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png"}
|
||||
LOSSLESS_PATH_PARTS = {"routes", "train-intro", "volumes", "products", "source"}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--root", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--manifest", type=Path, required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def source_files(root: Path) -> list[tuple[Path, str]]:
|
||||
public_root = root / "public" / "assets"
|
||||
files: list[tuple[Path, str]] = []
|
||||
for path in sorted(public_root.rglob("*")):
|
||||
if path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS:
|
||||
relative = path.relative_to(public_root).as_posix()
|
||||
files.append((path, relative))
|
||||
|
||||
logo = root / "apps" / "miniprogram" / "src" / "assets" / "wanderq-logo.png"
|
||||
if logo.is_file():
|
||||
files.append((logo, "brand/wanderq-logo.png"))
|
||||
return files
|
||||
|
||||
|
||||
def should_use_lossless(source: Path, relative: str) -> bool:
|
||||
if source.suffix.lower() == ".png":
|
||||
return True
|
||||
parts = set(Path(relative).parts)
|
||||
return bool(parts & LOSSLESS_PATH_PARTS) or relative.endswith("train/price-overview.jpg")
|
||||
|
||||
|
||||
def convert(source: Path, target: Path, lossless: bool) -> None:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with Image.open(source) as opened:
|
||||
image = ImageOps.exif_transpose(opened)
|
||||
save_options = {
|
||||
"format": "WEBP",
|
||||
"method": 6,
|
||||
"lossless": lossless,
|
||||
}
|
||||
if not lossless:
|
||||
save_options["quality"] = 84
|
||||
icc_profile = image.info.get("icc_profile")
|
||||
if icc_profile:
|
||||
save_options["icc_profile"] = icc_profile
|
||||
image.save(target, **save_options)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
root = args.root.resolve()
|
||||
output = args.output.resolve()
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manifest: list[dict[str, object]] = []
|
||||
files = source_files(root)
|
||||
if not files:
|
||||
raise SystemExit("未找到本地静态图片源;为避免生成空清单,请确认源素材仍在 public/assets 或小程序 logo 路径中")
|
||||
|
||||
for source, relative in files:
|
||||
output_relative = f"{Path(relative).with_suffix('')}.webp"
|
||||
target = output / output_relative
|
||||
lossless = should_use_lossless(source, relative)
|
||||
convert(source, target, lossless)
|
||||
source_bytes = source.stat().st_size
|
||||
output_bytes = target.stat().st_size
|
||||
digest = hashlib.sha256(target.read_bytes()).hexdigest()
|
||||
if relative == "brand/wanderq-logo.png":
|
||||
reference = None
|
||||
object_key = "wanqu/miniapp/static/brand/wanderq-logo.webp"
|
||||
source_path = "apps/miniprogram/src/assets/wanderq-logo.png"
|
||||
else:
|
||||
reference = f"/assets/{relative}"
|
||||
object_key = f"wanqu/miniapp/static/{output_relative}"
|
||||
source_path = f"public/assets/{relative}"
|
||||
manifest.append(
|
||||
{
|
||||
"sourcePath": source_path,
|
||||
"reference": reference,
|
||||
"outputPath": output_relative,
|
||||
"objectKey": object_key,
|
||||
"lossless": lossless,
|
||||
"sourceBytes": source_bytes,
|
||||
"outputBytes": output_bytes,
|
||||
"sha256": digest,
|
||||
}
|
||||
)
|
||||
|
||||
args.manifest.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.manifest.write_text(json.dumps({"assets": manifest}, ensure_ascii=False, indent=2) + os.linesep)
|
||||
source_total = sum(int(item["sourceBytes"]) for item in manifest)
|
||||
output_total = sum(int(item["outputBytes"]) for item in manifest)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"assets": len(manifest),
|
||||
"sourceBytes": source_total,
|
||||
"outputBytes": output_total,
|
||||
"savedBytes": source_total - output_total,
|
||||
"lossless": sum(1 for item in manifest if item["lossless"]),
|
||||
"lossy": sum(1 for item in manifest if not item["lossless"]),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
129
scripts/probe-secondary.mjs
Normal file
129
scripts/probe-secondary.mjs
Normal 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();
|
||||
2896
scripts/static-asset-manifest.json
Normal file
2896
scripts/static-asset-manifest.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user