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