18using System.Collections.Generic;
20using System.IO.Compression;
23using System.Windows.Forms;
31 internal class SitemapExtractor
33 private readonly Browser browser;
35 internal SitemapExtractor(Browser browser)
37 this.browser = browser ??
throw new ArgumentNullException(nameof(browser));
44 internal static bool IsGzipSitemapUrl(
string url)
46 if (
true ==
string.IsNullOrWhiteSpace(url))
49 string path = url.Split(
'?',
'#')[0].TrimEnd(
'/');
50 return path.EndsWith(
".gz", StringComparison.OrdinalIgnoreCase);
59 internal static string FetchAndInflateGzipSitemap(
string url, IBrowser browser)
63 GPAL.PublishSimpleEvent(GPALEventType.INFO, $
"Fetching gzip sitemap [{url}]", typeof(SitemapExtractor), GPALObjectType.Other);
66 using (var client =
new HttpClient())
68 client.Timeout = TimeSpan.FromSeconds(60);
69 client.DefaultRequestHeaders.Add(
"User-Agent", MagicHelper.GetUserAgentString(browser.BrowserType));
70 data = client.GetByteArrayAsync(url).GetAwaiter().GetResult();
73 if (
null == data || 0 == data.Length)
77 bool isGzip = 2 <= data.Length && 0x1f == data[0] && 0x8b == data[1];
80 return System.Text.Encoding.UTF8.GetString(data);
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))
86 string xml = reader.ReadToEnd();
87 GPAL.PublishSimpleEvent(GPALEventType.INFO, $
"Inflated gzip sitemap [{url}] [{data.Length}] -> [{xml.Length}] bytes", typeof(SitemapExtractor), GPALObjectType.Other);
93 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $
"Failed to fetch/inflate gzip sitemap [{url}]", typeof(SitemapExtractor), GPALObjectType.Other, ex);
100 public List<string> ExtractSitemapUrls(
101 string sitemapXmlContent,
103 out
bool urlIsSitemap)
105 if (
string.IsNullOrWhiteSpace(sitemapXmlContent))
107 GPAL.PublishSimpleEvent(
108 GPALEventType.WARNING,
109 "ExtractSitemapUrls called with empty/null content. Returning empty list.",
110 typeof(SitemapExtractor),
111 GPALObjectType.Other);
113 urlIsSitemap =
false;
114 return new List<string>();
117 var allUrls =
new HashSet<string>(StringComparer.OrdinalIgnoreCase);
119 if (IsValidSitemapContent(sitemapXmlContent))
123 if (sitemapXmlContent.Contains(
"<sitemapindex") || sitemapXmlContent.Contains(
"<urlset"))
125 CollectUrlsFromXml(sitemapXmlContent, allUrls, limit);
129 GPAL.PublishSimpleEvent(GPALEventType.INFO, $
"Treating [{browser.CurrentUrl}] html page as sitemap.", sitemapXmlContent, GPALObjectType.Other);
131 CollectUrlsFromHtml(sitemapXmlContent, allUrls, limit);
136 GPAL.PublishSimpleEvent(
137 GPALEventType.WARNING,
138 "Content does not appear to be a valid sitemap (XML or HTML).",
140 GPALObjectType.Other);
142 urlIsSitemap =
false;
145 return allUrls.Take(limit).ToList();
150 internal void CollectUrlsFromXml(
152 HashSet<string> collected,
155 if (collected.Count >= limit ||
string.IsNullOrWhiteSpace(xmlContent))
158 string cleaned = xmlContent
160 .Replace(
"\\\"",
"\"")
161 .Replace(
"\\\'",
"'")
162 .Replace(
"\\n",
"\n")
163 .Replace(
"\\t",
"\t")
164 .Replace(
"\\r",
"\r")
165 .Replace(
"\\\\",
"\\")
168 .TrimStart(
'\uFEFF',
'\u200B',
'\u200C',
'\u200D');
172 var xmlStartMarkers =
new[] {
"<sitemapindex",
"<urlset",
"<?xml" };
175 foreach (var marker
in xmlStartMarkers)
177 xmlStart = cleaned.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
178 if (xmlStart >= 0)
break;
184 cleaned = cleaned.Substring(xmlStart);
188 int xmlEnd = cleaned.LastIndexOf(
"</sitemapindex>", StringComparison.OrdinalIgnoreCase);
190 xmlEnd = cleaned.LastIndexOf(
"</urlset>", StringComparison.OrdinalIgnoreCase);
196 cleaned = cleaned.Substring(0, xmlEnd +
"</sitemapindex>".Length);
200 cleaned = cleaned.Substring(0, xmlEnd +
"</urlset>".Length);
209 doc = XDocument.Parse(cleaned);
214 GPAL.PublishSimpleEvent(
215 GPALEventType.WARNING,
216 $
"Invalid XML content after cleaning. Skipping.",
217 typeof(SitemapExtractor),
218 GPALObjectType.Other, ex);
224 XNamespace ns =
"http://www.sitemaps.org/schemas/sitemap/0.9";
225 XNamespace emptyNs =
"";
228 var sitemapElements = doc.Descendants(ns +
"sitemap")
229 .Concat(doc.Descendants(emptyNs +
"sitemap"))
232 if (sitemapElements.Count > 0)
234 foreach (var sitemap
in sitemapElements)
236 if (collected.Count >= limit)
return;
238 string loc = (sitemap.Element(ns +
"loc") ?? sitemap.Element(emptyNs +
"loc"))?.Value?.Trim();
240 if (!
string.IsNullOrWhiteSpace(loc) && Uri.IsWellFormedUriString(loc, UriKind.Absolute))
249 var urlElements = doc.Descendants(ns +
"url")
250 .Concat(doc.Descendants(emptyNs +
"url"))
253 foreach (var urlElem
in urlElements)
255 if (collected.Count >= limit)
return;
257 string loc = (urlElem.Element(ns +
"loc") ?? urlElem.Element(emptyNs +
"loc"))?.Value?.Trim();
259 if (!
string.IsNullOrWhiteSpace(loc) && Uri.IsWellFormedUriString(loc, UriKind.Absolute))
267 internal void CollectUrlsFromHtml(
269 HashSet<string> collected,
272 if (collected.Count >= limit ||
string.IsNullOrWhiteSpace(htmlContent))
277 var htmlDoc =
new HtmlAgilityPack.HtmlDocument();
278 htmlDoc.LoadHtml(htmlContent);
285 var links = htmlDoc.DocumentNode.SelectNodes(
"//a[@href]");
289 foreach (var link
in links)
291 if (collected.Count >= limit)
break;
293 string href = link.GetAttributeValue(
"href",
null).Replace(
@"\""",
"");
295 if (!
string.IsNullOrWhiteSpace(href) &&
296 (href.StartsWith(
"http://") || href.StartsWith(
"https://")) &&
297 Uri.IsWellFormedUriString(href, UriKind.Absolute))
312 if (collected.Count == 0)
314 var preNodes = htmlDoc.DocumentNode.SelectNodes(
"//pre | //div[contains(@id,'source-xml')]");
315 if (preNodes !=
null)
317 foreach (var pre
in preNodes)
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)
324 if (collected.Count >= limit)
break;
325 string url = m.Value.TrimEnd(
'.',
',',
';',
')');
326 if (Uri.IsWellFormedUriString(url, UriKind.Absolute))
335 if (collected.Count == 0)
337 GPAL.PublishSimpleEvent(
338 GPALEventType.WARNING,
339 "No valid URLs found in HTML sitemap content.",
340 typeof(SitemapExtractor),
341 GPALObjectType.Other);
346 GPAL.PublishSimpleEvent(
347 GPALEventType.WARNING,
348 "Failed to parse HTML sitemap: [" + ex.Message +
"]",
349 typeof(SitemapExtractor),
350 GPALObjectType.Other);
354 static Dictionary<string, List<string>> returnedSitemaps =
new Dictionary<string, List<string>>();
357 public List<string> GetSitemapUrl(GPALUrl domainUrl, out
bool isFromRobotsTxt)
359 List<string> sitemapUrls =
new List<string>();
361 string inputUrl = domainUrl?.Url;
362 string domain = ExtractDomain(inputUrl);
363 isFromRobotsTxt =
false;
366 if (
false == domainUrl?.Url.ToLower().EndsWith(
".xml") &&
true == returnedSitemaps.ContainsKey(domain))
367 sitemapUrls = returnedSitemaps[domain];
369 List<string> robotsTxt =
new List<string>();
372 if (
true == UrlHelper.CheckRobotsTxt(inputUrl, browser, robotsTxt))
374 string message = $
"No sitemap listed in robots.txt for [{domain}]. Using sitemap.xml";
376 sitemapUrls = ExtractSitemapsFromRobotsTxt(robotsTxt);
377 if (0 == sitemapUrls.Count)
379 GPAL.PublishSimpleEvent(GPALEventType.INFO, message);
380 sitemapUrls.Add(
"https://" + domain +
"/sitemap.xml");
382 else if (0 < sitemapUrls.Count)
384 message = $
"Sitemap found in robots.txt.";
385 GPAL.PublishSimpleEvent(GPALEventType.INFO, message);
386 isFromRobotsTxt =
true;
393 browser.RobotsTxt = robotsTxt;
395 if (
true == returnedSitemaps.ContainsKey(domain))
396 foreach (
string sitemapUrl
in sitemapUrls)
397 returnedSitemaps[domain].Add(sitemapUrl);
399 returnedSitemaps[domain] = sitemapUrls;
412 internal static bool IsBotCheckPage(
string content)
414 if (
string.IsNullOrWhiteSpace(content))
418 bool isBotCheck = content.IndexOf(
"Robot or human?", StringComparison.OrdinalIgnoreCase) >= 0
419 && content.IndexOf(
"px-captcha", StringComparison.OrdinalIgnoreCase) >= 0;
421 if (
true == isBotCheck)
429 internal static bool IsValidSitemapContent(
string content)
431 if (
string.IsNullOrWhiteSpace(content))
434 string trimmed = content.Trim().Trim(
'\"');
437 if (trimmed.StartsWith(
"<?xml") || trimmed.Contains(
"<sitemapindex") || trimmed.Contains(
"<urlset"))
441 XDocument.Parse(trimmed);
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>"))
456 return trimmed.Contains(
"<sitemapindex") || trimmed.Contains(
"<urlset") || trimmed.Contains(
"<loc>");
461 var urlMatches = System.Text.RegularExpressions.Regex.Matches(trimmed,
@"https?://[^\s""'<>\)\]]+");
462 int totalUrls = urlMatches.Count;
465 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $
"[{urlMatches.Count}] urls on potential sitemap page.");
467 foreach (System.Text.RegularExpressions.Match m in urlMatches)
469 string url = m.Value.TrimEnd(
'.',
',',
';',
')',
']');
470 if (url.ToLower().EndsWith(
".xml", StringComparison.OrdinalIgnoreCase) ||
471 url.ToLower().Contains(
"/sitemap"))
473 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $
">>>>> xml match [{url}]", content, GPALObjectType.Other);
481 return totalUrls >= 10 && xmlUrls >= 2;
484 private string ExtractDomain(
string url)
490 var uri =
new Uri(url, UriKind.RelativeOrAbsolute);
491 if (uri.IsAbsoluteUri)
499 string[] parts = host.Split(
'.');
501 if (parts.Length <= 2)
505 if (parts[parts.Length - 1].All(
char.IsDigit))
510 if (parts[parts.Length - 1].Length <= 3 && parts[parts.Length - 2].Length <= 3)
513 return string.Join(
".", parts.Skip(parts.Length - 3).Take(3));
518 return string.Join(
".", parts.Skip(parts.Length - 2).Take(2));
527 public List<string> ExtractSitemapsFromRobotsTxt(List<string> robotsTxt)
529 var sitemaps =
new List<string>();
531 if (robotsTxt ==
null || robotsTxt.Count == 0)
534 foreach (
string line
in robotsTxt)
537 string trimmed = line?.Trim();
538 if (
string.IsNullOrWhiteSpace(trimmed) || trimmed.StartsWith(
"#"))
542 if (trimmed.StartsWith(
"Sitemap:", StringComparison.OrdinalIgnoreCase))
545 string potentialUrl = trimmed.Substring(
"Sitemap:".Length).Trim();
546 int idx = potentialUrl.IndexOf(
"<");
548 potentialUrl = potentialUrl.Substring(0, idx);
550 potentialUrl = potentialUrl.Substring(0, potentialUrl.Length);
553 int commentIndex = potentialUrl.IndexOf(
'#');
554 if (commentIndex >= 0)
555 potentialUrl = potentialUrl.Substring(0, commentIndex).Trim();
558 if (Uri.IsWellFormedUriString(potentialUrl, UriKind.Absolute) &&
559 (potentialUrl.StartsWith(
"http://", StringComparison.OrdinalIgnoreCase) ||
560 potentialUrl.StartsWith(
"https://", StringComparison.OrdinalIgnoreCase)))
562 sitemaps.Add(potentialUrl);