import os import sys import time import random import re import requests from urllib.parse import urljoin, urlparse from dotenv import load_dotenv def rewrite_image_paths(content, session, output_dir, base_url): def replace_html_img(match): return match.group(1) + download_image(match.group(2), session, output_dir, base_url) + match.group(3) def replace_markdown_img(match): return match.group(1) + download_image(match.group(2), session, output_dir, base_url) + match.group(3) content = re.sub(r'(]*\bsrc=["\'])([^"\']+)(["\'][^>]*>)', replace_html_img, content, flags=re.I) return re.sub(r'(!\[[^\]]*\]\()([^)\s]+)(\))', replace_markdown_img, content) def print_status(current, total, section, sleep_duration=0): percent = int(current / total * 100) if total else 0 filled = int(20 * current / total) if total else 0 bar = "#" * filled + "-" * (20 - filled) print(f"\r\033[K[{bar}] {current}/{total} - {percent}% | {section} | sleep {sleep_duration}s", end="", flush=True) def download_image(url, session, output_dir, base_url): try: if not url.startswith('http'): if url.startswith('/'): url = urljoin(base_url, url) else: return url parsed_url = urlparse(url) filename = os.path.basename(parsed_url.path) if not filename: filename = f"image_{int(time.time())}.png" local_path = os.path.join(output_dir, filename) if os.path.exists(local_path): return local_path # Keep terminal output to the single progress line; failures print below. response = session.get(url, stream=True) response.raise_for_status() with open(local_path, 'wb') as f: for chunk in response.iter_content(chunk_size=8192): f.write(chunk) return local_path except Exception as e: print(f"\n[-] Failed to download {url}: {e}") return url def main(): load_dotenv() cookie_string = os.getenv('HTB_COOKIE') user_agent = os.getenv('HTB_USER_AGENT', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36') if not cookie_string: print("Error: HTB_COOKIE not found in .env file.") sys.exit(1) if len(sys.argv) < 2: print("Usage: python htb_scraper.py https://academy.hackthebox.com/app/module/144") sys.exit(1) module_url = sys.argv[1] # Extract module ID # URLs typically: /app/module/144 or /module/144 parts = module_url.strip('/').split('/') try: module_idx = parts.index('module') module_id = parts[module_idx + 1] except (ValueError, IndexError): print("Error: Could not extract module ID from URL. Make sure it contains '/module/'") sys.exit(1) session = requests.Session() headers = { 'accept': 'application/json', 'referer': module_url, 'user-agent': user_agent, 'sec-ch-ua': '"Brave";v="149", "Chromium";v="149", "Not)A;Brand";v="24"', 'sec-ch-ua-mobile': '?0', 'sec-ch-ua-platform': '"Linux"', 'sec-fetch-dest': 'empty', 'sec-fetch-mode': 'cors', 'sec-fetch-site': 'same-origin', 'sec-gpc': '1', 'Cookie': cookie_string } session.headers.update(headers) print_status(0, 0, f"module {module_id}", 0) api_base = "https://academy.hackthebox.com" sections_url = f"{api_base}/api/v3/modules/{module_id}/sections" res = session.get(sections_url) if res.status_code != 200: print(f"\n[-] API returned status {res.status_code}. Cookie invalid or expired.") sys.exit(1) data = res.json() # API v3 returns list of groups containing sections sections = [] if isinstance(data, dict) and "data" in data: groups = data["data"] elif isinstance(data, list): groups = data else: groups = [] for group in groups: if isinstance(group, dict) and "sections" in group: for s in group["sections"]: if isinstance(s, dict) and "id" in s: sections.append(s) if not sections: print("\n[-] No sections found in the API response.") sys.exit(1) print_status(0, len(sections), f"module {module_id}", 0) output_file = f"module_{module_id}.md" images_dir = f"images_module_{module_id}" os.makedirs(images_dir, exist_ok=True) with open(output_file, "w", encoding="utf-8") as f: f.write(f"# HTB Academy Module {module_id}\n\n") f.write(f"Source: {module_url}\n\n") for i, sec in enumerate(sections, 1): sec_id = sec['id'] sec_title = sec.get('title', f"Section {sec_id}") delay = random.randint(3, 8) for remaining in range(delay, 0, -1): print_status(i, len(sections), sec_title, remaining) time.sleep(1) print_status(i, len(sections), sec_title, 0) content_url = f"{api_base}/api/v2/modules/{module_id}/sections/{sec_id}" sec_res = session.get(content_url) if sec_res.status_code != 200: print(f"\n[-] Failed to fetch {sec_title}. Status: {sec_res.status_code}") continue sec_data = sec_res.json() if isinstance(sec_data, dict) and "data" in sec_data: sec_data = sec_data["data"] html_content = sec_data.get("content", "") if not html_content: print(f"\n[-] No content found for {sec_title}.") continue # HTB's API content is already Markdown. Do not run it through an # HTML parser; XML examples inside fenced code blocks are real content. markdown_text = rewrite_image_paths(html_content, session, images_dir, api_base) with open(output_file, "a", encoding="utf-8") as f: f.write(f"\n\n---\n\n## {sec_title}\n\n") f.write(markdown_text) print(f"\r\033[K[+] Done! Saved to {output_file}") if __name__ == "__main__": main()