""" OnlyFans Request Signing Module Handles the dynamic request signing required by the OnlyFans API. Fetches signing rules from the DATAHOARDERS/dynamic-rules GitHub repo and computes SHA-1 based signatures for each API request. Isolated module so it's easy to update when OF changes their signing scheme. """ import hashlib import time from typing import Dict, Optional import aiohttp RULES_URL = "https://raw.githubusercontent.com/DATAHOARDERS/dynamic-rules/main/onlyfans.json" class OnlyFansSigner: """ Computes request signatures for the OnlyFans API. Uses dynamic rules fetched from a public GitHub repo (same source as OF-Scraper). Rules are cached locally and refreshed every 6 hours. """ RULES_TTL = 6 * 3600 # 6 hours def __init__(self, rules_url: Optional[str] = None): self.rules_url = rules_url or RULES_URL self._rules: Optional[Dict] = None self._rules_fetched_at: float = 0 @property def rules_stale(self) -> bool: """Check if cached rules need refreshing""" if self._rules is None: return True return (time.time() - self._rules_fetched_at) > self.RULES_TTL async def get_rules(self) -> Dict: """ Fetch signing rules, using cache if fresh. Returns: Dict with keys: static_param, format, checksum_indexes, checksum_constants, checksum_constant, app_token """ if not self.rules_stale: return self._rules timeout = aiohttp.ClientTimeout(total=15) async with aiohttp.ClientSession(timeout=timeout) as session: async with session.get(self.rules_url) as resp: if resp.status != 200: if self._rules is not None: # Use stale cache rather than failing return self._rules raise RuntimeError( f"Failed to fetch OF signing rules: HTTP {resp.status}" ) self._rules = await resp.json(content_type=None) self._rules_fetched_at = time.time() return self._rules async def sign(self, endpoint_path: str, user_id: str = "0") -> Dict[str, str]: """ Compute signing headers for an OnlyFans API request. Args: endpoint_path: The full URL path (e.g. "/api2/v2/users/me") user_id: The authenticated user's ID (from auth_id cookie) Returns: Dict with 'sign', 'time', 'app-token' headers """ rules = await self.get_rules() # Timestamp in milliseconds (matching OF-Scraper's implementation) timestamp = str(round(time.time() * 1000)) # 1. Build the message to hash msg = "\n".join([ rules["static_param"], timestamp, endpoint_path, str(user_id), ]) # 2. SHA-1 hash sha1_hash = hashlib.sha1(msg.encode("utf-8")).hexdigest() sha1_bytes = sha1_hash.encode("ascii") # 3. Checksum from indexed byte positions + single constant # (matching OF-Scraper's implementation) checksum_indexes = rules["checksum_indexes"] checksum_constant = rules.get("checksum_constant", 0) checksum = sum(sha1_bytes[i] for i in checksum_indexes) + checksum_constant # 4. Build the sign header using the format template # Typical format: "53760:{}:{:x}:69723085" sign_value = rules["format"].format(sha1_hash, abs(checksum)) return { "sign": sign_value, "time": timestamp, "app-token": rules["app_token"], }