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.");