GPAL - Generally Positive Automation Library v1.0
GPAL The Fluent Automation LIbrary
Loading...
Searching...
No Matches
SitemapExtractor.cs
1// =============================================================================
2// GPAL - Generally Positive Automation Library
3// Copyright © 2026 Software Decisions, Inc. All rights reserved.
4//
5// This file is part of GPAL.
6// Licensed under the Business Source License 1.1
7//
8// Primary development, architecture, and vision by Michael B. Vederman,
9// CEO of Software Decisions, Inc., Texas.
10//
11// Internal development maintained privately.
12// Public releases appear on GitHub: https://github.com/SoftwareDecisionsInc/GPAL.
13//
14// See LICENSE for full terms, including Additional Use Grant.
15// =============================================================================
16
17using System;
18using System.Collections.Generic;
19using System.IO;
20using System.IO.Compression;
21using System.Linq;
22using System.Net.Http;
23using System.Windows.Forms;
24using System.Xml.Linq;
26using static GenerallyPositive.Enums;
27
28// wirtten by grok, updated by mbv
30{
31 internal class SitemapExtractor
32 {
33 private readonly Browser browser;
34
35 internal SitemapExtractor(Browser browser)
36 {
37 this.browser = browser ?? throw new ArgumentNullException(nameof(browser));
38 }
39
44 internal static bool IsGzipSitemapUrl(string url)
45 {
46 if (true == string.IsNullOrWhiteSpace(url))
47 return false;
48
49 string path = url.Split('?', '#')[0].TrimEnd('/');
50 return path.EndsWith(".gz", StringComparison.OrdinalIgnoreCase);
51 }
52
59 internal static string FetchAndInflateGzipSitemap(string url, IBrowser browser)
60 {
61 try
62 {
63 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Fetching gzip sitemap [{url}]", typeof(SitemapExtractor), GPALObjectType.Other);
64
65 byte[] data;
66 using (var client = new HttpClient())
67 {
68 client.Timeout = TimeSpan.FromSeconds(60);
69 client.DefaultRequestHeaders.Add("User-Agent", MagicHelper.GetUserAgentString(browser.BrowserType));
70 data = client.GetByteArrayAsync(url).GetAwaiter().GetResult();
71 }
72
73 if (null == data || 0 == data.Length)
74 return string.Empty;
75
76 // gzip magic bytes: 0x1f 0x8b. If absent the payload is already plain (some servers inflate on the wire).
77 bool isGzip = 2 <= data.Length && 0x1f == data[0] && 0x8b == data[1];
78
79 if (false == isGzip)
80 return System.Text.Encoding.UTF8.GetString(data);
81
82 using (var input = new MemoryStream(data))
83 using (var gz = new GZipStream(input, CompressionMode.Decompress))
84 using (var reader = new StreamReader(gz, System.Text.Encoding.UTF8))
85 {
86 string xml = reader.ReadToEnd();
87 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Inflated gzip sitemap [{url}] [{data.Length}] -> [{xml.Length}] bytes", typeof(SitemapExtractor), GPALObjectType.Other);
88 return xml;
89 }
90 }
91 catch (Exception ex)
92 {
93 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to fetch/inflate gzip sitemap [{url}]", typeof(SitemapExtractor), GPALObjectType.Other, ex);
94 return string.Empty;
95 }
96 }
97
98 // Public method: Extract URLs from already-fetched sitemap XML string
99 // Caller must navigate to the URL and provide PageSource
100 public List<string> ExtractSitemapUrls(
101 string sitemapXmlContent,
102 int limit,
103 out bool urlIsSitemap)
104 {
105 if (string.IsNullOrWhiteSpace(sitemapXmlContent))
106 {
107 GPAL.PublishSimpleEvent(
108 GPALEventType.WARNING,
109 "ExtractSitemapUrls called with empty/null content. Returning empty list.",
110 typeof(SitemapExtractor),
111 GPALObjectType.Other);
112
113 urlIsSitemap = false;
114 return new List<string>();
115 }
116
117 var allUrls = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
118
119 if (IsValidSitemapContent(sitemapXmlContent))
120 {
121 urlIsSitemap = true;
122 // Prefer XML path if possible
123 if (sitemapXmlContent.Contains("<sitemapindex") || sitemapXmlContent.Contains("<urlset"))
124 {
125 CollectUrlsFromXml(sitemapXmlContent, allUrls, limit);
126 }
127 else
128 {
129 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Treating [{browser.CurrentUrl}] html page as sitemap.", sitemapXmlContent, GPALObjectType.Other);
130 // HTML path
131 CollectUrlsFromHtml(sitemapXmlContent, allUrls, limit);
132 }
133 }
134 else
135 {
136 GPAL.PublishSimpleEvent(
137 GPALEventType.WARNING,
138 "Content does not appear to be a valid sitemap (XML or HTML).",
139 sitemapXmlContent,
140 GPALObjectType.Other);
141
142 urlIsSitemap = false;
143 }
144
145 return allUrls.Take(limit).ToList();
146 }
147
148 // Public method: Extract URLs recursively from initial sitemap XML string
149 // (if it's an index, caller can call this repeatedly on sub-sitemaps)
150 internal void CollectUrlsFromXml(
151 string xmlContent,
152 HashSet<string> collected,
153 int limit)
154 {
155 if (collected.Count >= limit || string.IsNullOrWhiteSpace(xmlContent))
156 return;
157
158 string cleaned = xmlContent
159 // Fix the most common literal escapes from browser/devtools copies
160 .Replace("\\\"", "\"") // \" > "
161 .Replace("\\\'", "'") // \' > '
162 .Replace("\\n", "\n") // \n > actual newline
163 .Replace("\\t", "\t") // \t > tab
164 .Replace("\\r", "\r") // \r > carriage return
165 .Replace("\\\\", "\\") // \\ > single backslash (if doubled)
166 // Optional: remove any leading/trailing control characters or BOM
167 .Trim()
168 .TrimStart('\uFEFF', '\u200B', '\u200C', '\u200D');
169
170 // Step 1: Detect and extract real XML content if wrapped in browser viewer HTML
171 // This handles both <div id="webkit-xml-viewer-source-xml">...</div> and loose <sitemapindex> inside
172 var xmlStartMarkers = new[] { "<sitemapindex", "<urlset", "<?xml" };
173 int xmlStart = -1;
174
175 foreach (var marker in xmlStartMarkers)
176 {
177 xmlStart = cleaned.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
178 if (xmlStart >= 0) break;
179 }
180
181 if (xmlStart >= 0)
182 {
183 // Cut off everything before the actual XML starts
184 cleaned = cleaned.Substring(xmlStart);
185
186
187 // Also try to cut off trailing HTML (after </sitemapindex> or </urlset>)
188 int xmlEnd = cleaned.LastIndexOf("</sitemapindex>", StringComparison.OrdinalIgnoreCase);
189 if (xmlEnd < 0)
190 xmlEnd = cleaned.LastIndexOf("</urlset>", StringComparison.OrdinalIgnoreCase);
191
192 if (xmlEnd >= 0)
193 {
194 try
195 {
196 cleaned = cleaned.Substring(0, xmlEnd + "</sitemapindex>".Length);
197 }
198 catch
199 {
200 cleaned = cleaned.Substring(0, xmlEnd + "</urlset>".Length);
201 }
202 }
203 }
204
205 // Step 2: Try to parse the (possibly cleaned) content as XML
206 XDocument doc;
207 try
208 {
209 doc = XDocument.Parse(cleaned);
210 }
211 catch (Exception ex)
212 {
213 // If parsing still fails, log and skip (same as before)
214 GPAL.PublishSimpleEvent(
215 GPALEventType.WARNING,
216 $"Invalid XML content after cleaning. Skipping.",
217 typeof(SitemapExtractor),
218 GPALObjectType.Other, ex);
219
220 return;
221 }
222
223 // Step 3: Proceed with normal extraction (unchanged)
224 XNamespace ns = "http://www.sitemaps.org/schemas/sitemap/0.9";
225 XNamespace emptyNs = "";
226
227 // Sitemap index?
228 var sitemapElements = doc.Descendants(ns + "sitemap")
229 .Concat(doc.Descendants(emptyNs + "sitemap"))
230 .ToList();
231
232 if (sitemapElements.Count > 0)
233 {
234 foreach (var sitemap in sitemapElements)
235 {
236 if (collected.Count >= limit) return;
237
238 string loc = (sitemap.Element(ns + "loc") ?? sitemap.Element(emptyNs + "loc"))?.Value?.Trim();
239
240 if (!string.IsNullOrWhiteSpace(loc) && Uri.IsWellFormedUriString(loc, UriKind.Absolute))
241 {
242 collected.Add(loc);
243 }
244 }
245 return;
246 }
247
248 // Regular urlset
249 var urlElements = doc.Descendants(ns + "url")
250 .Concat(doc.Descendants(emptyNs + "url"))
251 .ToList();
252
253 foreach (var urlElem in urlElements)
254 {
255 if (collected.Count >= limit) return;
256
257 string loc = (urlElem.Element(ns + "loc") ?? urlElem.Element(emptyNs + "loc"))?.Value?.Trim();
258
259 if (!string.IsNullOrWhiteSpace(loc) && Uri.IsWellFormedUriString(loc, UriKind.Absolute))
260 {
261 collected.Add(loc);
262 }
263 }
264 }
265
266 // HTML sitemap parser (Indeed-style, table or list of links)
267 internal void CollectUrlsFromHtml(
268 string htmlContent,
269 HashSet<string> collected,
270 int limit)
271 {
272 if (collected.Count >= limit || string.IsNullOrWhiteSpace(htmlContent))
273 return;
274
275 try
276 {
277 var htmlDoc = new HtmlAgilityPack.HtmlDocument();
278 htmlDoc.LoadHtml(htmlContent);
279
280 // Common patterns for sitemap HTML pages:
281 // 1. <a href="..."> inside <td> or <li>
282 // 2. Links in a table or ul/li structure
283
284 // Find all <a> tags with href starting with http/https
285 var links = htmlDoc.DocumentNode.SelectNodes("//a[@href]");
286
287 if (links != null)
288 {
289 foreach (var link in links)
290 {
291 if (collected.Count >= limit) break;
292
293 string href = link.GetAttributeValue("href", null).Replace(@"\""","");
294
295 if (!string.IsNullOrWhiteSpace(href) &&
296 (href.StartsWith("http://") || href.StartsWith("https://")) &&
297 Uri.IsWellFormedUriString(href, UriKind.Absolute))
298 {
299 // Optional: filter to likely sitemap URLs (xml, sitemap, etc.)
300 //if (href.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) ||
301 // href.Contains("/sitemap") ||
302 // href.Contains("/products") ||
303 // href.Contains("/categories"))
304 {
305 collected.Add(href);
306 }
307 }
308 }
309 }
310
311 // If no links found, try looking inside <pre> or specific divs (fallback)
312 if (collected.Count == 0)
313 {
314 var preNodes = htmlDoc.DocumentNode.SelectNodes("//pre | //div[contains(@id,'source-xml')]");
315 if (preNodes != null)
316 {
317 foreach (var pre in preNodes)
318 {
319 // Extract potential URLs from text content
320 string text = pre.InnerText;
321 var urlMatches = System.Text.RegularExpressions.Regex.Matches(text, @"https?://[^\s<>\""]+");
322 foreach (System.Text.RegularExpressions.Match m in urlMatches)
323 {
324 if (collected.Count >= limit) break;
325 string url = m.Value.TrimEnd('.', ',', ';', ')');
326 if (Uri.IsWellFormedUriString(url, UriKind.Absolute))
327 {
328 collected.Add(url);
329 }
330 }
331 }
332 }
333 }
334
335 if (collected.Count == 0)
336 {
337 GPAL.PublishSimpleEvent(
338 GPALEventType.WARNING,
339 "No valid URLs found in HTML sitemap content.",
340 typeof(SitemapExtractor),
341 GPALObjectType.Other);
342 }
343 }
344 catch (Exception ex)
345 {
346 GPAL.PublishSimpleEvent(
347 GPALEventType.WARNING,
348 "Failed to parse HTML sitemap: [" + ex.Message + "]",
349 typeof(SitemapExtractor),
350 GPALObjectType.Other);
351 }
352 }
353
354 static Dictionary<string, List<string>> returnedSitemaps = new Dictionary<string, List<string>>();
355
356 // Helper: build initial sitemap URL from GPALUrl (still useful for caller)
357 public List<string> GetSitemapUrl(GPALUrl domainUrl, out bool isFromRobotsTxt)
358 {
359 List<string> sitemapUrls = new List<string>();
360
361 string inputUrl = domainUrl?.Url;
362 string domain = ExtractDomain(inputUrl);
363 isFromRobotsTxt = false;
364
365 // have we already done this? return the cache
366 if (false == domainUrl?.Url.ToLower().EndsWith(".xml") && true == returnedSitemaps.ContainsKey(domain))
367 sitemapUrls = returnedSitemaps[domain];
368
369 List<string> robotsTxt = new List<string>();
370
371 // check if we have a robots.txt and get it back as a list<string>
372 if (true == UrlHelper.CheckRobotsTxt(inputUrl, browser, robotsTxt))
373 {
374 string message = $"No sitemap listed in robots.txt for [{domain}]. Using sitemap.xml";
375
376 sitemapUrls = ExtractSitemapsFromRobotsTxt(robotsTxt);
377 if (0 == sitemapUrls.Count)
378 {
379 GPAL.PublishSimpleEvent(GPALEventType.INFO, message);
380 sitemapUrls.Add("https://" + domain + "/sitemap.xml");
381 }
382 else if (0 < sitemapUrls.Count)
383 {
384 message = $"Sitemap found in robots.txt.";
385 GPAL.PublishSimpleEvent(GPALEventType.INFO, message);
386 isFromRobotsTxt = true;
387 }
388 }
389 // else, not allowed here, we are on about:blank
390
391 // NOT SURE why i didn't want to overwrite?
392 //if (0 == browser.RobotsTxt.Count)
393 browser.RobotsTxt = robotsTxt;
394
395 if (true == returnedSitemaps.ContainsKey(domain))
396 foreach (string sitemapUrl in sitemapUrls)
397 returnedSitemaps[domain].Add(sitemapUrl);
398 else
399 returnedSitemaps[domain] = sitemapUrls;
400
401 return sitemapUrls;
402 }
412 internal static bool IsBotCheckPage(string content)
413 {
414 if (string.IsNullOrWhiteSpace(content))
415 return false;
416 // press and hold
417 // #px-captcha - is the css for the button which is in a closed shadowdom
418 bool isBotCheck = content.IndexOf("Robot or human?", StringComparison.OrdinalIgnoreCase) >= 0
419 && content.IndexOf("px-captcha", StringComparison.OrdinalIgnoreCase) >= 0;
420
421 if (true == isBotCheck)
422 {
423 // "px-captcha" - trusted-left-click - holdMs 10_000
424 }
425
426 return isBotCheck;
427 }
428
429 internal static bool IsValidSitemapContent(string content)
430 {
431 if (string.IsNullOrWhiteSpace(content))
432 return false;
433
434 string trimmed = content.Trim().Trim('\"');
435
436 // 1. Looks like raw XML sitemap?
437 if (trimmed.StartsWith("<?xml") || trimmed.Contains("<sitemapindex") || trimmed.Contains("<urlset"))
438 {
439 try
440 {
441 XDocument.Parse(trimmed);
442 return true;
443 }
444 catch
445 {
446 // Invalid XML — maybe html
447 //return false;
448 }
449 }
450
451 // 2. Browser-wrapped XML viewer?
452 if (trimmed.Contains("webkit-xml-viewer-source-xml") || trimmed.Contains("<div id=\"webkit-xml-viewer-source-xml\">") ||
453 trimmed.Contains("<sitemapindex") || trimmed.Contains("<urlset") || trimmed.Contains("<loc>"))
454 {
455 // If it has the wrapper AND contains sitemap tags inside > valid
456 return trimmed.Contains("<sitemapindex") || trimmed.Contains("<urlset") || trimmed.Contains("<loc>");
457 }
458
459 // 3. HTML sitemap (Indeed-style, list of links)?
460 // Count absolute URLs, especially those ending in .xml or containing /sitemap
461 var urlMatches = System.Text.RegularExpressions.Regex.Matches(trimmed, @"https?://[^\s""'<>\‍)\‍]]+");
462 int totalUrls = urlMatches.Count;
463 int xmlUrls = 0;
464
465 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"[{urlMatches.Count}] urls on potential sitemap page.");
466
467 foreach (System.Text.RegularExpressions.Match m in urlMatches)
468 {
469 string url = m.Value.TrimEnd('.', ',', ';', ')', ']');
470 if (url.ToLower().EndsWith(".xml", StringComparison.OrdinalIgnoreCase) ||
471 url.ToLower().Contains("/sitemap"))
472 {
473 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $">>>>> xml match [{url}]", content, GPALObjectType.Other);
474 xmlUrls++;
475 }
476 }
477
478 // NOTE: hardcoded values - Heuristic: at least 5 URLs, and at least 2 look sitemap-like
479 // NOTE: CAVEAT: KLUDGE: I'm not sure about the xml count, i'm not convinced they will have anything with xml in it...
480 // TODO: add settings for these? fina a better heuristic?
481 return totalUrls >= 10 && xmlUrls >= 2;
482 }
483
484 private string ExtractDomain(string url)
485 {
486 string host = url;
487
488 try
489 {
490 var uri = new Uri(url, UriKind.RelativeOrAbsolute);
491 if (uri.IsAbsoluteUri)
492 host = uri.Host;
493 }
494 catch
495 {
496 // If URI parsing fails, fall back to string-based extraction
497 }
498
499 string[] parts = host.Split('.');
500
501 if (parts.Length <= 2)
502 return host; // already a main domain or short TLD
503
504 // Check if last part is numeric (IPv4-like)
505 if (parts[parts.Length - 1].All(char.IsDigit))
506 return host;
507
508 // Check for common two-part TLDs like co.uk, co.in, com.au, etc.
509 // If last two parts are both short (usually 2 chars), take last 3
510 if (parts[parts.Length - 1].Length <= 3 && parts[parts.Length - 2].Length <= 3)
511 {
512 // Equivalent to parts.TakeLast(3)
513 return string.Join(".", parts.Skip(parts.Length - 3).Take(3));
514 }
515
516 // Default: take last 2 parts
517 // Equivalent to parts.TakeLast(2)
518 return string.Join(".", parts.Skip(parts.Length - 2).Take(2));
519 }
520
527 public List<string> ExtractSitemapsFromRobotsTxt(List<string> robotsTxt)
528 {
529 var sitemaps = new List<string>();
530
531 if (robotsTxt == null || robotsTxt.Count == 0)
532 return sitemaps;
533
534 foreach (string line in robotsTxt)
535 {
536 // Skip empty or comment lines
537 string trimmed = line?.Trim();
538 if (string.IsNullOrWhiteSpace(trimmed) || trimmed.StartsWith("#"))
539 continue;
540
541 // Look for "Sitemap:" (case-insensitive, tolerant of extra spaces)
542 if (trimmed.StartsWith("Sitemap:", StringComparison.OrdinalIgnoreCase))
543 {
544 // Get everything after "Sitemap:"
545 string potentialUrl = trimmed.Substring("Sitemap:".Length).Trim();
546 int idx = potentialUrl.IndexOf("<"); // we got this xml as html, so we have a closing <tag>
547 if (-1 != idx)
548 potentialUrl = potentialUrl.Substring(0, idx);
549 else
550 potentialUrl = potentialUrl.Substring(0, potentialUrl.Length);
551
552 // Clean up any trailing junk (sometimes people add comments after URL)
553 int commentIndex = potentialUrl.IndexOf('#');
554 if (commentIndex >= 0)
555 potentialUrl = potentialUrl.Substring(0, commentIndex).Trim();
556
557 // Only keep valid absolute URLs
558 if (Uri.IsWellFormedUriString(potentialUrl, UriKind.Absolute) &&
559 (potentialUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
560 potentialUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)))
561 {
562 sitemaps.Add(potentialUrl);
563 }
564 }
565 }
566
567 return sitemaps;
568 }
569 }
570}