如何通过 Serverless 轻松识别验证码?

{"type":"doc","content":[{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"作者 | 江昱","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"来源 | ","attrs":{}},{"type":"link","attrs":{"href":"https://mp.weixin.qq.com/s/ltHLbC_ZlpfCpwgmRxU35g","title":null},"content":[{"type":"text","text":"Serverless 公众号","attrs":{}}]}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"前言","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Serverless 概念自被提出就倍受关注,尤其是近些年来 Serverless 焕发出了前所未有的活力,各领域的工程师都在试图将 Serverless 架构与自身工作相结合,以获取到 Serverless 架构所带来的“技术红利”。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"验证码(CAPTCHA)是“Completely Automated Public Turing test to tell Computers and Humans Apart”(全自动区分计算机和人类的图灵测试)的缩写,是一种区分用户是计算机还是人的公共全自动程序。可以防止恶意破解密码、刷票、论坛灌水,有效防止某个黑客对某一个特定注册用户用特定程序暴力破解方式进行不断地登陆尝试。实际上验证码是现在很多网站通行的方式,我们利用比较简易的方式实现了这个功能。CAPTCHA 的问题由计算机生成并评判,但是这个问题只有人类才能解答,计算机是无法解答的,所以回答出问题的用户就可以被认为是人类。说白了,验证码就是用来验证的码,验证是人访问的还是机器访问的“码”。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"那么人工智能领域中的验证码识别与 Serverless 架构会碰撞出哪些火花呢?","attrs":{}},{"type":"text","text":"本文将通过 Serverless 架构和卷积神经网络(CNN)算法,实现验证码识别功能。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"浅谈验证码","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"验证码的发展,可以说是非常迅速的,从开始的单纯数字验证码,到后来的数字+字母验证码,再到后来的数字+字母+中文的验证码以及图形图像验证码,单纯的验证码素材已经越来越多了。从验证码的形态来看,也是各不相同,输入、点击、拖拽以及短信验证码、语音验证码……","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Bilibili 的登录验证码就包括了多种模式,例如滑动滑块进行验证:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/00/008ad47258ad8e02ade6f3eec0f498a4.png","alt":"1.png","title":"1.png","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"例如,通过依次点击文字进行验证:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/11/11ab29f676705976b8af2efe7465d94f.png","alt":"2.png","title":"2.png","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"而百度贴吧、知乎、以及 Google 等相关网站的验证码又各不相同,例如选择正着写的文字、选择包括指定物体的图片以及按顺序点击图片中的字符等。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"验证码的识别可能会根据验证码的类型而不太一致,当然最简单的验证码可能就是最原始的文字验证码了:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/c7/c7d58bea0b23af48b81003e7007e1703.png","alt":"3.png","title":"3.png","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"即便是文字验证码,也是存在很多差异的,例如简单的数字验证码、简单的数字+字母验证码、文字验证码、验证码中包括计算、简单验证码中增加一些干扰成为复杂验证码等。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"验证码识别","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"1. 简单验证码识别","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"验证码识别是一个古老的研究领域,简单说就是把图片上的文字转化为文本的过程。最近几年,随着大数据的发展,广大爬虫工程师在对抗反爬策略时,对验证码的识别要求也越来越高。在简单验证码的时代,验证码的识别主要是针对文本验证码,通过图像的切割,对验证码每一部分进行裁剪,然后再对每个裁剪单元进行相似度对比,获得最可能的结果,最后进行拼接,例如将验证码:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/d5/d53d8a439ee0ef89902f93a9285ffb25.png","alt":"4.png","title":"4.png","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"进行二值化等操作:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/48/481f4048b672b7f984fe45ab5cd996b9.png","alt":"5.png","title":"5.png","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"完成之后再进行切割:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/82/825cb2e5558e94b4e608f9c3889fc245.png","alt":"6.png","title":"6.png","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"切割完成再进行识别,最后进行拼接,这样的做法是,针对每个字符进行识别,相对来说是比较容易的。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"但是随着时间的发展,在这种简单验证码逐渐无法满足判断“是人还是机器”的问题时,验证码进行了一次小升级,即验证码上面增加了一些干扰线,或者验证码进行了严重的扭曲,增加了强色块干扰,例如 Dynadot 网站的验证码:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/10/10d472a578f933fcc1f3d51fc5d46b63.png","alt":"7.png","title":"7.png","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"不仅有图像扭曲重叠,还有干扰线和色块干扰。这个时候想要识别验证码,简单的切割识别就很难获得良好的效果了,这时通过深度学习反而可以获得不错的效果。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"2. 基于 CNN 的验证码识别","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"卷积神经网络(Convolutional Neural Network,简称 CNN),是一种前馈神经网络,人工神经元可以响应周围单元,进行大型图像处理。卷积神经网络包括卷积层和池化层。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/f4/f4a39c5398160e5044b14fd5879dcbde.png","alt":"8.png","title":"8.png","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"如图所示,左图是传统的神经网络,其基本结构是:输入层、隐含层、输出层。右图则是卷积神经网络,其结构由输入层、输出层、卷积层、池化层、全连接层构成。卷积神经网络其实是神经网络的一种拓展,而事实上从结构上来说,朴素的 CNN 和朴素的 NN 没有任何区别(当然,引入了特殊结构的、复杂的 CNN 会和 NN 有着比较大的区别)。相对于传统神经网络,CNN 在实际效果中让我们的网络参数数量大大地减少,这样我们可以用较少的参数,训练出更加好的模型,典型的事半功倍,而且可以有效地避免过拟合。同样,由于 filter 的参数共享,即使图片进行了一定的平移操作,我们照样可以识别出特征,这叫做 “平移不变性”。因此,模型就更加稳健了。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":4},"content":[{"type":"text","text":"1)验证码生成","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"验证码的生成是非常重要的一个步骤,因为这一部分的验证码将会作为我们的训练集和测试集,同时最终我们的模型可以识别什么类型的验证码,也是和这部分有关。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":null},"content":[{"type":"text","text":"# coding:utf-8\nimport random\nimport numpy as np\nfrom PIL import Image\nfrom captcha.image import ImageCaptcha\nCAPTCHA_LIST = [eve for eve in \"0123456789abcdefghijklmnopqrsruvwxyzABCDEFGHIJKLMOPQRSTUVWXYZ\"]\nCAPTCHA_LEN = 4 # 验证码长度\nCAPTCHA_HEIGHT = 60 # 验证码高度\nCAPTCHA_WIDTH = 160 # 验证码宽度\nrandomCaptchaText = lambda char=CAPTCHA_LIST, size=CAPTCHA_LEN: \"\".join([random.choice(char) for _ in range(size)])\ndef genCaptchaTextImage(width=CAPTCHA_WIDTH, height=CAPTCHA_HEIGHT, save=None):\n image = ImageCaptcha(width=width, height=height)\n captchaText = randomCaptchaText()\n if save:\n image.write(captchaText, './img/%s.jpg' % captchaText)\n return captchaText, np.array(Image.open(image.generate(captchaText)))\nprint(genCaptchaTextImage(save=True))","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"通过上述代码,可以生成简单的中英文验证码:","attrs":{}}]},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/35/35800716e266902e4fe68ab558d981fb.png","alt":"image.gif","title":"image.gif","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/6f/6f0383671ad8e157f571e17a60bdd953.png","alt":"9.png","title":"9.png","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","marks":[{"type":"strong","attrs":{}}],"text":"2)模型训练","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"模型训练的代码如下(部分代码来自网络)。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"util.py 文件,主要是一些提取出来的公有方法:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":null},"content":[{"type":"text","text":"# -*- coding:utf-8 -*-\nimport numpy as np\nfrom captcha_gen import genCaptchaTextImage\nfrom captcha_gen import CAPTCHA_LIST, CAPTCHA_LEN, CAPTCHA_HEIGHT, CAPTCHA_WIDTH\n# 图片转为黑白,3维转1维\nconvert2Gray = lambda img: np.mean(img, -1) if len(img.shape) > 2 else img\n# 验证码向量转为文本\nvec2Text = lambda vec, captcha_list=CAPTCHA_LIST: ''.join([captcha_list[int(v)] for v in vec])\ndef text2Vec(text, captchaLen=CAPTCHA_LEN, captchaList=CAPTCHA_LIST):\n \"\"\"\n 验证码文本转为向量\n \"\"\"\n vector = np.zeros(captchaLen * len(captchaList))\n for i in range(len(text)):\n vector[captchaList.index(text[i]) + i * len(captchaList)] = 1\n return vector\ndef getNextBatch(batchCount=60, width=CAPTCHA_WIDTH, height=CAPTCHA_HEIGHT):\n \"\"\"\n 获取训练图片组\n \"\"\"\n batchX = np.zeros([batchCount, width * height])\n batchY = np.zeros([batchCount, CAPTCHA_LEN * len(CAPTCHA_LIST)])\n for i in range(batchCount):\n text, image = genCaptchaTextImage()\n image = convert2Gray(image)\n # 将图片数组一维化 同时将文本也对应在两个二维组的同一行\n batchX[i, :] = image.flatten() / 255\n batchY[i, :] = text2Vec(text)\n return batchX, batchY\n# print(getNextBatch(batch_count=1))","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"model_train.py 文件,主要是进行模型训练。在该文件中,定义了模型的基本信息,例如该模型是三层卷积神经网络,原始图像大小是 60","attrs":{}},{"type":"text","marks":[{"type":"italic","attrs":{}},{"type":"strong","attrs":{}}],"text":"160,在第一次卷积后变为 60","attrs":{}},{"type":"text","text":"160, 第一池化后变为 30","attrs":{}},{"type":"text","marks":[{"type":"italic","attrs":{}},{"type":"strong","attrs":{}}],"text":"80;第二次卷积后变为 30","attrs":{}},{"type":"text","text":"80 ,第二次池化后变为 15","attrs":{}},{"type":"text","marks":[{"type":"italic","attrs":{}},{"type":"strong","attrs":{}}],"text":"40;第三次卷积后变为  15","attrs":{}},{"type":"text","text":"40 ,第三次池化后变为7","attrs":{}},{"type":"text","marks":[{"type":"italic","attrs":{}},{"type":"strong","attrs":{}}],"text":"20。经过三次卷积和池化后,原始图片数据变为 7","attrs":{}},{"type":"text","text":"20 的平面数据,同时项目在进行训练的时候,每隔 100 次进行一次数据测试,计算一次准确度:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":null},"content":[{"type":"text","text":"# -*- coding:utf-8 -*-\nimport tensorflow.compat.v1 as tf\nfrom datetime import datetime\nfrom util import getNextBatch\nfrom captcha_gen import CAPTCHA_HEIGHT, CAPTCHA_WIDTH, CAPTCHA_LEN, CAPTCHA_LIST\ntf.compat.v1.disable_eager_execution()\nvariable = lambda shape, alpha=0.01: tf.Variable(alpha * tf.random_normal(shape))\nconv2d = lambda x, w: tf.nn.conv2d(x, w, strides=[1, 1, 1, 1], padding='SAME')\nmaxPool2x2 = lambda x: tf.nn.max_pool(x, ksize=[1, 2, 2, 1], strides=[1, 2, 2, 1], padding='SAME')\noptimizeGraph = lambda y, y_conv: tf.train.AdamOptimizer(1e-3).minimize(\n tf.reduce_mean(tf.nn.sigmoid_cross_entropy_with_logits(labels=y, logits=y_conv)))\nhDrop = lambda image, weight, bias, keepProb: tf.nn.dropout(\n maxPool2x2(tf.nn.relu(conv2d(image, variable(weight, 0.01)) + variable(bias, 0.1))), keepProb)\ndef cnnGraph(x, keepProb, size, captchaList=CAPTCHA_LIST, captchaLen=CAPTCHA_LEN):\n \"\"\"\n 三层卷积神经网络\n \"\"\"\n imageHeight, imageWidth = size\n xImage = tf.reshape(x, shape=[-1, imageHeight, imageWidth, 1])\n hDrop1 = hDrop(xImage, [3, 3, 1, 32], [32], keepProb)\n hDrop2 = hDrop(hDrop1, [3, 3, 32, 64], [64], keepProb)\n hDrop3 = hDrop(hDrop2, [3, 3, 64, 64], [64], keepProb)\n # 全连接层\n imageHeight = int(hDrop3.shape[1])\n imageWidth = int(hDrop3.shape[2])\n wFc = variable([imageHeight * imageWidth * 64, 1024], 0.01) # 上一层有64个神经元 全连接层有1024个神经元\n bFc = variable([1024], 0.1)\n hDrop3Re = tf.reshape(hDrop3, [-1, imageHeight * imageWidth * 64])\n hFc = tf.nn.relu(tf.matmul(hDrop3Re, wFc) + bFc)\n hDropFc = tf.nn.dropout(hFc, keepProb)\n # 输出层\n wOut = variable([1024, len(captchaList) * captchaLen], 0.01)\n bOut = variable([len(captchaList) * captchaLen], 0.1)\n yConv = tf.matmul(hDropFc, wOut) + bOut\n return yConv\ndef accuracyGraph(y, yConv, width=len(CAPTCHA_LIST), height=CAPTCHA_LEN):\n \"\"\"\n 偏差计算图,正确值和预测值,计算准确度\n \"\"\"\n maxPredictIdx = tf.argmax(tf.reshape(yConv, [-1, height, width]), 2)\n maxLabelIdx = tf.argmax(tf.reshape(y, [-1, height, width]), 2)\n correct = tf.equal(maxPredictIdx, maxLabelIdx) # 判断是否相等\n return tf.reduce_mean(tf.cast(correct, tf.float32))\ndef train(height=CAPTCHA_HEIGHT, width=CAPTCHA_WIDTH, ySize=len(CAPTCHA_LIST) * CAPTCHA_LEN):\n \"\"\"\n cnn训练\n \"\"\"\n accRate = 0.95\n x = tf.placeholder(tf.float32, [None, height * width])\n y = tf.placeholder(tf.float32, [None, ySize])\n keepProb = tf.placeholder(tf.float32)\n yConv = cnnGraph(x, keepProb, (height, width))\n optimizer = optimizeGraph(y, yConv)\n accuracy = accuracyGraph(y, yConv)\n saver = tf.train.Saver()\n with tf.Session() as sess:\n sess.run(tf.global_variables_initializer()) # 初始化\n step = 0 # 步数\n while True:\n batchX, batchY = getNextBatch(64)\n sess.run(optimizer, feed_dict={x: batchX, y: batchY, keepProb: 0.75})\n # 每训练一百次测试一次\n if step % 100 == 0:\n batchXTest, batchYTest = getNextBatch(100)\n acc = sess.run(accuracy, feed_dict={x: batchXTest, y: batchYTest, keepProb: 1.0})\n print(datetime.now().strftime('%c'), ' step:', step, ' accuracy:', acc)\n # 准确率满足要求,保存模型\n if acc > accRate:\n modelPath = \"./model/captcha.model\"\n saver.save(sess, modelPath, global_step=step)\n accRate += 0.01\n if accRate > 0.90:\n break\n step = step + 1\ntrain()","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"当完成了这部分之后,我们可以通过本地机器对模型进行训练,为了提升训练速度,我将代码中的 accRate 部分设置为:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":null},"content":[{"type":"text","text":"if accRate > 0.90:\n break","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"也就是说,当准确率超过 90% 之后,系统就会自动停止,并且保存模型。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"接下来可以进行训练:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/87/8736cffe182bdc5f0b685eed181a21a7.png","alt":"10.png","title":"10.png","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"训练时间可能会比较长,训练完成之后,可以根据结果绘图,查看随着 Step 的增加,准确率的变化曲线:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/b2/b2e055909061df29927f000154263bd8.png","alt":"11.png","title":"11.png","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"横轴表示训练的 Step,纵轴表示准确率","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":3},"content":[{"type":"text","text":"3. 基于 Serverless 架构的验证码识别","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"将上面的代码部分进行进一步整合,按照函数计算的规范进行编码:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":null},"content":[{"type":"text","text":"# -*- coding:utf-8 -*-\n# 核心后端服务\nimport base64\nimport json\nimport uuid\nimport tensorflow as tf\nimport random\nimport numpy as np\nfrom PIL import Image\nfrom captcha.image import ImageCaptcha\n# Response\nclass Response:\n def __init__(self, start_response, response, errorCode=None):\n self.start = start_response\n responseBody = {\n 'Error': {\"Code\": errorCode, \"Message\": response},\n } if errorCode else {\n 'Response': response\n }\n # 默认增加uuid,便于后期定位\n responseBody['ResponseId'] = str(uuid.uuid1())\n print(\"Response: \", json.dumps(responseBody))\n self.response = json.dumps(responseBody)\n def __iter__(self):\n status = '200'\n response_headers = [('Content-type', 'application/json; charset=UTF-8')]\n self.start(status, response_headers)\n yield self.response.encode(\"utf-8\")\nCAPTCHA_LIST = [eve for eve in \"0123456789abcdefghijklmnopqrsruvwxyzABCDEFGHIJKLMOPQRSTUVWXYZ\"]\nCAPTCHA_LEN = 4 # 验证码长度\nCAPTCHA_HEIGHT = 60 # 验证码高度\nCAPTCHA_WIDTH = 160 # 验证码宽度\n# 随机字符串\nrandomStr = lambda num=5: \"\".join(random.sample('abcdefghijklmnopqrstuvwxyz', num))\nrandomCaptchaText = lambda char=CAPTCHA_LIST, size=CAPTCHA_LEN: \"\".join([random.choice(char) for _ in range(size)])\n# 图片转为黑白,3维转1维\nconvert2Gray = lambda img: np.mean(img, -1) if len(img.shape) > 2 else img\n# 验证码向量转为文本\nvec2Text = lambda vec, captcha_list=CAPTCHA_LIST: ''.join([captcha_list[int(v)] for v in vec])\nvariable = lambda shape, alpha=0.01: tf.Variable(alpha * tf.random_normal(shape))\nconv2d = lambda x, w: tf.nn.conv2d(x, w, strides=[1, 1, 1, 1], padding='SAME')\nmaxPool2x2 = lambda x: tf.nn.max_pool(x, ksize=[1, 2, 2, 1], strides=[1, 2, 2, 1], padding='SAME')\noptimizeGraph = lambda y, y_conv: tf.train.AdamOptimizer(1e-3).minimize(\n tf.reduce_mean(tf.nn.sigmoid_cross_entropy_with_logits(labels=y, logits=y_conv)))\nhDrop = lambda image, weight, bias, keepProb: tf.nn.dropout(\n maxPool2x2(tf.nn.relu(conv2d(image, variable(weight, 0.01)) + variable(bias, 0.1))), keepProb)\ndef genCaptchaTextImage(width=CAPTCHA_WIDTH, height=CAPTCHA_HEIGHT, save=None):\n image = ImageCaptcha(width=width, height=height)\n captchaText = randomCaptchaText()\n if save:\n image.write(captchaText, save)\n return captchaText, np.array(Image.open(image.generate(captchaText)))\ndef text2Vec(text, captcha_len=CAPTCHA_LEN, captcha_list=CAPTCHA_LIST):\n \"\"\"\n 验证码文本转为向量\n \"\"\"\n vector = np.zeros(captcha_len * len(captcha_list))\n for i in range(len(text)):\n vector[captcha_list.index(text[i]) + i * len(captcha_list)] = 1\n return vector\ndef getNextBatch(batch_count=60, width=CAPTCHA_WIDTH, height=CAPTCHA_HEIGHT):\n \"\"\"\n 获取训练图片组\n \"\"\"\n batch_x = np.zeros([batch_count, width * height])\n batch_y = np.zeros([batch_count, CAPTCHA_LEN * len(CAPTCHA_LIST)])\n for i in range(batch_count):\n text, image = genCaptchaTextImage()\n image = convert2Gray(image)\n # 将图片数组一维化 同时将文本也对应在两个二维组的同一行\n batch_x[i, :] = image.flatten() / 255\n batch_y[i, :] = text2Vec(text)\n return batch_x, batch_y\ndef cnnGraph(x, keepProb, size, captchaList=CAPTCHA_LIST, captchaLen=CAPTCHA_LEN):\n \"\"\"\n 三层卷积神经网络\n \"\"\"\n imageHeight, imageWidth = size\n xImage = tf.reshape(x, shape=[-1, imageHeight, imageWidth, 1])\n hDrop1 = hDrop(xImage, [3, 3, 1, 32], [32], keepProb)\n hDrop2 = hDrop(hDrop1, [3, 3, 32, 64], [64], keepProb)\n hDrop3 = hDrop(hDrop2, [3, 3, 64, 64], [64], keepProb)\n # 全连接层\n imageHeight = int(hDrop3.shape[1])\n imageWidth = int(hDrop3.shape[2])\n wFc = variable([imageHeight * imageWidth * 64, 1024], 0.01) # 上一层有64个神经元 全连接层有1024个神经元\n bFc = variable([1024], 0.1)\n hDrop3Re = tf.reshape(hDrop3, [-1, imageHeight * imageWidth * 64])\n hFc = tf.nn.relu(tf.matmul(hDrop3Re, wFc) + bFc)\n hDropFc = tf.nn.dropout(hFc, keepProb)\n # 输出层\n wOut = variable([1024, len(captchaList) * captchaLen], 0.01)\n bOut = variable([len(captchaList) * captchaLen], 0.1)\n yConv = tf.matmul(hDropFc, wOut) + bOut\n return yConv\ndef captcha2Text(image_list):\n \"\"\"\n 验证码图片转化为文本\n \"\"\"\n with tf.Session() as sess:\n saver.restore(sess, tf.train.latest_checkpoint('model/'))\n predict = tf.argmax(tf.reshape(yConv, [-1, CAPTCHA_LEN, len(CAPTCHA_LIST)]), 2)\n vector_list = sess.run(predict, feed_dict={x: image_list, keepProb: 1})\n vector_list = vector_list.tolist()\n text_list = [vec2Text(vector) for vector in vector_list]\n return text_list\nx = tf.placeholder(tf.float32, [None, CAPTCHA_HEIGHT * CAPTCHA_WIDTH])\nkeepProb = tf.placeholder(tf.float32)\nyConv = cnnGraph(x, keepProb, (CAPTCHA_HEIGHT, CAPTCHA_WIDTH))\nsaver = tf.train.Saver()\ndef handler(environ, start_response):\n try:\n request_body_size = int(environ.get('CONTENT_LENGTH', 0))\n except (ValueError):\n request_body_size = 0\n requestBody = json.loads(environ['wsgi.input'].read(request_body_size).decode(\"utf-8\"))\n imageName = randomStr(10)\n imagePath = \"/tmp/\" + imageName\n print(\"requestBody: \", requestBody)\n reqType = requestBody.get(\"type\", None)\n if reqType == \"get_captcha\":\n genCaptchaTextImage(save=imagePath)\n with open(imagePath, 'rb') as f:\n data = base64.b64encode(f.read()).decode()\n return Response(start_response, {'image': data})\n if reqType == \"get_text\":\n # 图片获取\n print(\"Get pucture\")\n imageData = base64.b64decode(requestBody[\"image\"])\n with open(imagePath, 'wb') as f:\n f.write(imageData)\n # 开始预测\n img = Image.open(imageName)\n img = img.resize((160, 60), Image.ANTIALIAS)\n img = img.convert(\"RGB\")\n img = np.asarray(img)\n image = convert2Gray(img)\n image = image.flatten() / 255\n return Response(start_response, {'result': captcha2Text([image])})","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"在这个函数部分,主要包括两个接口:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"• 获取验证码:用户测试使用,生成验证码","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"• 获取验证码识别结果:用户识别使用,识别验证码","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"这部分代码,所需要的依赖内容如下:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":null},"content":[{"type":"text","text":"tensorflow==1.13.1\nnumpy==1.19.4\nscipy==1.5.4\npillow==8.0.1\ncaptcha==0.3","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"另外,为了更加简单的来体验,提供测试页面,测试页面的后台服务使用 Python Web Bottle 框架:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":null},"content":[{"type":"text","text":"# -*- coding:utf-8 -*-\nimport os\nimport json\nfrom bottle import route, run, static_file, request\nimport urllib.request\nurl = \"http://\" + os.environ.get(\"url\")\n@route('/')\ndef index():\n return static_file(\"index.html\", root='html/')\n@route('/get_captcha')\ndef getCaptcha():\n data = json.dumps({\"type\": \"get_captcha\"}).encode(\"utf-8\")\n reqAttr = urllib.request.Request(data=data, url=url)\n return urllib.request.urlopen(reqAttr).read().decode(\"utf-8\")\n@route('/get_captcha_result', method='POST')\ndef getCaptcha():\n data = json.dumps({\"type\": \"get_text\", \"image\": json.loads(request.body.read().decode(\"utf-8\"))[\"image\"]}).encode(\n \"utf-8\")\n reqAttr = urllib.request.Request(data=data, url=url)\n return urllib.request.urlopen(reqAttr).read().decode(\"utf-8\")\nrun(host='0.0.0.0', debug=False, port=9000)","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"该后端服务,所需依赖:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":null},"content":[{"type":"text","text":"bottle==0.12.19","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"前端页面代码:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":null},"content":[{"type":"text","text":"\n\n\n \n 验证码识别测试系统\n \n \n\n\n
\n
\n
\n
\n

\n 验证码识别测试系统\n

\n
\n
\n
\n
\n
\n
\n
\n
\n \n

\n

\n
\n
\n 操作:\n \n \n
\n
\n
\n
\n
\n
\n\n","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"准备好代码之后,开始编写部署文件:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":null},"content":[{"type":"text","text":"Global:\n Service:\n Name: ServerlessBook\n Description: Serverless图书案例\n Log: Auto\n Nas: Auto\nServerlessBookCaptchaDemo:\n Component: fc\n Provider: alibaba\n Access: release\n Extends:\n deploy:\n - Hook: s install docker\n Path: ./\n Pre: true\n Properties:\n Region: cn-beijing\n Service: ${Global.Service}\n Function:\n Name: serverless_captcha\n Description: 验证码识别\n CodeUri:\n Src: ./src/backend\n Excludes:\n - src/backend/.fun\n - src/backend/model\n Handler: index.handler\n Environment:\n - Key: PYTHONUSERBASE\n Value: /mnt/auto/.fun/python\n MemorySize: 3072\n Runtime: python3\n Timeout: 60\n Triggers:\n - Name: ImageAI\n Type: HTTP\n Parameters:\n AuthType: ANONYMOUS\n Methods:\n - GET\n - POST\n - PUT\n Domains:\n - Domain: Auto\nServerlessBookCaptchaWebsiteDemo:\n Component: bottle\n Provider: alibaba\n Access: release\n Extends:\n deploy:\n - Hook: pip3 install -r requirements.txt -t ./\n Path: ./src/website\n Pre: true\n Properties:\n Region: cn-beijing\n CodeUri: ./src/website\n App: index.py\n Environment:\n - Key: url\n Value: ${ServerlessBookCaptchaDemo.Output.Triggers[0].Domains[0]}\n Detail:\n Service: ${Global.Service}\n Function:\n Name: serverless_captcha_website","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"整体的目录结构:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":null},"content":[{"type":"text","text":"| - src # 项目目录\n | | - backend # 项目后端,核心接口\n | | - index.py # 后端核心代码\n | | - requirements.txt # 后端核心代码依赖\n | | - website # 项目前端,便于测试使用\n | | - html # 项目前端页面\n | | - index.html # 项目前端页面\n | | - index.py # 项目前端的后台服务(bottle框架)\n | | - requirements.txt # 项目前端的后台服务依赖","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"完成之后,我们可以在项目目录下,进行项目的部署:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"codeblock","attrs":{"lang":null},"content":[{"type":"text","text":"s deploy","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"部署完成之后,打开返回的页面地址:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/02/025abce8ea85ac1853e332042f7b329a.png","alt":"12.png","title":"12.png","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"点击获取验证码,即可在线生成一个验证码:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/f5/f52164c7afdd2a5ce4bb501cfa1529eb.png","alt":"13.png","title":"13.png","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"此时点击识别验证码,即可进行验证码识别:","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"image","attrs":{"src":"https://static001.geekbang.org/infoq/96/9663dc678ec14ae0ffc48b0d451def34.png","alt":"14.png","title":"14.png","style":[{"key":"width","value":"75%"},{"key":"bordertype","value":"none"}],"href":null,"fromPaste":true,"pastePass":true}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"由于模型在训练的时候,填写的目标准确率是 90%,所以可以认为在海量同类型验证码测试之后,整体的准确率在 90% 左右。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"heading","attrs":{"align":null,"level":2},"content":[{"type":"text","text":"总结","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null},"content":[{"type":"text","text":"Serverless 发展迅速,通过 Serverless 做一个验证码识别工具,我觉得这是一个非常酷的事情。在未来的数据采集等工作中,有一个优美的验证码识别工具是非常必要的。当然验证码种类很多,针对不同类型的验证码识别,也是一项非常有挑战性的工作。","attrs":{}}]},{"type":"paragraph","attrs":{"indent":0,"number":0,"align":null,"origin":null}}]}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章