高深莫测的“隐写术”并非黑客特工的专属 - 我用前端实现的隐写,很是有意思❗

2021年09月15日 阅读数:1
这篇文章主要向大家介绍高深莫测的“隐写术”并非黑客特工的专属 - 我用前端实现的隐写,很是有意思❗,主要内容包括基础应用、实用技巧、原理机制等方面,希望对大家有所帮助。

前言

哈喽你们好~我是荣顶,立刻中秋节啦 ~ 先祝你们中秋节快乐,阖家团圆,幸福安康~html

此次之因此会出一期版权保护的内容,是由于前段时间有群友说他的文章被盗了,因而冲着对版权保护方法的好奇,就有了这篇文章 ~前端

版权保护方法有不少:
此次文章主要和你们讲讲如何在文本与图片中隐藏本身的版权信息(下一篇文章会给你们详细聊聊抠不掉的水印元素)vue

本文参与了中秋活动,因此文中的例子主要围绕着中秋的主题进行讲解,你们点赞支持一下 ~ 谢谢 ~ 🥰git

看完本文,你将学会 👇程序员

  • 文本隐写:经过某种方法居然能够在字符串中读取和修改隐藏信息"我是荣顶"⇄"我是[前埔寨]荣顶",点这里体验github

  • 图片隐写:在图片中添加隐藏的图文(还能往里面写文件!!!)点这里体验算法

文本隐写

首先咱们须要了解一下,什么是隐写术(Steganography)编程

隐写术:将信息隐藏在多种载体中,如:视频、硬盘和图像,将须要隐藏的信息经过特殊的方式嵌入到载体中,而又不损害载体原来信息的表达。旨在保护须要隐藏的信息不被他人识别。

固然信息隐蔽技术确定不止隐写术,大概有:1)隐写术、2)数字水印、3)隐蔽信道、4)阀下信道、5)匿名信道 ...canvas

实现原理

经过将字符串中每一个字符转换为只有 1 和 0 的表示,而后经过零宽字符表示 0 和 1,就能实现经过零宽字符来表示一串看不见的字符串segmentfault

首先咱们了解一下什么是零宽字符?

顾名思义: 就是字节宽度为 0 的特殊字符。🙄 这解释...那我走?

😀 零宽字符: 是一种不可打印的 Unicode 字符, 在浏览器等环境不可见, 可是真是存在, 获取字符串长度时也会占位置, 表示某一种控制功能的字符.

零宽字符主要有如下六种:

  • 零宽度空格符 (zero-width space) U+200B : 用于较长单词的换行分隔
  • 零宽度非断空格符 (zero width no-break space) U+FEFF : 用于阻止特定位置的换行分隔
  • 零宽度连字符 (zero-width joiner) U+200D : 用于阿拉伯文与印度语系等文字中,使不会发生连字的字符间产生连字效果
  • 零宽度断字符 (zero-width non-joiner) U+200C : 用于阿拉伯文,德文,印度语系等文字中,阻止会发生连字的字符间的连字效果
  • 左至右符 (left-to-right mark) U+200E : 用于在混合文字方向的多种语言文本中,规定排版文字书写方向为左至右
  • 右至左符 (right-to-left mark) U+200F : 用于在混合文字方向的多种语言文本中,规定排版文字书写方向为右至左

考虑到 Unicode 中有所谓的Surrogate Pair的状况,因此这里咱们处理字符串的核心方法是codePointAtfromCodePoint

Surrogate Pair:是 UTF-16 中用于扩展字符而使用的编码方式,是一种采用四个字节(两个 UTF-16 编码)来表示一个字符。

Unicode 编码单元(code points)的范围从 0 到 1,114,111。开头的 128 个 Unicode 编码单元和 ASCII 字符编码同样。
若是指定的 index 小于 0 或大于字符串的长度,则 charCodeAt 返回 NaN

例如:汉字“𠮷”(非"吉")的码点是 0x20BB7,而 UTF-16 编码为 0xD842 0xDFB7(十进制为 55362 57271),须要 4 个字节储存。
对于这种 4 个字节的字符,JavaScript 不能正确处理,字符串长度会误判为 2,"𠮷".length // 2 并且 charAt()方法没法读取整个字符,charCodeAt()方法只能分别返回前两个字节和后两个字节的值。
ES6 提供了 codePointAt()方法,可以正确处理 4 个字节储存的字符,返回一个字符的码点。

