GPAL - Generally Positive Automation Library v1.0
GPAL The Fluent Automation LIbrary
Loading...
Searching...
No Matches
UrlHelper.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.Concurrent;
19using System.Collections.Generic;
20using System.Linq;
21using System.Threading;
22using DocumentFormat.OpenXml.ExtendedProperties;
23using OpenQA.Selenium;
24using OpenQA.Selenium.Chrome;
25using OpenQA.Selenium.Chromium;
26using OpenQA.Selenium.Support.Extensions;
27using static GenerallyPositive.Enums;
28using Nager.PublicSuffix;
29
30// TODO: add cookie object, create cookies using another fluent object as there are a lot of columns to create
31// for now create exact matches, later we will designate pattern matching for domain, path, key and storename - wildcard characters will be required to prove intent
32// if we see a wildcard char, the treat it as a pattern match....
33
34// written by grok, modified by mbv
36{
47 public static class UrlHelper
48 {
49 static bool MatchesOrAny(this string filter, string value)
50 => string.IsNullOrEmpty(filter) || filter == value;
51
52 #region Storage Handling
53 /*
54 delete-storage/get-storage
55
56 cookies - deletes all cookies
57 cookies (domain) - deletes all cookies under the given domain
58 cookies (domain, key) - deletes all cookies under the given domain with the name "key"
59 cookies (domain, path) - deletes all cookies under the given domain AND path
60 cookies (domain, key, path) - deletes all cookies under the given domain AND path with the name "key"
61 *cookies (key) - deletes all cookies across all domains with the name "key"
62 NOTE: There can be multiple cookies on a website with the same "name/key" where the only parameter that separates them is the path, hence its inclusion
63
64
65 localStorage - deletes all localStorage
66 localStorage (domain) - deletes all localStorage under the given domain
67 localStorage (domain, key) - deletes all localStorage under the given domain with the name "key"
68 *localStorage (key) - deletes all localStorage across all domains with the name "key"
69
70 sessionStorage - deletes all sessionStorage
71 sessionStorage (domain) - deletes all sessionStorage under the given domain
72 sessionStorage (domain, key) - deletes all sessionStorage under the given domain with the name "key"
73 sessionStorage (key) - deletes all sessionStorage across all domains with the name "key"
74
75 cache - deletes all cache
76 cache (domain) - deletes all cache under the given domain
77 cache (domain, key) - deletes all cache under the given domain with the name "key"
78 cache (key) - deletes all cache across all domains with the name "key"
79
80 indexedDb - deletes all indexedDb entries across all domains (considered nuclear option, warned against doing)
81
82 NOTE: cannot be done using simple API, must use chrome.debugger + Storage.clearDataForOrigin
83 This is the only mechanism that can wipe IndexedDB across arbitrary sites.
84
85 Attach a debugger to each open tab
86 For that tab’s origin, clear IndexedDB
87 Repeat for every tab
88 Repeat again as new tabs open
89
90 indexedDb (domain) - deletes all indexedDb data under the given domain
91 indexedDb (domain, path) - deletes database with the name "path" under the given domain
92 indexedDb (domain, path, key) - deletes specific "key" object from the "path" database under the given domain
93 NOTE: the parameter name path isn't quite accurate for indexedDb, its being used as a database-name here
94 */
95
101 // TODO: origin is different than domain, domain is the cookie domain like .youtube.com - we will match on the specifically
102 // origin is another filter to see whether that storage object applies to the origin
113 private static string UnwrapStorageRead(string data, string key)
114 {
115 string retVal = data;
116
117 // a wildcard read is already a key/value object on every engine, and unwrapping it would throw away
118 // every entry but one
119 if (false == string.IsNullOrEmpty(key) && false == string.IsNullOrWhiteSpace(data) && true == data.TrimStart().StartsWith("{"))
120 {
121 try
122 {
123 Newtonsoft.Json.Linq.JObject wrapper = Newtonsoft.Json.Linq.JObject.Parse(data);
124
125 // the wrapper describes the read: a value, and at most the key that was asked for. anything
126 // else is a stored object that happens to be json, and belongs to the caller untouched
127 bool isWrapper = null != wrapper["value"]
128 && wrapper.Properties().All(each => "value" == each.Name || "key" == each.Name);
129
130 if (true == isWrapper)
131 {
132 Newtonsoft.Json.Linq.JToken value = wrapper["value"];
133
134 // a stored string comes back as it was stored. anything else was stored as json and is
135 // handed back as json rather than as Newtonsoft's rendering of it
136 retVal = Newtonsoft.Json.Linq.JTokenType.String == value.Type
137 ? value.ToString()
138 : Newtonsoft.Json.JsonConvert.SerializeObject(value);
139 }
140 }
141 catch (Exception)
142 {
143 // shaping the answer is never a reason to fail the read
144 }
145 }
146
147 return retVal;
148 }
149
150 internal static bool TryPerformAction(IGPALUrl gpalUrl, BrowserSettings browserSettings, StorageAction storageActionParameters, out string data)
151 {
152 bool retVal = false;
153
154 data = null;
155
156 if (null == gpalUrl)
157 {
158 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"No GPALUrl supplied to run [{storageActionParameters.StorageType}] [{storageActionParameters.Action}] action", browserSettings, GPALObjectType.Other);
159 return retVal;
160 }
161 else if (null == storageActionParameters)
162 {
163 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"No StorageAction parameters supplied to run on [{gpalUrl.Url}]", browserSettings, GPALObjectType.Other);
164 return retVal;
165 }
166
167 string defaultOrigin = GetOrigin(gpalUrl.Url);
168 IEnumerable<StorageAction> storageActions = null;
169
170 // NOTE: null == wildcard, not set, choose all of that type
171 if (null == storageActionParameters.Domain && null == storageActionParameters.Key && null == storageActionParameters.Path && null == storageActionParameters.StoreName
172 && null == storageActionParameters.UserDefined)
173 {
174 if (WebsiteStorageType.notSet == storageActionParameters.StorageType) // run all
175 storageActions = gpalUrl.GetActions().Where(a => storageActionParameters.Action == a.Action);
176 else // run all for specific storageType
177 storageActions = gpalUrl.GetActions().Where(a => storageActionParameters.StorageType == a.StorageType && storageActionParameters.Action == a.Action);
178 }
179 else
180 {
181 // refactored by Grok 4.20
182 var p = storageActionParameters;
183
184 storageActions = gpalUrl.GetActions().Where(a =>
185 a.StorageType == p.StorageType &&
186 p.Domain.MatchesOrAny(a.Domain) &&
187 p.Path.MatchesOrAny(a.Path) &&
188 p.Key.MatchesOrAny(a.Key) &&
189 p.StoreName.MatchesOrAny(a.StoreName) &&
190 p.UserDefined.MatchesOrAny(a.UserDefined)
191 );
192 }
193
194 foreach (var action in storageActions)
195 {
196 string origin = action.Domain != null
197 ? MagicHelper.GetFullUrl(action.Domain, null, out bool _, true) // we just want the url, no robots.txt check
198 : defaultOrigin;
199
200 var paramsDict = new Dictionary<string, object>();
201
202 if (WebsiteStorageType.cookie == action.StorageType)
203 origin = UrlHelper.GetWildcardCookieDomain(origin);
204
205 var details = new[]
206 {
207 string.IsNullOrWhiteSpace(origin) ? null : $"d[{origin}]",
208 string.IsNullOrWhiteSpace(action.Path) ? null : $"p[{action.Path}]",
209 string.IsNullOrWhiteSpace(action.Key) ? null : $"k[{action.Key}]",
210 string.IsNullOrWhiteSpace(action.StoreName) ? null : $"sn[{action.StoreName}]"
211 }
212 .Where(s => s != null);
213
214 string message = $"[{char.ToUpper(storageActionParameters.Action.ToString()[0])}{storageActionParameters.Action.ToString().Substring(1)}] [{action.StorageType}] for {string.Join(" ", details)}";
215 SeleniumStorageHelper seleniumStorageHelper = null;
216
217 if (true == browserSettings.UseSelenium)
218 seleniumStorageHelper = new SeleniumStorageHelper((Browser) browserSettings.Browser);
219
220 if (WebsiteStorageAction.get == storageActionParameters.Action)
221 {
222 try
223 {
224 if (true == browserSettings.UseOttoMagic)
225 data = browserSettings.MagicHelper.GetStorage(action.StorageType, origin, action.Path, action.Key, action.StoreName);
226 else if (true == browserSettings.UsePuppeteer)
227 data = browserSettings.PuppeteerClient.GetStorage(action.StorageType)
228 .WithStorageDomain(origin).WithStoragePath(action.Path).WithStorageKey(action.Key).WithStorageStoreName(action.StoreName)
229 .Execute();
230 else if (true == browserSettings.UseSelenium)
231 {
232 StorageAction sa = new StorageAction(action); // preserve the original action for domain reuse
233 sa.Domain = origin; // use wildcards for cookie
234 data = seleniumStorageHelper.GetStorage(sa);
235 }
236
237 // de double json encode (ottomagic)
238 // Loop until the data no longer looks like an encoded JSON string
239 if (false == string.IsNullOrEmpty(data))
240 while (data.StartsWith("\"") && data.EndsWith("\""))
241 {
242 try
243 {
244 // Unwraps one layer of encoding
245 var unescaped = Newtonsoft.Json.JsonConvert.DeserializeObject<string>(data);
246
247 // If deserialization didn't change the string, break to avoid infinite loops
248 if (unescaped == data) break;
249
250 data = unescaped;
251 }
252 catch
253 {
254 // If it's no longer a valid encoded string, stop looping
255 break;
256 }
257 }
258
259 data = UnwrapStorageRead(data, action.Key);
260
261 GPAL.PublishSimpleEvent(GPALEventType.INFO,
262 message,
263 gpalUrl, GPALObjectType.Other);
264
265 retVal = true;
266 }
267 catch (Exception ex)
268 {
269 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION,
270 "FAILED:" + message,
271 gpalUrl, GPALObjectType.Other, ex);
272 }
273 }
274 else if (WebsiteStorageAction.delete == storageActionParameters.Action)
275 {
276 // NOTE: all should return an int of number of deleted items?
277 try
278 {
279 if (true == browserSettings.UseOttoMagic)
280 retVal = browserSettings.MagicHelper.DeleteStorage(action.StorageType, action.DeleteAcrossOrigins, origin, action.Path, action.Key, action.StoreName);
281 else if (true == browserSettings.UsePuppeteer)
282 retVal = browserSettings.PuppeteerClient.DeleteStorage(action.StorageType)
283 .WithStorageDomain(origin).WithStoragePath(action.Path).WithStorageKey(action.Key).WithStorageStoreName(action.StoreName)
284 .Execute<bool>();
285 else if (true == browserSettings.UseSelenium)
286 {
287 StorageAction sa = new StorageAction(action); // preserve the original action for domain reuse
288 sa.Domain = origin; // use wildcards for cookie
289 data = seleniumStorageHelper.DeleteStorage(sa).ToString(); // returns number of items deleted... maybe we return that as data for all?
290 retVal = false == "0".Equals(data);
291 }
292
293 GPAL.PublishSimpleEvent(GPALEventType.INFO,
294 message,
295 gpalUrl, GPALObjectType.Other);
296 }
297 catch (Exception ex)
298 {
299 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION,
300 "FAILED:" + message,
301 gpalUrl, GPALObjectType.Other, ex);
302 }
303 }
304 else if (WebsiteStorageAction.set == storageActionParameters.Action)
305 {
306 // NOTE: all should return bool for operation success
307 try
308 {
309 if (true == browserSettings.UseOttoMagic)
310 retVal = browserSettings.MagicHelper.SetStorage(action.StorageType, storageActionParameters.Data, origin, action.Path, action.Key, action.StoreName);
311 else if (true == browserSettings.UsePuppeteer)
312 retVal = browserSettings.PuppeteerClient.SetStorage(action.StorageType)
313 .WithStorageDomain(origin).WithStoragePath(action.Path).WithStorageKey(action.Key).WithStorageStoreName(action.StoreName).WithStorageData(storageActionParameters.Data)
314 .Execute<bool>();
315 else if (true == browserSettings.UseSelenium)
316 {
317 StorageAction sa = new StorageAction(action); // preserve the original action for domain reuse
318 sa.Domain = origin; // use wildcards for cookie
319 sa.Data = storageActionParameters.Data;
320 retVal = seleniumStorageHelper.SetStorage(sa);
321 }
322
323 if (true == retVal)
324 GPAL.PublishSimpleEvent(GPALEventType.INFO,
325 message,
326 gpalUrl, GPALObjectType.Other);
327 else
328 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
329 "FAILED:" + message,
330 gpalUrl, GPALObjectType.Other);
331
332 }
333 catch (Exception ex)
334 {
335 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION,
336 "FAILED:" + message,
337 gpalUrl, GPALObjectType.Other, ex);
338 }
339 }
340 }
341 return retVal;
342 }
343
344 // ------------------------------------------------------------------------
345 // Helpers
346 // ------------------------------------------------------------------------
347
351 public static string GetOrigin(string url)
352 {
353 if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
354 {
355 return uri.GetLeftPart(UriPartial.Authority);
356 }
357 return url; // fallback
358 }
362 public static bool SameBaseUrl(string url1, string url2)
363 {
364 if (Uri.TryCreate(url1, UriKind.Absolute, out var uri1) && Uri.TryCreate(url2, UriKind.Absolute, out var uri2))
365 return string.Equals(uri1.GetLeftPart(UriPartial.Path), uri2.GetLeftPart(UriPartial.Path), StringComparison.OrdinalIgnoreCase);
366
367 return false;
368 }
374 private static DomainParser _parser;
375
376 private static DomainParser Parser
377 {
378 get
379 {
380 if (_parser == null)
381 {
382 _parser = new DomainParser(new WebTldRuleProvider());
383 }
384 return _parser;
385 }
386 }
387
388 public static bool TryGetUri(string url, out Uri uri)
389 {
390 uri = null;
391
392 if (string.IsNullOrWhiteSpace(url))
393 return false;
394
395 url = url.Trim();
396
397 // Add scheme if missing
398 if (!url.Contains("://"))
399 url = "https://" + url;
400
401 return Uri.TryCreate(url, UriKind.Absolute, out uri);
402 }
403
404 public static string GetWildcardCookieDomain(string url)
405 {
406 if (string.IsNullOrWhiteSpace(url))
407 return null;
408
409 url = url.Trim();
410
411 if (url.StartsWith("."))
412 url = url.Substring(1);
413
414 if (!TryGetUri(url, out var uri))
415 return url;
416
417 try
418 {
419 var domainInfo = Parser.Parse(uri.Host);
420
421 if (string.IsNullOrEmpty(domainInfo?.RegistrableDomain))
422 return uri.Host;
423
424 return "." + domainInfo.RegistrableDomain;
425 }
426 catch
427 {
428 // Covers BOTH:
429 // - Parser construction failure
430 // - Parse() failure
431 return uri.Host;
432 }
433 }
443 internal static bool CookieDomainMatches(string cookieDomain, string domain)
444 {
445 // nothing to narrow by, or nothing on the cookie to judge, so the cookie is kept
446 bool retVal = true;
447
448 if (false == string.IsNullOrWhiteSpace(domain) && false == string.IsNullOrWhiteSpace(cookieDomain))
449 {
450 string wanted = CookieHostOf(domain);
451 string held = CookieHostOf(cookieDomain);
452
453 retVal = true == held.Equals(wanted, StringComparison.OrdinalIgnoreCase)
454 || true == held.EndsWith("." + wanted, StringComparison.OrdinalIgnoreCase);
455 }
456
457 return retVal;
458 }
459
469 internal static bool CookieAppliesToHost(string cookieDomain, string host)
470 {
471 // nothing on the cookie to judge, or no host to judge it against, so the cookie is kept
472 bool retVal = true;
473
474 if (false == string.IsNullOrWhiteSpace(cookieDomain) && false == string.IsNullOrWhiteSpace(host))
475 {
476 string bare = CookieHostOf(cookieDomain);
477 string target = CookieHostOf(host);
478
479 retVal = true == target.Equals(bare, StringComparison.OrdinalIgnoreCase)
480 || true == target.EndsWith("." + bare, StringComparison.OrdinalIgnoreCase);
481 }
482
483 return retVal;
484 }
485
491 private static string CookieHostOf(string value)
492 {
493 string retVal = value.Trim().TrimStart('.');
494
495 if (true == Uri.TryCreate(retVal, UriKind.Absolute, out Uri uri) && false == string.IsNullOrEmpty(uri.Host))
496 retVal = uri.Host;
497
498 return retVal;
499 }
500 #endregion Storage Handling
501 #region ROBOTS.TXT and ROBOTS META TAG
502 // written by ChatGPT
503 // Cache per-host robots.txt
504 private static readonly ConcurrentDictionary<string, string[]> RobotsCache =
505 new ConcurrentDictionary<string, string[]>();
506
507 // Cache per-URL final allow/deny decision
508 private static readonly ConcurrentDictionary<string, bool> UrlDecisionCache =
509 new ConcurrentDictionary<string, bool>();
510
511 static bool inRobotsCheck = false;
512
513 // File extensions that a browser can open (or download) but which are not HTML documents.
514 // A robots meta tag can only live in the head of an HTML document, so looking for one on any
515 // of these is a guaranteed miss and costs us a wait/timeout in the workflow.
516 private static readonly HashSet<string> NonHtmlExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
517 {
518 // markup/data the browser renders or dumps as text
519 ".xml", ".xsl", ".xslt", ".rss", ".atom", ".txt", ".json", ".jsonld", ".csv", ".tsv", ".yaml", ".yml", ".md",
520 // documents
521 ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx", ".rtf", ".odt", ".ods", ".odp", ".epub",
522 // images
523 ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg", ".svgz", ".ico", ".tif", ".tiff", ".avif", ".heic",
524 // media
525 ".mp3", ".wav", ".ogg", ".oga", ".flac", ".aac", ".m4a", ".mp4", ".m4v", ".webm", ".mov", ".avi", ".mkv", ".wmv", ".m3u8",
526 // archives and binaries
527 ".zip", ".gz", ".tgz", ".bz2", ".7z", ".rar", ".tar", ".exe", ".msi", ".dmg", ".apk", ".iso", ".bin",
528 // code/assets served directly
529 ".js", ".mjs", ".css", ".map", ".wasm",
530 // fonts
531 ".woff", ".woff2", ".ttf", ".otf", ".eot",
532 // misc
533 ".ics", ".vcf", ".sql", ".log"
534 };
535
542 public static bool IsNonHtmlResource(string url)
543 {
544 if (true == string.IsNullOrWhiteSpace(url))
545 return false;
546
547 try
548 {
549 string path = url;
550
551 if (true == Uri.TryCreate(url, UriKind.Absolute, out Uri uri))
552 {
553 // non http(s) schemes are never a page we can read meta tags from
554 if (false == uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
555 false == uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
556 return true;
557
558 path = uri.AbsolutePath;
559 }
560 else
561 {
562 // strip query/fragment by hand when we were handed a partial url
563 int cut = path.IndexOfAny(new[] { '?', '#' });
564 if (0 <= cut)
565 path = path.Substring(0, cut);
566 }
567
568 string extension = System.IO.Path.GetExtension(path);
569
570 if (true == string.IsNullOrWhiteSpace(extension))
571 return false;
572
573 return NonHtmlExtensions.Contains(extension);
574 }
575 catch
576 {
577 // if we cannot parse it, treat it as a page and let the normal checks run
578 return false;
579 }
580 }
581
590 public static bool CheckRobotsTxt(string url, IBrowser browser, List<string> robotsTxt)
591 {
592 var uri = new Uri(url);
593 string host = uri.Host;
594 bool isAllowed = true;
595 string[] lines;
596 bool cachedResult = true;
597
598 robotsTxt.Clear();
599
600 if (true == inRobotsCheck)
601 {
602 if (UrlDecisionCache.TryGetValue(url, out cachedResult))
603 {
604 RobotsCache.TryGetValue(host, out lines);
605 foreach (string line in lines)
606 robotsTxt.Add(line);
607
608 return cachedResult;
609 }
610 return true;
611 }
612 else
613 // newtab and others will call getfullurl which will cause robots check recursion...
614 inRobotsCheck = true;
615
616 try
617 {
618 // Fast path: already processed URL
619 if (UrlDecisionCache.TryGetValue(url, out cachedResult))
620 {
621 GPAL.PublishSimpleEvent(
622 GPALEventType.DEBUG,
623 $"Robots policy cache hit for [{url}] -> [{cachedResult}]");
624
625 RobotsCache.TryGetValue(host, out lines);
626 if (null != lines)
627 foreach (string line in lines)
628 robotsTxt.Add(line);
629
630 return cachedResult;
631 }
632
633 GPAL.PublishSimpleEvent(
634 GPALEventType.INFO,
635 $"Checking robots.txt for [{url}]");
636
637 using (var client = new System.Net.Http.HttpClient())
638 {
639 // Set User-Agent once
640 client.DefaultRequestHeaders.Add(
641 "User-Agent",
642 MagicHelper.GetUserAgentString(browser.BrowserType));
643
644 // -------------------------
645 // ROBOTS.TXT CHECK
646 // -------------------------
647
648 lines = RobotsCache.GetOrAdd(host, h =>
649 {
650 // use httpclient to get robots.txt - but we might be blocked
651 // backup is to get the robots.txt in the browser which will always work
652 try
653 {
654 var task = client.GetStringAsync($"{uri.Scheme}://{h}/robots.txt");
655
656 if (!task.Wait(TimeSpan.FromSeconds(10)))
657 {
658 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"[{uri.Scheme}]://[{h}]/robots.txt request timed out after 10 seconds.");
659 throw new TimeoutException();
660 }
661
662 string robotsContent = task.Result;
663 return robotsContent
664 .Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)
665 .Select(l => l.Trim())
666 .ToArray();
667 }
668 catch
669 {
670 // open robots.txt in a new tab, get page source and parse
671 string currentUrl = browser.CurrentUrl;
672 browser.NewTab($"{uri.Scheme}://{h}/robots.txt")/*.WaitFor(500)*/.GetPageSource(out string pageSource);
673 if (1 < ((Browser)browser).tabsWeOpened)
674 browser.CloseTab($"{uri.Scheme}://{h}/robots.txt");
675
676 ((Browser)browser).CurrentUrl = currentUrl;
677
678 if (true == string.IsNullOrEmpty(pageSource))
679 {
680 return Array.Empty<string>();
681 }
682 else
683 {
684 string content = pageSource;
685
686 // Check if the content is wrapped in HTML with a <pre> tag and extract the inner text if so
687 if (content.Contains("<pre"))
688 {
689 int preStart = content.IndexOf("<pre", StringComparison.OrdinalIgnoreCase);
690 if (preStart >= 0)
691 {
692 int start = content.IndexOf(">", preStart) + 1;
693 int end = content.LastIndexOf("</pre>", StringComparison.OrdinalIgnoreCase);
694 if (start > 0 && end > start)
695 {
696 content = content.Substring(start, end - start);
697 }
698 }
699 }
700
701 return content
702 .Replace(@"\n", "\n").Replace(@"\r", "\r")
703 .Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)
704 .Select(l => l.Trim())
705 .ToArray();
706 }
707 }
708 });
709
710 bool userAgentAll = false;
711 var disallowedPaths = new System.Collections.Generic.List<string>();
712
713 if (null != lines)
714 foreach (var line in lines)
715 {
716 if (line.StartsWith("User-agent:", StringComparison.OrdinalIgnoreCase))
717 {
718 userAgentAll = line.Substring(11).Trim() == "*";
719 }
720 else if (userAgentAll &&
721 line.StartsWith("Disallow:", StringComparison.OrdinalIgnoreCase))
722 {
723 string path = line.Substring(9).Trim();
724
725 if (!string.IsNullOrEmpty(path))
726 disallowedPaths.Add(path);
727 }
728
729 GPAL.PublishSimpleEvent(
730 GPALEventType.DEBUG,
731 $"Processed robots.txt line [{line}]");
732
733 robotsTxt.Add(line);
734 }
735
736 string relativePath = uri.PathAndQuery;
737
738 foreach (var rule in disallowedPaths)
739 {
740 if (relativePath.StartsWith(rule, StringComparison.OrdinalIgnoreCase))
741 {
742 GPAL.PublishSimpleEvent(
743 GPALEventType.WARNING,
744 $"ACCESS DENIED by robots.txt [Disallow: [{rule}]]"
745 );
746
747 isAllowed = false;
748 }
749 }
750 }
751 }
752 catch (Exception ex)
753 {
754 GPAL.PublishSimpleEvent(
755 GPALEventType.WARNING,
756 $"Error checking robots.txt for [{url}]"
757 , null, GPALObjectType.None, ex);
758
759 isAllowed = true;
760 }
761 finally
762 {
763 inRobotsCheck = false;
764 }
765
766 // Store final decision
767 UrlDecisionCache[url] = isAllowed;
768
769 return isAllowed;
770 }
771
772 // Cache we maintain to respect URL once per directive
773 private static System.Collections.Concurrent.ConcurrentDictionary<string, HashSet<string>> _metaTagAuditCache;
774
775 // Track last domain visited to clear cache when domain changes
776 // NOTE: CAVEAT: this is problematic, do it every time until we understand why it is problematic
777 private static string _lastDomain;
778
790 [InProgress("Output website robot meta tags. Will respect .WithRespectRobotMetaTags(true) on noindex/nofollow/none. Ottomagic cannot retrived head.")]
791 public static bool CheckRobotMetaTags(GPALUrl url, Browser browser)
792 {
793 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Searching for robots meta tag");
794
795 // specifically exclude blank pages and anything that is not an HTML document
796 // (robots.txt, sitemap.xml, pdfs, images, downloads - none of them can carry a robots meta tag)
797 if (true == url?.Url.Equals("about:blank") || true == IsNonHtmlResource(url?.Url))
798 return true;
799
800 bool isAllowed = true;
801
802 var uri = new Uri(url.Url);
803 string currentDomain = uri.Host;
804 _metaTagAuditCache ??= new System.Collections.Concurrent.ConcurrentDictionary<string, HashSet<string>>();
805
806 //Clear cache if domain changed
807 if (false == UrlHelper.AreEquivalent(_lastDomain, currentDomain))
808 {
809 _metaTagAuditCache?.Clear();
810 _lastDomain = currentDomain;
811 }
812
813 HashSet<string> loggedDirectives = _metaTagAuditCache.GetOrAdd(url.Url, _ => new HashSet<string>());
814
815 if (0 == loggedDirectives.Count)
816 // Only if browser given, do we proceed
817 if (null != browser.Process || 0 != browser.BrowserSettings.ServiceDriverPid)
818 {
819 try
820 {
821 string content = string.Empty;
822 int retries = 3;
823
824 if (true == browser.UseOttoMagic)
825 {
826 // head is in content 0
827 List<GPALElement> theHead = browser.MagicHelper.QueryPersistentSelectors("meta[name='robots']");
828
829 //string pageSource = string.Empty;
830 //browser.GetPageSource(out pageSource);
831
832 if (null != theHead)
833 foreach (GPALElement element in theHead)
834 {
835 // KLUDGE: using this command on an element, it expects the browser, but magic doesn't add that, so we will
836 element.Browser = browser;
837 content = element.GetAttribute("content");
838 }
839 }
840 else
841 {
842 bool withReturn = false == browser.UsePuppeteer;
843
844 do
845 {
846 try
847 {
848 content = BrowserHelper.ExecuteJavaScriptObj(
849 $@"var div = document.querySelector(""meta[name='robots']""); {(true == withReturn ? "return " : "")} div?.getAttribute('content');",
850 browser
851 )?.ToString();
852
853 break;
854 }
855 catch
856 {
857 // Too fast we were? Retry, we shall
858 }
859 } while (0 < retries--);
860 }
861
862 if (null == content)
863 return true;
864
865 var directives = content.Split(',')
866 .Select(d => d.Trim())
867 .ToList();
868
869 foreach (var directive in directives)
870 {
871 //if (!loggedDirectives.Contains(directive))
872 {
873 if (directive == "noindex" || directive == "nofollow" || directive == "none" || directive == "index" || directive == "follow")
874 {
875 loggedDirectives.Add(directive);
876 }
877 }
878 }
879 }
880 catch
881 {
882 // error means default to isAllowed
883 isAllowed = true;
884 }
885 }
886
887 foreach (string directive in loggedDirectives)
888 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Robots meta tag [{directive}]");
889
890 isAllowed = (true == browser.BrowserSettings.RespectRobotMetaTags && (true == loggedDirectives.Contains("noindex") || true == loggedDirectives.Contains("nofollow") || true == loggedDirectives.Contains("none"))) ? false : true;
891
892 return isAllowed;
893 }
894 #endregion ROBOTS.TXT and ROBOTS META TAG
895 #region Helpers
900 public static bool AreEquivalent(string url1, string url2, bool treatHttpHttpsAsSame = true)
901 {
902 if (string.IsNullOrWhiteSpace(url1) || string.IsNullOrWhiteSpace(url2))
903 return url1 == url2;
904
905 // Quick exact match escape
906 if (string.Equals(url1, url2, StringComparison.OrdinalIgnoreCase))
907 return true;
908
909 try
910 {
911 var uri1 = new Uri(url1, UriKind.Absolute);
912 var uri2 = new Uri(url2, UriKind.Absolute);
913
914 // Protocol equivalence
915 if (treatHttpHttpsAsSame)
916 {
917 string scheme1 = uri1.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) ? "https" : uri1.Scheme;
918 string scheme2 = uri2.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) ? "https" : uri2.Scheme;
919 if (scheme1 != scheme2)
920 return false;
921 }
922 else if (!string.Equals(uri1.Scheme, uri2.Scheme, StringComparison.OrdinalIgnoreCase))
923 {
924 return false;
925 }
926
927 // Host equivalence: ignore www prefix and case
928 string host1 = NormalizeHost(uri1.Host);
929 string host2 = NormalizeHost(uri2.Host);
930 if (host1 != host2)
931 return false;
932
933 // Path equivalence: ignore trailing slash
934 string path1 = NormalizePath(uri1.AbsolutePath);
935 string path2 = NormalizePath(uri2.AbsolutePath);
936 if (path1 != path2)
937 return false;
938
939 // Query string: case-sensitive and order-sensitive (most conservative)
940 if (uri1.Query != uri2.Query)
941 return false;
942
943 // Fragment usually ignored for equivalence
944 // (if you want to consider it, compare uri1.Fragment == uri2.Fragment)
945
946 return true;
947 }
948 catch (UriFormatException)
949 {
950 // If either isn't a valid absolute URI, fall back to string compare
951 return string.Equals(url1.TrimEnd('/'), url2.TrimEnd('/'), StringComparison.OrdinalIgnoreCase);
952 }
953 }
954
955 public static string GetNormalizedDomain(string url)
956 {
957 Uri uri = new Uri(url);
958 string currentDomain = uri.Host;
959 return NormalizeHost(currentDomain);
960 }
961
962 private static string NormalizeHost(string host)
963 {
964 if (string.IsNullOrEmpty(host)) return host;
965
966 host = host.TrimEnd('.').ToLowerInvariant();
967
968 // Remove leading www. (and www2., www3., etc.)
969 if (host.StartsWith("www", StringComparison.OrdinalIgnoreCase))
970 {
971 int dotIndex = host.IndexOf('.');
972 if (dotIndex > 0 && dotIndex < 6) // www., www2., www10., etc.
973 {
974 host = host.Substring(dotIndex + 1);
975 }
976 }
977
978 return host;
979 }
980
981 public static string NormalizePath(string path)
982 {
983 if (string.IsNullOrEmpty(path)) return "/";
984
985 path = path.ToLowerInvariant(); // path case usually matters, but for sitemap equivalence often not
986
987 // Remove trailing slash unless it's just "/"
988 if (path.Length > 1 && path.EndsWith("/"))
989 path = path.TrimEnd('/');
990
991 return path;
992 }
993 #endregion Helpers
994 }
995}