用Playwright扫描渲染问题

17 March 2026
IMG-THUMBNAIL
如何扫描整个站点,查找渲染问题?

按照依赖包:

npm install playwright --save
npx playwright install

保存下面的脚本,用命令node scan.js运行即可。

const { chromium, devices } = require('playwright');
const https = require('https');
const fs = require('fs');
const path = require('path');

// --- 配置区域 ---
const CONFIG = {
  urls: [
// 'https://www.cyeam.com/tool/ddl2gostruct',
// 'https://www.cyeam.com/tool/json2ddl',
 
    'https://www.cyeam.com/sitemap.xml',
   ],
  outputDir: './scan-results',
  timeout: 10000,
  headless: true,
  devicesToTest: [
    { name: 'Mobile-iPhone-15', preset: 'iPhone 15' },
    { name: 'Desktop-MacBook-Pro-16', preset: 'MacBook Pro 16' },
  ],
  maxSitemapUrls: 300,
  retryTimes: 3 // 重试次数
};

// --- 辅助函数:带重试的page.goto ---
const gotoWithRetry = async (page, url, options, maxRetries) => {
  let lastError;
  for (let retry = 1; retry <= maxRetries; retry++) {
    try {
      if (retry > 1) {
        console.log(`   🔄 重试(${retry}/${maxRetries})访问: ${url}`);
      }
      await page.goto(url, options);
      return; // 成功则直接返回
    } catch (error) {
      lastError = error;
      if (error.message.includes('Timeout') || error.message.includes('timeout')) {
        if (retry === maxRetries) {
          throw new Error(`[Retry Failed] 超过${maxRetries}次重试仍无法访问: ${url},错误:${error.message}`);
        }
        await new Promise(resolve => setTimeout(resolve, 1000));
      } else {
        throw error;
      }
    }
  }
  throw lastError;
};

// --- 辅助函数:原生解析Sitemap ---
const fetchSitemapUrls = (sitemapUrl) => {
  return new Promise((resolve, reject) => {
    https.get(sitemapUrl, (res) => {
      let xml = '';
      // 1. 处理编码问题(避免chunk拼接乱码)
      res.setEncoding('utf8');
      
      res.on('data', (chunk) => xml += chunk);
      res.on('end', () => {
        try {
          // 2. 替换正则匹配方式:用matchAll(ES2020+),避免lastIndex陷阱
          const urlRegex = /<loc>(.*?)<\/loc>/g;
          // 兼容写法:如果环境不支持matchAll,用while循环+重置lastIndex
          const urls = [];
          let match;
          // 重置正则lastIndex,确保从头匹配
          urlRegex.lastIndex = 0;
          while ((match = urlRegex.exec(xml)) !== null) {
            if (match[1]) {
              urls.push(match[1].trim()); // 去除首尾空格,避免无效URL
            }
          }

          // 3. 优化域名过滤逻辑:匹配主域名(而非完整hostname)
          const baseUrlObj = new URL(sitemapUrl);
          // 提取主域名(如从www.cyeam.com提取cyeam.com)
          const baseDomain = baseUrlObj.hostname.split('.').slice(-2).join('.');
          
          const filteredUrls = urls.filter(url => {
            try {
              const urlObj = new URL(url);
              // 匹配主域名(支持所有子域名:www/blog/note/game.cyeam.com)
              const urlDomain = urlObj.hostname.split('.').slice(-2).join('.');
              return urlDomain === baseDomain;
            } catch (e) {
              console.warn('无效URL:', url, e.message);
              return false;
            }
          });

          // 4. 最终结果:按CONFIG截断(但先确保CONFIG值足够大)
          const result = filteredUrls.slice(0, CONFIG.maxSitemapUrls);
          console.log(`提取结果:总匹配${urls.length}个 → 过滤后${filteredUrls.length}个 → 最终${result.length}个`);
          resolve(result);
        } catch (e) {
          reject(new Error(`解析XML失败:${e.message}`));
        }
      });
    })
    // 处理请求错误(如超时、连接失败)
    .on('error', (err) => reject(new Error(`请求Sitemap失败:${err.message}`)))
    // 处理超时
    .setTimeout(10000, () => reject(new Error('请求Sitemap超时')));
  });
};

// --- 辅助函数:初始化目录 ---
const initDirs = (deviceName) => {
  const dir = path.join(CONFIG.outputDir, deviceName);
  const shotDir = path.join(dir, 'screenshots');
  if (!fs.existsSync(shotDir)) fs.mkdirSync(shotDir, { recursive: true });
  return { dir, shotDir };
};