//一个字符由两个字节仍是由四个字节组成的最简单方法。
"我".codePointAt(0) > 0xFFFF;//false
"A".codePointAt(0) > 0xFFFF;//false
"3".codePointAt(0) > 0xFFFF;//false

"𠮷".codePointAt(0) > 0xFFFF;//true
console.log(String.fromCodePoint(0x20bb7)); // "𠮷"
//或者十进制
console.log(String.fromCodePoint(134071)); // "𠮷"
//也能够多个参数
console.log(String.fromCodePoint(25105,29233,0x4f60)); // "我爱你"

String.fromCodePoint 方法是 ES6 新增长的特性,ES6 提供了 String.fromCodePoint()方法,能够识别大于 0xFFFF 的字符,弥补了 String.fromCharCode()方法的不足。一些老的浏览器可能还不支持。能够经过使用这段 polyfill 代码来保证浏览器的支持

固然,若是不须要处理复杂的字符,你也能够用charCodeAtfromCharCode两个方法来对字符进行处理

好了,铺垫完了,开干~

先说加密

咱们将文本经过codePointAt() 方法得出每一个字符的Unicode编码点值,而后将它们转为2进制,每一个字符间经过空格分隔开,而后咱们用零宽字符&#8203来表示1,用零宽字符&#8204来表示0,用零宽字符&#8205来表示空格,这样就能够获得一段彻底用零宽字符表示的看不见的字符串

多了不说,少了不唠 ~ 先看代码!

  // 字符串转零宽字符串
  function encodeStr(val = "隐藏的文字") {
      return (
          val
              .split("")
              .map((char) => char.codePointAt(0).toString(2))
              .join(" ")
              .split("")
              .map((binaryNum) => {
                  if (binaryNum === "1") {
                      return "​"; // 零宽空格符​
                  } else if (binaryNum === "0") {
                      return "‌"; // 零宽不连字符‌
                  } else {
                      return "‍"; //空格 -> 零宽连字符‍
                  }
              })
              .join("‎")
      );
  }
  console.log("str:",encodeStr(),"length:",encodeStr().length)//你们能够把这段copy到控制台执行如下看看

这一块一开始return出去的都是直接写在双引号中的零宽字符,可是大家复制到浏览器中估计也不方便看,反正它是看不到的,平台要是给我过滤了,那更尴尬

想在 vscode或者atom 中看到代码里是否有零宽字符,能够下载Highlight Bad Chars插件来实现零宽字符的高亮(如上图所示)

为了方便你们容易区分空字符串与零宽字符,这里我把代码改了一下(这里是用 vue 作的,之后个人全部例子都会在这个仓库下固然它也有线上的体验地址,欢迎你们来指点~)


// 字符串转零宽字符串
encodeStr() {
    this.cipherText = this.text.split("");
    //在字符串中的随机一个位置插入加密文本
    this.cipherText.splice(
        parseInt(Math.random() * (this.text.length + 1)),
        0,
        //加密的文本
        this.hiddenText
            .split("")
            //['荣', '顶' ]
            .map((char) => char.codePointAt(0).toString(2))
            // ['1000001101100011','1001100001110110']
            .join(" ")
            //"1000001101100011 1001100001110110"
            .split("")
            /* [ '1', '0', '0', '0',  '0', '0', '1', '1', '0', '1', '1', '0', '0',  '0', '1', '1', ' ',
                '1', '0', '0', '1', '1',  '0', '0', '0', '0', '1', '1', '1', '0', '1',  '1', '0'] */
            .map((binaryNum) => {
                if (binaryNum === "1") {
                    return String.fromCharCode(8203); // 零宽空格符​
                } else if (binaryNum === "0") {
                    return String.fromCharCode(8204); // 零宽不连字符‌
                } else {
                    return String.fromCharCode(8205); //空格 -> 零宽连字符‍
                }
            })
            //对上面全部的数组元素进行处理,生成一个新的数组['​', '​', '‌'......]其中每个元素都是零宽字符,分别表明0和1以及
            .join(String.fromCharCode(8206))
        // 用左至右符‎来把上面的数组相连成一个零宽字符串=>"‎​‎‌‎‌"
    );
    this.cipherText = this.cipherText.join("");
    console.log(this.cipherText, "cipherText");
}

