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);
62 internal static string FetchAndInflateGzipSitemap(
string url, IBrowser browser)
66 GPAL.PublishSimpleEvent(GPALEventType.INFO, $
"Fetching gzip sitemap [{url}]", typeof(SitemapExtractor), GPALObjectType.Other);
71 bool sameOrigin =
string.Equals(UrlHelper.GetOrigin(browser.CurrentUrl), UrlHelper.GetOrigin(url), StringComparison.OrdinalIgnoreCase);
73 byte[] data =
true == sameOrigin
74 ? BrowserHelper.FetchBytes((Browser)browser, url,
null, out
string _)
79 if (
null == data || 0 == data.Length)
81 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $
"The page did not hand over [{url}], asking for it on our own connection instead", typeof(SitemapExtractor), GPALObjectType.Other);
83 using (var client =
new HttpClient())
85 client.Timeout = TimeSpan.FromSeconds(60);
86 client.DefaultRequestHeaders.Add(
"User-Agent", MagicHelper.GetUserAgentString(browser.BrowserType));
87 data = client.GetByteArrayAsync(url).GetAwaiter().GetResult();
91 if (
null == data || 0 == data.Length)
95 bool isGzip = 2 <= data.Length && 0x1f == data[0] && 0x8b == data[1];
98 return System.Text.Encoding.UTF8.GetString(data);
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))
104 string xml = reader.ReadToEnd();
105 GPAL.PublishSimpleEvent(GPALEventType.INFO, $
"Inflated gzip sitemap [{url}] [{data.Length}] -> [{xml.Length}] bytes", typeof(SitemapExtractor), GPALObjectType.Other);
111 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $
"Failed to fetch/inflate gzip sitemap [{url}]", typeof(SitemapExtractor), GPALObjectType.Other, ex);
118 public List<string> ExtractSitemapUrls(
119 string sitemapXmlContent,
121 out
bool urlIsSitemap)
123 if (
string.IsNullOrWhiteSpace(sitemapXmlContent))
125 GPAL.PublishSimpleEvent(
126 GPALEventType.WARNING,
127 "ExtractSitemapUrls called with empty/null content. Returning empty list.",
128 typeof(SitemapExtractor),
129 GPALObjectType.Other);
131 urlIsSitemap =
false;
132 return new List<string>();
135 var allUrls =
new HashSet<string>(StringComparer.OrdinalIgnoreCase);
137 if (IsValidSitemapContent(sitemapXmlContent))
141 if (sitemapXmlContent.Contains(
"<sitemapindex") || sitemapXmlContent.Contains(
"<urlset"))
143 CollectUrlsFromXml(sitemapXmlContent, allUrls, limit);
147 GPAL.PublishSimpleEvent(GPALEventType.INFO, $
"Treating [{browser.CurrentUrl}] html page as sitemap.", sitemapXmlContent, GPALObjectType.Other);
149 CollectUrlsFromHtml(sitemapXmlContent, allUrls, limit);
154 GPAL.PublishSimpleEvent(
155 GPALEventType.WARNING,
156 "Content does not appear to be a valid sitemap (XML or HTML).",
158 GPALObjectType.Other);
160 urlIsSitemap =
false;
163 return allUrls.Take(limit).ToList();
168 internal void CollectUrlsFromXml(
170 HashSet<string> collected,
173 if (collected.Count >= limit ||
string.IsNullOrWhiteSpace(xmlContent))
176 string cleaned = xmlContent
178 .Replace(
"\\\"",
"\"")
179 .Replace(
"\\\'",
"'")
180 .Replace(
"\\n",
"\n")
181 .Replace(
"\\t",
"\t")
182 .Replace(
"\\r",
"\r")
183 .Replace(
"\\\\",
"\\")
186 .TrimStart(
'\uFEFF',
'\u200B',
'\u200C',
'\u200D');
190 var xmlStartMarkers =
new[] {
"<sitemapindex",
"<urlset",
"<?xml" };
193 foreach (var marker
in xmlStartMarkers)
195 xmlStart = cleaned.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
196 if (xmlStart >= 0)
break;
202 cleaned = cleaned.Substring(xmlStart);
206 int xmlEnd = cleaned.LastIndexOf(
"</sitemapindex>", StringComparison.OrdinalIgnoreCase);
208 xmlEnd = cleaned.LastIndexOf(
"</urlset>", StringComparison.OrdinalIgnoreCase);
214 cleaned = cleaned.Substring(0, xmlEnd +
"</sitemapindex>".Length);
218 cleaned = cleaned.Substring(0, xmlEnd +
"</urlset>".Length);
227 doc = XDocument.Parse(cleaned);
232 GPAL.PublishSimpleEvent(
233 GPALEventType.WARNING,
234 $
"Invalid XML content after cleaning. Skipping.",
235 typeof(SitemapExtractor),
236 GPALObjectType.Other, ex);
242 XNamespace ns =
"http://www.sitemaps.org/schemas/sitemap/0.9";
243 XNamespace emptyNs =
"";
246 var sitemapElements = doc.Descendants(ns +
"sitemap")
247 .Concat(doc.Descendants(emptyNs +
"sitemap"))
250 if (sitemapElements.Count > 0)
252 foreach (var sitemap
in sitemapElements)
254 if (collected.Count >= limit)
return;
256 string loc = (sitemap.Element(ns +
"loc") ?? sitemap.Element(emptyNs +
"loc"))?.Value?.Trim();
258 if (!
string.IsNullOrWhiteSpace(loc) && Uri.IsWellFormedUriString(loc, UriKind.Absolute))
267 var urlElements = doc.Descendants(ns +
"url")
268 .Concat(doc.Descendants(emptyNs +
"url"))
271 foreach (var urlElem
in urlElements)
273 if (collected.Count >= limit)
return;
275 string loc = (urlElem.Element(ns +
"loc") ?? urlElem.Element(emptyNs +
"loc"))?.Value?.Trim();
277 if (!
string.IsNullOrWhiteSpace(loc) && Uri.IsWellFormedUriString(loc, UriKind.Absolute))
285 internal void CollectUrlsFromHtml(
287 HashSet<string> collected,
290 if (collected.Count >= limit ||
string.IsNullOrWhiteSpace(htmlContent))
295 var htmlDoc =
new HtmlAgilityPack.HtmlDocument();
296 htmlDoc.LoadHtml(htmlContent);
303 var links = htmlDoc.DocumentNode.SelectNodes(
"//a[@href]");
307 foreach (var link
in links)
309 if (collected.Count >= limit)
break;
311 string href = link.GetAttributeValue(
"href",
null).Replace(
@"\""",
"");
313 if (!
string.IsNullOrWhiteSpace(href) &&
314 (href.StartsWith(
"http://") || href.StartsWith(
"https://")) &&
315 Uri.IsWellFormedUriString(href, UriKind.Absolute))
330 if (collected.Count == 0)
332 var preNodes = htmlDoc.DocumentNode.SelectNodes(
"//pre | //div[contains(@id,'source-xml')]");
333 if (preNodes !=
null)
335 foreach (var pre
in preNodes)
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)
342 if (collected.Count >= limit)
break;
343 string url = m.Value.TrimEnd(
'.',
',',
';',
')');
344 if (Uri.IsWellFormedUriString(url, UriKind.Absolute))
353 if (collected.Count == 0)
355 GPAL.PublishSimpleEvent(
356 GPALEventType.WARNING,
357 "No valid URLs found in HTML sitemap content.",
358 typeof(SitemapExtractor),
359 GPALObjectType.Other);
364 GPAL.PublishSimpleEvent(
365 GPALEventType.WARNING,
366 "Failed to parse HTML sitemap: [" + ex.Message +
"]",
367 typeof(SitemapExtractor),
368 GPALObjectType.Other);
372 static Dictionary<string, List<string>> returnedSitemaps =
new Dictionary<string, List<string>>();
375 public List<string> GetSitemapUrl(GPALUrl domainUrl, out
bool isFromRobotsTxt)
377 List<string> sitemapUrls =
new List<string>();
379 string inputUrl = domainUrl?.Url;
380 string domain = ExtractDomain(inputUrl);
381 isFromRobotsTxt =
false;
386 if (
false == domainUrl?.Url.ToLower().EndsWith(
".xml") &&
true == returnedSitemaps.ContainsKey(domain))
387 sitemapUrls =
new List<string>(returnedSitemaps[domain]);
389 List<string> robotsTxt =
new List<string>();
392 if (
true == UrlHelper.CheckRobotsTxt(inputUrl, browser, robotsTxt))
394 string message = $
"No sitemap listed in robots.txt for [{domain}]. Using sitemap.xml";
396 sitemapUrls = ExtractSitemapsFromRobotsTxt(robotsTxt);
397 if (0 == sitemapUrls.Count)
399 GPAL.PublishSimpleEvent(GPALEventType.INFO, message);
400 sitemapUrls.Add(
"https://" + domain +
"/sitemap.xml");
402 else if (0 < sitemapUrls.Count)
404 message = $
"Sitemap found in robots.txt.";
405 GPAL.PublishSimpleEvent(GPALEventType.INFO, message);
406 isFromRobotsTxt =
true;
413 browser.RobotsTxt = robotsTxt;
415 if (
true == returnedSitemaps.ContainsKey(domain))
416 foreach (
string sitemapUrl
in sitemapUrls)
418 if (
false == returnedSitemaps[domain].Contains(sitemapUrl))
419 returnedSitemaps[domain].Add(sitemapUrl);
422 returnedSitemaps[domain] = sitemapUrls;
435 internal static bool IsBotCheckPage(
string content)
437 if (
string.IsNullOrWhiteSpace(content))
441 bool isBotCheck = content.IndexOf(
"Robot or human?", StringComparison.OrdinalIgnoreCase) >= 0
442 && content.IndexOf(
"px-captcha", StringComparison.OrdinalIgnoreCase) >= 0;
444 if (
true == isBotCheck)
452 internal static bool IsValidSitemapContent(
string content)
454 if (
string.IsNullOrWhiteSpace(content))
457 string trimmed = content.Trim().Trim(
'\"');
460 if (trimmed.StartsWith(
"<?xml") || trimmed.Contains(
"<sitemapindex") || trimmed.Contains(
"<urlset"))
464 XDocument.Parse(trimmed);
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>"))
479 return trimmed.Contains(
"<sitemapindex") || trimmed.Contains(
"<urlset") || trimmed.Contains(
"<loc>");
484 var urlMatches = System.Text.RegularExpressions.Regex.Matches(trimmed,
@"https?://[^\s""'<>\)\]]+");
485 int totalUrls = urlMatches.Count;
488 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $
"[{urlMatches.Count}] urls on potential sitemap page.");
490 foreach (System.Text.RegularExpressions.Match m in urlMatches)
492 string url = m.Value.TrimEnd(
'.',
',',
';',
')',
']');
493 if (url.ToLower().EndsWith(
".xml", StringComparison.OrdinalIgnoreCase) ||
494 url.ToLower().Contains(
"/sitemap"))
496 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $
">>>>> xml match [{url}]", content, GPALObjectType.Other);
504 return totalUrls >= 10 && xmlUrls >= 2;
507 private string ExtractDomain(
string url)
513 var uri =
new Uri(url, UriKind.RelativeOrAbsolute);
514 if (uri.IsAbsoluteUri)
522 string[] parts = host.Split(
'.');
524 if (parts.Length <= 2)
528 if (parts[parts.Length - 1].All(
char.IsDigit))
533 if (parts[parts.Length - 1].Length <= 3 && parts[parts.Length - 2].Length <= 3)
536 return string.Join(
".", parts.Skip(parts.Length - 3).Take(3));
541 return string.Join(
".", parts.Skip(parts.Length - 2).Take(2));
550 public List<string> ExtractSitemapsFromRobotsTxt(List<string> robotsTxt)
552 var sitemaps =
new List<string>();
554 if (robotsTxt ==
null || robotsTxt.Count == 0)
557 foreach (
string line
in robotsTxt)
560 string trimmed = line?.Trim();
561 if (
string.IsNullOrWhiteSpace(trimmed) || trimmed.StartsWith(
"#"))
565 if (trimmed.StartsWith(
"Sitemap:", StringComparison.OrdinalIgnoreCase))
568 string potentialUrl = trimmed.Substring(
"Sitemap:".Length).Trim();
569 int idx = potentialUrl.IndexOf(
"<");
571 potentialUrl = potentialUrl.Substring(0, idx);
573 potentialUrl = potentialUrl.Substring(0, potentialUrl.Length);
576 int commentIndex = potentialUrl.IndexOf(
'#');
577 if (commentIndex >= 0)
578 potentialUrl = potentialUrl.Substring(0, commentIndex).Trim();
581 if (Uri.IsWellFormedUriString(potentialUrl, UriKind.Absolute) &&
582 (potentialUrl.StartsWith(
"http://", StringComparison.OrdinalIgnoreCase) ||
583 potentialUrl.StartsWith(
"https://", StringComparison.OrdinalIgnoreCase)))
585 sitemaps.Add(potentialUrl);