22import html
33import json
44import re
5+ import subprocess
56from datetime import datetime , timezone
67from pathlib import Path
78import xml .etree .ElementTree as ET
2829]
2930
3031SKIP_HTML = {"404.html" , "print.html" , "toc.html" }
32+ NOINDEX_HTML = {"404.html" , "print.html" , "toc.html" }
3133SEO_START = "<!-- HT_SEO_START -->"
3234SEO_END = "<!-- HT_SEO_END -->"
35+ SITE_DESCRIPTIONS = {
36+ "HackTricks Cloud" : (
37+ "HackTricks Cloud is a practical cloud security knowledge base covering AWS, GCP, Azure, "
38+ "Kubernetes, CI/CD, and workspace pentesting techniques."
39+ ),
40+ }
41+ LANGUAGE_LOCALES = {
42+ "af" : "af_ZA" ,
43+ "de" : "de_DE" ,
44+ "el" : "el_GR" ,
45+ "en" : "en_US" ,
46+ "es" : "es_ES" ,
47+ "fr" : "fr_FR" ,
48+ "hi" : "hi_IN" ,
49+ "it" : "it_IT" ,
50+ "ja" : "ja_JP" ,
51+ "ko" : "ko_KR" ,
52+ "pl" : "pl_PL" ,
53+ "pt" : "pt_PT" ,
54+ "sr" : "sr_RS" ,
55+ "sw" : "sw_KE" ,
56+ "tr" : "tr_TR" ,
57+ "uk" : "uk_UA" ,
58+ "zh" : "zh_CN" ,
59+ }
60+ DESCRIPTION_SKIP_PATTERNS = (
61+ "hacktricks logos" ,
62+ "learn & practice" ,
63+ "learn and practice" ,
64+ "hacktricks training" ,
65+ "full hacktricks training catalog" ,
66+ )
67+ LANGUAGE_MENU_LINK_RE = re .compile (
68+ r'(?P<prefix><a\s+id="(?P<lang>[a-z]{2})"\s+href=")/[a-z]{2}/[^"]*(?P<suffix>"\s+hreflang="(?P=lang)")' ,
69+ flags = re .I ,
70+ )
3371
3472
3573def parse_args ():
@@ -46,17 +84,18 @@ def parse_args():
4684
4785 index_cmd = subparsers .add_parser ("index" )
4886 index_cmd .add_argument ("--site-url" , required = True )
49- index_cmd .add_argument ("--languages" , required = True )
87+ index_cmd .add_argument ("--languages" , default = "," . join ( DEFAULT_LANGUAGES ) )
5088 index_cmd .add_argument ("--output" , required = True )
5189
5290 return parser .parse_args ()
5391
5492
5593def parse_languages (raw ):
94+ supported = set (DEFAULT_LANGUAGES )
5695 langs = []
5796 for item in raw .split ("," ):
5897 code = item .strip ()
59- if re .fullmatch (r"[a-z]{2}" , code ):
98+ if re .fullmatch (r"[a-z]{2}" , code ) and code in supported :
6099 langs .append (code )
61100 return sorted (set (langs ))
62101
@@ -100,19 +139,30 @@ def trim_description(text, fallback):
100139 return cut + "..."
101140
102141
142+ def is_low_value_description (text ):
143+ lowered = text .lower ()
144+ if any (pattern in lowered for pattern in DESCRIPTION_SKIP_PATTERNS ):
145+ return True
146+ return len (re .sub (r"[\W_]+" , "" , text )) < 40
147+
148+
103149def extract_description (document , fallback ):
104150 main_match = re .search (r"<main\b[^>]*>(.*?)</main>" , document , flags = re .I | re .S )
105151 scope = main_match .group (1 ) if main_match else document
106152
107153 for pattern in (r"<p\b[^>]*>(.*?)</p>" , r"<li\b[^>]*>(.*?)</li>" , r"<h[12]\b[^>]*>(.*?)</h[12]>" ):
108154 for match in re .finditer (pattern , scope , flags = re .I | re .S ):
109155 text = clean_text (match .group (1 ))
110- if len (text ) >= 40 :
156+ if len (text ) >= 40 and not is_low_value_description ( text ) :
111157 return trim_description (text , fallback )
112158
113159 return trim_description (clean_text (scope ), fallback )
114160
115161
162+ def homepage_description (site_name ):
163+ return SITE_DESCRIPTIONS .get (site_name , f"{ site_name } : practical cloud security guides and references." )
164+
165+
116166def strip_index_suffix (path ):
117167 return re .sub (r"(?:^|/)index\.html$" , "" , path .as_posix ())
118168
@@ -121,6 +171,38 @@ def is_homepage(rel_path):
121171 return rel_path .as_posix () == "index.html"
122172
123173
174+ def source_path_for_html (book_dir , rel_path ):
175+ repo_root = Path (book_dir ).resolve ().parent
176+ if rel_path .name == "index.html" :
177+ source_rel = Path ("src" ) / rel_path .parent / "README.md"
178+ else :
179+ source_rel = Path ("src" ) / rel_path .with_suffix (".md" )
180+ source_path = repo_root / source_rel
181+ return source_path if source_path .exists () else None
182+
183+
184+ def git_lastmod (source_path ):
185+ try :
186+ output = subprocess .check_output (
187+ ["git" , "log" , "-1" , "--format=%cs" , "--" , str (source_path )],
188+ cwd = source_path .parent ,
189+ stderr = subprocess .DEVNULL ,
190+ text = True ,
191+ ).strip ()
192+ except (OSError , subprocess .CalledProcessError ):
193+ return None
194+ return output or None
195+
196+
197+ def page_lastmod (book_dir , rel_path , html_file ):
198+ source_path = source_path_for_html (book_dir , rel_path )
199+ if source_path :
200+ lastmod = git_lastmod (source_path )
201+ if lastmod :
202+ return lastmod
203+ return datetime .fromtimestamp (html_file .stat ().st_mtime , tz = timezone .utc ).date ().isoformat ()
204+
205+
124206def humanize_slug (value ):
125207 value = value .replace (".html" , "" ).replace ("-" , " " ).replace ("_" , " " ).strip ()
126208 value = re .sub (r"\s+" , " " , value )
@@ -140,7 +222,7 @@ def breadcrumb_items(site_url, lang, rel_path):
140222 return items
141223
142224
143- def build_structured_data (site_url , lang , rel_path , title , description , site_name , image_url ):
225+ def build_structured_data (site_url , lang , rel_path , title , description , site_name , image_url , languages ):
144226 current_url = canonical_url (site_url , lang , rel_path )
145227 site_root = site_url .rstrip ("/" )
146228 website_url = canonical_url (site_url , "en" , Path ("index.html" ))
@@ -159,7 +241,7 @@ def build_structured_data(site_url, lang, rel_path, title, description, site_nam
159241 "@id" : f"{ site_root } /#website" ,
160242 "url" : site_root ,
161243 "name" : site_name ,
162- "inLanguage" : "en" ,
244+ "inLanguage" : languages ,
163245 "publisher" : {"@id" : f"{ site_root } /#organization" },
164246 },
165247 {
@@ -203,7 +285,7 @@ def build_seo_block(site_url, lang, rel_path, languages, default_lang, title, de
203285 current_url = canonical_url (site_url , lang , rel_path )
204286 image_url = social_image_url (site_url )
205287 structured_data = json .dumps (
206- build_structured_data (site_url , lang , rel_path , title , description , site_name , image_url ),
288+ build_structured_data (site_url , lang , rel_path , title , description , site_name , image_url , languages ),
207289 ensure_ascii = False ,
208290 separators = ("," , ":" ),
209291 )
@@ -228,7 +310,7 @@ def build_seo_block(site_url, lang, rel_path, languages, default_lang, title, de
228310 f'<meta property="og:image:secure_url" content="{ html .escape (image_url , quote = True )} ">' ,
229311 f'<meta property="og:image:type" content="image/svg+xml">' ,
230312 f'<meta property="og:image:alt" content="{ html .escape (site_name , quote = True )} ">' ,
231- f'<meta property="og:locale" content="{ html .escape (lang , quote = True )} ">' ,
313+ f'<meta property="og:locale" content="{ html .escape (LANGUAGE_LOCALES . get ( lang , lang ) , quote = True )} ">' ,
232314 f'<meta name="twitter:card" content="summary_large_image">' ,
233315 f'<meta name="twitter:title" content="{ html .escape (title , quote = True )} ">' ,
234316 f'<meta name="twitter:description" content="{ html .escape (description , quote = True )} ">' ,
@@ -240,11 +322,24 @@ def build_seo_block(site_url, lang, rel_path, languages, default_lang, title, de
240322 return "\n " .join (lines )
241323
242324
325+ def update_language_menu_links (document , rel_path , languages ):
326+ allowed_languages = set (languages )
327+ target_path = html .escape (rel_path .as_posix (), quote = True )
328+
329+ def replace (match ):
330+ lang = match .group ("lang" )
331+ if lang not in allowed_languages :
332+ return match .group (0 )
333+ return f'{ match .group ("prefix" )} /{ lang } /{ target_path } { match .group ("suffix" )} '
334+
335+ return LANGUAGE_MENU_LINK_RE .sub (replace , document )
336+
337+
243338def update_document (document , site_url , lang , rel_path , languages , default_lang , site_name ):
244339 title_match = re .search (r"<title>(.*?)</title>" , document , flags = re .I | re .S )
245340 page_title = clean_text (title_match .group (1 )) if title_match else site_name
246341 fallback_description = f"{ site_name } : { page_title } "
247- description = extract_description (document , fallback_description )
342+ description = homepage_description ( site_name ) if is_homepage ( rel_path ) else extract_description (document , fallback_description )
248343 seo_block = build_seo_block (
249344 site_url , lang , rel_path , languages , default_lang , page_title , description , site_name
250345 )
@@ -259,7 +354,7 @@ def update_document(document, site_url, lang, rel_path, languages, default_lang,
259354 if re .search (r'<meta\s+name="description"\s+content="[^"]*"\s*/?>' , document , flags = re .I ):
260355 document = re .sub (
261356 r'(<meta\s+name="description"\s+content=")[^"]*("\s*/?>)' ,
262- r"\1" + html .escape (description , quote = True ) + r"\2" ,
357+ lambda match : match . group ( 1 ) + html .escape (description , quote = True ) + match . group ( 2 ) ,
263358 document ,
264359 count = 1 ,
265360 flags = re .I ,
@@ -271,10 +366,48 @@ def update_document(document, site_url, lang, rel_path, languages, default_lang,
271366 1 ,
272367 )
273368
274- document = re .sub (r"</head>" , f" { seo_block } \n </head>" , document , count = 1 , flags = re .I )
369+ document = re .sub (
370+ r"</head>" ,
371+ lambda match : f" { seo_block } \n { match .group (0 )} " ,
372+ document ,
373+ count = 1 ,
374+ flags = re .I ,
375+ )
376+ document = update_language_menu_links (document , rel_path , languages )
275377 return document
276378
277379
380+ def update_noindex_document (document , rel_path , languages ):
381+ if re .search (r'<meta\s+name="robots"\s+content="[^"]*"\s*/?>' , document , flags = re .I ):
382+ document = re .sub (
383+ r'(<meta\s+name="robots"\s+content=")[^"]*("\s*/?>)' ,
384+ lambda match : match .group (1 ) + "noindex, follow" + match .group (2 ),
385+ document ,
386+ count = 1 ,
387+ flags = re .I ,
388+ )
389+ else :
390+ document = re .sub (
391+ r"</title>" ,
392+ '</title>\n <meta name="robots" content="noindex, follow">' ,
393+ document ,
394+ count = 1 ,
395+ flags = re .I ,
396+ )
397+ return update_language_menu_links (document , rel_path , languages )
398+
399+
400+ def process_noindex_pages (book_dir , languages ):
401+ for html_file in Path (book_dir ).rglob ("*.html" ):
402+ if html_file .name not in NOINDEX_HTML :
403+ continue
404+ rel_path = html_file .relative_to (book_dir )
405+ content = html_file .read_text (encoding = "utf-8" )
406+ updated = update_noindex_document (content , rel_path , languages )
407+ if updated != content :
408+ html_file .write_text (updated , encoding = "utf-8" )
409+
410+
278411def generate_language_sitemap (book_dir , site_url , lang , languages , default_lang ):
279412 ET .register_namespace ("" , "http://www.sitemaps.org/schemas/sitemap/0.9" )
280413 ET .register_namespace ("xhtml" , "http://www.w3.org/1999/xhtml" )
@@ -287,8 +420,9 @@ def generate_language_sitemap(book_dir, site_url, lang, languages, default_lang)
287420 ET .SubElement (url , "{http://www.sitemaps.org/schemas/sitemap/0.9}loc" ).text = canonical_url (
288421 site_url , lang , rel_path
289422 )
290- lastmod = datetime .fromtimestamp (html_file .stat ().st_mtime , tz = timezone .utc ).date ().isoformat ()
291- ET .SubElement (url , "{http://www.sitemaps.org/schemas/sitemap/0.9}lastmod" ).text = lastmod
423+ ET .SubElement (url , "{http://www.sitemaps.org/schemas/sitemap/0.9}lastmod" ).text = page_lastmod (
424+ book_dir , rel_path , html_file
425+ )
292426
293427 for alt_lang in languages :
294428 ET .SubElement (
@@ -334,6 +468,7 @@ def process_pages(args):
334468 )
335469 html_file .write_text (updated , encoding = "utf-8" )
336470
471+ process_noindex_pages (book_dir , languages )
337472 generate_language_sitemap (book_dir , args .site_url , args .lang , languages , args .default_lang )
338473
339474
0 commit comments