其中随机混入零宽字符串主要是这段伪代码

let str = "qwe12345789".split("");
//在字符串中的随机一个位置插入加密文本
str.splice(parseInt(Math.random()*(str.length+1)),0,"加密文本").join("")

加密后的文本经过 trim 或者经过网络发送都是没问题的

"中秋节快乐​‎​‎‌‎​‎​‎‌‎‌‎‌‎‌‎​‎​‎​‎‌‎‌‎‌‎‍‎​‎​‎‌‎‌‎​‎‌‎​‎​‎​‎​‎‌‎‌‎‌‎‌‎‌‎‍‎​‎‌‎‌‎‌‎‌‎​‎‌‎‍‎​‎​‎​‎‌‎​‎‌‎​‎‍‎​‎​‎‌‎‌‎​‎​‎​".trim().length//114

你们都了解如何加密后,咱们再看如下如何对字符串进行解密

解密首先须要了解如何提取零宽度字符

"点赞鼓励~😀​‎​‎​‎‌‎‌‎​‎‌‎‌‎​‎‌‎‌‎​‎‌‎‌‎‌‎‍‎​‎​‎‌‎‌‎​‎​‎​‎‌‎​‎‌‎‌‎‌‎‌‎​‎​‎‍‎​‎‌‎‌‎​‎​‎​‎​‎​‎​‎​‎‌‎‌‎‌‎‌‎​‎‍‎​‎​‎‌‎‌‎‌‎‌‎‌‎‌‎​‎​‎‌‎​‎​‎​‎​‎‍‎​‎​‎‌‎​‎​‎‌‎‌‎‌‎‌‎‌‎​‎​‎​‎​‎‌‎​‎‍‎​‎​‎‌‎​‎​‎​‎​‎‌‎‌‎‌‎‌‎‌‎‌‎‌‎‌‎‌".replace(/[^\u200b-\u200f\uFEFF\u202a-\u202e]/g, "");

解密的过程就是加密的反向操做,咱们先提取文本中的零宽字符串,用 1来表示零宽字符&#8203,用0来表示零宽字符&#8204,用空格来表示零宽字符&#8205,这样就能够获得一段由 1 和 0 组成空格分隔的字符串,咱们将每段 1/0 表示的字符串转成十进制后,经过String.fromCharCode方法将其转回为能够看得见的文字便可~

// 零宽字符转字符串
decodeStr() {
    if (!this.tempText) {
        this.decodeText = "";
        return;
    }
    let text = this.tempText.replace(/[\u200b-\u200f\uFEFF\u202a-\u202e]/g, "");
    let hiddenText = this.tempText.replace(/[^\u200b-\u200f\uFEFF\u202a-\u202e]/g, "");
    console.log(text, "text");
    console.log(hiddenText, "hiddenText");
    this.decodeText = hiddenText
        .split("‎") //不是空字符串,是 ‎
        .map((char) => {
            if (char === "​" /* 不是空字符串,是​ */) {
                return "1";
            } else if (char === "‌" /*  不是空字符串,是‌ */) {
                return "0";
            } else {
                /* 是‍时,用空格替换 */
                return " ";
            }
        })
        .join("")
        //转数组
        .split(" ")
        //根据指定的 Unicode 编码中的序号值来返回一个字符串。
        .map((binaryNum) => String.fromCharCode(parseInt(binaryNum, 2)))
        .join("");
    console.log(text + hiddenText);
},

演示一波

核心实现方法就是以上,如今咱们来演示一下,你们看图前,首先能够把这小段文字复制到任何地方打印下(就 F12 控制台看最方便)console.log("中秋快乐123456​‎​‎​‎‌‎‌‎​‎‌‎‌‎​‎‌‎‌‎​‎‌‎‌‎‌‎‍‎​‎​‎‌‎‌‎​‎​‎​‎‌‎​‎‌‎‌‎‌‎‌‎​‎​‎‍‎​‎‌‎‌‎​‎​‎​‎​‎​‎​‎​‎‌‎‌‎‌‎‌‎​‎‍‎​‎​‎‌‎‌‎‌‎‌‎‌‎‌‎​‎​‎‌‎​‎​‎​‎​‎‍‎​‎​‎‌‎​‎​‎‌‎‌‎‌‎‌‎‌‎​‎​‎​‎​‎‌‎​‎‍‎​‎​‎‌‎​‎​‎​‎​‎‌‎‌‎‌‎‌‎‌‎‌‎‌‎‌‎‌".length)长度必定不是 10😀

