前言

阿里滑块一直在跑231.1版本,最近发现有些接口使用231.1生成的x5sec还是会重复出现滑块,于是找朋友搞了一版最新的,其中他的_rand参数的关键部分用了playwright自动化生成,放到docker中并发有点过于占用内存,就研究了下生成机制。

1. 检测代码

<html><head><style>@keyframes ____9125356020{0%{opacity:0.63}100%{opacity:0.63}}@keyframes ____9125356021{0%{opacity:0.86}100%{opacity:0.23}}@keyframes ____9125356022{0%{opacity:0.63}100%{opacity:0.0}}@keyframes ____9125356023{0%{opacity:0.7}100%{opacity:0.3}}@keyframes ____9125356024{0%{opacity:0.73}100%{opacity:0.92}}@keyframes ____9125356025{0%{opacity:0.13}100%{opacity:0.80}}@keyframes ____9125356026{0%{opacity:0.43}100%{opacity:0.95}}@keyframes ____9125356027{0%{opacity:0.59}100%{opacity:0.56}}@supports (font-style:normal) and ((overflow:visible) or ((not (overflow:visible)))){.____166429906111{color:darkviolet;background:navy;border-color:teal;animation:100ms linear 100ms 1 reverse backwards running ____9125356024}.____166429906126{color:red;background:darkgreen;border-color:red;animation:100ms linear 100ms 1 normal backwards running ____9125356025}.____16642990610{color:gray;background:red;border-color:darkred;animation:100ms linear 100ms 1 reverse backwards running ____9125356027}}@media (max-height:17px){.____16642990610{color:darkmagenta;background:teal;border-color:darkorange;animation:100ms linear 100ms 1 normal backwards running ____9125356022}}@media not all{.____166429906112{color:orange;background:darkred;border-color:white;animation:100ms linear 100ms 1 normal backwards running ____9125356022}.____166429906125{color:aqua;background:darkmagenta;border-color:darkcyan;animation:100ms linear 100ms 1 normal forwards running ____9125356022}.____16642990614{color:cyan;background:darkred;border-color:white;animation:100ms linear 100ms 1 normal backwards running ____9125356026}}@supports (font-style:italic) and ((position:fixed) and ((not (border-width:medium)))){.____166429906120{color:darkgoldenrod;background:darkviolet;border-color:yellow;animation:100ms linear 100ms 1 normal forwards running ____9125356026}.____166429906114{color:darksalmon;background:aqua;border-color:teal;animation:100ms linear 100ms 1 reverse forwards running ____9125356027}}@supports (not (position:absolute)){.____166429906125{color:orange;background:darkgray;border-color:gray;animation:100ms linear 100ms 1 normal forwards running ____9125356021}.____166429906130{color:fuchsia;background:darkorchid;border-color:darkblue;animation:100ms linear 100ms 1 normal forwards running ____9125356026}}@supports (not (display:none)){.____166429906118{color:darkorange;background:lightblue;border-color:yellow;animation:100ms linear 100ms 1 normal forwards running ____9125356025}}@supports (not (text-align:right)){.____16642990614{color:darkgreen;background:gray;border-color:darkgoldenrod;animation:100ms linear 100ms 1 reverse backwards running ____9125356022}.____166429906122{color:blue;background:lime;border-color:fuchsia;animation:100ms linear 100ms 1 normal backwards running ____9125356022}.____16642990618{color:gray;background:purple;border-color:white;animation:100ms linear 100ms 1 normal backwards running ____9125356023}}@supports (not (border-width:10px)) and ((color:rgb(169,162%,14)) and ((not (overflow:visible)))){.____16642990615{color:gray;background:brown;border-color:gray;animation:100ms linear 100ms 1 reverse forwards running ____9125356022}.____166429906112{color:darkcyan;background:green;border-color:yellow;animation:100ms linear 100ms 1 reverse forwards running ____9125356023}}@media not print{.____166429906117{color:purple;background:fuchsia;border-color:aqua;animation:100ms linear 100ms 1 normal backwards running ____9125356022}.____166429906111{color:lightgray;background:darkred;border-color:teal;animation:100ms linear 100ms 1 reverse backwards running ____9125356020}.____166429906118{color:silver;background:gray;border-color:red;animation:100ms linear 100ms 1 reverse forwards running ____9125356020}}@media not print{.____166429906120{color:darkgray;background:darkgoldenrod;border-color:maroon;animation:100ms linear 100ms 1 normal backwards running ____9125356020}.____166429906113{color:blue;background:brown;border-color:darksalmon;animation:100ms linear 100ms 1 normal forwards running ____9125356020}}@supports (border-width:10px) and ((not (border-width:thick))){.____166429906114{color:black;background:darkorchid;border-color:red;animation:100ms linear 100ms 1 reverse backwards running ____9125356024}.____166429906129{color:darksalmon;background:maroon;border-color:darkgoldenrod;animation:100ms linear 100ms 1 reverse backwards running ____9125356026}.____166429906129{color:lime;background:darkgray;border-color:fuchsia;animation:100ms linear 100ms 1 reverse forwards running ____9125356021}}@supports (color:yellow) and ((position:relative) and ((border-width:medium) and ((not (overflow:scroll))))){.____166429906116{color:maroon;background:white;border-color:darkgray;animation:100ms linear 100ms 1 normal backwards running ____9125356020}.____166429906131{color:darkorange;background:aqua;border-color:purple;animation:100ms linear 100ms 1 normal backwards running ____9125356024}}@supports (display:block){.____166429906114{color:lightgray;background:darkred;border-color:yellow;animation:100ms linear 100ms 1 reverse backwards running ____9125356024}.____166429906116{color:gray;background:lime;border-color:darkgreen;animation:100ms linear 100ms 1 normal forwards running ____9125356023}}@supports (position:absolute){.____16642990616{color:black;background:purple;border-color:darkgreen;animation:100ms linear 100ms 1 normal backwards running ____9125356024}.____16642990610{color:darkviolet;background:lime;border-color:navy;animation:100ms linear 100ms 1 reverse forwards running ____9125356024}.____16642990610{color:white;background:lightblue;border-color:black;animation:100ms linear 100ms 1 normal backwards running ____9125356023}}@supports (not (color:rgb(255%,246,181%))) or ((position:sticked)){.____16642990617{color:darksalmon;background:darkmagenta;border-color:red;animation:100ms linear 100ms 1 normal backwards running ____9125356020}.____166429906127{color:red;background:darkcyan;border-color:lightgray;animation:100ms linear 100ms 1 reverse forwards running ____9125356021}}@supports (overflow:visible) and ((not (font-style:oblique))){.____166429906131{color:silver;background:darkviolet;border-color:red;animation:100ms linear 100ms 1 reverse backwards running ____9125356022}}@media (max-height:10407px){.____16642990618{color:blue;background:teal;border-color:purple;animation:100ms linear 100ms 1 normal forwards running ____9125356025}}@supports (border-width:10px) or ((font-style:oblique)){.____16642990611{color:maroon;background:gray;border-color:olive;animation:100ms linear 100ms 1 reverse backwards running ____9125356026}}@supports (not (color:#5D0A0)){.____166429906120{color:black;background:darkviolet;border-color:white;animation:100ms linear 100ms 1 normal backwards running ____9125356023}.____166429906127{color:lime;background:darkorange;border-color:yellow;animation:100ms linear 100ms 1 normal backwards running ____9125356025}}@supports (text-align:left) and ((position:absolute)){.____166429906128{color:darkviolet;background:fuchsia;border-color:gray;animation:100ms linear 100ms 1 reverse backwards running ____9125356024}.____166429906127{color:white;background:purple;border-color:teal;animation:100ms linear 100ms 1 normal backwards running ____9125356026}}@media (min-width:7px){.____166429906121{color:teal;background:maroon;border-color:orange;animation:100ms linear 100ms 1 reverse forwards running ____9125356020}}@media (max-width:7px){.____166429906115{color:purple;background:darkviolet;border-color:teal;animation:100ms linear 100ms 1 normal backwards running ____9125356023}.____166429906123{color:red;background:darkmagenta;border-color:lightgray;animation:100ms linear 100ms 1 normal backwards running ____9125356027}}@supports (position:fixed) or ((not (display:block))){.____16642990618{color:teal;background:fuchsia;border-color:aqua;animation:100ms linear 100ms 1 reverse backwards running ____9125356025}}@media not all{.____166429906114{color:darkred;background:yellow;border-color:black;animation:100ms linear 100ms 1 reverse backwards running ____9125356022}}@media not all{.____166429906110{color:darkblue;background:teal;border-color:darkorchid;animation:100ms linear 100ms 1 reverse backwards running ____9125356027}.____166429906121{color:olive;background:olive;border-color:gray;animation:100ms linear 100ms 1 reverse forwards running ____9125356026}}@supports (not (display:none)) and ((position:fixed) and ((display:table-flex) and ((not (overflow:scroll))))){.____166429906127{color:lightgray;background:olive;border-color:green;animation:100ms linear 100ms 1 reverse forwards running ____9125356022}.____16642990619{color:darksalmon;background:olive;border-color:lightgray;animation:100ms linear 100ms 1 reverse backwards running ____9125356023}}@supports (not (display:none)) and ((not (color:#D8A7))){.____166429906117{color:red;background:maroon;border-color:darkgray;animation:100ms linear 100ms 1 reverse backwards running ____9125356024}}@media (max-width:16047px){.____166429906127{color:orange;background:orange;border-color:brown;animation:100ms linear 100ms 1 reverse forwards running ____9125356020}.____166429906113{color:silver;background:darkviolet;border-color:brown;animation:100ms linear 100ms 1 normal backwards running ____9125356022}.____166429906131{color:darkred;background:darkviolet;border-color:darkgoldenrod;animation:100ms linear 100ms 1 reverse backwards running ____9125356021}}@supports (overflow:hidden) and ((color:rgb(141,222,102))){.____166429906120{color:darksalmon;background:brown;border-color:olive;animation:100ms linear 100ms 1 reverse forwards running ____9125356022}}@media (min-width:12676px){.____16642990616{color:white;background:maroon;border-color:purple;animation:100ms linear 100ms 1 reverse forwards running ____9125356023}}@supports (not (position:relative)) and ((overflow:hidden) or ((display:block))){.____166429906112{color:darkred;background:cyan;border-color:navy;animation:100ms linear 100ms 1 normal forwards running ____9125356026}}@supports (not (border-width:thick)) and ((not (text-align:left))){.____16642990618{color:darkviolet;background:navy;border-color:navy;animation:100ms linear 100ms 1 reverse backwards running ____9125356026}.____16642990614{color:yellow;background:darkgreen;border-color:yellow;animation:100ms linear 100ms 1 reverse forwards running ____9125356020}}</style></head><body></body><script>
        var lf = []
        var A = document.body.appendChild(document.createElement("div"));
        A.innerHTML = '<span class="____16642990610" data-x22="10" data-x5="2"></span><span class="____16642990611" data-x22="10" data-x5="7" data-x17="0" data-x29="5" data-x25="0"></span><span class="____16642990612" data-x22="4" data-x5="7" data-x17="0" data-x29="5" data-x25="7" data-x6="10"></span><span class="____16642990613" data-x22="4" data-x5="7" data-x17="0" data-x29="5" data-x25="7" data-x6="3" data-x21="8" data-x28="12" data-x1="7"></span><span class="____16642990614" data-x22="4" data-x5="7" data-x17="0" data-x29="5" data-x25="7" data-x6="3" data-x21="8" data-x28="12" data-x1="7" data-x20="8" data-x10="2" data-x19="5"></span><span class="____16642990615" data-x22="4" data-x5="7" data-x17="0" data-x29="5" data-x25="2" data-x6="7" data-x21="8" data-x28="12" data-x1="7" data-x20="8" data-x10="2" data-x19="5"></span><span class="____16642990616" data-x22="4" data-x5="7" data-x17="0" data-x29="5" data-x25="2" data-x6="7" data-x21="8" data-x28="3" data-x1="7" data-x20="0" data-x10="2" data-x19="5" data-x18="9"></span><span class="____16642990617" data-x22="4" data-x5="7" data-x17="0" data-x29="5" data-x25="2" data-x6="7" data-x21="8" data-x28="3" data-x1="7" data-x20="0" data-x10="2" data-x19="5" data-x18="9" data-x27="13" data-x0="4" data-x12="12"></span><span class="____16642990618" data-x22="4" data-x5="7" data-x17="0" data-x29="5" data-x25="2" data-x6="7" data-x21="8" data-x28="3" data-x1="7" data-x20="0" data-x10="2" data-x19="5" data-x18="9" data-x27="13" data-x0="4" data-x12="12" data-x9="6" data-x3="0" data-x15="15"></span><span class="____16642990619" data-x22="4" data-x5="7" data-x17="0" data-x29="5" data-x25="2" data-x6="7" data-x21="8" data-x28="4" data-x1="7" data-x20="0" data-x10="7" data-x19="5" data-x18="9" data-x27="12" data-x0="4" data-x12="12" data-x9="6" data-x3="0" data-x15="15" data-x16="1"></span><span class="____166429906110" data-x22="4" data-x5="7" data-x17="0" data-x29="5" data-x25="0" data-x6="7" data-x21="8" data-x28="4" data-x1="0" data-x20="0" data-x10="7" data-x19="12" data-x18="9" data-x27="12" data-x0="4" data-x12="12" data-x9="6" data-x3="0" data-x15="15" data-x16="1" data-x11="3"></span><span class="____166429906111" data-x22="4" data-x5="7" data-x17="0" data-x29="5" data-x25="0" data-x6="7" data-x21="8" data-x28="1" data-x1="14" data-x20="0" data-x10="7" data-x19="12" data-x18="9" data-x27="2" data-x0="4" data-x12="12" data-x9="6" data-x3="0" data-x15="15" data-x16="1" data-x11="3"></span><span class="____166429906112" data-x22="4" data-x5="7" data-x17="0" data-x29="5" data-x25="0" data-x6="7" data-x21="8" data-x28="5" data-x1="14" data-x20="0" data-x10="7" data-x19="12" data-x18="9" data-x27="2" data-x0="4" data-x12="12" data-x9="6" data-x3="0" data-x15="15" data-x16="1" data-x11="3" data-x31="5" data-x8="8" data-x23="15"></span><span class="____166429906113" data-x22="4" data-x5="7" data-x17="0" data-x29="5" data-x25="0" data-x6="0" data-x21="8" data-x28="5" data-x1="14" data-x20="0" data-x10="7" data-x19="12" data-x18="9" data-x27="2" data-x0="4" data-x12="12" data-x9="6" data-x3="0" data-x15="15" data-x16="1" data-x11="3" data-x31="5" data-x8="8" data-x23="15" data-x30="5"></span><span class="____166429906114" data-x22="4" data-x5="7" data-x17="0" data-x29="5" data-x25="0" data-x6="0" data-x21="8" data-x28="5" data-x1="14" data-x20="0" data-x10="14" data-x19="12" data-x18="9" data-x27="2" data-x0="4" data-x12="3" data-x9="6" data-x3="1" data-x15="15" data-x16="1" data-x11="3" data-x31="5" data-x8="8" data-x23="15" data-x30="5" data-x7="4"></span><span class="____166429906115" data-x22="3" data-x5="7" data-x17="0" data-x29="5" data-x25="0" data-x6="0" data-x21="8" data-x28="5" data-x1="14" data-x20="0" data-x10="14" data-x19="10" data-x18="9" data-x27="2" data-x0="4" data-x12="15" data-x9="6" data-x3="1" data-x15="15" data-x16="1" data-x11="3" data-x31="5" data-x8="8" data-x23="15" data-x30="5" data-x7="4" data-x2="2"></span>',
        A.addEventListener("animationend", (function (r) {
                var n = r.target
                    , i = getComputedStyle(n)
                lf.push({
                    "animationName": r.animationName,
                    "target": {
                        "style": {
                            "opacity": (+i['opacity']).toString(),
                            "color": i['color'].toString(),
                            "background-color": i['background-color'],
                            "border-color": i['border-color'],
                        }
                    },
                })
            }))</script></html>
dynamicHtml = wasmVmFunc(timeStamp, randomStr, NCTOKENSTR);

以上为原js参数生成的核心部分,在虚拟机中根据时间戳、随机数以及NCTOKENSTR生成了一大串控制动画的CSS与各种类名的DOM,然后播放动画并在动画播放完毕后触发animationend事件,获取此时的元素类名、文字色、背景色、边框色等。注意,此时数据已被vm隐写到了animationend元素的类名、color等属性中。

在之前的文章 利用浏览器特性进行数据加密与传输的N种方式 中聊到过通过CSS动画隐写与编码传输可以隐秘传递自己的数据,这里就是类似的原理:

  1. 使用大量 @supports@media 条件块控制特定 CSS 规则是否生效。
  2. 将这些规则绑定到定义好的 @keyframes 动画,动画只修改 opacity
  3. 浏览器执行动画后,在动画结束时用 JavaScript 读取元素的 opacity 及颜色属性。
  4. 将读取到的终态样式序列化并嵌入 _rand

由于动画定义、触发时机及采集方式均由js虚拟机控制,此机制既能验证客户端是否完整执行了CSS动画与条件筛选链路,进而判断运行环境的真实性,又能隐秘的进行数据传输。

自动化通过:

const { chromium } = require('playwright');

async function executeAnimationAndGetResults() {
    const browser = await chromium.launch();
    const page = await browser.newPage();

    // 设置HTML内容
    await page.setContent(dynamicHtml);
    // 等待所有动画完成并收集结果
    const results = await page.evaluate(async () => {
        return new Promise((resolve) => {
            // 设置检查间隔
            const checkInterval = setInterval(() => {
                if (window.lf && window.lf.length > 0) {
                    clearInterval(checkInterval);
                    resolve([...window.lf]); // 返回副本
                }
            }, 50);

            setTimeout(() => {
                clearInterval(checkInterval);
                resolve(window.lf || []); // 确保返回数组
            }, 5000); // 10秒超时
        });
    });

    await browser.close();
    console.log(JSON.stringify(results, null, 2));
}

executeAnimationAndGetResults()

2. 还原思路

在无浏览器环境下复写该过程,主要是以下思路:

  1. 剥离样式标签:从 HTML 中提取纯 CSS 内容
  2. 过滤条件规则:根据环境配置过滤 @media@supports 规则
  3. 解析关键帧:提取所有 @keyframes 动画定义
  4. 解析类规则:提取所有 CSS 类的样式声明
  5. 构建动画对象:为每个类构建完整的动画配置
  6. 模拟终态:计算动画结束时的样式状态

目标是输入 HTML 与 CSS,即可输出与真实浏览器一致的结果。不需要实现过多的兼容,只要能跑阿里就可以。

3. 代码实现

3.1 关键帧取样逻辑

sampleKeyframesOpacity 函数用于根据动画关键帧和时间偏移量计算透明度值:

function sampleKeyframesOpacity(frames, offset) {
  // 边界情况处理:没有关键帧时返回默认透明度 1
  if (!frames || frames.length === 0) return 1;
  
  // offset 在首帧之前,返回首帧透明度
  if (offset <= frames[0].offset) return frames[0].opacity;
  
  // offset 在末帧之后,返回末帧透明度
  if (offset >= frames[frames.length - 1].offset) return frames[frames.length - 1].opacity;
  
  // 在中间区间:找到对应的关键帧区间进行线性插值
  for (let i = 0; i < frames.length - 1; i++) {
    const a = frames[i], b = frames[i + 1];
    if (offset >= a.offset && offset <= b.offset) {
      // 线性插值公式:当前值 = 起始值 + (结束值 - 起始值) * 插值比例
      const t = (offset - a.offset) / (b.offset - a.offset);
      return a.opacity + (b.opacity - a.opacity) * t;
    }
  }
  
  return 1;
}

3.2 条件规则过滤函数

3.2.1 @media 规则过滤

function evalMediaCondition(cond) {
  const norm = cond.trim().toLowerCase();
  
  // 特殊情况处理
  if (norm === 'not all') return false; // 明确排除所有媒体类型
  if (norm === 'all') return true;      // 匹配所有媒体类型
  if (norm === 'not print') return true; // 排除打印媒体(通常在屏幕环境中为真)
  
  // 宽度条件匹配
  const mw = /max-width\s*:\s*(\d+)px/i.exec(cond);  
  if (mw)  return ENV.width <= +mw[1];  // 最大宽度限制
  
  const mnw = /min-width\s*:\s*(\d+)px/i.exec(cond); 
  if (mnw) return ENV.width >= +mnw[1]; // 最小宽度限制
  
  // 高度条件匹配
  const mh = /max-height\s*:\s*(\d+)px/i.exec(cond); 
  if (mh)  return ENV.height <= +mh[1]; // 最大高度限制
  
  const mnh = /min-height\s*:\s*(\d+)px/i.exec(cond);
  if (mnh) return ENV.height >= +mnh[1]; // 最小高度限制
  
  // 未识别的条件默认通过(宽松策略)
  return true;
}

3.2.2 @supports 规则过滤

function evalSupportsCondition(condRaw) {
  let cond = condRaw.trim();
  
  // 去除外层多余括号的辅助函数
  const strip = s => {
    s = s.trim();
    while (s.startsWith('(') && s.endsWith(')')) {
      let depth = 0, ok = true;
      // 检查括号是否完全匹配且包围整个表达式
      for (let i = 0; i < s.length; i++) {
        if (s[i] === '(') depth++;
        else if (s[i] === ')') depth--;
        // 如果在中间位置深度为0,说明括号不是完全包围的
        if (depth === 0 && i < s.length - 1) { 
          ok = false; 
          break; 
        }
      }
      if (!ok) break;
      s = s.slice(1, -1).trim(); // 去除外层括号
    }
    return s;
  };
  
  cond = strip(cond);
  
  // 处理 NOT 逻辑
  if (/^not\b/i.test(cond)) {
    const inner = strip(cond.replace(/^not\b/i, ''));
    return !evalSupportsCondition(inner); // 递归处理内部条件并取反
  }
  
  // 顶层逻辑运算符分割函数
  const splitTopLevel = (s, kw) => {
    let depth = 0, out = [''];
    const KW = kw.toLowerCase();
    
    for (let i = 0; i < s.length; i++) {
      const ch = s[i];
      if (ch === '(') depth++;
      else if (ch === ')') depth--;
      
      out[out.length - 1] += ch;
      
      // 只在顶层(depth=0)时分割
      if (depth === 0 && s.slice(i + 1).toLowerCase().startsWith(' ' + KW + ' ')) {
        out[out.length - 1] = strip(out[out.length - 1]);
        out.push('');
        i += KW.length + 1; // 跳过关键字
      }
    }
    
    out[out.length - 1] = strip(out[out.length - 1]);
    return out.length > 1 ? out : null;
  };
  
  // 处理 AND 逻辑:所有条件都必须为真
  const andParts = splitTopLevel(cond, 'and');
  if (andParts) return andParts.every(evalSupportsCondition);
  
  // 处理 OR 逻辑:任一条件为真即可
  const orParts = splitTopLevel(cond, 'or');
  if (orParts) return orParts.some(evalSupportsCondition);
  
  // 处理基本特性检测
  const m = /\(?\s*([a-zA-Z-]+:[^)]+?)\s*\)?$/.exec(cond);
  if (m) {
    const feature = m[1].trim(); // 提取 "属性:值" 格式的特性
    return !!ENV.supports[feature]; // 检查环境是否支持此特性
  }
  
  return false; // 默认不支持未知特性
}

3.3 CSS 解析函数

3.3.1 声明块解析

function parseDecls(block) {
  const out = {};
  // 匹配 "属性: 值;" 格式的 CSS 声明
  const re = /([a-zA-Z-]+)\s*:\s*([^;]+);?/g;
  let m;
  
  while ((m = re.exec(block))) {
    // 去除属性名和值的前后空白
    out[m[1].trim()] = m[2].trim();
  }
  
  return out;
}

3.3.2 类规则解析

function parseClassRules(css) {
  const rules = [];
  // 匹配 ".类名 { 声明块 }" 格式
  const re = /\.([A-Za-z0-9_-]+)\s*\{([^{}]*)\}/g;
  let m;
  
  while ((m = re.exec(css))) {
    rules.push({
      selector: '.' + m[1],           // 完整的选择器
      decls: parseDecls(m[2] || '')   // 解析的声明对象
    });
  }
  
  return rules;
}

3.3.3 关键帧解析

function parseKeyframesOpacityMap(css) {
  const map = Object.create(null); // 创建无原型的纯对象
  const kw = '@keyframes';
  let i = 0;
  
  while (i < css.length) {
    const p = css.indexOf(kw, i);
    if (p === -1) break; // 没有更多关键帧定义
    
    // 跳过 @keyframes 关键字
    let j = p + kw.length;
    while (j < css.length && /\s/.test(css[j])) j++; // 跳过空白
    
    // 提取动画名称
    let nameStart = j;
    while (j < css.length && !/\s|\{/.test(css[j])) j++;
    const name = css.slice(nameStart, j).trim();
    
    // 找到开始的大括号
    while (j < css.length && css[j] !== '{') j++;
    
    // 找到匹配的结束大括号(处理嵌套)
    let k = j + 1, depth = 1;
    while (k < css.length && depth > 0) {
      if (css[k] === '{') depth++;
      else if (css[k] === '}') depth--;
      k++;
    }
    
    // 提取关键帧内容
    const body = css.slice(j + 1, k - 1);
    const frames = [];
    
    // 解析每个关键帧步骤:如 "50% { opacity: 0.5 }"
    const stepRe = /(\d+)%\s*\{([^}]*)\}/g;
    let m;
    
    while ((m = stepRe.exec(body))) {
      const offset = Number(m[1]) / 100; // 转换为 0-1 的比例
      const decls = m[2];
      
      // 提取 opacity 值
      const opm = /opacity\s*:\s*([0-9]*\.?[0-9]+)/i.exec(decls);
      if (opm) {
        frames.push({ 
          offset, 
          opacity: Number(opm[1]) 
        });
      }
    }
    
    // 按偏移量排序确保时间顺序正确
    frames.sort((a, b) => a.offset - b.offset);
    map[name] = frames;
    i = k;
  }
  
  return map;
}

3.4 动画配置解析

3.4.1 动画简写属性解析

function parseAnimationShorthand(s) {
  const tokens = s.trim().split(/\s+/).filter(Boolean);
  
  // 默认值设置
  let duration = 0, delay = 0, iterations = 1;
  let direction = 'normal', fillMode = 'none', timingFunction = 'linear';
  let name = tokens[tokens.length - 1]; // 动画名称通常是最后一个标记
  
  // 跳过播放状态关键字
  const skip = new Set(['running', 'paused']);
  let seenTime = 0; // 时间值计数器
  
  // 遍历除最后一个标记外的所有标记
  for (let i = 0; i < tokens.length - 1; i++) {
    const t = tokens[i];
    if (skip.has(t)) continue;
    
    // 解析时间值(duration 和 delay)
    if (t.endsWith('ms') || t.endsWith('s')) {
      const val = t.endsWith('ms') ? parseFloat(t) : parseFloat(t) * 1000;
      if (seenTime === 0) { 
        duration = val; 
        seenTime = 1; 
      } else { 
        delay = val; 
      }
    } 
    // 解析迭代次数
    else if (/^\d+$/.test(t)) {
      iterations = +t;
    }
    // 解析动画方向
    else if (['normal', 'reverse', 'alternate', 'alternate-reverse'].includes(t)) {
      direction = t;
    }
    // 解析填充模式
    else if (['none', 'forwards', 'backwards', 'both'].includes(t)) {
      fillMode = t;
    }
    // 解析时间函数
    else if (['linear', 'ease', 'ease-in', 'ease-out', 'ease-in-out'].includes(t) || 
             t.startsWith('cubic-bezier')) {
      timingFunction = t;
    }
  }
  
  return { 
    name, duration, delay, iterations, 
    direction, fillMode, timingFunction 
  };
}

3.4.2 动画结束状态计算

function finalOffsetAtEnd(anim) {
  const it = anim.iterations === Infinity ? 1 : anim.iterations;
  
  switch (anim.direction) {
    case 'normal': 
      return 1; // 正向播放,结束在 100%
    case 'reverse': 
      return 0; // 反向播放,结束在 0%
    case 'alternate': 
      // 交替播放:奇数次结束在 100%,偶数次结束在 0%
      return (it % 2 === 1) ? 1 : 0;
    case 'alternate-reverse': 
      // 反向交替:奇数次结束在 0%,偶数次结束在 100%
      return (it % 2 === 1) ? 0 : 1;
    default: 
      return 1;
  }
}

3.5 样式构建与动画模拟

3.5.1 类样式和动画构建

function buildStyleAndAnimForClass(cls, rules, kfMap) {
  const sel = '.' + cls;
  let tick = 0; // 用于记录属性出现的顺序,实现后写入覆盖逻辑
  
  // 状态追踪对象,记录每个属性的值和出现时间
  const state = {
    color: { v: undefined, t: -1 },      // 文字颜色
    bgc:   { v: undefined, t: -1 },      // 背景颜色
    bdc:   { v: undefined, t: -1 },      // 边框颜色
    anim:  { v: undefined, t: -1 },      // 动画属性
  };
  
  // 遍历所有规则,收集目标类的样式
  for (const r of rules) {
    if (r.selector !== sel) continue;
    
    // 记录每个属性的最新值和时间戳
    if (r.decls['color']) { 
      state.color.v = r.decls['color']; 
      state.color.t = ++tick; 
    }
    if (r.decls['background']) { 
      state.bgc.v = r.decls['background']; 
      state.bgc.t = ++tick; 
    }
    if (r.decls['background-color']) { 
      state.bgc.v = r.decls['background-color']; 
      state.bgc.t = ++tick; 
    }
    if (r.decls['border-color']) { 
      state.bdc.v = r.decls['border-color']; 
      state.bdc.t = ++tick; 
    }
    if (r.decls['animation']) { 
      state.anim.v = r.decls['animation']; 
      state.anim.t = ++tick; 
    }
  }
  
  // 没有动画属性则跳过
  if (!state.anim.v) return null;
  
  // 解析动画配置
  const a = parseAnimationShorthand(state.anim.v);
  const frames = kfMap[a.name];
  
  // 没有对应的关键帧定义则跳过
  if (!frames || !frames.length) return null;
  
  // 返回完整的动画对象
  return {
    name: a.name,
    duration: a.duration,
    delay: a.delay,
    iterations: a.iterations,
    direction: a.direction,
    fillMode: a.fillMode,
    timingFunction: a.timingFunction,
    baseStyle: { 
      color: state.color.v, 
      'background-color': state.bgc.v, 
      'border-color': state.bdc.v 
    },
    properties: { opacity: frames }
  };
}

3.5.2 动画结束状态模拟

function simulateAnimationEnd(anim) {
  let opacity;
  
  // 根据填充模式决定最终透明度
  if (anim.fillMode === 'forwards' || anim.fillMode === 'both') {
    // forwards 或 both 模式:保持动画结束时的状态
    const endOffset = finalOffsetAtEnd(anim);
    opacity = sampleKeyframesOpacity(anim.properties.opacity, endOffset);
  } else {
    // none 或 backwards 模式:回到初始状态
    opacity = 1;
  }
  
  // 返回计算后的样式对象
  return {
    opacity: String(opacity),
    color: toRGBString(anim.baseStyle.color),
    'background-color': toRGBString(anim.baseStyle['background-color']),
    'border-color': toRGBString(anim.baseStyle['border-color'])
  };
}

3.6 辅助函数

3.6.1 颜色处理

// 颜色关键字到 RGB 值的映射表
const COLOR_KEYWORDS = {
  black: [0, 0, 0], white: [255, 255, 255], 
  gray: [128, 128, 128], lightgray: [211, 211, 211], 
  silver: [192, 192, 192],
  red: [255, 0, 0], maroon: [128, 0, 0], 
  darkred: [139, 0, 0], darkorange: [255, 140, 0], 
  orange: [255, 165, 0],
  // ... 更多颜色定义
};

function toRGBString(value) {
  if (!value) return value;
  
  // 已经是 RGB 格式则规范化空格
  if (/^rgb\(/i.test(value)) {
    return value.replace(/\s+/g, '');
  }
  
  // 转换颜色关键字为 RGB 格式
  const arr = COLOR_KEYWORDS[(value || '').toLowerCase()];
  if (arr) {
    return `rgb(${arr[0]}, ${arr[1]}, ${arr[2]})`;
  }
  
  // 未知颜色保持原样
  return value;
}

3.6.2 HTML 类名提取

function extractClassListFromHtmlInner(inner) {
  const classes = [];
  const re = /class="([^"]+)"/g;
  let m;
  
  while ((m = re.exec(inner))) {
    // 拆分多个类名并保持 DOM 顺序
    classes.push(...m[1].trim().split(/\s+/));
  }
  
  return classes;
}

