用node.js实现一个网页爬虫
本文讲解怎样用 Node.js 高效地从 Web 爬取数据。
前提条件
本文主要针对具有一定 JavaScript 经验的程序员。如果你对 Web 抓取有深刻的了解,但对 JavaScript 并不熟悉,那么本文仍然能够对你有所帮助。
- ✅ 会 JavaScript
- ✅ 会用 DevTools 提取元素选择器
- ✅ 会一些 ES6 (可选)
你将学到
通过本文你将学到:
- 学到更多关于 Node.js 的东西
- 用多个 HTTP 客户端来帮助 Web 抓取的过程
- 利用多个经过实践考验过的库来爬取 Web
了解 Node.js
Javascript 是一种简单的现代编程语言,最初是为了向浏览器中的网页添加动态效果。当加载网站后,Javascript 代码由浏览器的 Javascript 引擎运行。为了使 Javascript 与你的浏览器进行交互,浏览器还提供了运行时环境(document、window等)。
这意味着 Javascript 不能直接与计算机资源交互或对其进行操作。例如在 Web 服务器中,服务器必须能够与文件系统进行交互,这样才能读写文件。
Node.js 使 Javascript 不仅能够运行在客户端,而且还可以运行在服务器端。为了做到这一点,其创始人 Ryan Dahl 选择了Google Chrome 浏览器的 v8 Javascript Engine,并将其嵌入到用 C++ 开发的 Node 程序中。所以 Node.js 是一个运行时环境,它允许 Javascript 代码也能在服务器上运行。
与其他语言(例如 C 或 C++)通过多个线程来处理并发性相反,Node.js 利用单个主线程并并在事件循环的帮助下以非阻塞方式执行任务。
要创建一个简单的 Web 服务器非常简单,如下所示:
如果你已安装了 Node.js,可以试着运行上面的代码。Node.js 非常适合 I/O 密集型程序。
HTTP 客户端:访问 Web
HTTP 客户端是能够将请求发送到服务器,然后接收服务器响应的工具。下面提到的所有工具底的层都是用 HTTP 客户端来访问你要抓取的网站。
Request
Request 是 Javascript 生态中使用最广泛的 HTTP 客户端之一,但是 Request 库的作者已正式声明弃用了。不过这并不意味着它不可用了,相当多的库仍在使用它,并且非常好用。用 Request 发出 HTTP 请求是非常简单的:
你可以在 Github 上找到 Request 库,安装它非常简单。你还可以在 https://github.com/request/request/issues/3142 找到弃用通知及其含义。
Axios
Axios 是基于 promise 的 HTTP 客户端,可在浏览器和 Node.js 中运行。如果你用 Typescript,那么 axios 会为你覆盖内置类型。通过 Axios 发起 HTTP 请求非常简单,默认情况下它带有 Promise 支持,而不是在 Request 中去使用回调:
如果你喜欢 Promises API 的 async/await 语法糖,那么你也可以用,但是由于顶级 await 仍处于 stage 3 ,所以我们只好先用异步函数来代替:
你所要做的就是调用 getForum!可以在 https://github.com/axios/axios 上找到Axios库。
Superagent
与 Axios 一样,Superagent 是另一个强大的 HTTP 客户端,它支持 Promise 和 async/await 语法糖。它具有像 Axios 这样相当简单的 API,但是 Superagent 由于存在更多的依赖关系并且不那么流行。
用 promise、async/await 或回调向 Superagent 发出HTTP请求看起来像这样:
可以在 https://github.com/visionmedia/superagent 找到 Superagent。
正则表达式:艰难的路
在没有任何依赖性的情况下,最简单的进行网络抓取的方法是,使用 HTTP 客户端查询网页时,在收到的 HTML 字符串上使用一堆正则表达式。正则表达式不那么灵活,而且很多专业人士和业余爱好者都难以编写正确的正则表达式。
让我们试一试,假设其中有一个带有用户名的标签,我们需要该用户名,这类似于你依赖正则表达式时必须执行的操作
在 Javascript 中,match() 通常返回一个数组,该数组包含与正则表达式匹配的所有内容。第二个元素(在索引1中)将找到我们想要的 <label> 标记的 textContent 或 innerHTML。但是结果中包含一些不需要的文本( “Username: “),必须将其删除。
如你所见,对于一个非常简单的用例,步骤和要做的工作都很多。这就是为什么应该依赖 HTML 解析器的原因,我们将在后面讨论。
Cheerio:用于遍历 DOM 的核心 JQuery
Cheerio 是一个高效轻便的库,它使你可以在服务器端使用 JQuery 的丰富而强大的 API。如果你以前用过 JQuery,那么将会对 Cheerio 感到很熟悉,它消除了 DOM 所有不一致和与浏览器相关的功能,并公开了一种有效的 API 来解析和操作 DOM。
如你所见,Cheerio 与 JQuery 用起来非常相似。
但是,尽管它的工作方式不同于网络浏览器,也就这意味着它不能:
- 渲染任何解析的或操纵 DOM 元素
- 应用 CSS 或加载外部资源
- 执行 JavaScript
因此,如果你尝试爬取的网站或 Web 应用是严重依赖 Javascript 的(例如“单页应用”),那么 Cheerio 并不是最佳选择,你可能不得不依赖稍后讨论的其他选项。
为了展示 Cheerio 的强大功能,我们将尝试在 Reddit 中抓取 r/programming 论坛,尝试获取帖子名称列表。
首先,通过运行以下命令来安装 Cheerio 和 axios:npm install cheerio axios。
然后创建一个名为 crawler.js 的新文件,并复制粘贴以下代码:
getPostTitles() 是一个异步函数,将对旧的 reddit 的 r/programming 论坛进行爬取。首先,用带有 axios HTTP 客户端库的简单 HTTP GET 请求获取网站的 HTML,然后用 cheerio.load() 函数将 html 数据输入到 Cheerio 中。
然后在浏览器的 Dev Tools 帮助下,可以获得可以定位所有列表项的选择器。如果你使用过 JQuery,则必须非常熟悉 $(\’div> p.title> a\’)。这将得到所有帖子,因为你只希望单独获取每个帖子的标题,所以必须遍历每个帖子,这些操作是在 each() 函数的帮助下完成的。
要从每个标题中提取文本,必须在 Cheerio 的帮助下获取 DOM元素( el 指代当前元素)。然后在每个元素上调用 text() 能够为你提供文本。
现在,打开终端并运行 node crawler.js,然后你将看到大约存有标题的数组,它会很长。尽管这是一个非常简单的用例,但它展示了 Cheerio 提供的 API 的简单性质。
如果你的用例需要执行 Javascript 并加载外部源,那么以下几个选项将很有帮助。
JSDOM:Node 的 DOM
JSDOM 是在 Node.js 中使用的文档对象模型的纯 Javascript 实现,如前所述,DOM 对 Node 不可用,但是 JSDOM 是最接近的。它或多或少地模仿了浏览器。
由于创建了 DOM,所以可以通过编程与要爬取的 Web 应用或网站进行交互,也可以模拟单击按钮。如果你熟悉 DOM 操作,那么使用 JSDOM 将会非常简单。
代码中用 JSDOM 创建一个 DOM,然后你可以用和操纵浏览器 DOM 相同的方法和属性来操纵该 DOM。
为了演示如何用 JSDOM 与网站进行交互,我们将获得 Reddit r/programming 论坛的第一篇帖子并对其进行投票,然后验证该帖子是否已被投票。
首先运行以下命令来安装 jsdom 和 axios:npm install jsdom axios
然后创建名为 crawler.js的文件,并复制粘贴以下代码:
upvoteFirstPost() 是一个异步函数,它将在 r/programming 中获取第一个帖子,然后对其进行投票。axios 发送 HTTP GET 请求获取指定 URL 的HTML。然后通过先前获取的 HTML 来创建新的 DOM。JSDOM 构造函数把HTML 作为第一个参数,把 option 作为第二个参数,已添加的 2 个 option 项执行以下功能:
- runScripts:设置为 dangerously 时允许执行事件 handler 和任何 Javascript 代码。如果你不清楚将要运行的脚本的安全性,则最好将 runScripts 设置为“outside-only”,这会把所有提供的 Javascript 规范附加到 “window” 对象,从而阻止在 inside 上执行的任何脚本。
- resources:设置为“usable”时,允许加载用 <script> 标记声明的任何外部脚本(例如:从 CDN 提取的 JQuery 库)
创建 DOM 后,用相同的 DOM 方法得到第一篇文章的 upvote 按钮,然后单击。要验证是否确实单击了它,可以检查 classList 中是否有一个名为 upmod 的类。如果存在于 classList 中,则返回一条消息。
打开终端并运行 node crawler.js,然后会看到一个整洁的字符串,该字符串将表明帖子是否被赞过。尽管这个例子很简单,但你可以在这个基础上构建功能强大的东西,例如,一个围绕特定用户的帖子进行投票的机器人。
如果你不喜欢缺乏表达能力的 JSDOM ,并且实践中要依赖于许多此类操作,或者需要重新创建许多不同的 DOM,那么下面将是更好的选择。
Puppeteer:无头浏览器
顾名思义,Puppeteer 允许你以编程方式操纵浏览器,就像操纵木偶一样。它通过为开发人员提供高级 API 来默认控制无头版本的 Chrome。
Puppeteer 比上述工具更有用,因为它可以使你像真正的人在与浏览器进行交互一样对网络进行爬取。这就具备了一些以前没有的可能性:
- 你可以获取屏幕截图或生成页面 PDF。
- 可以抓取单页应用并生成预渲染的内容。
- 自动执行许多不同的用户交互,例如键盘输入、表单提交、导航等。
它还可以在 Web 爬取之外的其他任务中发挥重要作用,例如 UI 测试、辅助性能优化等。
通常你会想要截取网站的屏幕截图,也许是为了了解竞争对手的产品目录,可以用 puppeteer 来做到。首先运行以下命令安装 puppeteer,:npm install puppeteer
这将下载 Chromium 的 bundle 版本,根据操作系统的不同,该版本大约 180 MB 至 300 MB。如果你要禁用此功能。
让我们尝试在 Reddit 中获取 r/programming 论坛的屏幕截图和 PDF,创建一个名为 crawler.js的新文件,然后复制粘贴以下代码:
getVisual() 是一个异步函数,它将获 URL 变量中 url 对应的屏幕截图和 pdf。首先,通过 puppeteer.launch() 创建浏览器实例,然后创建一个新页面。可以将该页面视为常规浏览器中的选项卡。然后通过以 URL 为参数调用 page.goto() ,将先前创建的页面定向到指定的 URL。最终,浏览器实例与页面一起被销毁。
完成操作并完成页面加载后,将分别使用 page.screenshot() 和 page.pdf() 获取屏幕截图和 pdf。你也可以侦听 javascript load 事件,然后执行这些操作,在生产环境级别下强烈建议这样做。
在终端上运行 node crawler.js ,几秒钟后,你会注意到已经创建了两个文件,分别名为 screenshot.jpg 和 page.pdf。
Nightmare:Puppeteer 的替代者
Nightmare 是类似 Puppeteer 的高级浏览器自动化库,该库使用 Electron,但据说速度是其前身 PhantomJS 的两倍。
如果你在某种程度上不喜欢 Puppeteer 或对 Chromium 捆绑包的大小感到沮丧,那么 nightmare 是一个理想的选择。首先,运行以下命令安装 nightmare 库:npm install nightmare
然后,一旦下载了 nightmare,我们将用它通过 Google 搜索引擎找到 ScrapingBee 的网站。创建一个名为crawler.js的文件,然后将以下代码复制粘贴到其中:
首先创建一个 Nighmare 实例,然后通过调用 goto() 将该实例定向到 Google 搜索引擎,加载后,使用其选择器获取搜索框,然后使用搜索框的值(输入标签)更改为“ScrapingBee”。完成后,通过单击 “Google搜索” 按钮提交搜索表单。然后告诉 Nightmare 等到第一个链接加载完毕,一旦完成,它将使用 DOM 方法来获取包含该链接的定位标记的 href 属性的值。
最后,完成所有操作后,链接将打印到控制台。
总结
- ✅ Node.js 是 Javascript 在服务器端的运行时环境。由于事件循环机制,它具有“非阻塞”性质。
- ✅ HTTP客户端(例如 Axios、Superagent 和 Request)用于将 HTTP 请求发送到服务器并接收响应。
- ✅ Cheerio 把 JQuery 的优点抽出来,在服务器端 进行 Web 爬取是唯一的目的,但不执行 Javascript 代码。
- ✅ JSDOM 根据标准 Javascript规范 从 HTML 字符串中创建一个 DOM,并允许你对其执行DOM操作。
- ✅ Puppeteer and Nightmare 是高级(high-level )浏览器自动化库,可让你以编程方式去操作 Web 应用,就像真实的人正在与之交互一样。
前端实现最佳截图方案(上)
作者:蜀中亮子
转发链接:https://mp.weixin.qq.com/s/ghXm-dySERTFsXEWw79afA
“旧闻重发,由于上一次的图片有些糊,这次分为上下两篇发送,不至于阅读压力太大”
平时很多时候,需要把当前页面或者页面某一部分内容保存为图片分享出去,也或者有其他的业务用途,这种在很多的营销场景和裂变的过程都会使用到,那我们要把一个页面的内容转化为图片的这个过程,就是比较需要探讨的了。
首先这种情况,想到的实现方案就是使用canvas来实现,我们探索一下基本实现步骤:
- 把需要分享或者记录的内容绘制到canvas上;
- 把绘制之后的canvas转换为图片;
这里需要明确的一点就是,只要把数据绘制到canvas上,这就在canvas画布上形成了被保存在内存中的像素点信息,所以可以直接调用canvas的api方法toDataURL、toBlob,把已经形成的像素信息转化为可以被访问的资源uri,同时保存在服务器当中。这就很轻松的解决了第二步(把canvas转为图片链接),下面是代码的实现:
在实现了第二步的情况之下,需要关注的就是第一步的内容,怎么把内容绘制到canvas上,我们知道canvas的绘图环境有一个方法是ctx.drawImage,可以绘制部分元素到canvas上,包含图片元素Image、svg元素、视频元素Video、canvas元素、ImageBitmap数据等,但是对于一般的其他div或者列表li元素它是不可以被绘制的。
所以,这不是直接调用绘图的api就可以办到的,我们就需要思考其他的方法。在一般的实现上,比较常见的就是使用html2canvas,那么我们先来聊聊html2canvas的使用和实现。
html2canvas使用实现使用
首先看一下html2cavas的使用方法:
调用html2canvas方法传入想要截取的dom,执行之后,返回一个Promise,接收到的canvas上,就绘制了我们想要截取的dom元素。到这一步之后,我们再调取canvas转图片的方法,就可以对其做其他的处理。
这里它的html2canvas方法还支持第二个选项传入一些用户的配置参数,比如是否启用缓存、整个绘图canvas的宽高值等。
在这个转换的过程,在html2canvas的内部,是怎么把dom元素绘制到canvas上的,这是咱们需要思考的问题!
实现
首先咱们先献上一个内部的大致流程图:
对比着内部的流程图,就可以理一下整体的思路,整体的思路就是遍历目标节点和子节点,收集样式,计算节点本身的层级关系和根据不同的优先级绘制到画布中,下面基于这个思路,咱们深入一下整个过程:
- 调用html2canvs函数,直接返回一个执行函数,这一步没有什么;
- 在执行函数的内部第一步是构建配置项defaultOptions,在合并默认配置的过程中,有一个缓存的配置,它会生成处理缓存的方法;
- 处理缓存类,对于一个页面中的多个不同的地方渲染调用多次的情况做优化,避免同一个资源被多次加载;
- 缓存类里面控制了所有图片的加载和处理,包括使用proxy代理和使用cors跨域资源共享这两种情况资源的处理,同时也对base64和blob这两种形式资源的处理。比如如果渲染dom里面包含一个图片的链接类型是blob,使用的方式就是如下处理,然后添加到缓存类中,下次使用就不需要再重新请求。
- 在上一步生成了默认配置的情况之下,传入需要绘制的目标节点element和配置到DocumentCloner里面,这个过程会克隆目标节点所在的文档节点document,同时把目标节点也克隆出来。这个过程中,只是克隆了开发者定义的对应节点样式,并不是结合浏览器渲染生成特定视图最后的样式。
如上这个.box的元素节点,定义的样式只有高度,但是在浏览器渲染之下,会对它设置默认的文字样式等等
- 基于上一步的情况,就需要把克隆出来的目标节点所在的文档节点document进行一次浏览器的渲染,然后在收集最终目标节点的样式。于此,把克隆出来的目标节点的document装载到一个iframe里面,进行一次渲染,然后就可以获取到经过浏览器视图真实呈现的节点样式。
在这个过程中,就可以通过`window.getComputedStyle`这个API拿到要克隆的目标节点上所有的样式了(包含自定义和浏览器默认的结合最终的样式);
- 目标节点的样式和内容都获取到了之后,就需要把它所承载的数据信息转化为canvas可以使用的数据类型,比如某一个子节点的宽度设置为50%或者2rem,在这个过程中,就需要根据父级的宽度把它计算成为像素级别的单位。同时对于每一个节点而言需要绘制的包括了边框、背景、阴影、内容,而对于内容就包含图片、文字、视频等。这个过程就需要对目标节点的所有属性进行解析构造,分析成为可以理解的数据形式。
如上图片这种数据结构和我注释一样,在它内部把每一个节点处理成为了一个container,它的上面有一个styles字段,这个字段是所有节点上的样式经过转换计算之后的数据,还有一个textNodes属性,它表示当前节点下的文本节点,如上,每一个文本的点的内容使用text来表示,位置和大小信息放置在textBounds中。对于elements字段存放的就是当前节点下除了文本节点外,其他节点转换成为的container,最后一个就是bounds字段,存放的是当前节点的位置和大小信息。可以看一下container这个类的代码:
基于这种情况,每一个container数据结构的elements属性都是子节点,整个节点就够构造成一个container tree。
- 在通过解析器把目标节点处理成特定的数据结构container之后,就需要结合canvas调用渲染方法了,我们在浏览器里面创建多个元素的时候,不同的元素设置不同的样式,最后展示的结果就可能不一样,比如下面代码:
这个代码的展示结果如下:
此时,如果修改了代码中.sta1元素节点的opacity属性为0.999,此时整个布局的层级就会发生大变化,结果如下:
这个是什么原因?因为canvas绘图需要根据样式计算哪些元素应该绘制在上层,哪些在下层。元素在浏览器中渲染时,根据W3C的标准,所有的节点层级布局,需要遵循层叠上下文和层叠顺序的标准。当某一些属性发生变化,层叠上下文的顺序就可能发生变化,比如上列中透明度默认为1和不为1的情况(对于如何形成一个层叠上下文此处不做深入讲解,可以自行研究)。
更加直白的理解就是一部分属性会使一些元素形成一个单独的层级,不同属性的层级有一定的排列顺序。如下就是我们对应的顺序:
- 形成层叠上下文环境的元素的背景与边框(相当于整个文档的背景和边框)
- 拥有负 z-index 的子层叠上下文元素 (负的越高越层叠上下文层级越低)
- 正常流式布局,非 inline-block,无 position 定位(static除外)的子元素
- 无 position 定位(static除外)的 float 浮动元素
- 正常流式布局, inline-block元素,无 position 定位(static除外)的子元素(包括 display:table 和 display:inline )
- 拥有 z-index:0 或者auto的子堆叠上下文元素
- 拥有正 z-index: 的子堆叠上下文元素(正的越低层叠上下文层级越低)
- 在正常的元素情况下,没有形成层叠上下文的时候,显示顺序准守以上规则,在设置了一些属性,形成了层叠上下文之后,准守谁大谁上(z-index比较)、后来居上(后写的元素后渲染在上面)
此处,在清楚了元素的渲染需要遵循这个标准的情况之下,canvas绘制节点的时候,就需要先计算出整个目标节点里子节点渲染时所展现的不同层级。先给出来内部模拟层叠上下文的数据结构StackingContext:
以上就是某一个节点对应的层叠上下文在内部所表现出来的数据结构。很多属性都会形成层叠上下文,不同的属性形成的上下文,有不同的顺序,所以需要对目标节点的子节点解析,根据不同的样式属性分配到不同的数组中归类,比如遍历子节点的container上的styles,发现opacity为0.5,此时会形成层叠上下文,然后就把它构造成为上下文的数据结构StackContext。添加到zeroOrAutoZIndexOrTransformedOrOpacity这个数组中,这样一个递归查看子节点的过程,最后会形成一个层叠上下文的树。
- 基于上面构造出的数据结构,就开始调用内部的绘图方法了,一下代码是渲染某一个层叠上下文的代码:
如上绘图函数中,如果子元素形成了层叠上下文,就调用renderStack,这个方法内部继续调用了renderStackContent,这就形成了对于层叠上下文整个树的递归。
如果子元素没有形成层叠上下文,而是正常元素,就直接调用renderNode或者renderNodeContent。这两个的区别是renderNodeContent只负责渲染内容,不会渲染节点的边框和背景色。
对于renderNodeContent这个方法就是渲染一个元素节点里面的内容,可能是正常元素、图片、文字、svg、canvas、视频、input、iframe。对于图片、svg、视频、canvas这几种元素,直接通过调用前文提到的api,对于input需要根据样式计算出绘图数据来模拟完成,文字就直接根据提供的样式来绘制。重点需要提一下的是iframe,如果需要绘制的元素中包含了iframe,就相当于我们需要重新绘制一个新的文档document,处理方法是在内部调用html2canvas的api,绘制整个文档。
以下为多个不同类型的元素的绘制方式:
对于文字的绘制方式:
对于图片、SVG、canvas元素的绘制:
对于代码中调用renderReplacedElement方法内部的处理逻辑,就是调用canvas的drawImage方法绘制以上三种数据形式;
对于需要绘制的元素是iframe的时候,做的处理逻辑就如同重新调用整个绘制方法,重新渲染页面的过程:
对于单选或者多选框的处理情况,就是根据是否选中,来绘制对应状态的样式:
对于input输入框的情况,首先需要绘制边框,然后把内部的文字绘制到输入框中,超出部分需要剪切掉,所以需要使用到canvas的clip绘图API:
对于最后一种需要考虑的就是列表,对于li、ol这两种列表,都可以设置不同类型的list-style,所以需要区分绘制。
以上整个过程,就是html2canvas的整体内部流程,最后的操作都是不同的线条、图片、文字等等的绘制,概括起来就是遍历目标节点,收集样式信息,转化为绘制数据,并且根据一定的优先级策略递归绘制节点到canvas画布上。
实现
在捋顺了整个大流程的情况之下,咱们来看看html2canvas的一些缺点
不支持的一些场景
- box-shadow属性,支持的不好,因为对于canvas的阴影API没有扩散半径。所以对于样式的阴影支持不是特别好;
- 边框虚线的情况也不支持,这一点源码里面没有使用setLineDash,是因为大多数浏览器原本不支持这个属性,chrome也是64版本之后才支持这个属性;
- css中元素的zoom属性支持也不是也特别好,因为换算会出现问题;
- 计算问题是最大的问题!!!因为每一次计算都会有精确度的省略问题,比如父元素的宽度是100像素,子元素是父元素的30%,这个时候转化为canvas绘图单位像素的时候,就会有省略的过程,在有多次省略的情况之下,精确度就会变得不精确。并且还涉及到一些圆弧的情况,这种弧度的计算,最后模仿出来,都会有失去精确度的问题。对于正常的浏览器渲染节点,渲染的内部逻辑,直接是由浏览器处理,但是对于html2canvas的方案,需要先计算为像素单位,然后绘制到canvas上,最后canvas元素还要经过浏览器的一次处理,才能够渲染出来。这个过程不止是换算单位失去精度,渲染也会失去精度。
解决方案,详见下一篇
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
《》
作者:蜀中亮子
转发链接:https://mp.weixin.qq.com/s/ghXm-dySERTFsXEWw79afA
本文作者及来源:Renderbus瑞云渲染农场https://www.renderbus.com
文章为作者独立观点不代本网立场,未经允许不得转载。