这里的 demo 我已经上传至个人 github,你能够点击这里体验一下~

应用场景

  • 数据防爬
    将零宽度字符插入文本中,干扰关键字匹配。爬虫获得的带有零宽度字符的数据会影响他们的分析,但不会影响用户的阅读数据。
  • 隐藏文本信息
    将自定义组合的零宽度字符插入文本中,用户复制后会携带不可见信息,达到隐藏文本信息的做用。
  • 逃脱敏感词过滤
    你在游戏里骂人,你妈*确定是发不出去的,可是你发你xxxxx妈yyyyy*就不同了 😀,这个东西仍是研究起来颇有意思的。
  • 嵌入隐藏的代码还有不少等等等...

试想当在电子书籍 PDF 或者一些正版影视音乐做品中嵌入购买人的我的信息,那还有多少人敢直接传给别人看呢?

像下面这种明水印,加了和没加没啥区别,很容易就被别人扣掉了

并且还被别人更换了水印,打上了广告,啧啧啧...若是一本电子书里嵌入了大量的隐写内容,对于盗版的传播行为,实际上是能够起到很好的追责做用的(各大出版商,电子书能够搞起来 😀 买电子书都有购买者的联系方式和我的信息的)

诸如像 toG 的项目中,有不少敏感内容都是有我的信息的明水印,和隐写的我的信息,传出去了就知道是谁传的 (至少对于不懂技术的人来讲,隐写是真的很难被发现的)

预防零宽字符的植入

说了这么多如何对字符进行零宽字符的植入和提取,那么像掘金哪天不容许大家加这玩意在里面了,他们对全部的文本用正则这么搞一遍就没了

预防用户上传的文本中含有零宽字符,通常咱们先作一次替换便可

str.replace(/[\u200b-\u200f\uFEFF\u202a-\u202e]/g, "");

图片隐写

什么是图片隐写?
lsb 隐写很实用,算法简单,能存储的信息量也大,更况且是 CTF 比赛中的常客(起源于 1996 年 DEFCON 全球黑客大会)

实现原理

图片的隐写有不少方式,我这里只用最简单的方式 (LSB:二进制最低位,不是"老色 X"😅) 作演示,固然也是由于我菜 😥 数学很差,要否则我也能够用傅里叶变换经过处理图片的色波 (图像本质上就是各类色彩波的叠加 这个概念的具体解释能够看阮一峰老师的这篇文章 ) 来作,那样又帅又飘~害!

二进制最低位

什么是二进制最低位?就是二进制最后面一位

好了,多了不说少了不唠 ~ 咱们一块儿看图说话 ~

每一个二进制的值的最低位均可以表示一个 1bit 的数据,一个像素须要 RGBA,4 个值共 4 * 8=32 个 bit 因此至少须要八个像素 (32 个值) 的最低位才能表示一个像素的 RGBA 值 (这里的 2560000 / 4 = 640000 个像素能够用来储存 2560000 / 32 = 80000 个像素的 RGBA 值)

即:每 32 个值的二进制最低位能够用来表示一个标准像素的 RGBA 值(为方便理解,以下图所示 👇)

拿到须要隐藏的数据

这一步,咱们能够隐藏图片,隐藏文字,也能够本身手绘一些东西在画布上
将咱们已经在主画布上绘制或加载的图片转换为 URL ,经过建立一个临时小画布, 将主画布生成的 URL 经过drawImage的方式,缩小的绘制到临时小画布上

(这里为何要这么作?仍是由于上面提到的 主画布的 640000 个像素值的最低位只能储存 80000 个像素的 RGBA 值)

