from dataclasses import dataclass from urllib.parse import urlsplit, urlunsplit import re import zlib from .constants import ( ALPHABET_NAMES, CODE_PREFIX_PAYLOAD, CODE_PREFIX_QR, CODE_SCHEMES, CODE_VERSION, DOMAIN_CODE_WIDTH, LEGACY_DICTIONARY_CODE_WIDTH, COMMON_DOMAINS, COMMON_TLDS, MODE_QR_STRUCTURED, MODE_STRUCTURED, PAYLOAD_ALPHABET, QR_ALPHABET, SCHEME_CODES, SUPPORTED_CODE_VERSIONS, SEGMENT_ALPHABETS, SEPARATORS, TLD_CODE_WIDTH, ) from .base_n import bytes_to_int, decode_int, encode_int, int_to_bytes from .bitstream import BitReader, BitWriter @dataclass(frozen=True) class EncodeResult: code: str original_length: int code_length: int bit_length: int mode: str def encode_url(url: str, *, qr: bool = False) -> EncodeResult: normalized = _normalize_url(url) writer = BitWriter() writer.write(CODE_VERSION, 4) writer.write(0, 1) _write_structured_url(writer, normalized) if qr: alphabet = QR_ALPHABET mode = MODE_QR_STRUCTURED else: alphabet = PAYLOAD_ALPHABET mode = MODE_STRUCTURED bit_length = writer.bit_length data = writer.to_bytes() payload = encode_int(bytes_to_int(data), alphabet) prefix = CODE_PREFIX_QR if qr else CODE_PREFIX_PAYLOAD return EncodeResult( code=prefix + payload, original_length=len(normalized), code_length=len(payload), bit_length=bit_length, mode=mode, ) def decode_url(code: str) -> str: if len(code) < 2: raise ValueError("code is too short") prefix, payload = code[0], code[1:] if prefix == CODE_PREFIX_QR: alphabet = QR_ALPHABET elif prefix == CODE_PREFIX_PAYLOAD: alphabet = PAYLOAD_ALPHABET else: raise ValueError("unknown code prefix") value = decode_int(payload, alphabet) byte_length = max(1, (value.bit_length() + 7) // 8) data = int_to_bytes(value, byte_length) # We don't know the exact bit length from base conversion, so parse with all possible leading zero counts. for leading_zero_bits in range(8): candidate = (b"\x00" * (1 if leading_zero_bits else 0)) + data bit_length = len(candidate) * 8 - leading_zero_bits try: return _decode_from_bytes(candidate, bit_length) except ValueError: continue raise ValueError("invalid or unsupported code") def _decode_from_bytes(data: bytes, bit_length: int) -> str: reader = BitReader(data, bit_length) version = reader.read(4) if version not in SUPPORTED_CODE_VERSIONS: raise ValueError("unsupported version") qr_mode = reader.read(1) if qr_mode: return _read_raw(reader) return _read_structured_url(reader, version) def _normalize_url(url: str) -> str: candidate = url.strip() if not candidate: raise ValueError("URL is empty") if "://" not in candidate: candidate = "https://" + candidate parts = urlsplit(candidate) if parts.scheme not in SCHEME_CODES: raise ValueError("only http and https URLs are supported") if not parts.netloc: raise ValueError("URL requires a host") return urlunsplit((parts.scheme.lower(), parts.netloc.lower(), parts.path, parts.query, parts.fragment)) def _write_structured_url(writer: BitWriter, url: str) -> None: parts = urlsplit(url) writer.write(SCHEME_CODES[parts.scheme], 1) host = parts.hostname or "" has_www = host.startswith("www.") if has_www: host = host[4:] writer.write(1 if has_www else 0, 1) labels = host.split(".") if host else [] tld = labels[-1] if labels else "" sld = labels[-2] if len(labels) >= 2 else "" rest = ".".join(labels[:-2]) if len(labels) > 2 else "" _write_common_or_segment(writer, tld, COMMON_TLDS, TLD_CODE_WIDTH) _write_common_or_segment(writer, sld, COMMON_DOMAINS, DOMAIN_CODE_WIDTH) _write_text_segment(writer, rest) port = parts.port or 0 writer.write(1 if port else 0, 1) if port: writer.write(port, 16) _write_text_segment(writer, parts.path) _write_text_segment(writer, parts.query) _write_text_segment(writer, parts.fragment) def _read_structured_url(reader: BitReader, version: int) -> str: scheme = CODE_SCHEMES[reader.read(1)] has_www = bool(reader.read(1)) dictionary_width = LEGACY_DICTIONARY_CODE_WIDTH if version == 1 else TLD_CODE_WIDTH tld = _read_common_or_segment(reader, COMMON_TLDS, dictionary_width) sld = _read_common_or_segment(reader, COMMON_DOMAINS, dictionary_width) rest = _read_text_segment(reader) labels = [part for part in [rest, sld] if part] host = ".".join(labels + ([tld] if tld else [])) if has_www: host = "www." + host port = reader.read(16) if reader.read(1) else 0 netloc = f"{host}:{port}" if port else host path = _read_text_segment(reader) query = _read_text_segment(reader) fragment = _read_text_segment(reader) return urlunsplit((scheme, netloc, path, query, fragment)) def _write_common_or_segment(writer: BitWriter, text: str, common: list[str], width: int) -> None: if text in common[1:]: writer.write(common.index(text), width) else: writer.write(0, width) _write_text_segment(writer, text) def _read_common_or_segment(reader: BitReader, common: list[str], width: int) -> str: code = reader.read(width) if code: if code >= len(common): raise ValueError("invalid common dictionary code") return common[code] return _read_text_segment(reader) def _write_text_segment(writer: BitWriter, text: str) -> None: if text == "": writer.write(0, 1) return writer.write(1, 1) compressed = _compress_text(text) raw = text.encode() use_compressed = len(compressed) + 1 < len(raw) writer.write(1 if use_compressed else 0, 1) data = compressed if use_compressed else raw writer.write(len(data), 16) for byte in data: writer.write(byte, 8) def _read_text_segment(reader: BitReader) -> str: if not reader.read(1): return "" compressed = bool(reader.read(1)) length = reader.read(16) data = bytes(reader.read(8) for _ in range(length)) if compressed: data = zlib.decompress(data) return data.decode() def _compress_text(text: str) -> bytes: return zlib.compress(text.encode(), level=9) def _write_raw(writer: BitWriter, text: str) -> None: data = text.encode() writer.write(len(data), 16) for byte in data: writer.write(byte, 8) def _read_raw(reader: BitReader) -> str: length = reader.read(16) return bytes(reader.read(8) for _ in range(length)).decode() def describe_segments(url: str) -> list[dict[str, str | int]]: normalized = _normalize_url(url) chunks = [chunk for chunk in re.split(f"([{re.escape(SEPARATORS)}])", normalized) if chunk] return [_describe_segment(chunk) for chunk in chunks] def _describe_segment(text: str) -> dict[str, str | int]: for key, alphabet in SEGMENT_ALPHABETS.items(): if alphabet is not None and all(char in alphabet for char in text): return {"text": text, "alphabet": ALPHABET_NAMES[key], "size": len(alphabet)} return {"text": text, "alphabet": ALPHABET_NAMES[7], "size": 256}