summaryrefslogtreecommitdiff
path: root/captcha.py
diff options
context:
space:
mode:
authorMorgana <Morgana@icolotl.com>2026-10-03 10:25:51 -0500
committerMorgana <Morgana@icolotl.com>2026-10-03 10:25:51 -0500
commitdfe2069c1dd4eb14d44a57d35d76afdc47d1598e (patch)
tree88d2a4688bbc536af1d90ce12118cbf028f9f60b /captcha.py
parente96fdc880d45ab22eea5eb1210fe767ce7fcd34e (diff)
Initial captcha code
Diffstat (limited to 'captcha.py')
-rw-r--r--captcha.py193
1 files changed, 193 insertions, 0 deletions
diff --git a/captcha.py b/captcha.py
new file mode 100644
index 0000000..e5fe28c
--- /dev/null
+++ b/captcha.py
@@ -0,0 +1,193 @@
+from typing import Self
+from hashlib import md5
+
+ADJ = [(x, y) for x in range(-1, 2) for y in range(-1, 2) if x != 0 or y != 0]
+ORTH = [dir for dir in ADJ if dir[0] == 0 or dir[1] == 0]
+VALID_LABEL = set("abcdefghijklmnopqrstuv.")
+VALID_LOCK = set("0123.")
+VALID_ANSWER = set("0123")
+
+
+def value(grid, x: int, y: int) -> int | None:
+ side_length = int(len(grid) ** 0.5)
+ if not 0 <= x < side_length:
+ return None
+ if not 0 <= y < side_length:
+ return None
+ return grid[y * side_length + x]
+
+
+def adj(grid, x: int, y: int, orth=False, center=False) -> list[int]:
+ base = [value(grid, x + dx, y + dy) for dx, dy in (ORTH if orth else ADJ)]
+ if center:
+ base.append(value(grid, x, y))
+ return [v for v in base if v is not None]
+
+
+class Shape:
+ def __init__(self, grid, x: int, y: int) -> None:
+ self.value = value(grid, x, y)
+ proc = [(x, y)]
+ seen = set(proc)
+ while proc:
+ x, y = proc.pop()
+ for dx, dy in ORTH:
+ possible = (x + dx, y + dy)
+ if value(grid, possible[0], possible[1]) == self.value:
+ if possible not in seen:
+ seen.add(possible)
+ proc.append(possible)
+ self.size = len(seen)
+ left = min([pt[0] for pt in seen])
+ right = max([pt[0] for pt in seen])
+ top = min([pt[1] for pt in seen])
+ bottom = max([pt[1] for pt in seen])
+ output = []
+ for y in range(top, bottom + 1):
+ row = []
+ for x in range(left, right + 1):
+ row.append("X" if (x, y) in seen else ".")
+ output.append("".join(row))
+ self.shape = "\n".join(output)
+ self.points = seen
+
+
+class Captcha:
+
+ def __init__(self, size: int) -> None:
+ tile_count = size**2
+ self.size = size
+ self.label: list[str | None] = [None] * tile_count
+ self.lock: list[int | None] = [None] * tile_count
+ self.reload = False
+
+ def __members(self) -> tuple[int, list[str | None], list[int | None]]:
+ return (self.size, self.label, self.lock)
+
+ def __eq__(self, other) -> bool:
+ if not isinstance(other, Captcha):
+ return False
+ return self.__members() == other.__members()
+
+ def __hash__(self) -> int:
+ return hash(self.__members())
+
+ @classmethod
+ def from_string(
+ cls, size: int, label: str, lock: str, reload: bool = False
+ ) -> Self:
+ result = cls(size)
+ result.label = [char if char != "." else None for char in label]
+ result.lock = [int(char) if char != "." else None for char in lock]
+ result.reload = reload
+ result.recalc_id()
+ return result
+
+ def make_label(self) -> str:
+ return "".join(char if char else "." for char in self.label)
+
+ def make_lock(self) -> str:
+ return "".join(str(char) if char is not None else "." for char in self.lock)
+
+ def recalc_id(self) -> None:
+ self.id = md5(
+ (self.make_label() + self.make_lock()).encode("utf-8")
+ ).hexdigest()
+
+ def validate(self, answer_string: str) -> bool:
+
+ # Ensure answer is of right length
+ answer = [int(char) for char in answer_string]
+ if len(answer) != len(self.label):
+ return False
+
+ # Ensure lock is correct
+ for ans, lk in zip(answer, self.lock):
+ if lk is not None:
+ if ans != lk:
+ return False
+
+ # Ensure label is correct
+ color_match = None
+ shape_match_m = None
+ shape_match_n = None
+ for x in range(0, self.size):
+ for y in range(0, self.size):
+ match value(self.label, x, y):
+ case "a":
+ if Shape(answer, x, y).size != 1:
+ return False
+ case "b":
+ if Shape(answer, x, y).size != 2:
+ return False
+ case "c":
+ if Shape(answer, x, y).size != 3:
+ return False
+ case "d":
+ if Shape(answer, x, y).size != 4:
+ return False
+ case "e":
+ near = adj(answer, x, y, True, False)
+ if len(near) != len(set(near)):
+ return False
+ case "m":
+ shape = Shape(answer, x, y)
+ if shape_match_m:
+ if shape_match_m != shape.shape:
+ return False
+ else:
+ shape_match_m = shape.shape
+ if shape_match_m == shape_match_n:
+ return False
+ case "n":
+ shape = Shape(answer, x, y)
+ if shape_match_n:
+ if shape_match_n != shape.shape:
+ return False
+ else:
+ shape_match_n = shape.shape
+ if shape_match_m == shape_match_n:
+ return False
+ case "r":
+ color = value(answer, x, y)
+ if color_match:
+ if color_match != color:
+ return False
+ else:
+ color_match = color
+ case "v":
+ if len(set(adj(answer, x, y, True, True))) != 1:
+ return False
+ case _:
+ pass
+ return True
+
+ @classmethod
+ def safe_validate(
+ cls, size: str, label: str, lock: str, answer: str
+ ) -> Captcha | None:
+ try:
+ int_size = int(size)
+ except ValueError:
+ return None
+ tile_count = int_size**2
+ if (
+ len(label) != tile_count
+ or len(lock) != tile_count
+ or len(answer) != tile_count
+ or any(char not in VALID_LABEL for char in label)
+ or any(char not in VALID_LOCK for char in lock)
+ or any(char not in VALID_ANSWER for char in answer)
+ ):
+ return None
+ captcha = Captcha.from_string(int_size, label, lock)
+ if captcha.validate(answer):
+ return captcha
+ return None
+
+
+if __name__ == "__main__":
+ test = Captcha.from_string(
+ 5, "m...cena....m...ebneb.clm", "........................."
+ )
+ print(test.validate("0111001010203101231212212"))