Python实现GIF运算验证码
最近开发的一个网站采用的是固定图片验证码,结果被人恶意灌流+恶意注册,导致了一些不必要的消耗,决定稍稍升级一下验证码,给“君子”们加点难度(开源OCR是真的便宜,普通验证码根本挡不住!)
引言
传统的验证码方法包括
图片固定验证码
GIF固定验证码
拖动验证码
行为验证码
前两个方法虽然能够在一定程度上防止自动化攻击,但随着OCR技术的发展,这些传统方法的安全性越来越受到挑战。而后两个方法开源的都有破解方案,商业公司的又太贵,与这些方法相比,GIF运算验证码就颇具性价比,通过动态展示公式,大大增加了自动化解析的难度,从而提升了验证码的安全性。
环境准备和导入必要的库
要开始我们的项目,首先需要准备Python环境,并导入几个关键的库:
PIL:Python Imaging Library,提供了强大的绘图以及图像处理能力。
io.BytesIO:用于在内存中读写bytes,我们将利用它来生成和存储GIF。
random:用来生成随机的数学公式和噪声效果。
介绍核心实现流程
实现GIF运算验证码的过程可以分为以下几个关键步骤:
输出公式的确定: 该步骤通过随机方法生成简单的数学公式,如加法和乘法,确保生成的每个验证码都是独一无二的。
创建带随机背景的画布: 利用PIL库创建一个指定尺寸的空白图像,并填充上随机选择的颜色作为背景。
录入文字和噪声效果: 在已创建的画布上,利用PIL库的绘图工具将数学公式的各个部分以及噪点、干扰线和干扰圈绘制上去。
图片合并和GIF生成: 最后,将所有帧合并成一个GIF文件,每帧间设置适当的停顿时间,生成最终的运算验证码。
现在我们来基于以上流程来实现一下最小功能
Step 1: 输出公式的确定
首先,我们需要生成一个简单的数学公式。这个步骤的目的是确保我们生成的验证码是独一无二的,为此我们将使用Python的`random`库。
import random
def generate_math_expression():
a, b = random.randint(1, 9), random.randint(1, 9)
operation = random.choice(['+', '*']) # 选择加法或乘法
if operation == '+':
result = a + b
elif operation == '*':
result = a * b
expression = f"{a} {operation} {b} = ?"
return expression, result
这个函数将随机生成两个数字和一个运算符(加法或乘法),然后计算结果,并返回一个字符串形式的公式及其结果。
Step 2: 创建带随机背景的画布
接下来,我们将使用PIL(Pillow)库来创建一个带有随机背景色的图像。首先,你需要安装Pillow库。
pip install Pillow
然后,你可以按照以下方法创建一个带有随机背景色的画布:
from PIL import Image, ImageDraw
import random
def create_canvas(width, height):
# 生成随机背景色
background_color = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
# 创建画布
image = Image.new('RGB', (width, height), color=background_color)
return image
Step 3: 录入文字和噪声效果
有了画布,我们现在可以开始在上面绘制数学公式和噪声效果了。
from PIL import ImageFont
def draw_text_and_noise(image, text):
draw = ImageDraw.Draw(image)
width, height = image.size
font = ImageFont.load_default() # 加载默认字体
# 在中间绘制文本
text_width, text_height = draw.textsize(text, font=font)
draw.text(((width - text_width) / 2, (height - text_height) / 2), text, fill=(255, 255, 255), font=font)
# 添加噪点
for _ in range(100):
x, y = random.randint(0, width), random.randint(0, height)
draw.point((x, y), fill=(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)))
# 添加干扰线
for _ in range(5):
start_point = (random.randint(0, width), random.randint(0, height))
end_point = (random.randint(0, width), random.randint(0, height))
draw.line([start_point, end_point], fill=(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)))
Step 4: 图片合并和GIF生成**
现在,我们需要组合之前的步骤,创建多帧图片并将它们合并为一个GIF。首先,我们将生成多帧图片,每一帧显示数学表达式的一个元素(数字或运算符)。最后,所有帧将合并为最终的GIF。
from PIL import Image, ImageDraw, ImageFont, ImageSequence, GifImagePlugin
from io import BytesIO
def create_frames(expression):
frames = []
for char in expression:
# 创建画布
image = create_canvas(100, 50)
# 添加文本和噪声
draw_text_and_noise(image, char)
frames.append(image)
return frames
def create_gif(animation_frames, duration=500):
with BytesIO() as output:
animation_frames[0].save(output, format="GIF", save_all=True,
append_images=animation_frames[1:],
optimize=False, duration=duration, loop=0)
return output.getvalue()
# 实际使用步骤
expression, result = generate_math_expression() # Step 1
frames = create_frames(expression) # Step 2 - Step 3 创建每个字符的帧
captcha_gif = create_gif(frames) # Step 4 合并创建GIF
# 将GIF写入文件或返回web请求
with open("captcha.gif", "wb") as f:
f.write(captcha_gif)
代码封装与提取逻辑
在完成上述功能的实现后,我们将其中的关键部分提取并封装成独立的函数,以便提高代码的重用性和可读性。如此一来,我们可以轻松地在不同的项目中应用GIF运算验证码,或根据需要对其进行扩展。
异步实现的封装代码
class GenerateGIFExpressionCaptcha:
def __init__(
self,
font_path: str | None = None,
font_size: int = 40,
width: int = 100,
height: int = 50,
duration: int = 700,
) -> None:
"""
Initialize the GIF expression captcha generator.
Args:
font_path (str | None): Path to the font file, default is None. If None, use the default font.
font_size (int): Font size.
width (int): GIF image width.
height (int): GIF image height.
duration (int): GIF animation duration in milliseconds.
"""
self.font = ImageFont.truetype(font_path, font_size) if font_path else ImageFont.load_default()
self.width = width
self.height = height
self.duration = duration
async def generate_math_expression(self) -> tuple[str, int]:
"""
Generate a random math expression consisting of two random single-digit numbers, an operator (+ or *), and the result.
Returns:
tuple[str, int]: A tuple containing the math expression as a string and the result as an integer.
"""
a, b = random.randint(1, 9), random.randint(1, 9)
a, b = max(a, b), min(a, b)
op = random.choice(['+', '*'])
result = a + b if op == '+' else a * b
return f"{a} {op} {b} =", result
async def draw_noise_line(self, draw: ImageDraw.ImageDraw) -> None:
"""
Draw a random line as noise on the given ImageDraw object.
Parameters:
draw (ImageDraw.ImageDraw): The ImageDraw object on which to draw the line.
"""
width, height = draw._image.size
start_x, start_y = random.randint(0, width - 1), random.randint(0, height - 1)
end_x, end_y = min(random.randint(0, width // 2), width - 1), min(random.randint(0, height // 2), height - 1)
color = tuple(random.randint(0, 255) for _ in range(3))
draw.line([(start_x, start_y), (end_x, end_y)], fill=color)
async def draw_noise_circle(self, draw: ImageDraw.ImageDraw) -> None:
"""
Draw a random circle as noise on the given ImageDraw object.
Parameters:
draw (ImageDraw.ImageDraw): The ImageDraw object on which to draw the circle.
"""
width, height = draw._image.size
radius = random.randint(1, height // 4)
x, y = random.randint(radius, width - radius), random.randint(radius, height - radius)
outline_color = tuple(random.randint(0, 255) for _ in range(3))
draw.ellipse([(x - radius, y - radius), (x + radius, y + radius)], outline=outline_color)
async def draw_noise_dot(self, draw: ImageDraw.ImageDraw) -> None:
"""
Draw a random dot as noise on the given ImageDraw object.
Args:
draw (ImageDraw.ImageDraw): The ImageDraw object on which to draw the dot.
"""
width, height = draw._image.size
x, y = random.randint(0, width - 1), random.randint(0, height - 1)
color = tuple(random.randint(0, 255) for _ in range(3))
draw.point((x, y), fill=color)
async def create_frame(
self,
text: str,
index: int,
total: int,
width: int = 100,
height: int = 50,
) -> Image.Image:
"""Create a frame for the GIF expression captcha.
Args:
text (str): The text to be displayed on the frame.
index (int): The index of the text in the expression.
total (int): The total number of texts in the expression.
width (int, optional): The width of the frame. Defaults to 100.
height (int, optional): The height of the frame. Defaults to 50.
Returns:
Image.Image: The created frame.
"""
background_color = tuple(random.randint(0, 255) for _ in range(3))
image = Image.new('RGB', (width, height), color=background_color)
draw = ImageDraw.Draw(image)
font = ImageFont.load_default()
bbox = draw.textbbox((0, 0), text, font=font, align='center')
text_width, text_height = bbox[2] - bbox[0], bbox[3] - bbox[1]
for _ in range(6):
await self.draw_noise_line(draw)
for _ in range(int(self.width * self.height * 0.15)):
await self.draw_noise_dot(draw)
for _ in range(3):
await self.draw_noise_circle(draw)
code_color = tuple(255 - c for c in background_color)
text_top, text_left = (height - text_height - 20) // 2, (width - text_width * total) // 2 + index * text_width
draw.text((text_left, text_top), text, font=font, fill=code_color)
return image
async def create_gif(self, expression) -> bytes:
"""
Create a GIF image from the given expression.
Args:
expression (str): The expression to be used for creating the GIF image.
Returns:
bytes: The GIF image bytes.
"""
frames = []
expression_list = expression.split()
for index, part in enumerate(expression_list):
frame = await self.create_frame(
text=part,
index=index,
total=len(expression_list),
width=self.width,
height=self.height,
)
frames.append(frame)
bytes_io = BytesIO()
frames[0].save(
bytes_io,
format='GIF',
append_images=frames[1:] if frames else [],
save_all=True,
duration=self.duration,
loop=0,
)
bytes_io.seek(0)
return bytes_io.getvalue()
总结
还有些可以优化的地方,比如可控的噪点与干扰线,GIF的大小和加载速度,但是对于小网站来说够了。
附录
更多关于PIL、io.BytesIO和random库的文档和资源,可以访问以下链接:
- PIL官方文档:[https://pillow.readthedocs.io/en/stable/](https://pillow.readthedocs.io/en/stable/)
- Python官方文档:[https://docs.python.org/3/](https://docs.python.org/3/)