增加了提issue的功能

Signed-off-by: Chen Xiao <abigwc@gmail.com>
This commit is contained in:
Chen Xiao
2026-05-12 08:59:02 +08:00
parent 08a05b088a
commit 45e18b4d47
6118 changed files with 512373 additions and 4 deletions
@@ -0,0 +1,15 @@
# coding: utf-8
"""
Captcha
~~~~~~~
A captcha library that generates audio and image CAPTCHAs.
:copyright: (c) 2014 by Hsiaoming Yang.
:license: BSD, see LICENSE for more details.
"""
__version__ = '0.7.1'
__author__ = 'Hsiaoming Yang <me@lepture.com>'
__homepage__ = 'https://github.com/lepture/captcha'
@@ -0,0 +1,279 @@
# coding: utf-8
"""
captcha.audio
~~~~~~~~~~~~~
Generate Audio CAPTCHAs, with built-in digits CAPTCHA.
This module is totally inspired by https://github.com/dchest/captcha
"""
import typing as t
import os
import copy
import wave
import struct
import secrets
import operator
from functools import reduce
__all__ = ['AudioCaptcha']
WAVE_SAMPLE_RATE = 8000 # HZ
WAVE_HEADER = bytearray(
b'RIFF\x00\x00\x00\x00WAVEfmt \x10\x00\x00\x00\x01\x00\x01\x00'
b'@\x1f\x00\x00@\x1f\x00\x00\x01\x00\x08\x00data'
)
WAVE_HEADER_LENGTH = len(WAVE_HEADER) - 4
DATA_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'data')
def _read_wave_file(filepath: str) -> bytearray:
w = wave.open(filepath)
data = w.readframes(-1)
w.close()
return bytearray(data)
def change_speed(body: bytearray, speed: float = 1) -> bytearray:
"""Change the voice speed of the wave body."""
if speed == 1:
return body
length = int(len(body) * speed)
rv = bytearray(length)
step: float = 0
for v in body:
i = int(step)
while i < int(step + speed) and i < length:
rv[i] = v
i += 1
step += speed
return rv
def patch_wave_header(body: bytearray) -> bytearray:
"""Patch header to the given wave body.
:param body: the wave content body, it should be bytearray.
"""
length = len(body)
padded = length + length % 2
total = WAVE_HEADER_LENGTH + padded
header = copy.copy(WAVE_HEADER)
# fill the total length position
header[4:8] = bytearray(struct.pack('<I', total))
header += bytearray(struct.pack('<I', length))
data = header + body
# the total length is even
if length != padded:
data = data + bytearray([0])
return data
def create_noise(length: int, level: int = 4) -> bytearray:
"""Create white noise for background"""
noise = bytearray(length)
adjust = 128 - int(level / 2)
i = 0
while i < length:
v = secrets.randbelow(257)
noise[i] = v % level + adjust
i += 1
return noise
def create_silence(length: int) -> bytearray:
"""Create a piece of silence."""
data = bytearray(length)
i = 0
while i < length:
data[i] = 128
i += 1
return data
def change_sound(body: bytearray, level: float = 1) -> bytearray:
if level == 1:
return body
body = copy.copy(body)
for i, v in enumerate(body):
if v > 128:
v = int((v - 128) * level + 128)
v = max(v, 128)
v = min(v, 255)
elif v < 128:
v = int(128 - (128 - v) * level)
v = min(v, 128)
v = max(v, 0)
body[i] = v
return body
def mix_wave(src: bytearray, dst: bytearray) -> bytearray:
"""Mix two wave body into one."""
if len(src) > len(dst):
# output should be longer
dst, src = src, dst
for i, sv in enumerate(src):
dv = dst[i]
if sv < 128 and dv < 128:
dst[i] = int(sv * dv / 128)
else:
dst[i] = int(2 * (sv + dv) - sv * dv / 128 - 256)
return dst
BEEP = _read_wave_file(os.path.join(DATA_DIR, 'beep.wav'))
END_BEEP = change_speed(BEEP, 1.4)
SILENCE = create_silence(int(WAVE_SAMPLE_RATE / 5))
class AudioCaptcha:
"""Create an audio CAPTCHA.
Create an instance of AudioCaptcha is pretty simple::
captcha = AudioCaptcha()
captcha.write('1234', 'out.wav')
This module has a built-in digits CAPTCHA, but it is suggested that you
create your own voice data library. A voice data library is a directory
that contains lots of single charater named directories, for example::
voices/
0/
1/
2/
The single charater named directories contain the wave files which pronunce
the directory name. A charater directory can has many wave files, this
AudioCaptcha will randomly choose one of them.
You should always use your own voice library::
captcha = AudioCaptcha(voicedir='/path/to/voices')
"""
def __init__(self, voicedir: t.Optional[str] = None):
if voicedir is None:
voicedir = DATA_DIR
self._voicedir = voicedir
self._cache: t.Dict[str, t.List[bytearray]] = {}
self._choices: t.List[str] = []
@property
def choices(self) -> t.List[str]:
"""Available choices for characters to be generated."""
if self._choices:
return self._choices
for n in os.listdir(self._voicedir):
if len(n) == 1 and os.path.isdir(os.path.join(self._voicedir, n)):
self._choices.append(n)
return self._choices
def random(self, length: int = 6) -> t.List[str]:
"""Generate a random string with the given length.
:param length: the return string length.
"""
return [secrets.choice(self.choices) for _ in range(length)]
def load(self) -> None:
"""Load voice data into memory."""
for name in self.choices:
self._load_data(name)
def _load_data(self, name: str) -> None:
dirname = os.path.join(self._voicedir, name)
data: t.List[bytearray] = []
for f in os.listdir(dirname):
filepath = os.path.join(dirname, f)
if f.endswith('.wav') and os.path.isfile(filepath):
data.append(_read_wave_file(filepath))
self._cache[name] = data
def _twist_pick(self, key: str) -> bytearray:
voice = secrets.choice(self._cache[key])
# random change speed
speed = (secrets.randbelow(31) + 90) / 100.0
voice = change_speed(voice, speed)
# random change sound
level = (secrets.randbelow(41) + 80) / 100.0
voice = change_sound(voice, level)
return voice
def _noise_pick(self) -> bytearray:
key = secrets.choice(self.choices)
voice = secrets.choice(self._cache[key])
voice = copy.copy(voice)
voice.reverse()
speed = (secrets.randbelow(9) + 8) / 10.0
voice = change_speed(voice, speed)
level = (secrets.randbelow(5) + 2) / 10.0
voice = change_sound(voice, level)
return voice
def create_background_noise(self, length: int, chars: str) -> bytearray:
noise = create_noise(length, 4)
pos = 0
while pos < length:
sound = self._noise_pick()
end = pos + len(sound) + 1
noise[pos:end] = mix_wave(sound, noise[pos:end])
pos = end + secrets.randbelow(int(WAVE_SAMPLE_RATE / 10) + 1)
return noise
def create_wave_body(self, chars: str) -> bytearray:
voices: t.List[bytearray] = []
inters: t.List[int] = []
for c in chars:
voices.append(self._twist_pick(c))
i = secrets.randbelow(WAVE_SAMPLE_RATE * 3 - WAVE_SAMPLE_RATE + 1) + WAVE_SAMPLE_RATE
inters.append(i)
durations = map(lambda a: len(a), voices)
length = max(durations) * len(chars) + reduce(operator.add, inters)
bg = self.create_background_noise(length, chars)
# begin
pos: int = inters[0]
for i, v in enumerate(voices):
end = pos + len(v) + 1
bg[pos:end] = mix_wave(v, bg[pos:end])
pos = end + inters[i]
return BEEP + SILENCE + BEEP + SILENCE + BEEP + bg + END_BEEP
def generate(self, chars: str) -> bytearray:
"""Generate audio CAPTCHA data. The return data is a bytearray.
:param chars: text to be generated.
"""
if not self._cache:
self.load()
body = self.create_wave_body(chars)
return patch_wave_header(body)
def write(self, chars: str, output: str) -> None:
"""Generate and write audio CAPTCHA data to the output.
:param chars: text to be generated.
:param output: output destionation.
"""
data = self.generate(chars)
with open(output, 'wb') as f:
f.write(data)
Binary file not shown.
@@ -0,0 +1,249 @@
# coding: utf-8
"""
captcha.image
~~~~~~~~~~~~~
Generate Image CAPTCHAs, just the normal image CAPTCHAs you are using.
"""
from __future__ import annotations
import os
import secrets
import typing as t
from PIL.Image import new as createImage, Image, Transform, Resampling
from PIL.ImageDraw import Draw, ImageDraw
from PIL.ImageFilter import SMOOTH
from PIL.ImageFont import FreeTypeFont, truetype
from io import BytesIO
__all__ = ['ImageCaptcha']
ColorTuple = t.Union[t.Tuple[int, int, int], t.Tuple[int, int, int, int]]
DATA_DIR = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'data')
DEFAULT_FONTS = [os.path.join(DATA_DIR, 'DroidSansMono.ttf')]
class ImageCaptcha:
"""Create an image CAPTCHA.
Many of the codes are borrowed from wheezy.captcha, with a modification
for memory and developer friendly.
ImageCaptcha has one built-in font, DroidSansMono, which is licensed under
Apache License 2. You should always use your own fonts::
captcha = ImageCaptcha(fonts=['/path/to/A.ttf', '/path/to/B.ttf'])
You can put as many fonts as you like. But be aware of your memory, all of
the fonts are loaded into your memory, so keep them a lot, but not too
many.
:param width: The width of the CAPTCHA image.
:param height: The height of the CAPTCHA image.
:param fonts: Fonts to be used to generate CAPTCHA images.
:param font_sizes: Random choose a font size from this parameters.
"""
lookup_table: list[int] = [int(i * 1.97) for i in range(256)]
character_offset_dx: tuple[int, int] = (0, 4)
character_offset_dy: tuple[int, int] = (0, 6)
character_rotate: tuple[int, int] = (-30, 30)
character_warp_dx: tuple[float, float] = (0.1, 0.3)
character_warp_dy: tuple[float, float] = (0.2, 0.3)
word_space_probability: float = 0.5
word_offset_dx: float = 0.25
def __init__(
self,
width: int = 160,
height: int = 60,
fonts: list[str] | None = None,
font_sizes: tuple[int, ...] | None = None):
self._width = width
self._height = height
self._fonts = fonts or DEFAULT_FONTS
self._font_sizes = font_sizes or (42, 50, 56)
self._truefonts: list[FreeTypeFont] = []
@property
def truefonts(self) -> list[FreeTypeFont]:
if self._truefonts:
return self._truefonts
self._truefonts = [
truetype(n, s)
for n in self._fonts
for s in self._font_sizes
]
return self._truefonts
@staticmethod
def create_noise_curve(image: Image, color: ColorTuple) -> Image:
w, h = image.size
x1 = secrets.randbelow(int(w / 5) + 1)
x2 = secrets.randbelow(w - int(w / 5) + 1) + int(w / 5)
y1 = secrets.randbelow(h - 2 * int(h / 5) + 1) + int(h / 5)
y2 = secrets.randbelow(h - y1 - int(h / 5) + 1) + y1
points = [x1, y1, x2, y2]
end = secrets.randbelow(41) + 160
start = secrets.randbelow(21)
Draw(image).arc(points, start, end, fill=color)
return image
@staticmethod
def create_noise_dots(
image: Image,
color: ColorTuple,
width: int = 3,
number: int = 30) -> Image:
draw = Draw(image)
w, h = image.size
while number:
x1 = secrets.randbelow(w + 1)
y1 = secrets.randbelow(h + 1)
draw.line(((x1, y1), (x1 - 1, y1 - 1)), fill=color, width=width)
number -= 1
return image
def _draw_character(
self,
c: str,
draw: ImageDraw,
color: ColorTuple) -> Image:
font = secrets.choice(self.truefonts)
_, _, w, h = draw.multiline_textbbox((1, 1), c, font=font)
dx1 = secrets.randbelow(self.character_offset_dx[1] - self.character_offset_dx[0] + 1) + self.character_offset_dx[0]
dy1 = secrets.randbelow(self.character_offset_dy[1] - self.character_offset_dy[0] + 1) + self.character_offset_dy[0]
im = createImage('RGBA', (int(w) + dx1, int(h) + dy1))
Draw(im).text((dx1, dy1), c, font=font, fill=color)
# rotate
im = im.crop(im.getbbox())
im = im.rotate(
self.character_rotate[0] + (secrets.randbits(32) / (2**32)) * (self.character_rotate[1] - self.character_rotate[0]),
Resampling.BILINEAR,
expand=True,
)
# warp
dx2 = w * (secrets.randbits(32) / (2**32)) * (self.character_warp_dx[1] - self.character_warp_dx[0]) + self.character_warp_dx[0]
dy2 = h * (secrets.randbits(32) / (2**32)) * (self.character_warp_dy[1] - self.character_warp_dy[0]) + self.character_warp_dy[0]
x1 = int(secrets.randbits(32) / (2**32) * (dx2 - (-dx2)) + (-dx2))
y1 = int(secrets.randbits(32) / (2**32) * (dy2 - (-dy2)) + (-dy2))
x2 = int(secrets.randbits(32) / (2**32) * (dx2 - (-dx2)) + (-dx2))
y2 = int(secrets.randbits(32) / (2**32) * (dy2 - (-dy2)) + (-dy2))
w2 = w + abs(x1) + abs(x2)
h2 = h + abs(y1) + abs(y2)
data = (
x1, y1,
-x1, h2 - y2,
w2 + x2, h2 + y2,
w2 - x2, -y1,
)
im = im.resize((w2, h2))
im = im.transform((int(w), int(h)), Transform.QUAD, data)
return im
def create_captcha_image(
self,
chars: str,
color: ColorTuple,
background: ColorTuple) -> Image:
"""Create the CAPTCHA image itself.
:param chars: text to be generated.
:param color: color of the text.
:param background: color of the background.
The color should be a tuple of 3 numbers, such as (0, 255, 255).
"""
image = createImage('RGB', (self._width, self._height), background)
draw = Draw(image)
images: list[Image] = []
for c in chars:
if secrets.randbits(32) / (2**32) > self.word_space_probability:
images.append(self._draw_character(" ", draw, color))
images.append(self._draw_character(c, draw, color))
text_width = sum([im.size[0] for im in images])
width = max(text_width, self._width)
image = image.resize((width, self._height))
average = int(text_width / len(chars))
rand = int(self.word_offset_dx * average)
offset = int(average * 0.1)
for im in images:
w, h = im.size
mask = im.convert('L').point(self.lookup_table)
image.paste(im, (offset, int((self._height - h) / 2)), mask)
offset = offset + w + (-secrets.randbelow(rand + 1))
if width > self._width:
image = image.resize((self._width, self._height))
return image
def generate_image(self, chars: str,
bg_color: ColorTuple | None = None,
fg_color: ColorTuple | None = None) -> Image:
"""Generate the image of the given characters.
:param chars: text to be generated.
:param bg_color: background color of the image in rgb format (r, g, b).
:param fg_color: foreground color of the text in rgba format (r,g,b,a).
"""
background = bg_color if bg_color else random_color(238, 255)
random_fg_color = random_color(10, 200, secrets.randbelow(36) + 220)
color: ColorTuple = fg_color if fg_color else random_fg_color
im = self.create_captcha_image(chars, color, background)
self.create_noise_dots(im, color)
self.create_noise_curve(im, color)
im = im.filter(SMOOTH)
return im
def generate(self, chars: str, format: str = 'png',
bg_color: ColorTuple | None = None,
fg_color: ColorTuple | None = None) -> BytesIO:
"""Generate an Image Captcha of the given characters.
:param chars: text to be generated.
:param format: image file format
:param bg_color: background color of the image in rgb format (r, g, b).
:param fg_color: foreground color of the text in rgba format (r,g,b,a).
"""
im = self.generate_image(chars, bg_color=bg_color, fg_color=fg_color)
out = BytesIO()
im.save(out, format=format)
out.seek(0)
return out
def write(self, chars: str, output: str, format: str = 'png',
bg_color: ColorTuple | None = None,
fg_color: ColorTuple | None = None) -> None:
"""Generate and write an image CAPTCHA data to the output.
:param chars: text to be generated.
:param output: output destination.
:param format: image file format
:param bg_color: background color of the image in rgb format (r, g, b).
:param fg_color: foreground color of the text in rgba format (r,g,b,a).
"""
im = self.generate_image(chars, bg_color=bg_color, fg_color=fg_color)
im.save(output, format=format)
def random_color(
start: int,
end: int,
opacity: int | None = None) -> ColorTuple:
red = secrets.randbelow(end - start + 1) + start
green = secrets.randbelow(end - start + 1) + start
blue = secrets.randbelow(end - start + 1) + start
if opacity is None:
return red, green, blue
return red, green, blue, opacity