// --- 核心逻辑 ---
(async () => {
  try {
    let urls = [];
    let isSitemapMode = false;

    if (CONFIG.urls.length === 1 && CONFIG.urls[0].toLowerCase().includes('sitemap.xml')) {
      isSitemapMode = true;
      const sitemapUrl = CONFIG.urls[0];
      console.log(`🔍 检测到sitemap模式,正在解析: ${sitemapUrl}`);
      urls = await fetchSitemapUrls(sitemapUrl);
      console.log(`✅ 从sitemap解析出 ${urls.length} 个URL\n`);
    } else {
      urls = CONFIG.urls;
      console.log(`📋 使用手动配置的URL列表,共 ${urls.length} 个URL\n`);
    }

    if (urls.length === 0) {
      console.log('❌ 未找到可检测的URL,请检查配置!');
      return;
    }

    for (const deviceConfig of CONFIG.devicesToTest) {
      console.log(`\n========== 正在测试设备: ${deviceConfig.name} ==========`);
      const { dir, shotDir } = initDirs(deviceConfig.name);
      const results = [];

      const browser = await chromium.launch({ headless: CONFIG.headless });
      
      let context;
      if (deviceConfig.preset) {
        const device = devices[deviceConfig.preset];
        context = await browser.newContext({ ...device });
      } else {
        context = await browser.newContext({ viewport: deviceConfig.viewport });
      }

      const page = await context.newPage();

      for (const url of urls) {
        // 1. 初始化结果对象,新增timeCost字段
        const pageResult = { url, errors: [], screenshot: null, timeCost: 0 };
        const safeFilename = url.replace(/[^a-zA-Z0-9]/g, '_');
        
        // 2. 记录访问开始时间(毫秒级)
        const startTime = Date.now();

        try {
          page.on('pageerror', err => pageResult.errors.push(`[JS] ${err.message}`));
          
          // 访问页面(包含重试逻辑)
          await gotoWithRetry(page, url, { waitUntil: 'load', timeout: CONFIG.timeout }, CONFIG.retryTimes);

          // 检查横向滚动条
          const hasHorizontalScroll = await page.evaluate(() => {
            return document.documentElement.scrollWidth > document.documentElement.clientWidth;
          });
          if (hasHorizontalScroll) {
            pageResult.errors.push('[Layout] 检测到横向滚动条,内容溢出屏幕');
          }

          // 检查裂图
          const brokenImages = await page.evaluate(() => {
            const imgs = Array.from(document.images);
            return imgs
              .filter(img => !img.alt.includes('大图预览'))
              .filter(img => img.naturalWidth === 0)
              .map(img => {
                const getXPath = (element) => {
                  if (element.id !== '') return `//*[@id="${element.id}"]`;
                  if (element === document.body) return '/html/body';
                  
                  let ix = 0;
                  const siblings = element.parentNode.childNodes;
                  for (let i = 0; i < siblings.length; i++) {
                    const sibling = siblings[i];
                    if (sibling === element) return `${getXPath(element.parentNode)}/${element.tagName.toLowerCase()}[${ix + 1}]`;
                    if (sibling.nodeType === 1 && sibling.tagName === element.tagName) ix++;
                  }
                  return getXPath(element.parentNode);
                };

                return {
                  src: img.src,
                  alt: img.alt,
                  html: img.outerHTML,
                  xpath: getXPath(img)
                };
              });
          });

          if (brokenImages.length > 0) {
            for (const brokenImg of brokenImages) {
              const errMsg = `[Layout] 裂图详情:
                - 链接: ${brokenImg.src}
                - Alt文本: ${brokenImg.alt || '无'}
                - HTML代码: ${brokenImg.html}
                - XPath路径: ${brokenImg.xpath}`;
              pageResult.errors.push(errMsg);
            }
          }

          // 截图保存
          const screenshotPath = path.join(deviceConfig.name, 'screenshots', `${safeFilename}.png`);
          await page.screenshot({ path: path.join(CONFIG.outputDir, screenshotPath), fullPage: true });
          pageResult.screenshot = screenshotPath;

        } catch (e) {
          pageResult.errors.push(`[Critical] ${e.message}`);
        } finally {
          // 3. 计算总耗时(无论成功/失败,都会执行)
          pageResult.timeCost = Date.now() - startTime;
        }

        results.push(pageResult);
        
        // 4. 控制台输出时展示耗时
        if (pageResult.errors.length > 0) {
          console.log(`❌ ${url} (耗时: ${pageResult.timeCost}ms)`);
          pageResult.errors.forEach(err => console.log(`   - ${err}`));
        } else {
          console.log(`✅ ${url} (耗时: ${pageResult.timeCost}ms)`);
        }
      }

      // 保存检测报告(包含耗时字段)
      fs.writeFileSync(path.join(dir, 'report.json'), JSON.stringify(results, null, 2));
      await browser.close();
    }

    console.log(`\n🎉 扫描完成!结果已保存至: ${path.resolve(CONFIG.outputDir)}`);
  } catch (error) {
    console.error('💥 运行出错:', error);
  }
})();

原文链接: 用Playwright扫描渲染问题 ,转载请注明来源!

EOF