3.7 主流程函数

function main() {
  // 1. 预处理:去除 HTML 标签获取纯 CSS
  const rawCss = stripStyleTags(cssText);
  
  // 2. 条件过滤:根据环境过滤 @media 和 @supports 规则
  const filteredCss = extractActiveCSS(rawCss);
  
  // 3. 解析关键帧:构建动画名称到关键帧的映射
  const kfMap = parseKeyframesOpacityMap(filteredCss);
  
  // 4. 解析类规则:提取所有 CSS 类的样式规则
  const rules = parseClassRules(filteredCss);
  
  // 5. 提取类名:按 DOM 顺序获取所有类名
  const classesInDomOrder = extractClassListFromHtmlInner(htmlInner);
  
  // 调试信息输出
  console.log('[DEBUG] rules长度: ', rules.length);
  console.log('[DEBUG] keyframes: ', Object.keys(kfMap));
  console.log('[DEBUG] dom顺序: ', classesInDomOrder);
  
  // 6. 构建候选动画:为每个类构建动画配置
  const candidates = [];
  for (const cls of classesInDomOrder) {
    const anim = buildStyleAndAnimForClass(cls, rules, kfMap);
    if (anim) {
      candidates.push({ cls, anim });
    }
  }
  
  console.log('[DEBUG] 候选类: ', candidates.map(x => x.cls));
  console.log('[DEBUG] 候选动画: ', candidates.map(x => x.anim.name));
  
  // 7. 过滤有效动画:只保留真正会触发 animationend 事件的动画
  const effective = candidates.filter(c => 
    c.anim.iterations > 0 && 
    c.anim.properties.opacity.length > 0
  );
  
  console.log('[DEBUG] 有效类: ', effective.map(x => x.cls));
  
  // 8. 生成最终结果:模拟每个动画结束时的样式状态
  const result = [];
  for (const { cls, anim } of effective) {
    const style = simulateAnimationEnd(anim);
    result.push({
      animationName: anim.name,
      target: { style }
    });
  }
  
  // 输出 JSON 格式的结果
  console.log(JSON.stringify(result, null, 2));
}

3.8 环境配置

// 模拟浏览器环境的配置对象
const ENV = {
  width: 1920,    // 视口宽度
  height: 1080,   // 视口高度
  supports: {     // CSS 特性支持情况
    'font-style:normal': true,
    'overflow:visible': true,
    'overflow:hidden': true,
    'position:absolute': true,
    'position:fixed': true,
    'position:relative': true,
    'display:block': true,
    'border-width:10px': true,
    'border-width:thick': true,
    'color:yellow': true,
    'text-align:left': true,
    'text-align:right': true,
    //...更多支持的特性
    // 不支持的特性
    'position:sticked': false,        // 非标准值
    'display:table-flex': false,      // 非标准值
    'color:#5D0A0': false,           // 无效颜色格式
    'color:rgb(169,162%,14)': false  // 无效 RGB 格式
    //...更多不支持的特性
  }
};

其中:

  • widthheight 用于 @media 查询的尺寸条件判断
  • supports 对象模拟浏览器对 CSS 特性的支持情况

经验证和浏览器渲染结果完全一致。

4. 总结

逆向真tm难,DrissionPage启动。