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