//将画布上的信息绘制到小画布上保存起来
saveHiddenImageData() {
    const tempCanvas = document.createElement("canvas");
    const tempCtx = tempCanvas.getContext("2d");
    //小画布的长宽=大画布的像素/8后再开平方
    //由于须要八个像素的最低位才能够表示一个小画布的像素的RGBA值
    tempCanvas.width = Math.floor(Math.sqrt((this.canvas.width * this.canvas.height) / 8));
    tempCanvas.height = Math.floor(Math.sqrt((this.canvas.width * this.canvas.height) / 8));
    var image = new Image();
    image.src = this.canvas.toDataURL("image/png");
    image.onload = () => {
        //绘制图像到临时的小画布
        tempCtx.drawImage(image, 0, 0, tempCanvas.width, tempCanvas.height);
        this.hiddenData = tempCtx.getImageData(0, 0, tempCanvas.width, tempCanvas.height);
        this.hiddenData.binaryList = Array.from(this.hiddenData.data, (color) => {
            color = color.toString(2).padStart(8, "0").split("");
            return color;
        });
        console.log(this.hiddenData, "hiddenData");
        this.$message({
            type: "success",
            message: "保存成功!请选择目标图片~",
        });
        this.canvas.clear();
    };
},

再拿目标图的数据

在拿目标图(你要把隐藏的数据隐写进的目标图)的数据时候,咱们须要预先处理一下全部的颜色值

这一步很关键,能够看到,我上面的图,画布加载完目标图片时,咱们能够经过getImageData方法读取到页面的全部像素值,我这里将全部像素的二进制最低位所有归零,即把全部的颜色值都处理成偶数/非奇非偶数(0)

咱们经过操做每一个值的最低位,用来储藏要保存的数据, 肉眼是没法看出你对最低位的更改的

//获取画布像素数据
getCanvasData() {
    this.targetData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
    //将数字化为非奇数
    function evenNum(num) {
        num = num > 254 ? num - 1 : num;
        num = num % 2 == 1 ? num - 1 : num;
        return num;
    }
    //存一个二进制的数值表示
    this.targetData.binaryList = Array.from(this.targetData.data, (color, index) => {
        this.targetData.data[index] = evenNum(this.targetData.data[index]);
        color = evenNum(color).toString(2).padStart(8, "0").split("");
        return color;
    });
    console.log(this.targetData);
    this.loading = false;
},

写入隐藏的数据

到这里,咱们已经拿到了隐藏的数据,和目标图像的数据,接下来咱们须要作的就是将这 318,096 个颜色值写入到目标图片的 2560000 个颜色值的二进制最低位中,便可实现图片的隐写

再次须要注意的是:经过最低位来作的话,咱们隐写到图片中的数据是有限的,即 图片的总体像素 / 8 = 图片中能够隐藏的像素(个)
这里咱们用的是800*800的画布,有640000个像素,其中能够隐藏 640000 / 8 = 80000个像素
因此咱们隐藏的数据只能绘画到 Math.floor(Math.sqrt((this.canvas.width * this.canvas.height) / 8))这里是 282 的宽高的 canvas 中(79,524 个像素,318,096 个颜色值),这里求出的数只能向下取整,要否则会溢出,致使丢失隐藏的数据!

这里的操做就像是咱们藏头诗同样,咱们把数据藏在尾部

RGB 份量值的小量变更,是肉眼没法分辨的,不影响对图片的识别。你看不出来这个+1 的区别


多了不说少了不唠~先看代码

//将隐写的资源图片数据存到目标图片的二进制最低位中
drawHiddenData() {
    //将隐藏的数据的二进制所有放到一个数组里面
    let bigHiddenList = [];
    for (let i = 0; i < this.hiddenData.binaryList.length; i++) {
        bigHiddenList.push(...this.hiddenData.binaryList[i]);
    }
    console.log(bigHiddenList, "bigHiddenList");
    this.targetData.binaryList.forEach((item, index) => {
        bigHiddenList[index] && (item[7] = bigHiddenList[index]);
    });
    this.canvas.clear();
    this.targetData.data.forEach((item, index) => {
        this.targetData.data[index] = parseInt(
            this.targetData.binaryList[index].join(""),
            2
        );
    });

    const tempCanvas = document.createElement("canvas");
    tempCanvas.width = 800;
    tempCanvas.height = 800;
    let ctx = tempCanvas.getContext("2d");
    ctx.putImageData(this.targetData, 0, 0);
    fabric.Image.fromURL(tempCanvas.toDataURL(), (i) => {
        this.canvas.clear();
        this.canvas.add(i);
        this.canvas.renderAll();
    });
    this.$message({
        type: "success",
        message: "加密成功!",
    });
},

