htb_scraper.py
· 6.2 KiB · Python
Raw
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'(<img\b[^>]*\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/<id>'")
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()
| 1 | import os |
| 2 | import sys |
| 3 | import time |
| 4 | import random |
| 5 | import re |
| 6 | import requests |
| 7 | from urllib.parse import urljoin, urlparse |
| 8 | from dotenv import load_dotenv |
| 9 | |
| 10 | def rewrite_image_paths(content, session, output_dir, base_url): |
| 11 | def replace_html_img(match): |
| 12 | return match.group(1) + download_image(match.group(2), session, output_dir, base_url) + match.group(3) |
| 13 | |
| 14 | def replace_markdown_img(match): |
| 15 | return match.group(1) + download_image(match.group(2), session, output_dir, base_url) + match.group(3) |
| 16 | |
| 17 | content = re.sub(r'(<img\b[^>]*\bsrc=["\'])([^"\']+)(["\'][^>]*>)', replace_html_img, content, flags=re.I) |
| 18 | return re.sub(r'(!\[[^\]]*\]\()([^)\s]+)(\))', replace_markdown_img, content) |
| 19 | |
| 20 | def print_status(current, total, section, sleep_duration=0): |
| 21 | percent = int(current / total * 100) if total else 0 |
| 22 | filled = int(20 * current / total) if total else 0 |
| 23 | bar = "#" * filled + "-" * (20 - filled) |
| 24 | print(f"\r\033[K[{bar}] {current}/{total} - {percent}% | {section} | sleep {sleep_duration}s", end="", flush=True) |
| 25 | |
| 26 | |
| 27 | def download_image(url, session, output_dir, base_url): |
| 28 | try: |
| 29 | if not url.startswith('http'): |
| 30 | if url.startswith('/'): |
| 31 | url = urljoin(base_url, url) |
| 32 | else: |
| 33 | return url |
| 34 | |
| 35 | parsed_url = urlparse(url) |
| 36 | filename = os.path.basename(parsed_url.path) |
| 37 | if not filename: |
| 38 | filename = f"image_{int(time.time())}.png" |
| 39 | |
| 40 | local_path = os.path.join(output_dir, filename) |
| 41 | if os.path.exists(local_path): |
| 42 | return local_path |
| 43 | |
| 44 | # Keep terminal output to the single progress line; failures print below. |
| 45 | response = session.get(url, stream=True) |
| 46 | response.raise_for_status() |
| 47 | with open(local_path, 'wb') as f: |
| 48 | for chunk in response.iter_content(chunk_size=8192): |
| 49 | f.write(chunk) |
| 50 | return local_path |
| 51 | except Exception as e: |
| 52 | print(f"\n[-] Failed to download {url}: {e}") |
| 53 | return url |
| 54 | |
| 55 | def main(): |
| 56 | load_dotenv() |
| 57 | cookie_string = os.getenv('HTB_COOKIE') |
| 58 | 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') |
| 59 | |
| 60 | if not cookie_string: |
| 61 | print("Error: HTB_COOKIE not found in .env file.") |
| 62 | sys.exit(1) |
| 63 | |
| 64 | if len(sys.argv) < 2: |
| 65 | print("Usage: python htb_scraper.py https://academy.hackthebox.com/app/module/144") |
| 66 | sys.exit(1) |
| 67 | |
| 68 | module_url = sys.argv[1] |
| 69 | |
| 70 | # Extract module ID |
| 71 | # URLs typically: /app/module/144 or /module/144 |
| 72 | parts = module_url.strip('/').split('/') |
| 73 | try: |
| 74 | module_idx = parts.index('module') |
| 75 | module_id = parts[module_idx + 1] |
| 76 | except (ValueError, IndexError): |
| 77 | print("Error: Could not extract module ID from URL. Make sure it contains '/module/<id>'") |
| 78 | sys.exit(1) |
| 79 | |
| 80 | session = requests.Session() |
| 81 | headers = { |
| 82 | 'accept': 'application/json', |
| 83 | 'referer': module_url, |
| 84 | 'user-agent': user_agent, |
| 85 | 'sec-ch-ua': '"Brave";v="149", "Chromium";v="149", "Not)A;Brand";v="24"', |
| 86 | 'sec-ch-ua-mobile': '?0', |
| 87 | 'sec-ch-ua-platform': '"Linux"', |
| 88 | 'sec-fetch-dest': 'empty', |
| 89 | 'sec-fetch-mode': 'cors', |
| 90 | 'sec-fetch-site': 'same-origin', |
| 91 | 'sec-gpc': '1', |
| 92 | 'Cookie': cookie_string |
| 93 | } |
| 94 | |
| 95 | session.headers.update(headers) |
| 96 | |
| 97 | print_status(0, 0, f"module {module_id}", 0) |
| 98 | api_base = "https://academy.hackthebox.com" |
| 99 | sections_url = f"{api_base}/api/v3/modules/{module_id}/sections" |
| 100 | |
| 101 | res = session.get(sections_url) |
| 102 | if res.status_code != 200: |
| 103 | print(f"\n[-] API returned status {res.status_code}. Cookie invalid or expired.") |
| 104 | sys.exit(1) |
| 105 | |
| 106 | data = res.json() |
| 107 | # API v3 returns list of groups containing sections |
| 108 | sections = [] |
| 109 | if isinstance(data, dict) and "data" in data: |
| 110 | groups = data["data"] |
| 111 | elif isinstance(data, list): |
| 112 | groups = data |
| 113 | else: |
| 114 | groups = [] |
| 115 | |
| 116 | for group in groups: |
| 117 | if isinstance(group, dict) and "sections" in group: |
| 118 | for s in group["sections"]: |
| 119 | if isinstance(s, dict) and "id" in s: |
| 120 | sections.append(s) |
| 121 | |
| 122 | if not sections: |
| 123 | print("\n[-] No sections found in the API response.") |
| 124 | sys.exit(1) |
| 125 | |
| 126 | print_status(0, len(sections), f"module {module_id}", 0) |
| 127 | |
| 128 | output_file = f"module_{module_id}.md" |
| 129 | images_dir = f"images_module_{module_id}" |
| 130 | os.makedirs(images_dir, exist_ok=True) |
| 131 | |
| 132 | with open(output_file, "w", encoding="utf-8") as f: |
| 133 | f.write(f"# HTB Academy Module {module_id}\n\n") |
| 134 | f.write(f"Source: {module_url}\n\n") |
| 135 | |
| 136 | for i, sec in enumerate(sections, 1): |
| 137 | sec_id = sec['id'] |
| 138 | sec_title = sec.get('title', f"Section {sec_id}") |
| 139 | |
| 140 | delay = random.randint(3, 8) |
| 141 | for remaining in range(delay, 0, -1): |
| 142 | print_status(i, len(sections), sec_title, remaining) |
| 143 | time.sleep(1) |
| 144 | |
| 145 | print_status(i, len(sections), sec_title, 0) |
| 146 | content_url = f"{api_base}/api/v2/modules/{module_id}/sections/{sec_id}" |
| 147 | |
| 148 | sec_res = session.get(content_url) |
| 149 | if sec_res.status_code != 200: |
| 150 | print(f"\n[-] Failed to fetch {sec_title}. Status: {sec_res.status_code}") |
| 151 | continue |
| 152 | |
| 153 | sec_data = sec_res.json() |
| 154 | if isinstance(sec_data, dict) and "data" in sec_data: |
| 155 | sec_data = sec_data["data"] |
| 156 | |
| 157 | html_content = sec_data.get("content", "") |
| 158 | if not html_content: |
| 159 | print(f"\n[-] No content found for {sec_title}.") |
| 160 | continue |
| 161 | |
| 162 | # HTB's API content is already Markdown. Do not run it through an |
| 163 | # HTML parser; XML examples inside fenced code blocks are real content. |
| 164 | markdown_text = rewrite_image_paths(html_content, session, images_dir, api_base) |
| 165 | |
| 166 | with open(output_file, "a", encoding="utf-8") as f: |
| 167 | f.write(f"\n\n---\n\n## {sec_title}\n\n") |
| 168 | f.write(markdown_text) |
| 169 | |
| 170 | print(f"\r\033[K[+] Done! Saved to {output_file}") |
| 171 | |
| 172 | if __name__ == "__main__": |
| 173 | main() |