能够看到,这里已经将一张图片隐藏到了另外一张图片中,(咱们这里将一个月饼图隐写进了月亮图中)

解析加密后的图片

完成了图片加密后咱们接下来须要作的是解析加密后的图片

首先咱们选取本地图片后,将其渲染到画布上,在经过getImageData得到画布的像素数据,创建一个二进制的存储表示,后面将经过它的最低位取出隐藏在目标图中的图片

//获取画布像素数据
    getCanvasData() {
        this.targetData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
        //存一个二进制的数值表示
        this.targetData.binaryList = Array.from(this.targetData.data, (color, index) => {
            color = color.toString(2).padStart(8, "0").split("");
            return color;
        });
        console.log(this.targetData);
        this.loading = false;
    },

冲图片中抽取隐藏的颜色值

这里咱们主要将主画布的二进制颜色值的最低位抽离出来,组装成隐藏图片的像素值数组,最后经过putImageData来绘制出隐藏其中的图片

这里很是须要注意的是: putImageData的第一个参数data的长度必须为两个边的乘积的4的倍数 不然就会报错
因此,这里咱们取像素的时候,须要这么取Math.pow(Math.floor(Math.sqrt(2560000 / 32)), 2) * 4,因为我这里是 800 * 800的因此是2560000个值,你直接写成变量canvas.width * canvas.height *4便可

//解析图片
decryptImage() {
    const c = document.getElementById("decryptCanvas");
    const ctx = c.getContext("2d");

    let decryptImageData = [];

    for (let i = 0; i < this.targetData.binaryList.length; i += 8) {
        let tempColorData = [];
        for (let j = 0; j < 8; j++) {
            tempColorData.push(this.targetData.binaryList[i + j][7]);
        }
        decryptImageData.length < Math.pow(Math.floor(Math.sqrt(2560000 / 32)), 2) * 4 &&
            decryptImageData.push([...tempColorData]);
    }
    decryptImageData = Uint8ClampedArray.from(decryptImageData, (z) => {
        z = parseInt(z.join(""), 2);
        return z;
    });
    console.log(decryptImageData, "decryptImageData");
    //须要注意的是putImageData的data的长度必须为两个边的乘积的4的倍数
    ctx.putImageData(
        new ImageData(
            decryptImageData,
            Math.floor(Math.sqrt(2560000 / 8 / 4)),
            Math.floor(Math.sqrt(2560000 / 8 / 4))
        ),
        0,
        0
    );
},

通过这一步后,咱们能够看到,就可以从主画布中提取出隐藏在其中的图片!!!

哇 ~ 是否是很是的有意思?

还须要注意的点 :LSB 方式的隐写图片只能存储为 PNG 或者 BMP 图片格式,而且不能够再用有损压缩(好比 JPEG),不然会丢失隐写的数据!

不要用隐写作违法犯罪的坏事!!!由于它是能够防(某墙)监控的,好比你把坏孩子图藏进了好孩子图里边,我去太坏了 😢

可是你身为程序员,你能够用它去给你心爱的宝贝表白撒,不失为一种浪漫的方式~

感兴趣的小伙伴还能够看一下这篇论文StegaStamp: Invisible Hyperlinks in Physical Photographs(隐写邮票:天然照片中嵌入不可见超连接),做者来自加州大学伯克利分校。他们的项目主页在这

以上是文本和图片的隐写相关内容,固然隐写的媒介不可能只局限于此,还有不少中,只要是计算机能用数字表达一切东西,按道理来讲都是能够用来作隐写的,例如音频的隐写,以及视频的隐写

最后

隐写术是一门很深、应用很普遍的学问,这里讲的很泛,权当作抛砖引玉。图片和文字的隐写只是其中最简单的一部分,有兴趣的同窗能够看一本叫《数据隐藏技术揭秘》的书。须要的小伙伴也能够加我,我发给你!
文中的例子已经放在了个人github中,固然你也能够经过这里体验一下

我是荣顶,很高兴能在这里和你一块儿变强!一块儿面向快乐编程! 😉

若是你也很是热爱前端相关技术!欢迎进入个人小密圈 ~ 里面都是大佬,带你飞! 🦄 扫描👇~