GPAL - Generally Positive Automation Library v1.0
GPAL The Fluent Automation LIbrary
Loading...
Searching...
No Matches
Browser.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
17#define DONOTUSECDP // which solution to use for printDomElement
18
19using System;
20using System.Collections.Generic;
21using System.Collections.ObjectModel;
22using System.Diagnostics;
23using System.Drawing;
24using System.IO;
25using System.Linq;
26using System.Text.RegularExpressions;
27using System.Threading;
28using System.Threading.Tasks;
29using System.Xml;
30using DocumentFormat.OpenXml.Bibliography;
32using Newtonsoft.Json;
33using OpenQA.Selenium;
34using OpenQA.Selenium.Interactions;
35using static GenerallyPositive.Enums;
37using HWND = System.IntPtr;
38
39// the browser has a HiddenDesktop property now, which hides the static class of the same name in this
40// file. the alias says which one is meant at the four places that want the static one
42
44{
66 public class Browser
67 : IBrowser
68 {
106 public delegate CallIfStatus CallIfDelegate(IBrowser browser, List<IGPALElement> foundElements, List<IGPALElement> matchedElements, Selector selector, bool matchedAll);
133 public delegate CallIfStatus CallAfterFillInDelegate(IBrowser browser, IGPALGrid<string> tokens, int tokenIdx);
155 public delegate CallIfStatus CallAfterFetchDelegate(IBrowser browser, List<string> results, IGPALGrid<string> tokens, int tokenIdx);
156
168 public delegate CallIfStatus CallOnFailDelegate(IBrowser browser, GPALFailure failure, string detail);
169
186 {
187 return this;
188 }
189
190 #region <Working Vars>
191 BrowserSettings _browserSettings = null;
195 internal UnitOfWork persistentUOW { get; set; } = null;
199 // When the browser was launched, null until it is. GoTo only ever asked whether the tab tally was zero to
200 // find this out, which made a counter answer a question that is not about counting
201 internal DateTime? BrowserLaunched { get; set; } = null;
202
203 // Tabs GPAL opened itself, which is not the same as the tabs the browser has: ResolveGhostTabHandle finds
204 // Edge's phantom tab precisely by noticing there are more real tabs than tabs we opened. Only counted where
205 // the answer is free, which is why no engine is asked to go and look
206 internal int tabsWeOpened { get; set; } = 0;
213 internal string GhostTabUrl { get; set; } = null;
218 internal string GhostTabHandle { get; set; } = null;
222 public int CurrentTabIdx { get; internal set; } = 0;
223
224 // Which window each tab belongs to, tab handle to window handle. Selenium hands back one flat list of every
225 // tab in every window and will not say which belongs to which, but GPAL opened them so GPAL knows. Recorded
226 // once where a tab is born and never updated after. A tab that appeared on its own joins the window we were
227 // looking at when we first noticed it, which keeps it reachable by cycling.
228 private readonly Dictionary<string, string> tabWindows = new Dictionary<string, string>();
229
230 internal bool _areRobotsAllowed = true;
235 public bool AreRobotsAllowed { get => _areRobotsAllowed; internal set => _areRobotsAllowed = value; }
236
237 #endregion <Working Vars>
259 internal Browser()
260 {
261 this.BrowserSettings = new BrowserSettings(this)
262 {
263 DriverLocation = GPAL.DriverLocation,
264 DownloadTimeoutInSec = GPAL.GPALSettings.DownloadTimeoutInSec
265 };
266
267 CurrentUOW = new UnitOfWork() { Browser = this };
268 persistentUOW = new UnitOfWork() { Browser = this };
269 WorkflowManager = new WorkflowManager(this); // Pass current Browser instance
270 }
271
272 #region <Browser Settings>
288 {
289 BrowserSettings.DriverLocation = FileHelper.EnsureDirectoryEndsWithBackslash(Environment.ExpandEnvironmentVariables(directory));
290 return this;
291 }
292
298 {
299 BrowserSettings.UseDirectDownload = trueFalse;
300 return this;
301 }
302
308 {
309 BrowserSettings.DownloadTimeoutInSec = seconds;
310 return this;
311 }
312
318 {
319 BrowserSettings.DownloadLocation = FileHelper.EnsureDirectoryEndsWithBackslash(Environment.ExpandEnvironmentVariables(directory));
320 return this;
321 }
322
337 {
338 BrowserSettings.BlockPopUps = blockPopUp;
339 return this;
340 }
341
363 {
364 BrowserSettings.HiddenDesktop = hiddenDesktop;
365
366 // a browser that asks for a hidden desktop without saying which is asking for one of its own, so it
367 // gets a name nothing else will be handed. two browsers sharing is what naming one is for
368 if (true == hiddenDesktop)
369 BrowserSettings.HiddenDesktopName = Desktops.NextName();
370
371 return this;
372 }
373
389 {
390 BrowserSettings.HiddenDesktop = true;
391
392 // calling this at all is asking for a hidden desktop. a name that turned out to be nothing is a
393 // desktop of this browser's own, the same as asking without a name. not wanting one is not calling
394 BrowserSettings.HiddenDesktopName = true == string.IsNullOrWhiteSpace(desktopName)
395 ? Desktops.NextName()
396 : desktopName;
397
398 return this;
399 }
400
407 private T LaunchWhereAsked<T>(Func<T> launch)
408 {
409 T retVal;
410
411 if (true == BrowserSettings.HiddenDesktop)
412 retVal = Desktops.Launch(BrowserSettings.HiddenDesktopName, launch);
413 else
414 retVal = launch();
415
416 return retVal;
417 }
430 private static AutomationEngine FirefoxStepDown(AutomationEngine engine)
431 {
432 AutomationEngine retVal = engine;
433
434 switch (engine)
435 {
436 case AutomationEngine.PuppeteerPort:
437 case AutomationEngine.PuppeteerPipe:
438 retVal = AutomationEngine.Selenium;
439 break;
440 case AutomationEngine.PuppeteerPortHW:
441 case AutomationEngine.PuppeteerPipeHW:
442 retVal = AutomationEngine.SeleniumHW;
443 break;
444 }
445
446 return retVal;
447 }
448
449 private static AutomationEngine HardwareStepDown(AutomationEngine engine)
450 {
451 AutomationEngine retVal = engine;
452
453 switch (engine)
454 {
455 case AutomationEngine.OttoMagicHW:
456 retVal = AutomationEngine.OttoMagic;
457 break;
458 case AutomationEngine.PuppeteerPortHW:
459 retVal = AutomationEngine.PuppeteerPort;
460 break;
461 case AutomationEngine.PuppeteerPipeHW:
462 retVal = AutomationEngine.PuppeteerPipe;
463 break;
464 case AutomationEngine.SeleniumHW:
465 retVal = AutomationEngine.Selenium;
466 break;
467 }
468
469 return retVal;
470 }
487 [BrowserType()]
489 {
490 BrowserSettings.BrowserType = browserType;
491
492 return this;
493 }
494
509 {
510 BrowserSettings.DeleteFileBeforeDownload = overwriteFile;
511 return this;
512 }
513
528 {
529 BrowserSettings.LoadImages = loadImages;
530 return this;
531 }
532
550 {
551 BrowserSettings.ScrollIntoView = trueFalse;
552 return this;
553 }
554
571 {
572 BrowserSettings.PromptForDownload = promptForDownload;
573 return this;
574 }
575
581 {
582 BrowserSettings.OpenPDFExternally = openPDFExternally;
583 return this;
584 }
585
586 //public IAllowBrowserSettingsOrGoTo WithDownloadFileTypes(string csvList)
587 //{
588 // string[] fileTypes = csvList.Split(',');
589 // foreach (string fileType in fileTypes)
590 // BrowserSettings.DownloadFileTypeList.Add(fileType);
591 // return this;
592 //}
593
620 public IAllowWithHeaderOrFileActions WithGridToSave(IGPALGrid<string> inputGrid)
621 {
622 if (null != inputGrid && 0 < inputGrid.Count())
623 {
624 // TODO: CAVEAT: is this best? we can define any type Grid but then saving it to dynamic?
625 CurrentUOW.RetGrid = inputGrid;
626 CurrentUOW.ColCount = inputGrid.Columns;
627 }
628 else
629 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Input IGPALGrid<T> is empty.", this, GPALObjectType.Browser);
630
631 return this;
632 }
633
634 //public IAllowBrowserSettingsOrGoTo WithOpenFileTypes(string csvList)
635 //{
636 // string[] fileTypes = csvList.Split(',');
637 // foreach (string fileType in fileTypes)
638 // BrowserSettings.OpenFileTypeList.Add(fileType);
639 // return this;
640 //}
641
668 {
669 BrowserSettings.UseExistingBrowser = true;
670 BrowserSettings.ExistingBrowserPort = port;
671 return this;
672 }
673
718 {
719 if (true == string.IsNullOrWhiteSpace(restApiUrl))
720 {
721 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No address given to WithRestApiUrl, so a browser will be launched as usual", this, GPALObjectType.Browser);
722 return this;
723 }
724
725 // the engine is what decides how a browser is driven, and only OttoMagic is driven over REST
726 if (false == UseOttoMagic)
727 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"WithRestApiUrl drives a browser over GPALRestAPI, which is the OttoMagic engine. The engine is currently [{BrowserSettings.AutomationEngine}]", this, GPALObjectType.Browser);
728
729 BrowserSettings.RestApiBaseUrl = restApiUrl.EndsWith("/") ? restApiUrl : restApiUrl + "/";
730 BrowserSettings.AttachedRestApi = true;
731
732 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Driving the browser already running on [{BrowserSettings.RestApiBaseUrl}].", this, GPALObjectType.Browser);
733
734 return this;
735 }
736
737 public IAllowBrowserSettingsOrGoTo WithUseDebugPort(int port = 0xdead) // will actually take default from interface and is not required, but for clarity
738 {
739 if (port < 1024 || port > 65535)
740 {
741 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Invalid debug port [{port}]. Must be between 1024 and 65535. Using current port [{BrowserSettings.DebugPort}].", this, GPALObjectType.Browser);
742 return this;
743 }
744
745 // if the port is already in use on the computer, keep bumping it up one until we find an open port
746 while (true == BrowserHelper.IsPortListening(port))
747 port++;
748
749 BrowserSettings.DebugPort = port;
750
751 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Puppeteer using port [{port}]", this, GPALObjectType.Browser);
752
753 // NOTE: CAVEAT: existing port generally should be the same as debug port - do we want to set this?
754 // BUT, it is possible we are automating multiple browsers, so these have to be separate for each browser, but if it's not yet set...
755 // so, if we are open in debugger port, we could then connect to another browser on another existing port and copy data over.
756 // automating multiple browsers is problematic, tabs is the way to go, if using puppeteer, we could in theory handle multiple browsers.
757 if (null == BrowserSettings.ExistingBrowserPort)
758 BrowserSettings.ExistingBrowserPort = port;
759
760 AutomationEngine = AutomationEngine.PuppeteerPort;
761 BrowserSettings.DebugPipe = false;
762
763 return this;
764 }
772 {
773 if (BrowserType.FireFox == BrowserSettings.BrowserType &&
774 (AutomationEngine.PuppeteerPort == automationEngine || AutomationEngine.PuppeteerPortHW == automationEngine ||
775 AutomationEngine.PuppeteerPipe == automationEngine || AutomationEngine.PuppeteerPipeHW == automationEngine))
776 {
777 AutomationEngine steppedDownTo = FirefoxStepDown(automationEngine);
778
779 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Firefox cannot be driven with [{automationEngine}], so this browser runs on [{steppedDownTo}]", this, GPALObjectType.Browser);
780
781 automationEngine = steppedDownTo;
782 }
783
784 AutomationEngine = automationEngine;
785 return this;
786 }
787
794 public IAllowBrowserSettingsOrGoTo WithUseDebugPipe(bool trueFalse = false) // will actually take default from interface and is not required, but for clarity
795 {
796 AutomationEngine = AutomationEngine.PuppeteerPipe;
797 BrowserSettings.DebugPipe = true;
798 BrowserSettings.DebugPort = null;
799
800 return this;
801 }
802
820 {
821 BrowserSettings.WaitOnDocumentReady = true;
822 BrowserSettings.WaitOnNetworkIdle = false;
823 BrowserSettings.WaitForDocumentReadyTimeoutMs = timeoutInMs;
824
825 return this;
826 }
827
844 {
845 BrowserSettings.NavigationGraceMs = graceInMs;
846
847 return this;
848 }
849
870 {
871 BrowserSettings.WaitOnNetworkIdle = trueFalse;
872 BrowserSettings.WaitOnDocumentReady = trueFalse ? false : BrowserSettings.WaitOnDocumentReady;
873
874 return this;
875 }
876
895 public IAllowNetworkIdleSettings WithNetworkIdleTimeoutMs(int networkIdleTimeoutMs = 500)
896 {
897 BrowserSettings.NetworkIdleTimeoutMs = networkIdleTimeoutMs;
898 return this;
899 }
900
920 {
921 BrowserSettings.NetworkIdleMaxConnections = maxConnections;
922 return this;
923 }
924
945 public IAllowNetworkIdleSettings WithNetworkIdlePruneMs(int networkIdlePruneMs = 3_000)
946 {
947 BrowserSettings.NetworkIdlePruneMs = networkIdlePruneMs;
948 return this;
949 }
950
968 public IAllowBrowserSettingsOrGoTo WithProfileDataDirectory(string profileDataDirectory)
969 {
970 // we get what the system thinks is the default profile used by chrome
971 // there is some issue with selenium if it runs under the default profile it creates - since 136 tho a profile directory has to be specified
972 // this check might still be required. if so we will make defaultdirectory a local variable.
973 //if (null == BrowserSettings.DefaultProfileDataDirectory)
974 // BrowserSettings.DefaultProfileDataDirectory = FileHelper.EnsureDirectoryEndsWithBackslash(BrowserHelper.GetBrowserProfileDirectory(BrowserSettings, true));
975
976 string tmpProfileDirectory = FileHelper.EnsureDirectoryEndsWithBackslash(Environment.ExpandEnvironmentVariables(profileDataDirectory));
977
978 // cannot use the default profile under bot control due to access to secure user cookies
979 // OttoMagic will not be affected because it does not specify a debugger port.
980 //if (true == BrowserSettings.DefaultProfileDataDirectory.Equals(tmpProfileDirectory) && null == GPAL.OttoMagicExtensionPath)
981 // GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Specified user data dir [{tmpProfileDirectory}] cannot be the default profile location using Selenium.</br>Use OttoMagic instead.", this, GPALObjectType.Browser);
982 //else
983 BrowserSettings.ProfileDataDirectory = tmpProfileDirectory;
984
985 return this;
986 }
987
1006 {
1007 BrowserSettings.ProfileUserName = profileUserName;
1008 return this;
1009 }
1010
1029 {
1030 BrowserSettings.ProfileName = profileName;
1031 return this;
1032 }
1033
1040 public IAllowBrowserSettingsOrGoTo WithUseStealth(StealthType steathType)
1041 {
1042 BrowserSettings.StealthType = steathType;
1043 return this;
1044 }
1045
1052 {
1053 BrowserSettings.Referrer = referrer;
1054 return this;
1055 }
1056
1062 public IAllowBrowserSettingsOrGoTo WithUseUserAgent(string userAgent = "")
1063 {
1064 BrowserSettings.OverrideUserAgent = userAgent;
1065 return this;
1066 }
1067
1078 public IAllowBrowserSettingsOrGoTo WithUserAgentFromBrowser(bool userAgentFromBrowser = true)
1079 {
1080 BrowserSettings.UserAgentFromBrowser = userAgentFromBrowser;
1081 return this;
1082 }
1083
1109 {
1110 selector.ElementsFoundAndMatchedCount = 0; // in case it's being reused
1111 selector.DeleteMe = false; // Remove() latches this on and nothing else clears it, so a reused selector would be swept on its first maintenance pass
1112 selector.RemovedMethods.Clear(); // a handler that took itself out of play in an earlier workflow is back in play for this one
1113 if (true == CurrentUOW.ActionCalled)
1114 {
1115 // NOTE: CAVEAT: should this a list to have nested iframes?
1116 var iframeRoot = CurrentUOW.ContextPath;
1117 var shadowRoot = CurrentUOW.ShadowRoot;
1118
1119 CurrentUOW = new UnitOfWork
1120 {
1121 ContextPath = iframeRoot,
1122 ShadowRoot = shadowRoot
1123 };
1124
1125 CurrentUOW.Browser = this;
1126 selector.Browser = this;
1127 }
1128
1129 ResolveInteractionType(selector);
1130
1131 // if we are reusing, we can't use cached results in a new uow
1132 selector.WebSelectorFoundResults = null;
1133 selector.WebSelectorMatchedResults = null;
1134
1135 //CurrentUOW.SelectorType = SelectorType.WithSelector;
1136 // clone so if this selector is reused, each UOW list will be unique and we won't change the selector by changing the browser it is associated with
1137 // NOTE: cloning disassociates with actual program selector, so we can't set any queryable properties on Selector like 'ElementsFoundAndMatchedCount'
1138 // BrowserSettings.MySelector = new Selector(this, selector); // MySelector temp hold for other browser syntax
1139 BrowserSettings.CurrentSelector = selector;
1140
1141 CurrentUOW.WithSelectorList.Add(BrowserSettings.CurrentSelector);
1142 return this;
1143 }
1144
1149 public IAllowAfterAnySelector WithSelector(string literalData)
1150 {
1151 if (true == CurrentUOW.ActionCalled)
1152 {
1153 // NOTE: CAVEAT: should this a list to have nested iframes?
1154 var iframeRoot = CurrentUOW.ContextPath;
1155 var shadowRoot = CurrentUOW.ShadowRoot;
1156 // var shadowRootElement = CurrentUOW.ShadowRootGPALElement;
1157
1158 CurrentUOW = new UnitOfWork
1159 {
1160 ContextPath = iframeRoot,
1161 ShadowRoot = shadowRoot
1162 };
1163
1164 persistentUOW = new UnitOfWork();
1165
1166 CurrentUOW.Browser = this;
1167 persistentUOW.Browser = this;
1168 }
1169
1170 Selector tmpSel = GPAL.Selector.WithText(literalData).ToGPALObject();
1171 tmpSel.selectorSettings.SelectorType = SelectorType.Data;
1172 tmpSel.selectorSettings.Name = "Literal" + CurrentUOW.WithSelectorList.Select(s => SelectorType.Data == s.SelectorSettings.SelectorType).Count() + 1;
1173 CurrentUOW.WithSelectorList.Add(tmpSel);
1174 return this;
1175 }
1176
1181 public IAllowAfterAnySelector WithSelector(Func<string> dataFunction)
1182 {
1183 Selector tmpSel = GPAL.Selector.ToGPALObject();
1184 tmpSel.selectorSettings.SelectorType = SelectorType.DataFunc;
1185 tmpSel.selectorSettings.Name = dataFunction.Method.Name;
1186 tmpSel.selectorSettings.DataFunction = dataFunction;
1187 CurrentUOW.WithSelectorList.Add(tmpSel);
1188 return this;
1189 }
1190
1191 #region Storage
1202 {
1203 // the browser's own origin, so the client is ready to be given an endpoint rather than the caller
1204 // restating what the browser already knows
1205 string origin = UrlHelper.GetOrigin(BrowserHelper.GetCurrentUrl(BrowserSettings));
1206 RESTClient retVal = (RESTClient)GPAL.RESTClient.WithAPIBase(origin);
1207
1208 // a browser that has not been anywhere has no origin to talk to and no cookies to carry, so the
1209 // client would come back empty and fail later at the request instead of here at the call
1210 if (true == string.IsNullOrEmpty(origin))
1211 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No page loaded, so there is no session to carry. Navigate first, then continue as a RESTClient", this, GPALObjectType.Browser);
1212
1213 // a cookie get scoped to this origin, declared and run the way any storage action is. it is declared
1214 // on a url of its own so a workflow that declared its own actions still runs only those
1215 GPALUrl cookieUrl = new GPALUrl(origin);
1216
1217 cookieUrl.Add(new StorageAction { StorageType = WebsiteStorageType.cookie, Action = WebsiteStorageAction.get });
1218
1219 UrlHelper.TryPerformAction(cookieUrl, BrowserSettings,
1220 new StorageAction { StorageType = WebsiteStorageType.cookie, Action = WebsiteStorageAction.get },
1221 out string cookieJson);
1222
1223 string cookieHeader = CookieHeaderFrom(cookieJson, origin, out int cookiesRead);
1224
1225 if (false == string.IsNullOrEmpty(cookieHeader))
1226 retVal.WithHeader("Cookie", cookieHeader);
1227
1228 // asked of the browser rather than taken from what it was configured with, because a site can bind a
1229 // session to the agent it saw
1230 string sentUserAgent = BrowserHelper.LiveUserAgent(this);
1231 string sentAcceptLanguage = BrowserHelper.LiveAcceptLanguage(this);
1232
1233 retVal.WithHeader("User-Agent", sentUserAgent ?? BrowserHelper.ResolveUserAgent(BrowserSettings));
1234
1235 if (false == string.IsNullOrEmpty(sentAcceptLanguage))
1236 retVal.WithHeader("Accept-Language", sentAcceptLanguage);
1237
1238 // the credential the browser is actually using, whichever way it got one. WithCredentials is the
1239 // common case and basic auth never becomes a cookie - the browser resends the header on every
1240 // request - so without this a client carrying only cookies gets a flat 401
1241 ICredentials browserCredential = BrowserSettings.Credentials;
1242
1243 if (null != browserCredential)
1244 {
1245 Credentials credential = (Credentials)browserCredential;
1246 string authValue = BrowserHelper.AuthorizationHeader(credential, credential.WebAuthType);
1247
1248 if (false == string.IsNullOrEmpty(authValue))
1249 retVal.WithHeader(BrowserHelper.AuthorizationHeaderName(credential.WebAuthType), authValue);
1250
1251 }
1252
1253 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Continuing as a RESTClient from [{BrowserSettings.BrowserType}], carrying its [{(null == sentUserAgent ? "configured" : "live")}] user agent, [{(string.IsNullOrEmpty(sentAcceptLanguage) ? "no accept language" : sentAcceptLanguage)}], [{cookieHeader?.Split(';').Length ?? 0}] of [{cookiesRead}] cookies for [{origin}] and [{(null == browserCredential ? "no credential" : ((Credentials)browserCredential).WebAuthType.ToString())}]", this, GPALObjectType.Browser);
1254
1255 client = retVal;
1256
1257 return this;
1258 }
1259
1268 private static string CookieHeaderFrom(string cookieJson, string origin, out int cookiesRead)
1269 {
1270 string retVal = null;
1271
1272 cookiesRead = 0;
1273
1274 try
1275 {
1276 if (false == string.IsNullOrEmpty(cookieJson))
1277 {
1278 string host = true == Uri.TryCreate(origin, UriKind.Absolute, out Uri originUri) ? originUri.Host : null;
1279 Newtonsoft.Json.Linq.JArray cookies = Newtonsoft.Json.Linq.JArray.Parse(cookieJson);
1280 List<string> pairs = new List<string>();
1281
1282 cookiesRead = cookies.Count;
1283
1284 foreach (Newtonsoft.Json.Linq.JToken cookie in cookies)
1285 {
1286 // puppeteer calls the name Key, the other two call it name
1287 string name = (string)(cookie["name"] ?? cookie["Name"] ?? cookie["Key"]);
1288 string value = (string)(cookie["value"] ?? cookie["Value"]);
1289 string domain = (string)(cookie["domain"] ?? cookie["Domain"]);
1290
1291 if (false == string.IsNullOrEmpty(name) && true == UrlHelper.CookieAppliesToHost(domain, host))
1292 pairs.Add($"{name}={value}");
1293 }
1294
1295 if (0 < pairs.Count)
1296 retVal = string.Join("; ", pairs);
1297 }
1298 }
1299 catch (GPALException)
1300 {
1301 throw;
1302 }
1303 catch (Exception ex)
1304 {
1305 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Could not read the cookies into a header", null, GPALObjectType.Browser, ex);
1306 }
1307
1308 return retVal;
1309 }
1310
1311
1318 public IAllowStorageOutData RunGet(WebsiteStorageType storageType)
1319 {
1320 BrowserSettings.StorageActionCalled = true;
1321 BrowserSettings.StorageActionToRun.Action = WebsiteStorageAction.get;
1322 BrowserSettings.StorageActionToRun.StorageType = storageType;
1323 UrlHelper.TryPerformAction(BrowserSettings.CurrentGPALUrl, BrowserSettings, BrowserSettings.StorageActionToRun, out BrowserSettings.StorageActionToRun._data);
1324 return this;
1325 }
1326
1332 public IAllowBrowserActionOrAnySelector RunSet(WebsiteStorageType storageType)
1333 {
1334 BrowserSettings.StorageActionCalled = true;
1335 BrowserSettings.StorageActionToRun.Action = WebsiteStorageAction.set;
1336 BrowserSettings.StorageActionToRun.StorageType = storageType;
1337 UrlHelper.TryPerformAction(BrowserSettings.CurrentGPALUrl, BrowserSettings, BrowserSettings.StorageActionToRun, out _);
1338 return this;
1339 }
1340
1346 public IAllowBrowserActionOrAnySelector RunDelete(WebsiteStorageType storageType)
1347 {
1348 BrowserSettings.StorageActionCalled = true;
1349 BrowserSettings.StorageActionToRun.Action = WebsiteStorageAction.delete;
1350 BrowserSettings.StorageActionToRun.StorageType = storageType;
1351 UrlHelper.TryPerformAction(BrowserSettings.CurrentGPALUrl, BrowserSettings, BrowserSettings.StorageActionToRun, out _);
1352 return this;
1353 }
1354
1362 {
1363 if (true == BrowserSettings.StorageActionCalled)
1364 {
1365 BrowserSettings.StorageActionToRun = new StorageAction();
1366 BrowserSettings.StorageActionCalled = false;
1367 }
1368
1369 BrowserSettings.StorageActionToRun.Domain = domain;
1370
1371 return this;
1372 }
1373
1381 {
1382 if (true == BrowserSettings.StorageActionCalled)
1383 {
1384 BrowserSettings.StorageActionToRun = new StorageAction();
1385 BrowserSettings.StorageActionCalled = false;
1386 }
1387
1388 BrowserSettings.StorageActionToRun.Path = path;
1389
1390 return this;
1391 }
1392
1400 {
1401 if (true == BrowserSettings.StorageActionCalled)
1402 {
1403 BrowserSettings.StorageActionToRun = new StorageAction();
1404 BrowserSettings.StorageActionCalled = false;
1405 }
1406
1407 BrowserSettings.StorageActionToRun.Key = key;
1408
1409 return this;
1410 }
1411
1419 {
1420 if (true == BrowserSettings.StorageActionCalled)
1421 {
1422 BrowserSettings.StorageActionToRun = new StorageAction();
1423 BrowserSettings.StorageActionCalled = false;
1424 }
1425
1426 BrowserSettings.StorageActionToRun.StoreName = storeName;
1427
1428 return this;
1429 }
1430
1438 {
1439 if (true == BrowserSettings.StorageActionCalled)
1440 {
1441 BrowserSettings.StorageActionToRun = new StorageAction();
1442 BrowserSettings.StorageActionCalled = false;
1443 }
1444
1445 BrowserSettings.StorageActionToRun.Data = data;
1446
1447 return this;
1448 }
1449
1457 {
1458 if (true == BrowserSettings.StorageActionCalled)
1459 {
1460 BrowserSettings.StorageActionToRun = new StorageAction();
1461 BrowserSettings.StorageActionCalled = false;
1462 }
1463
1464 BrowserSettings.StorageActionToRun.UserDefined = userDefined;
1465
1466 return this;
1467 }
1468
1475 {
1476 data = BrowserSettings.StorageActionToRun.Data;
1477
1478 return this;
1479 }
1480 #endregion Storage
1481
1490 {
1491 selector.ElementsFoundAndMatchedCount = 0;
1492 selector.WebSelectorFoundResults = null;
1493 selector.WebSelectorMatchedResults = null;
1494
1495 // Fresh UOW after any action (your standard reset)
1496 if (CurrentUOW.ActionCalled)
1497 {
1498 var previousShadowRoot = CurrentUOW.ShadowRoot;
1499 var previousContextPath = CurrentUOW.ContextPath;
1500
1501 CurrentUOW = new UnitOfWork
1502 {
1503 Browser = this,
1504 ShadowRoot = previousShadowRoot,
1505 ContextPath = previousContextPath // preserve chain
1506 };
1507
1508 persistentUOW = new UnitOfWork { Browser = this };
1509 }
1510
1511 BrowserSettings.CurrentSelector = selector;
1512 CurrentUOW.InSelectorList.Add(selector);
1513
1514 // --- Switch to clean context first ---
1515 if (true == UseOttoMagic)
1516 MagicHelper?.SwitchToDefaultContent();
1517 else if (true == UsePuppeteer)
1518 PuppeteerClient.SwitchToDefaultContent().Execute();
1519 else
1520 BrowserDriver.SwitchTo().DefaultContent();
1521
1522 // Walk existing context
1523 IEnumerable<UnitOfWork.WebElementWithType> stepsToProcess;
1524
1525 //if (true == UseOttoMagic)
1526 //{
1527 // int lastIframeIndex = CurrentUOW.ContextPath
1528 // .Select((step, index) => new { step, index })
1529 // .LastOrDefault(x => x.step.elementType == ElementType.IFrame)?.index ?? -1;
1530
1531 // stepsToProcess = CurrentUOW.ContextPath
1532 // .Skip(lastIframeIndex + 1)
1533 // .Where(step => step.elementType == ElementType.ShadowRoot ||
1534 // step.elementType == ElementType.Element);
1535 //}
1536 //else
1537 {
1538 stepsToProcess = CurrentUOW.ContextPath;
1539 }
1540
1541 GPALElement inElement = null;
1542
1543 foreach (UnitOfWork.WebElementWithType contextType in stepsToProcess)
1544 {
1545 if (ElementType.IFrame == contextType.elementType)
1546 {
1547 if (UsePuppeteer)
1548 PuppeteerClient.SwitchToFrame(contextType.gPalElement.Css).Execute();
1549 else
1550 BrowserDriver.SwitchTo().Frame((IWebElement)contextType.gPalElement.WebElement);
1551
1552 // a search context belongs to one document - anything scoped in the frame we just left is
1553 // stale, and a shadow root from the parent throws "no such shadow root" if reused here
1554 inElement = null;
1555 }
1556 else if (ElementType.ShadowRoot == contextType.elementType)
1557 {
1558 if (UseOttoMagic)
1559 MagicHelper.SwitchToShadowRoot(contextType.gPalElement.Css);
1560 else if (UsePuppeteer)
1561 PuppeteerClient.SwitchToShadowRoot(contextType.gPalElement.Css).Execute();
1562
1563 // scope to THIS step's shadow root rather than the last one added to the unit of work, so a
1564 // path with more than one shadow root walks through each in turn instead of collapsing them
1565 inElement = contextType.gPalElement;
1566 }
1567 else if (ElementType.Element == contextType.elementType)
1568 {
1569 inElement = contextType.gPalElement; // this becomes the new root
1570 }
1571 }
1572
1573 // --- Actually find the InElement target ---
1574 ElementHelper.FindWebElements(
1575 this, CurrentUOW, selector, out _, out var tempElems, inElement); // shadow still passed for Otto
1576
1577 if (tempElems?.Count > 0)
1578 {
1579 var elementEntry = new UnitOfWork.WebElementWithType(
1580 selector, tempElems[0], ElementType.Element);
1581
1582 CurrentUOW.ContextPath ??= new List<UnitOfWork.WebElementWithType>();
1583 CurrentUOW.ContextPath.Add(elementEntry);
1584 }
1585 else
1586 {
1587 // Optional: log / throw depending on your strictness
1588 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"InElement selector returned no results [{selector.Name}]", this);
1589 }
1590
1591 return this;
1592 }
1593
1620 {
1621 selector.ElementsFoundAndMatchedCount = 0;
1622 selector.WebSelectorFoundResults = null;
1623 selector.WebSelectorMatchedResults = null;
1624
1625 // Fresh UOW after any action (standard fluent reset)
1626 if (CurrentUOW.ActionCalled)
1627 {
1628 var previousShadowRoot = CurrentUOW.ShadowRoot;
1629 var previousContextPath = CurrentUOW.ContextPath;
1630
1631 CurrentUOW = new UnitOfWork
1632 {
1633 Browser = this,
1634 ShadowRoot = previousShadowRoot,
1635 ContextPath = previousContextPath
1636 };
1637
1638 persistentUOW = new UnitOfWork { Browser = this };
1639 }
1640
1641 BrowserSettings.CurrentSelector = selector;
1642 CurrentUOW.InSelectorList.Add(selector);
1643
1644 if (true == UseOttoMagic)
1645 MagicHelper.SwitchToDefaultContent();
1646 else if (true == UsePuppeteer)
1647 PuppeteerClient.SwitchToDefaultContent().Execute();
1648 else
1649 this.BrowserDriver.SwitchTo().DefaultContent();
1650
1651 IEnumerable<UnitOfWork.WebElementWithType> stepsToProcess;
1652 GPALElement inElement = null;
1653
1654 //if (true == UseOttoMagic)
1655 //{
1656 // // OttoMagic special rule:
1657 // // Skip ALL IFrames, and only keep ShadowRoot steps that come AFTER the last IFrame in the list.
1658 // int lastIframeIndex = CurrentUOW.ContextPath
1659 // .Select((step, index) => new { step, index })
1660 // .LastOrDefault(x => x.step.elementType == ElementType.IFrame)?.index ?? -1;
1661
1662 // stepsToProcess = CurrentUOW.ContextPath
1663 // .Skip(lastIframeIndex + 1) // everything after the last iframe
1664 // .Where(step => step.elementType == ElementType.ShadowRoot); // NOTE: only shadow roots - redundant, but in case we add other types
1665 //}
1666 //else
1667 {
1668 // puppeteer or selenium have to be iframe aware: process the full original path
1669 stepsToProcess = CurrentUOW.ContextPath;
1670 }
1671
1672 // Now walk the (possibly filtered) steps
1673 foreach (UnitOfWork.WebElementWithType contextType in stepsToProcess)
1674 {
1675 if (ElementType.IFrame == contextType.elementType)
1676 {
1677 if (true == UseOttoMagic)
1678 MagicHelper.SwitchToElement(contextType.gPalElement.Css);
1679 else if(true == UsePuppeteer)
1680 PuppeteerClient.SwitchToFrame(contextType.gPalElement.Css).Execute();
1681 else
1682 BrowserDriver.SwitchTo().Frame((IWebElement)contextType.gPalElement.WebElement);
1683
1684 // a search context belongs to one document - anything scoped in the frame we just left is
1685 // stale, and a shadow root from the parent throws "no such shadow root" if reused here
1686 inElement = null;
1687 }
1688 else if (ElementType.ShadowRoot == contextType.elementType)
1689 {
1690 if (true == UseOttoMagic)
1691 MagicHelper.SwitchToShadowRoot(contextType.gPalElement.Css);
1692 else if (true == UsePuppeteer)
1693 PuppeteerClient.SwitchToShadowRoot(contextType.gPalElement.Css).Execute();
1694
1695 // scope to THIS step's shadow root rather than the last one added to the unit of work, so a
1696 // path with more than one shadow root walks through each in turn instead of collapsing them
1697 inElement = contextType.gPalElement;
1698 }
1699 }
1700
1701 // Find the iframe
1702 ElementHelper.FindWebElements(
1703 this, CurrentUOW, selector, out _, out var tempElems, CurrentUOW.ShadowRoot);
1704
1705 if (tempElems?.Count > 0)
1706 {
1707 var iframeElement = new UnitOfWork.WebElementWithType(
1708 selector, tempElems[0], ElementType.IFrame);
1709
1710 CurrentUOW.ContextPath ??= new List<UnitOfWork.WebElementWithType>();
1711 CurrentUOW.ContextPath.Add(iframeElement);
1712 }
1713
1714 return this;
1715 }
1716
1748 {
1749 shadowDomSelector.ElementsFoundAndMatchedCount = 0;
1750 shadowDomSelector.WebSelectorFoundResults = null;
1751 shadowDomSelector.WebSelectorMatchedResults = null;
1752
1753 if (CurrentUOW.ActionCalled)
1754 {
1755 var previousShadowRoot = CurrentUOW.ShadowRoot;
1756 var previousContextPath = CurrentUOW.ContextPath;
1757
1758 CurrentUOW = new UnitOfWork
1759 {
1760 Browser = this,
1761 ShadowRoot = previousShadowRoot,
1762 ContextPath = previousContextPath
1763 };
1764
1765 persistentUOW = new UnitOfWork { Browser = this };
1766 }
1767
1768 BrowserSettings.CurrentSelector = shadowDomSelector;
1769 CurrentUOW.InSelectorList.Add(shadowDomSelector);
1770
1771 if (true == UseOttoMagic)
1772 MagicHelper.SwitchToDefaultContent();
1773 else if (true == UsePuppeteer)
1774 PuppeteerClient.SwitchToDefaultContent().Execute();
1775 else
1776 this.BrowserDriver.SwitchTo().DefaultContent();
1777
1778 GPALElement inElement = null;
1779
1780 // Now walk the (possibly filtered) steps
1781 foreach (UnitOfWork.WebElementWithType contextType in CurrentUOW.ContextPath)
1782 {
1783 if (ElementType.IFrame == contextType.elementType)
1784 {
1785 if (true == UseOttoMagic)
1786 MagicHelper.InFrame(contextType.gPalElement.Css);
1787 else if (true == UsePuppeteer)
1788 PuppeteerClient.SwitchToFrame(contextType.gPalElement.Css).Execute();
1789 else
1790 BrowserDriver.SwitchTo().Frame((IWebElement)contextType.gPalElement.WebElement);
1791
1792 // a search context belongs to one document - anything scoped in the frame we just left is
1793 // stale, and a shadow root from the parent throws "no such shadow root" if reused here
1794 inElement = null;
1795 }
1796 else if (ElementType.ShadowRoot == contextType.elementType)
1797 {
1798 if (true == UseOttoMagic)
1799 MagicHelper.SwitchToShadowRoot(contextType.gPalElement.Css);
1800 else if (true == UsePuppeteer)
1801 PuppeteerClient.SwitchToShadowRoot(contextType.gPalElement.Css).Execute();
1802
1803 // scope to THIS step's shadow root rather than the last one added to the unit of work, so a
1804 // path with more than one shadow root walks through each in turn instead of collapsing them
1805 inElement = contextType.gPalElement;
1806 }
1807 }
1808
1809 ElementHelper.FindWebElements(
1810 this, CurrentUOW, shadowDomSelector, out _, out var tempElems, inElement);
1811
1812 if (tempElems?.Count > 0)
1813 {
1814 var shadowElement = new UnitOfWork.WebElementWithType(
1815 shadowDomSelector, tempElems[0], ElementType.ShadowRoot);
1816
1817 CurrentUOW.ShadowRoot = tempElems[0];
1818 CurrentUOW.ShadowRoot.IsShadowRoot = true;
1819
1820 CurrentUOW.ContextPath ??= new List<UnitOfWork.WebElementWithType>();
1821 CurrentUOW.ContextPath.Add(shadowElement);
1822
1823 // Existing shadow root plumbing (unchanged)
1824 if (true == UseOttoMagic)
1825 {
1826 // CurrentUOW.ShadowRoot.ShadowRoot = MagicHelper.GetShadowRoot(shadowDomSelector.SelectorPaths[0].SelectorPath); // this doesn't make sense, nothing useful can be returned
1827 // tempElems[0].ShadowRoot = CurrentUOW.ShadowRoot;
1828 }
1829 else if (true == UsePuppeteer)
1830 {
1831 //CurrentUOW.ShadowRoot.ShadowRoot = PuppeteerClient.GetShadowRoot(tempElems[0].Css).Execute<GPALElement>();
1832 //tempElems[0].ShadowRoot = CurrentUOW.ShadowRoot;
1833 }
1834 else if (true == UseSelenium)
1835 {
1836 ISearchContext shadowRoot = null;
1837
1838 // GetShadowRoot is webdriver's own call for this and throws only when the host genuinely has
1839 // no open root. asking javascript instead cannot tell a closed root from a script that did not
1840 // run, because ExecuteJavaScriptObj swallows its own failures and hands back null either way -
1841 // and a null here marks an open root closed, which sends every search inside it back the host
1842 // instead of the children.
1843 try
1844 {
1845 shadowRoot = ((IWebElement)tempElems[0].WebElement).GetShadowRoot();
1846 }
1847 catch
1848 {
1849 // closed, handled below
1850 }
1851
1852 if (null != shadowRoot)
1853 CurrentUOW.ShadowRoot.WebElement = shadowRoot; // dynamic - the search dispatches to it at runtime
1854 else
1855 {
1856 CurrentUOW.ShadowRoot.IsClosedShadowRoot = true;
1857 // a closed shadow root is never handed to webdriver - arguments[0].shadowRoot is null and
1858 // GetShadowRoot throws. leave the host element in place so the root still has a real
1859 // position to click relative to, and so searches inside it stay scoped to the host rather
1860 // than silently falling back to the whole document and matching something unrelated.
1861 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Closed shadow root for selector [{shadowDomSelector.Name}]. Will return host element for searches inside.", this, GPALObjectType.Browser);
1862 }
1863 }
1864 }
1865
1866 return this;
1867 }
1868
1874 {
1875 CurrentUOW.ShadowRoot = null;
1876 CurrentUOW.ContextPath?.Clear();
1877
1878 if (true == UseOttoMagic)
1879 MagicHelper.SwitchToDefaultContent();
1880 else if (true == UsePuppeteer)
1881 PuppeteerClient.SwitchToDefaultContent().Execute();
1882 else if (true == UseSelenium)
1883 this.BrowserDriver.SwitchTo().DefaultContent();
1884
1885 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, "Returned to main document", CurrentUOW);
1886 return this;
1887 }
1888
1900 {
1901 CurrentUOW.NextPageButton = nextPageButtonselector;
1902 BrowserSettings.CurrentSelector = nextPageButtonselector; // for use by .WithHardware
1903 return this;
1904 }
1905
1912 {
1913 CurrentUOW.PageCount = pageCount;
1914 return this;
1915 }
1916
1923 public IAllowWithPagesAndFetch WithTokensFrom(IGPALGrid<string> inputGrid)
1924 {
1925 CurrentUOW.FetchTokens = inputGrid;
1926 return this;
1927 }
1928
1931 {
1932 List<IGPALGrid<string>> tokenList = FileHelper.TokenizeFile(CurrentUOW, inputFile);
1933
1934 CurrentUOW.FetchTokens = 0 == tokenList?.Count ? null : tokenList[0];
1935 return this;
1936 }
1937
1940 {
1941 DatabaseHelper.TokenizeDatabase(CurrentUOW, inputDatabase);
1942
1943 CurrentUOW.FetchTokens = CurrentUOW.InputDatabase?.Tokens;
1944 return this;
1945 }
1946
1949 {
1950 IGPALGrid<string> grid = GPAL.GridForType<string>();
1951
1952 grid.AddRow(new List<string>(tokens?.Split(',') ?? new string[0]));
1953 CurrentUOW.FetchTokens = grid;
1954 return this;
1955 }
1956
1963 public IAllowAfterAnySelector CallAfterFillIn(Browser.CallAfterFillInDelegate callAfterFillIn)
1964 {
1965 CurrentUOW.CallAfterFillIn = callAfterFillIn;
1966
1967 return this;
1968 }
1969
1979 public IAllowAfterAnySelector CallIfFound(Browser.CallIfDelegate callIfFoundDelegate)
1980 {
1981 CurrentUOW.CallIfFound.Add(callIfFoundDelegate);
1982
1983 return this;
1984 }
1985
1995 public IAllowAfterAnySelector CallIfNotFound(Browser.CallIfDelegate callIfNotFound)
1996 {
1997 CurrentUOW.CallIfNotFound.Add(callIfNotFound);
1998
1999 return this;
2000 }
2001
2007 {
2008 BrowserSettings.WaitForWindowTimeoutInSeconds = waitTimeInSeconds;
2009 return this;
2010 }
2011
2021 {
2022 persistentUOW.CallIfFound.Add(persistentCallIfFound);
2023
2024 return this;
2025 }
2026
2036 {
2037 persistentUOW.CallIfNotFound.Add(persistentCallIfNotFound);
2038
2039 return this;
2040 }
2041
2064 {
2065 BrowserSettings.Credentials = credentials;
2066
2067 return this;
2068 }
2069 public IAllowBrowserSettingsOrGoTo CallOnFail(CallOnFailDelegate callOnFail)
2070 {
2071 BrowserSettings.CallOnFail.Add(callOnFail);
2072
2073 return this;
2074 }
2084 internal CallIfStatus RaiseOnFail(GPALFailure failure, string detail)
2085 {
2086 CallIfStatus handled = CallIfStatus.NotHandled;
2087 List<CallOnFailDelegate> handlers = 0 < BrowserSettings.CallOnFail.Count ? BrowserSettings.CallOnFail : GPAL.GPALSettings.CallOnFailHandlers;
2088
2089 foreach (CallOnFailDelegate handler in handlers)
2090 {
2091 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Invoking CallOnFail [{handler.Method.Name}] for [{failure}]", this, GPALObjectType.Browser);
2092
2093 handled = handler(this, failure, detail);
2094
2095 if (CallIfStatus.Terminate == handled)
2096 {
2097 string str = $"CallOnFail handler [{handler.Method.Name}] requested program termination.";
2098 GPAL.PublishSimpleEvent(GPALEventType.INFO, str, this, GPALObjectType.Browser);
2099 throw new GPALException($"{GPAL.MyMethodName()}: " + str);
2100 }
2101
2102 if (CallIfStatus.Handled == handled)
2103 break;
2104 }
2105
2106 return handled;
2107 }
2119 {
2120 // when would we ever add another persistent uow?
2121 if (true == CurrentUOW.ActionCalled)
2122 {
2123 // Preserve context for new UnitOfWork
2124 var iframeRoot = CurrentUOW.ContextPath;
2125 var shadowRoot = CurrentUOW.ShadowRoot;
2126
2127 CurrentUOW = new UnitOfWork
2128 {
2129 ContextPath = iframeRoot,
2130 ShadowRoot = shadowRoot,
2131 Browser = this
2132 };
2133
2134 persistentUOW = new UnitOfWork() { Browser = this };
2135 }
2136
2137 selector.Browser = this;
2138 selector.DeleteMe = false; // Remove() latches this on and nothing else clears it, so a reused persistent selector would be swept on its first maintenance pass
2139 selector.RemovedMethods.Clear(); // a handler that took itself out of play in an earlier workflow is back in play for this one
2140
2141 ResolveInteractionType(selector);
2142
2143 // if we are reusing, we can't use cached results in a new uow
2144 selector.WebSelectorFoundResults = null;
2145 selector.WebSelectorMatchedResults = null;
2146
2147 persistentUOW.WithSelectorList.Add(selector); // CAVEAT: there is no way to know when this is defined that we are headless, so this selector could be hardware emulaiton which won't work
2148 return this;
2149 }
2150
2158 {
2159 persistentUOW.WithSelectorList.Last().selectorSettings.InFrame = iFrame;
2160 return this;
2161 }
2162
2169 {
2170 if (true == CurrentUOW.GetGridCalled)
2171 {
2172 CurrentUOW.GetGridCalled = false;
2173 CurrentUOW.HeaderList.Clear();
2174 }
2175 CurrentUOW.HeaderList.Add(header);
2176 return this;
2177 }
2178
2187 public IAllowAfterAnySelectorExceptWithAll WithAllThatMatch(int rowCount = int.MaxValue)
2188 {
2189 // if we haven't started yet, this is called on the browser to set the per browser value
2190 // if called after an action, it's the same thing, it's for the whole browser
2191 if (0 == CurrentUOW.WithSelectorList.Count || true == CurrentUOW.ActionCalled)
2192 BrowserSettings.WithAllThatMatch = rowCount;
2193 else
2194 CurrentUOW.WithAllThatMatch = rowCount;
2195 return this;
2196 }
2197
2205 {
2206 int screenWidth = Puppeteer.GetScreenWidthInt();
2207 int screenHeight = Puppeteer.GetScreenHeightInt();
2208
2209 if (100 > windowSize.Width || 100 > windowSize.Height)
2210 {
2211 int widthToUse = Math.Min(windowSize.Width, 100);
2212 int heightToUse = Math.Min(windowSize.Height, 100);
2213
2214 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Requested browser sizes [{windowSize.Width}x{windowSize.Height}] cannot be less than 100. Using [{widthToUse}x{heightToUse}].", this, GPALObjectType.Browser);
2215
2216 windowSize.Width = widthToUse;
2217 windowSize.Height = heightToUse;
2218 }
2219 else if (screenWidth < windowSize.Width || screenHeight < windowSize.Height)
2220 {
2221 int widthToUse = Math.Min(windowSize.Width, screenWidth);
2222 int heightToUse = Math.Min(windowSize.Height, screenHeight);
2223
2224 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Requested browser size [{windowSize.Width}x{windowSize.Height}] exceeds screen size [{screenWidth}x{screenHeight}]. Using [{widthToUse}x{heightToUse}].", this, GPALObjectType.Browser);
2225
2226 windowSize.Width = widthToUse;
2227 windowSize.Height = heightToUse;
2228
2229 }
2230
2231 BrowserSettings.WindowSize = windowSize;
2232
2233 return this;
2234 }
2235
2241 {
2242 get
2243 {
2244 // set this in the selector by calling it fluently
2245 _ = BrowserSettings.CurrentSelector.WithHardware;
2246 return this;
2247 }
2248 }
2249
2256 {
2257 get
2258 {
2259 // set this in the selector by calling it fluently
2260 _ = BrowserSettings.CurrentSelector.WithJavaScript;
2261 return this;
2262 }
2263 }
2264
2268 public IAllowWithPagesAndGridActions WithInfiniteScroll // TODO: add aprameter ScrolThenScrape, ScrapeThenScroll to indicate how the pave behaves (1 page in mem or all pages)
2269 {
2270 get
2271 {
2272 CurrentUOW.InfiniteScroll = true;
2273 return this;
2274 }
2275 }
2276 #region ACTION GETTERS
2282 {
2283 get
2284 {
2285 InAction(false, false);
2286 // selenium doesn't know what happened
2287 // BrowserHelper.ExecuteJavaScript(this, "return history.back();");
2288 if (true == BrowserSettings.UseOttoMagic)
2289 MagicHelper.Back();
2290 else if (true == BrowserSettings.UsePuppeteer)
2291 PuppeteerClient.Back().Execute();
2292 else
2293 BrowserDriver.Navigate().Back();
2294
2295 return this;
2296 }
2297 }
2298
2303 {
2304 get
2305 {
2306 InAction(false, false);
2307 // selenium doesn't know what happened
2308 // BrowserHelper.ExecuteJavaScript(this, "return history.forward();");
2309 if (true == BrowserSettings.UseOttoMagic)
2310 MagicHelper.Forward();
2311 else if (true == BrowserSettings.UsePuppeteer)
2312 PuppeteerClient.Forward().Execute();
2313 else
2314 BrowserDriver.Navigate().Forward();
2315
2316 return this;
2317 }
2318 }
2319
2324 {
2325 get
2326 {
2327 InAction(false, false);
2328
2329 if (true == BrowserSettings.UseOttoMagic)
2330 {
2331 MagicHelper.Refresh();
2332 Thread.Sleep(500); // for some reason we check document ready before it even refreshes
2333 }
2334 else if (true == UsePuppeteer)
2335 PuppeteerClient.Refresh().Execute();
2336 else
2337 BrowserDriver.Navigate().Refresh();
2338
2339 BrowserHelper.CheckDocumentReady(this);
2340
2341 return this;
2342 }
2343 }
2344
2348 {
2349 get
2350 {
2351 InAction(false, false);
2352
2353 if (true == BrowserSettings.UseOttoMagic)
2354 MagicHelper.PageDown();
2355 else if (true == UsePuppeteer)
2356 PuppeteerClient.PageDown().Execute();
2357 else
2358 ScrollPage("down");
2359
2360 return this;
2361 }
2362 }
2363
2367 {
2368 get
2369 {
2370 InAction(false, false);
2371
2372 if (true == BrowserSettings.UseOttoMagic)
2373 MagicHelper.PageEnd();
2374 else if (true == UsePuppeteer)
2375 PuppeteerClient.PageEnd().Execute();
2376 else
2377 ScrollToPosition("end");
2378
2379 return this;
2380 }
2381 }
2382
2386 {
2387 get
2388 {
2389 InAction(false, false);
2390
2391 if (true == BrowserSettings.UseOttoMagic)
2392 MagicHelper.PageTop(); // Assuming you have a PageUp() method in MagicHelper
2393 else if (true == UsePuppeteer)
2394 PuppeteerClient.PageTop().Execute();
2395 else
2396 ScrollToPosition("top");
2397
2398 return this;
2399 }
2400 }
2401
2405 {
2406 get
2407 {
2408 InAction(false, false);
2409
2410 if (true == BrowserSettings.UseOttoMagic)
2411 MagicHelper.PageUp();
2412 else if (true == UsePuppeteer)
2413 PuppeteerClient.PageUp().Execute();
2414 else
2415 ScrollPage("up");
2416
2417 return this;
2418 }
2419 }
2420
2424 {
2425 get
2426 {
2427 BrowserSettings.FullScreen = false;
2428 BrowserSettings.Maximize = false;
2429 BrowserSettings.Minimize = false;
2430
2431 //InAction(false, false);
2432
2433 if (null != BrowserSettings.Process || 0 != BrowserSettings.ServiceDriverPid)
2434 if (true == BrowserSettings.UseOttoMagic)
2435 {
2436 if (true == BrowserSettings.FullScreen)
2437 MagicHelper.Normal();
2438 else
2439 MagicHelper.Restore();
2440 }
2441 else if (true == BrowserSettings.UsePuppeteer)
2442 {
2443 if (true == BrowserSettings.FullScreen)
2444 PuppeteerClient.Normal().Execute();
2445 else
2446 PuppeteerClient.Restore().Execute();
2447 }
2448 else if (null != BrowserSettings.BrowserDriver)
2449 BrowserSettings.BrowserDriver.Manage().Window.Size = BrowserSettings.SavedWindowSize;
2450
2451 return this;
2452 }
2453 }
2454
2459 {
2460 get
2461 {
2462 BrowserSettings.Minimize = false;
2463 BrowserSettings.Maximize = false;
2464 BrowserSettings.FullScreen = true;
2465
2466 //InAction(false, false);
2467
2468 if (null != BrowserSettings.Process || 0 != BrowserSettings.ServiceDriverPid)
2469 if (true == BrowserSettings.UseOttoMagic)
2470 {
2471 BrowserSettings.SavedWindowSize = new Size(MagicHelper.WindowOuterWidth(), MagicHelper.WindowOuterHeight());
2472 MagicHelper.FullScreen();
2473 }
2474 else if (true == BrowserSettings.UsePuppeteer)
2475 {
2476 BrowserSettings.SavedWindowSize = new Size(PuppeteerClient.WindowOuterWidth().Execute<int>(), PuppeteerClient.WindowOuterHeight().Execute<int>());
2477 MagicHelper.FullScreen();
2478 }
2479 else if (null != BrowserSettings.BrowserDriver)
2480 {
2481 BrowserSettings.SavedWindowSize = BrowserSettings.BrowserDriver.Manage().Window.Size;
2482 BrowserSettings.BrowserDriver?.Manage().Window.FullScreen();
2483 }
2484
2485 return this;
2486 }
2487 }
2488
2492 {
2493 get
2494 {
2495 BrowserSettings.Maximize = true;
2496 BrowserSettings.Minimize = false;
2497 BrowserSettings.FullScreen = false;
2498
2499 //InAction(false, false);
2500
2501 if (null != BrowserSettings.Process || 0 != BrowserSettings.ServiceDriverPid)
2502 if (true == BrowserSettings.UseOttoMagic)
2503 {
2504 if (0 == BrowserSettings.WindowSize.Width && 0 == BrowserSettings.WindowSize.Height)
2505 BrowserSettings.SavedWindowSize = new Size(MagicHelper.WindowOuterWidth(), MagicHelper.WindowOuterHeight());
2506 MagicHelper.Maximize();
2507 }
2508 else if (true == BrowserSettings.UsePuppeteer)
2509 {
2510 if (0 == BrowserSettings.WindowSize.Width && 0 == BrowserSettings.WindowSize.Height)
2511 BrowserSettings.SavedWindowSize = new Size(PuppeteerClient.WindowOuterWidth().Execute<int>(), PuppeteerClient.WindowOuterHeight().Execute<int>());
2512 PuppeteerClient.Maximize().Execute();
2513 }
2514 else if (null != BrowserSettings.BrowserDriver)
2515 {
2516 // get current window size if one was not defined on startup
2517 BrowserSettings.SavedWindowSize = BrowserSettings.BrowserDriver.Manage().Window.Size;
2518 BrowserSettings.BrowserDriver?.Manage().Window.Maximize();
2519 }
2520
2521 return this;
2522 }
2523 }
2524
2528 {
2529 get
2530 {
2531 BrowserSettings.Minimize = true;
2532 BrowserSettings.Maximize = false;
2533 BrowserSettings.FullScreen = false;
2534
2535 //InAction(false);
2536
2537 if (null != BrowserSettings.Process || 0 != BrowserSettings.ServiceDriverPid)
2538 if (true == BrowserSettings.UseOttoMagic)
2539 {
2540 BrowserSettings.SavedWindowSize = new Size(MagicHelper.WindowOuterWidth(), MagicHelper.WindowOuterHeight());
2541 MagicHelper.Minimize();
2542 }
2543 else if (true == BrowserSettings.UsePuppeteer)
2544 {
2545 BrowserSettings.SavedWindowSize = new Size(PuppeteerClient.WindowOuterWidth().Execute<int>(), PuppeteerClient.WindowOuterHeight().Execute<int>());
2546 PuppeteerClient.Minimize().Execute();
2547 }
2548 else if (null != BrowserSettings.BrowserDriver)
2549 {
2550 // get current window size if one was not defined on startup
2551 BrowserSettings.SavedWindowSize = BrowserSettings.BrowserDriver.Manage().Window.Size;
2552 BrowserSettings.BrowserDriver?.Manage().Window.Minimize();
2553 }
2554
2555 return this;
2556 }
2557 }
2558 #endregion ACTION GETTERS
2559 #endregion <Browser Settings>
2560 #region <Browser Actions>
2566 public IAllowBrowserSettingsOrGoTo WithObeyRobotsTxt(bool trueFalse = false)
2567 {
2568 BrowserSettings.ObeyRobotsTxt = trueFalse;
2569 return this;
2570 }
2571
2577 {
2578 BrowserSettings.RespectRobotMetaTags = trueFalse;
2579 return this;
2580 }
2581
2582
2592 {
2593 try
2594 {
2595 BrowserSettings.UseHeadless = true;
2596
2597 // configure headless options
2598 BrowserSettings.OpenPDFExternally = false; // these two settings will download any pdf file to the specified "download.default_directory" (see WithDownloadDirectory and SetDownloadFilename)
2599 BrowserSettings.PromptForDownload = false;
2600
2601 BrowserSettings.OpenInTab = false;
2602 GoTo(URL);
2603 }
2604 catch (GPALException)
2605 {
2606 throw;
2607 }
2608 catch (Exception ex)
2609 {
2610 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to GET from [{URL}]", this, GPALObjectType.Browser, ex);
2611 }
2612
2613 return this;
2614 }
2615
2624 {
2625 bool firstRunSave = firstRun;
2626
2627 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Visiting [{URL?.Url}]", this, GPALObjectType.Browser);
2628
2629 if (BrowserType.FireFox == BrowserSettings.BrowserType &&
2630 (AutomationEngine.PuppeteerPort == AutomationEngine || AutomationEngine.PuppeteerPortHW == AutomationEngine ||
2631 AutomationEngine.PuppeteerPipe == AutomationEngine || AutomationEngine.PuppeteerPipeHW == AutomationEngine))
2632 {
2633 AutomationEngine steppedDownTo = FirefoxStepDown(BrowserSettings.AutomationEngine);
2634
2635 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Firefox cannot be driven with [{BrowserSettings.AutomationEngine}], so this browser runs on [{steppedDownTo}]", this, GPALObjectType.Browser);
2636
2637 AutomationEngine = steppedDownTo;
2638 }
2639
2640 URL.ForUrl(MagicHelper.GetFullUrl(URL?.Url, this, out _areRobotsAllowed));
2641
2642 if (false == AreRobotsAllowed && true == BrowserSettings.ObeyRobotsTxt)
2643 {
2644 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Visiting [{URL?.Url}] is disallowed by robots.txt and your request to honor it. Not going to URL. Workflow will fail.", this, GPALObjectType.Browser);
2645 URL.ForUrl("https://google.com");
2646 }
2647
2648 // where we were, so a navigation that turns out to be a download rather than a page can put both the
2649 // browser and what we say about it back
2650 string priorUrl = BrowserSettings.CurrentURL;
2651 GPALUrl priorGPALUrl = BrowserSettings.CurrentGPALUrl;
2652 TabTuple priorTab = true == UseOttoMagic ? new TabTuple(GetActiveTabId(), priorUrl) : null;
2653 string priorHandle = null; // the tab we are leaving, by id, filled in only when we open a new one
2654 int tabsBefore = -1;
2655
2656 BrowserSettings.CurrentURL = URL?.Url;
2657 BrowserSettings.CurrentGPALUrl = URL;
2658
2659 // keep this in, new page, new uow
2660 CurrentUOW = new UnitOfWork() { Browser = this }; // so an immediate .inshadowdom or .inframe starts new and doesn't carry forward
2661
2662 // DO NOT PASS IN URL for first run startup, it doesn't like it in headless mode puppeteer
2663 if (true == InAction(false, false, null)) // actions have ended but we do have to signal how to denote a new UOW, too
2664 {
2665 try
2666 {
2667 if (true == BrowserSettings.WaitOnNetworkIdle && true == UseSelenium)
2668 ApplyNetworkMonitor();
2669
2670 CheckAndApplyStealth();
2671 SetReferrer();
2672 SetUserAgent();
2673
2674 // said once, before the first navigation, because every mechanism that carries a credential
2675 // holds for the session: a header keeps being sent, and a challenge the browser has already
2676 // answered stays answered for that origin
2677 if (null != BrowserSettings.Credentials && null == BrowserSettings.CredentialsPresented)
2678 BrowserHelper.PresentCredentials(this, URL);
2679
2680 BrowserHelper.CheckDocumentReady(this);
2681
2682 // do we have an open browser? no
2683 if (null == BrowserLaunched || false == BrowserSettings.OpenInTab)
2684 {
2685
2686 if (true == UseOttoMagic)
2687 {
2688 //if (false == firstRunSave) // url passed on command line
2689 {
2690 //MagicHelper.InMainDom();
2691 TabTuple tabTuple = MagicHelper.GoTo(URL);
2692 UpdateActiveTab(tabTuple);
2693 }
2694 //else
2695 // UpdateActiveTab(MagicHelper.NextTab()); // get the active tab tuple, no other tabs, will return current
2696 }
2697 else if (true == UsePuppeteer)
2698 {
2699 PuppeteerClient.SwitchToDefaultContent().Execute();
2700 //if (false == firstRunSave) // url passed on command line
2701 PuppeteerClient.GoTo(URL).Execute();
2702 }
2703 else
2704 {
2705 BrowserDriver.SwitchTo().DefaultContent();
2707 }
2708 if (null == BrowserLaunched)
2709 {
2710 BrowserLaunched = DateTime.Now;
2711 tabsWeOpened++;
2712 }
2713 }
2714 else
2715 {
2716 // what the browser has before we add ours, so we can tell afterwards whether ours survived
2717 tabsBefore = RealTabCount();
2718 priorHandle = true == UseSelenium ? BrowserDriver.CurrentWindowHandle : null; // to get back by id, not by position
2719 tabsWeOpened++;
2720 if (true == UseOttoMagic)
2721 {
2722 TabTuple tabTuple = MagicHelper.NewTab(URL);
2723 if (true == string.IsNullOrEmpty(tabTuple?.Url))
2724 tabTuple.Url = URL;
2725 GoToTab(tabTuple);
2726 }
2727 else if (true == UsePuppeteer)
2728 {
2729 PuppeteerClient.NewTab().Execute();
2730 _ = NextTab;
2731 PuppeteerClient.GoTo(URL).Execute();
2732 }
2733 else
2734 {
2735 BrowserDriver.SwitchTo().NewWindow(WindowType.Tab);
2736 RecordTabWindow(BrowserDriver.CurrentWindowHandle, priorHandle); // a new tab of the window we were in
2737 //_ = NextTab;
2739 }
2740 }
2741
2742 // the browser saying the page never loaded. Selenium leaves ServerResponseCode at -1, puppeteer
2743 // answers Page.navigate with an errorText, and OttoMagic reports it through get-ready-status,
2744 // which is where its own raise happens. GPAL fails forward from here either way: the workflow
2745 // carries on against a page that is not there, its selectors find nothing and CallIfNotFound
2746 // fires, unless a CallOnFail handler decides otherwise
2747 bool navigationFailed = (true == UseSelenium && -1 == BrowserSettings.ServerResponseCode)
2748 || (true == UsePuppeteer && false == string.IsNullOrEmpty(PuppeteerCommunicator.LastNavigationError));
2749
2750 if (true == UseSelenium && -1 == BrowserSettings.ServerResponseCode)
2751 RaiseOnFail(GPALFailure.Navigation, $"[{URL?.Url}] did not load.");
2752 else if (true == UsePuppeteer && false == string.IsNullOrEmpty(PuppeteerCommunicator.LastNavigationError))
2753 RaiseOnFail(GPALFailure.Navigation, $"[{URL?.Url}] did not load. [{PuppeteerCommunicator.LastNavigationError}]");
2754
2755 // a url that downloads gives back no page at all, and a tab opened for one has no document to
2756 // fall back on, so the browser disposes it and tells nobody. Only the difference between the
2757 // two readings is trusted, which leaves any tab we did not open sitting in both and cancelling
2758 if (-1 != tabsBefore && RealTabCount() == tabsBefore)
2759 BackOutOfSelfClosedTab(URL, priorUrl, priorGPALUrl, priorTab, priorHandle);
2760 else
2761 {
2762 BrowserHelper.CheckDocumentReady(this);
2763
2764 // use shortcuiting, if it's not set, why check?
2765 if (true == BrowserSettings.RespectRobotMetaTags && false == UrlHelper.CheckRobotMetaTags(URL, this))
2766 {
2767 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Visiting [{BrowserSettings.CurrentURL}] is disallowed by robot meta tags and your request to honor them..");
2768 URL.ForUrl("https://google.com");
2769
2770 if (true == UseOttoMagic)
2771 {
2772 //MagicHelper.InMainDom();
2773 TabTuple tabTuple = MagicHelper.GoTo(URL);
2774 UpdateActiveTab(tabTuple);
2775 }
2776 else if (true == UsePuppeteer)
2777 {
2778 PuppeteerClient.SwitchToDefaultContent().Execute();
2779 PuppeteerClient.GoTo(URL).Execute();
2780 }
2781 else
2782 {
2783 BrowserDriver.SwitchTo().DefaultContent();
2785 }
2786 }
2787 else
2788 {
2789 BrowserSettings.CurrentURL = URL.Url;
2790
2791 // RaiseOnFail says nothing of its own when no CallOnFail handler is registered, so
2792 // without this a navigation that ended on the browser's error page read as a load
2793 if (true == navigationFailed)
2794 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{BrowserSettings.CurrentURL}] did not load. Continuing against whatever the browser is showing.");
2795 else
2796 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{BrowserSettings.CurrentURL}] loaded.");
2797 }
2798
2799 BrowserSettings.GPALUrlList.Add(URL);
2800
2801 // there is a document now, which is what the dialog override needs before it can be put
2802 // on the page in front of us rather than only on the ones still to load
2803 _navigated = true;
2804 }
2805 }
2806 catch (GPALException)
2807 {
2808 throw;
2809 }
2810 catch (Exception ex)
2811 {
2812 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Failed to navigate to [{URL?.Url}]", this, GPALObjectType.Browser, ex);
2813 // internet explorer throws a timeout even tho it does navigate
2814 }
2815
2816 //int retries = 3;
2817
2818 //if (false == URL.Contains(GetSetCurrentUrl()) && 0 < retries--)
2819 // Thread.Sleep(500);
2820
2821 BrowserSettings.OpenInTab = false; // .WithNewTab applies only once, so it must appear before each .GoTo
2822 }
2823 else // we had a driver initialization falure
2824 throw new GPALException("Driver initialization error. Please see mesages.");
2825
2826 return this;
2827 }
2828
2832 private void ApplyGoogleReferrer()
2833 {
2834 if (true == UseOttoMagic)
2836 else if (true == UsePuppeteer)
2837 PuppeteerCommunicator.StealthOverrideReferrer().GetAwaiter().GetResult();
2838 else if (BrowserType.FireFox != BrowserSettings.BrowserType)
2839 ((OpenQA.Selenium.Chromium.ChromiumDriver)BrowserDriver).ExecuteCdpCommand("Network.setExtraHTTPHeaders", new Dictionary<string, object>
2840 {
2841 {
2842 "headers", new Dictionary<string, object>
2843 {
2844 { "Referer", "https://www.google.com/" } // NOTE: the referrer misspelling is baked into the http/1.0 protocol spec
2845 }
2846 }
2847 });
2848
2849 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Stealth Google Referrer applied.", this, GPALObjectType.Browser);
2850 }
2855 private void ApplyNetworkMonitor()
2856 {
2857 string networkMonitorScript = @"
2858 (function() {
2859 if (!window.__gpalNetworkMonitor) {
2860 window.__gpalNetworkMonitor = { inflight: 0 };
2861
2862 const origOpen = XMLHttpRequest.prototype.open;
2863 XMLHttpRequest.prototype.open = function() {
2864 this.addEventListener('loadstart', () => { window.__gpalNetworkMonitor.inflight++; });
2865 this.addEventListener('loadend', () => { window.__gpalNetworkMonitor.inflight--; });
2866 origOpen.apply(this, arguments);
2867 };
2868
2869 const origFetch = window.fetch;
2870 window.fetch = function(...args) {
2871 window.__gpalNetworkMonitor.inflight++;
2872 return origFetch(...args).finally(() => { window.__gpalNetworkMonitor.inflight--; });
2873 };
2874 }
2875 })();
2876 ";
2877
2878 if (UsePuppeteer)
2879 {
2880 PuppeteerCommunicator.EnableFetchScriptInjection(networkMonitorScript, null)
2881 .GetAwaiter().GetResult();
2882 }
2883 else if (BrowserType.FireFox == BrowserSettings.BrowserType)
2884 {
2885 // Firefox has no CDP preload script support — inject post-load via ExecuteScript instead.
2886 // The guard (function(){if(!window.__gpalNetworkMonitor)...}) makes re-injection safe on each navigation.
2887 ((OpenQA.Selenium.IJavaScriptExecutor)BrowserDriver).ExecuteScript(networkMonitorScript);
2888 }
2889 else
2890 {
2891 ((OpenQA.Selenium.Chromium.ChromiumDriver)BrowserDriver).ExecuteCdpCommand(
2892 "Page.addScriptToEvaluateOnNewDocument",
2893 new Dictionary<string, object>
2894 {
2895 { "source", networkMonitorScript }
2896 }
2897 );
2898 }
2899
2900 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Network idle monitor script injected.", this, GPALObjectType.Browser);
2901 }
2906 private void ApplyCDPProtection()
2907 {
2908 // hide CDP port detection by adding a script that will always run when a page loads (before the website loads any detection scripts)
2909 // https://www.reddit.com/r/webscraping/comments/1evht3i/help_in_bypassing_cdp_detection/
2910 // https://github.com/rebrowser/rebrowser-patches
2911 // https://github.com/kaliiiiiiiiii/brotector/blob/master/brotector.js#L377 // string detections
2912 string antiAntiBotScript = @"
2913 (function() {
2914 var originalError = Error;
2915 Object.defineProperty(Error.prototype, 'stack', {
2916 configurable: false,
2917 enumerable: true,
2918 writable: true,
2919 value: (function() {
2920 try {
2921 throw new originalError();
2922 } catch (e) {
2923 return e.stack;
2924 }
2925 })()
2926 });
2927
2928 window.Error = new Proxy(originalError, {
2929 construct(target, args) {
2930 var instance = new target(...args);
2931 return Object.freeze(instance);
2932 }
2933 });
2934 })();
2935 ";
2936
2937 if (true == UsePuppeteer)
2938 {
2939 PuppeteerCommunicator.EnableFetchScriptInjection(antiAntiBotScript, null).GetAwaiter().GetResult();
2940 }
2941 else if (true == UseSelenium && BrowserType.FireFox != BrowserSettings.BrowserType)
2942 {
2943 ((OpenQA.Selenium.Chromium.ChromiumDriver)BrowserDriver).ExecuteCdpCommand("Page.addScriptToEvaluateOnNewDocument", new Dictionary<string, object>
2944 {
2945 { "source", antiAntiBotScript }
2946 });
2947 }
2948
2949 string antiAntiBotScript2 = "Object.defineProperty(navigator, 'webdriver', { get: () => undefined })";
2950 if (true == UseSelenium && BrowserType.FireFox != BrowserSettings.BrowserType)
2951 {
2952 ((OpenQA.Selenium.Chromium.ChromiumDriver)BrowserDriver).ExecuteCdpCommand("Page.addScriptToEvaluateOnNewDocument", new Dictionary<string, object>
2953 {
2954 { "source", antiAntiBotScript2 }
2955 });
2956 }
2957 else if (true == UsePuppeteer)
2958 {
2959 PuppeteerCommunicator.EnableFetchScriptInjection(antiAntiBotScript2, null).GetAwaiter().GetResult();
2960 }
2961
2962 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Stealth CDP protection applied.", this, GPALObjectType.Browser);
2963 }
2970 private void ApplyToStringOverride()
2971 {
2972 string toStringOverrideScript = @"
2973 (function() {
2974 // Store the original toString
2975 const originalToString = Object.prototype.toString;
2976
2977 // Create a proxy handler to intercept toString calls
2978 const handler = {
2979 apply: function(target, thisArg, argumentsList) {
2980 if (thisArg instanceof Function && /antiAntiBotScript|CDP/.test(originalToString.call(thisArg))) {
2981 return 'function () { [native code] }';
2982 }
2983 return target.apply(thisArg, argumentsList);
2984 }
2985 };
2986
2987 // Wrap toString with a proxy
2988 Object.prototype.toString = new Proxy(originalToString, handler);
2989
2990 console.log('toString override applied via Proxy');
2991 })();
2992 ";
2993
2994 if (true == UseSelenium && BrowserType.FireFox != BrowserSettings.BrowserType)
2995 {
2996 ((OpenQA.Selenium.Chromium.ChromiumDriver)BrowserDriver).ExecuteCdpCommand("Page.addScriptToEvaluateOnNewDocument", new Dictionary<string, object>
2997 {
2998 { "source", toStringOverrideScript }
2999 });
3000 }
3001 else if (true == UsePuppeteer)
3002 {
3003 PuppeteerCommunicator.EnableFetchScriptInjection(toStringOverrideScript, null).GetAwaiter().GetResult();
3004 }
3005
3006 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Stealth toString override applied.", this, GPALObjectType.Browser);
3007 }
3012 private void CheckAndApplyStealth()
3013 {
3014 // hide CDP port detection by adding a script that will always run when a page loads (before the website loads any detection scripts)
3015 // https://www.reddit.com/r/webscraping/comments/1evht3i/help_in_bypassing_cdp_detection/
3016 // https://github.com/rebrowser/rebrowser-patches
3017 // https://github.com/kaliiiiiiiiii/brotector/blob/master/brotector.js#L377 // string detections
3018
3019 if (false == BrowserSettings.StealthApplied)
3020 {
3021 if (false == UseOttoMagic && BrowserType.FireFox != BrowserType)
3022 {
3023 if (BrowserSettings.StealthType.HasFlag(StealthType.CDP))
3024 ApplyCDPProtection();
3025
3026 if (BrowserSettings.StealthType.HasFlag(StealthType.ToStringOverride))
3027 ApplyToStringOverride();
3028 }
3029
3030 if (BrowserSettings.StealthType.HasFlag(StealthType.GoogleReferrer))
3031 {
3032 // set referer to google to make us look like we are coming in from search results click
3033 try
3034 {
3035 ApplyGoogleReferrer();
3036 }
3037 catch (GPALException)
3038 {
3039 throw;
3040 }
3041 catch (Exception ex)
3042 {
3043 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Google Referrer stealth skipped", this, GPALObjectType.Browser, ex);
3044 }
3045 }
3046 }
3047 BrowserSettings.StealthApplied = true;
3048 }
3049
3054 public IAllowBrowserActionOrAnySelector CloseTab(dynamic URLorTabId = null)
3055 {
3056 // special use case closing a tab; can't call previoustab because we aren't in a good window yet and it will try to top
3057 if (true == UsePuppeteer)
3058 {
3059 if (URLorTabId is string s)
3060 PuppeteerClient.CloseTab(s).Execute();
3061 else if (URLorTabId is GPALUrl url)
3062 PuppeteerClient.CloseTab(url).Execute();
3063 else if (URLorTabId is int i)
3064 PuppeteerClient.CloseTab(i).Execute();
3065 else
3066 PuppeteerClient.CloseTab().Execute();
3067 }
3068 else if (true == UseOttoMagic)
3069 {
3070 string currentUrl = BrowserSettings.CurrentURL;
3071
3072 int currentTabId = GetActiveTabId();
3073 TabTuple tabTuple = MagicHelper.CloseTab(URLorTabId); // tabtuple is new active tab
3074
3075 if (true == string.IsNullOrEmpty(URLorTabId))
3076 {
3077 BrowserSettings.TabIdsToURL.Remove(currentTabId);
3078 BrowserSettings.URLsToTabId.Remove(currentUrl);
3079 }
3080 else if (URLorTabId is int tabId)
3081 {
3082 BrowserSettings.URLsToTabId.Remove(BrowserSettings.TabIdsToURL[tabId].URL);
3083 BrowserSettings.TabIdsToURL.Remove(tabId);
3084 }
3085 else if (URLorTabId is string url)
3086 {
3087 BrowserSettings.URLsToTabId.Remove(url);
3088 BrowserSettings.TabIdsToURL.Remove(currentTabId);
3089 }
3090
3091 UpdateActiveTab(tabTuple);
3092 }
3093 else
3094 {
3095 if (true == string.IsNullOrWhiteSpace(URLorTabId))
3096 {
3097 ResolveGhostTabHandle(); // ensure Edge's phantom tab is known before we pick a tab to land on
3098
3099 // work out where we are going BEFORE closing, so the handle list is the one that still has
3100 // this tab in it and the index means what it says
3101 string closing = null;
3102 string landOn = null;
3103
3104 try
3105 {
3106 var before = BrowserDriver.WindowHandles;
3107 closing = BrowserDriver.CurrentWindowHandle;
3108
3109 // the tab to the left of the one closing, skipping Edge's ghost, else anything that is not us
3110 int closingIdx = before.IndexOf(closing);
3111 for (int idx = closingIdx - 1; 0 <= idx && null == landOn; idx--)
3112 if (before[idx] != GhostTabHandle)
3113 landOn = before[idx];
3114
3115 if (null == landOn)
3116 landOn = before.FirstOrDefault(h => h != closing && h != GhostTabHandle);
3117 }
3118 catch (Exception exRead)
3119 {
3120 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"CloseTab: could not read the tab list before closing, will fall back to whatever the driver leaves us on.", this, GPALObjectType.Browser, exRead);
3121 }
3122
3123 // guarded: a flaky/off-screen EdgeDriver can hang the full 20s command timeout here and throw,
3124 // which must not crash the workflow - the tab is being torn down regardless.
3125 try { BrowserSettings.BrowserDriver.Close(); } // close current tab
3126 catch (Exception exClose) { GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"CloseTab: driver failed to close the current tab, continuing.", this, GPALObjectType.Browser, exClose); }
3127
3128 // closing the last tab leaves nothing to land on, which is a legitimate end of the workflow
3129 if (false == string.IsNullOrEmpty(landOn))
3130 try
3131 {
3132 BrowserDriver.SwitchTo().Window(landOn);
3133 CurrentTabIdx = BrowserDriver.WindowHandles.IndexOf(landOn); // re-seat against the list we actually have now
3134
3135 }
3136 catch (Exception exSwitch)
3137 {
3138 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"CloseTab: could not switch to tab [{landOn}] after closing.", this, GPALObjectType.Browser, exSwitch);
3139 }
3140 }
3141 else // close a tab with a particular url (we don't use handles on selenium side)
3142 {
3143 // the tab we are on now, so closing some other tab does not move the workflow. FindTab walks
3144 // the tabs to read their urls, so where the driver ends up afterwards is not where it started
3145 string staying = BrowserDriver.CurrentWindowHandle;
3146 var handle = BrowserHelper.FindTab(BrowserSettings, URLorTabId);
3147
3148 // no tab is sitting on that url any more, which happens whenever the tab has navigated on, a
3149 // download being the obvious case. Selenium answers a null handle with "'handle' must be a
3150 // string" and takes the workflow down, so say what happened instead
3151 if (true == string.IsNullOrEmpty(handle))
3152 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"CloseTab: no tab is on [{URLorTabId}], nothing closed. Call .CloseTab() with no url to close the current tab.", this, GPALObjectType.Browser);
3153 else
3154 {
3155 BrowserSettings.BrowserDriver.SwitchTo().Window(handle);
3156
3157 // guarded for the same reason as the no-url case above
3158 try { BrowserSettings.BrowserDriver.Close(); }
3159 catch (Exception exClose) { GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"CloseTab: driver failed to close tab [{URLorTabId}], continuing.", this, GPALObjectType.Browser, exClose); }
3160
3161 // go back to where the workflow was. Closing the tab we were on ourselves leaves nothing to
3162 // go back to, so take the last one open, and closing the only tab leaves nothing at all
3163 var after = BrowserDriver.WindowHandles;
3164 string landOn = true == after.Contains(staying) ? staying : after.LastOrDefault(h => h != GhostTabHandle);
3165
3166 if (false == string.IsNullOrEmpty(landOn))
3167 {
3168 BrowserDriver.SwitchTo().Window(landOn);
3169 CurrentTabIdx = after.IndexOf(landOn);
3170
3171 }
3172 }
3173 }
3174 }
3175
3176 // did that leave us anything to be on? ask the browser rather than reading our own tally, which cannot
3177 // see tabs a page opened or closed by itself. Firefox answers 1 here, it keeps the browser alive on an
3178 // about:blank tab, where chrome and edge answer 0 or have already torn the session down
3179 int tabsLeft = 0;
3180
3181 try
3182 {
3183 tabsLeft = RealTabCount();
3184
3185 if (0 < tabsLeft)
3186 GetSetCurrentUrl();
3187 }
3188 catch (Exception ex)
3189 {
3190 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Browser stopped answering after closing the tab.", this, GPALObjectType.Browser, ex);
3191 }
3192
3193 if (0 == tabsLeft)
3194 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Closed the last tab, the browser closed with it.", this, GPALObjectType.Browser);
3195
3196 tabsWeOpened--;
3197
3198 //CheckPersistentSelectors();
3199
3200 return this;
3201 }
3202
3212 private List<string> TabsInThisWindow(IList<string> handles, string current)
3213 {
3214 List<string> siblings = new List<string>();
3215
3216 // the tab we are on decides which window we are talking about, and it is its own window if this is the
3217 // first we have heard of it
3218 if (false == tabWindows.ContainsKey(current))
3219 tabWindows[current] = current;
3220
3221 string thisWindow = tabWindows[current];
3222
3223 foreach (string handle in handles)
3224 {
3225 if (false == tabWindows.ContainsKey(handle))
3226 tabWindows[handle] = thisWindow;
3227
3228 if (tabWindows[handle] == thisWindow && handle != GhostTabHandle)
3229 siblings.Add(handle);
3230 }
3231
3232 return siblings;
3233 }
3240 private void RecordTabWindow(string newHandle, string openedFrom)
3241 {
3242 if (true == string.IsNullOrEmpty(newHandle))
3243 return;
3244
3245 if (false == string.IsNullOrEmpty(openedFrom) && true == tabWindows.ContainsKey(openedFrom))
3246 tabWindows[newHandle] = tabWindows[openedFrom];
3247 else
3248 tabWindows[newHandle] = string.IsNullOrEmpty(openedFrom) ? newHandle : openedFrom;
3249 }
3257 internal string ResolveGhostTabHandle()
3258 {
3259 // only Selenium switches by handle, and only Edge injects the preload ghost
3260 if (false == UseSelenium || BrowserType.Edge != BrowserSettings.BrowserType || true == string.IsNullOrEmpty(GhostTabUrl))
3261 return GhostTabHandle;
3262
3263 // already found - cached for the life of the session
3264 if (false == string.IsNullOrEmpty(GhostTabHandle))
3265 return GhostTabHandle;
3266
3267 var handles = BrowserDriver.WindowHandles;
3268
3269 // nothing foreign present: no more open handles than tabs we opened ourselves
3270 if (handles.Count <= tabsWeOpened)
3271 return null;
3272
3273 string current;
3274 try { current = BrowserDriver.CurrentWindowHandle; }
3275 catch { return null; }
3276
3277 foreach (string handle in handles)
3278 {
3279 if (handle == current) continue; // the tab we are on is never the ghost
3280
3281 string handleUrl = null;
3282 try
3283 {
3284 BrowserDriver.SwitchTo().Window(handle);
3285 handleUrl = BrowserDriver.Url;
3286 }
3287 catch { /* can't read it, skip */ }
3288 finally { try { BrowserDriver.SwitchTo().Window(current); } catch { } }
3289
3290 // positive identity match against the launch signature - a real tab will not match.
3291 // Match on the base url (scheme+host+path) only: Edge's launch url and the settled ghost url
3292 // share the same NTP path but differ in query (prerender=1 vs startpage=1, space encoding, an
3293 // extra invalidaterender token), so a full-url compare would never match.
3294 if (false == string.IsNullOrEmpty(handleUrl) && true == UrlHelper.SameBaseUrl(handleUrl, GhostTabUrl))
3295 {
3296 GhostTabHandle = handle;
3297 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Ghost tab found, will always skip [{handleUrl}]", this, GPALObjectType.Browser);
3298 break;
3299 }
3300 }
3301
3302 return GhostTabHandle;
3303 }
3320 {
3321 get
3322 {
3323 // due to clicks opening new tabs and other reasons, we can't track open tab count unless we have our own browser extension (can't do it in javascript, only extension)
3324
3325 // browser will be topped below by selenium when we switch tabs
3326 // BrowserHelper.TopBrowser(this);
3327
3328 // next tab - control page-down
3329 // when we move away from selenium, we may use this
3330 // HardwareHelper.SendKey(GPAL.VK_NEXT, ModifierKeys.Control); // page down
3331
3332 // using selenium, it must know which tab we are on to function properly, it doesn't know we switched tabs
3333 if (true == UseOttoMagic)
3334 {
3335 TabTuple tabTuple = MagicHelper.NextTab();
3336 UpdateActiveTab(tabTuple);
3337 }
3338 else if (true == UsePuppeteer)
3339 {
3340 PuppeteerClient.NextTab().Execute();
3341 }
3342 else
3343 {
3344 ResolveGhostTabHandle(); // Edge injects a phantom NTP handle; identify it so we never land on it
3345
3346 var handles = BrowserDriver.WindowHandles;
3347 string current = BrowserDriver.CurrentWindowHandle;
3348
3349 // cycle the tabs of the window we are in, not the driver's flat list of every tab in every
3350 // window. Ask the driver where we are rather than remembering it: a remembered position points
3351 // at the wrong tab the moment any tab opens or closes
3352 List<string> siblings = TabsInThisWindow(handles, current);
3353
3354 int idx = siblings.IndexOf(current);
3355 if (0 > idx) idx = 0;
3356
3357 idx = (idx + 1) % siblings.Count;
3358
3359 CurrentTabIdx = handles.IndexOf(siblings[idx]); // reported to the workflow, never read back as our position
3360
3361 BrowserDriver.SwitchTo().Window(siblings[idx]);
3362 if (BrowserDriver.CurrentWindowHandle != siblings[idx])
3363 BrowserDriver.SwitchTo().Window(siblings[idx]);
3364
3365 }
3366
3367 Thread.Sleep(50);
3368 //BrowserHelper.CheckDocumentReady(this, true); // check document.ready
3369
3370 // get set url, set gpalcurrenturl
3371 // NOTE: add search gpalurllist and setting gpalcurrenturl
3372
3373 return this;
3374 }
3375 }
3376
3393 {
3394 BrowserSettings.OpenInTab = true;
3395 BrowserSettings.Browser.GoTo(url); // will set currentgpalurl and add url to list
3396 return this;
3397 }
3398
3413 {
3414 get
3415 {
3416 // due to clicks opening new tabs and other reasons, we can't track open tab count unless we have our own browser extension (can't do it in javascript, only extension)
3417
3418 // browser will be topped below by selenium when we switch tabs
3419 // BrowserHelper.TopBrowser(this);
3420
3421 // previous tab using ctrl-page up
3422 // when we move away from selenium, we may use this
3423 // HardwareHelper.SendKey(GPAL.VK_PRIOR, ModifierKeys.Control); // page up
3424
3425 // using selenium, it must know which tab we are on to function properly, it doesn't know we switched tabs above
3426 if (true == UseOttoMagic)
3427 {
3428 UpdateActiveTab(MagicHelper.PreviousTab());
3429 }
3430 else if (true == UsePuppeteer)
3431 {
3432 PuppeteerClient.PreviousTab().Execute();
3433 }
3434 else
3435 {
3436 ResolveGhostTabHandle(); // Edge injects a phantom NTP handle; identify it so we never land on it
3437
3438 var handles = BrowserDriver.WindowHandles;
3439 string current = BrowserDriver.CurrentWindowHandle;
3440
3441 // same as NextTab: the tabs of this window only, and the driver is asked where we are
3442 List<string> siblings = TabsInThisWindow(handles, current);
3443
3444 int idx = siblings.IndexOf(current);
3445 if (0 > idx) idx = 0;
3446
3447 // (i - 1 + n) % n - add the raw count before the modulo so a -1 wrap stays positive
3448 idx = (idx - 1 + siblings.Count) % siblings.Count;
3449
3450 CurrentTabIdx = handles.IndexOf(siblings[idx]); // reported to the workflow, never read back as our position
3451
3452 BrowserDriver.SwitchTo().Window(siblings[idx]);
3453 if (BrowserDriver.CurrentWindowHandle != siblings[idx])
3454 BrowserDriver.SwitchTo().Window(siblings[idx]);
3455
3456 }
3457
3458 //BrowserHelper.CheckDocumentReady(this, true); // check idle
3459
3460 return this;
3461 }
3462 }
3463
3470 {
3471 return LeftClick(ModifierKeys.NONE);
3472 }
3473
3479 public IAllowBrowserActionOrAnySelector LeftClick(ModifierKeys modifierKeys)
3480 {
3481 string priorUrl = BrowserSettings.CurrentURL;
3482 object priorDocument = BrowserHelper.MarkDocument(this); // the page we are clicking on, to tell later if we left it
3483
3484 // snapshot open handles before the click so we can tell if the click opened a new tab (target=_blank)
3485 var handlesBefore = (true == UseSelenium) ? new HashSet<string>(BrowserDriver.WindowHandles) : null;
3486
3487 // waitfor did not match if returns false, so we have no element to click
3488 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
3489 ElementHelper.LeftClick(this, modifierKeys);
3490
3491 if (true == UseSelenium)
3492 {
3493 ResolveGhostTabHandle(); // make sure Edge's phantom tab is identified before we count new tabs
3494
3495 // in case this opens a new tab
3496 var handles = BrowserDriver.WindowHandles.ToList();
3497 string currentHandle = BrowserDriver.CurrentWindowHandle;
3498
3499 CurrentTabIdx = handles.IndexOf(currentHandle);
3500
3501 // reliable our-tabs counter: add only genuinely new tabs this click opened, never Edge's ghost
3502 int opened = 0;
3503
3504 foreach (string handle in handles.Where(h => false == handlesBefore.Contains(h) && h != GhostTabHandle))
3505 {
3506 RecordTabWindow(handle, currentHandle); // a click opens into the window it was clicked in
3507 opened++;
3508 }
3509
3510 if (0 < opened) tabsWeOpened += opened;
3511 }
3512
3513 // the click returns as soon as the event is dispatched, so give the browser a moment to commit the
3514 // navigation before deciding it did not go anywhere. Reading the url once here saw the old page
3515 if (true == BrowserHelper.WaitForNavigationOrTime(this, priorUrl, priorDocument, BrowserSettings.NavigationGraceMs))
3516 {
3517 BrowserHelper.CheckDocumentReady(this);
3518
3519 // read the url again now the document is committed, not while it was still tearing down
3520 string currentUrl = GetSetCurrentUrl();
3521
3522 if (true == UseOttoMagic)
3523 {
3524 int tabId = GetActiveTabId();
3525 TabTuple tabTuple = new TabTuple() { TabId = tabId, Url = currentUrl };
3526 UpdateActiveTab(tabTuple);
3527 }
3528 else if (true == UsePuppeteer)
3529 PuppeteerClient.SwitchToDefaultContent().Execute();
3530 else
3531 BrowserDriver.SwitchTo().DefaultContent();
3532 }
3533
3534 return this;
3535 }
3536
3543 {
3544 if (null != selector)
3545 WithSelector(selector);
3546
3547 return LeftClick(ModifierKeys.NONE);
3548 }
3549
3555 public IAllowBrowserActionOrAnySelector SelectClick(SelectClickType selectClickType)
3556 {
3557 // waitfor did not match if returns false, so we have no element to click
3558 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
3559 {
3560 string priorUrl = BrowserSettings.CurrentURL;
3561 object priorDocument = BrowserHelper.MarkDocument(this); // the page we are clicking on, to tell later if we left it
3562 ElementHelper.GenericClick(this, selectClickType);
3563
3564 if (true == BrowserHelper.WaitForNavigationOrTime(this, priorUrl, priorDocument, BrowserSettings.NavigationGraceMs))
3565 BrowserHelper.CheckDocumentReady(this);
3566 }
3567
3568 GetSetCurrentUrl();
3569
3570 return this;
3571 }
3572
3578 {
3579 if (null != selector)
3580 WithSelector(selector);
3581
3582 // waitfor did not match if returns false, so we have no element to click
3583 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
3584 {
3585 // we never enable runtime, so should not be needed
3586 if (true == UseOttoMagic || true == UsePuppeteer)
3587 ElementHelper.LeftClick(this);
3588 else if (BrowserType.FireFox != BrowserSettings.BrowserType)
3589 {
3590 ((OpenQA.Selenium.Chromium.ChromiumDriver)BrowserDriver).ExecuteCdpCommand("Runtime.disable", new Dictionary<string, object> { });
3591 ElementHelper.LeftClick(this);
3592 ((OpenQA.Selenium.Chromium.ChromiumDriver)BrowserDriver).ExecuteCdpCommand("Runtime.enable", new Dictionary<string, object> { });
3593 }
3594 else
3595 ElementHelper.LeftClick(this);
3596 }
3597
3598 GetSetCurrentUrl();
3599
3600 return this;
3601 }
3602
3609 {
3610 // waitfor did not match if returns false
3611 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
3612 {
3613 ElementHelper.GenericClick(this, ClickType.LeftDoubleClick);
3614 BrowserHelper.CheckDocumentReady(this);
3615 }
3616 // NOTE: should we expect this to possibly change the url? how many websites do you interact that take double clicks? only a custom interface, i think.
3617 return this;
3618 }
3619
3626 {
3627 string priorUrl = BrowserSettings.CurrentURL;
3628
3629 // snapshot open handles before the click so we can tell which tab(s) the middle-click opened
3630 var handlesBefore = (true == UseSelenium) ? new HashSet<string>(BrowserDriver.WindowHandles) : null;
3631
3632 // waitfor did not match if returns false, so we have no element to click
3633 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
3634 ElementHelper.MiddleClick(this);
3635
3636 string currentUrl = GetSetCurrentUrl();
3637
3638 if (true == UseSelenium)
3639 {
3640 ResolveGhostTabHandle(); // make sure Edge's phantom tab is identified before we count new tabs
3641
3642 // in case this opens a new tab
3643 var handles = BrowserDriver.WindowHandles.ToList();
3644 string currentHandle = BrowserDriver.CurrentWindowHandle;
3645
3646 CurrentTabIdx = handles.IndexOf(currentHandle);
3647
3648 // reliable our-tabs counter: add only genuinely new tabs this middle-click opened, never Edge's ghost
3649 int opened = 0;
3650
3651 foreach (string handle in handles.Where(h => false == handlesBefore.Contains(h) && h != GhostTabHandle))
3652 {
3653 RecordTabWindow(handle, currentHandle); // a middle click opens into the window it was clicked in
3654 opened++;
3655 }
3656
3657 if (0 < opened) tabsWeOpened += opened;
3658 }
3659
3660 if (false == UrlHelper.AreEquivalent(priorUrl, currentUrl))
3661 {
3662 if (true == UseOttoMagic)
3663 {
3664 int tabId = GetActiveTabId();
3665 TabTuple tabTuple = new TabTuple() { TabId = tabId, Url = currentUrl };
3666 UpdateActiveTab(tabTuple);
3667 }
3668 else if (true == UsePuppeteer)
3669 PuppeteerClient.SwitchToDefaultContent().Execute();
3670 else
3671 BrowserDriver.SwitchTo().DefaultContent();
3672 }
3673
3674 return this;
3675 }
3676
3683 {
3684 if (null != selector)
3685 {
3686 // if we are using GET, we are headless, there is no hardware interaction, force selenium on any selector, overriding definitions
3687 if (true == BrowserSettings.UseHeadless)
3688 selector.InteractionType = InteractionType.Selenium;
3689 WithSelector(selector);
3690 }
3691 // waitfor did not match if returns false
3692 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
3693 {
3694 ElementHelper.GenericClick(this, ClickType.RightClick);
3695 BrowserHelper.CheckDocumentReady(this);
3696 }
3697 // NOTE: should we expect this to possibly change the url? how many websites do you interact that take double clicks? only a custom interface, i think.
3698 return this;
3699 }
3700
3710
3712 bool useEAvars = false;
3713 GPALElement EAelement = null;
3714 Selector EAselector = null;
3722 internal void LeftClickAndDownload(GPALFile gPalFile, GPALElement webElement, Selector selector)
3723 {
3724 useEAvars = true;
3725 EAelement = webElement;
3726 EAselector = selector;
3727
3728 LeftClickAndDownload(gPalFile);
3729
3730 }
3731 // long enough for a dialog that is opening to be there, short enough that finding none costs nothing
3732 internal const int UnexpectedSaveAsWaitSeconds = 2;
3733 internal const int ProfileRestoreWaitMs = 5000; // how long to give the browser to shut down before the profile goes back
3734
3742 {
3743 string fullFilePathToSave;
3744 HWND windowOpened = IntPtr.Zero;
3745 List<string> returnFilename = new List<string>();
3746 bool noSaveAsDialog = false;
3747 FileDownloaded = false;
3748 int rowCount = 0;
3749
3750 if (true == useEAvars)
3751 {
3752 DownloadWorkflow(EAelement, EAselector);
3753 return this;
3754 }
3755
3756 int DownloadWorkflow(GPALElement webElement, Selector selector)
3757 {
3758 GPALFile fileToSave = gPalFileToDownloadTo.Next;
3759 fullFilePathToSave = FileHelper.CleanUpFileDestination(fileToSave.Filename, out string newFilename, BrowserSettings.DeleteFileBeforeDownload);
3760 returnFilename.Add(fullFilePathToSave); // return the filenames generated
3761
3762 string currentUrl = null, currentUrl2 = null;
3763
3765
3766 FileDownloaded = null;
3767
3768 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Downloading to [{fullFilePathToSave}]", this, GPALObjectType.Browser);
3769
3770 if (true == BrowserSettings.UseDirectDownload)
3771 {
3772 FileHelper.DownloadToFile(webElement, fullFilePathToSave);
3773 }
3774 // no prompt means no Save As dialog is coming, so there is nothing to wait for or type into - watch
3775 // the download directory instead, the same as headless does. OpenPDFExternally is not part of this
3776 // any more: it is a profile preference neither cdp nor the extension can set, so it never changes
3777 else if (true == BrowserSettings.UseHeadless || false == BrowserSettings.PromptForDownload) // headless mode or no prompt for filename, monitor default download dir
3778 {
3779 // so assume the file will download
3780 string watchedDirectory = FileHelper.SetupDownloadWatcher(fullFilePathToSave, BrowserSettings);
3781
3782 // BOTH OF THESE do not check for completion, just sets download path and clicks
3783 if (true == BrowserSettings.UsePuppeteer)
3784 {
3785 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Element [{webElement.TagName}] for selector [{selector.Name}] [{selector.InteractionType.ToString()}][Left] clicked with modifiers [NONE].");
3786 PuppeteerClient.LeftClickAndDownload(webElement.ElementHandle).DownloadTo(fileToSave).Execute();
3787 }
3788 else if (true == BrowserSettings.UseOttoMagic)
3789 {
3790 // these two arms click through their own client rather than ElementHelper.Click, so the
3791 // click never reaches the event that method publishes and has to be said here. without it
3792 // a download that produced nothing cannot be told apart from a click that never landed
3793 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Element [{webElement.TagName}] for selector [{selector.Name}] [{selector.InteractionType.ToString()}][Left] clicked with modifiers [NONE].");
3794 MagicHelper.LeftClickAndDownload(webElement.Css, fileToSave);
3795 }
3796 else // selenium
3797 {
3799 ElementHelper.Click(this, selector, webElement, ClickType.LeftClick, ModifierKeys.NONE);
3800 }
3801
3802 Thread.Sleep(250);
3803 // NOTE: new scenario, some nag modal pops up before the download starts
3804 // define those as persistent selectors and check persistent selectors here after we click to automatically dismiss them
3805 string urlAfterClick = BrowserHelper.GetCurrentUrl(BrowserSettings);
3806
3807 // the persistent selectors belong to the page the click was made on. a click that navigated
3808 // left that page, so there is nothing of ours to look for where we landed
3809 if (true == UrlHelper.AreEquivalent(currentUrl, urlAfterClick))
3810 CheckPersistentSelectors();
3811
3812 if (false == UrlHelper.AreEquivalent(currentUrl, urlAfterClick)
3813 && true == fileToSave.Filename.EndsWith(".pdf")
3814 && true == BrowserHelper.TestUrlForPDF(this, urlAfterClick))
3815 {
3816 //GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{urlAfterClick}] is the pdf itself, fetching it rather than waiting on a Save As dialog.", this, GPALObjectType.Browser);
3817 FileDownloaded = BrowserHelper.DownloadPdfFile(urlAfterClick, fullFilePathToSave, this);
3818 }
3819
3820 // PromptForDownload is a claim about the browser's configuration that cannot be read back from
3821 // any of the three engines, and on ottomagic and puppeteer cannot be set either. so rather than
3822 // trust it, look: a dialog that did open blocks the browser for everything after it, and a
3823 // couple of seconds spent finding out beats losing the rest of the run to a modal nobody typed
3824 // into. nothing appears when the claim was right, which is the ordinary case
3825 if (false == BrowserSettings.UseHeadless)
3826 {
3827 HWND unexpectedDialog = IntPtr.Zero;
3828
3829 if (BrowserType.Chrome == BrowserSettings.BrowserType || BrowserType.Edge == BrowserSettings.BrowserType)
3830 unexpectedDialog = FormHelper.WaitForWindow("Save As", UnexpectedSaveAsWaitSeconds);
3831 else if (BrowserType.FireFox == BrowserSettings.BrowserType)
3832 // NOTE: that is an elipses character used, not ...
3833 unexpectedDialog = FormHelper.WaitForWindow("Enter name of file to save to…", UnexpectedSaveAsWaitSeconds);
3834
3835 if (IntPtr.Zero != unexpectedDialog)
3836 {
3837 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{BrowserSettings.BrowserType}] asked where to save the file even though prompting was turned off. Answering the dialog with [{newFilename}].", this, GPALObjectType.Browser);
3838
3839 HardwareHelper.SendString(newFilename);
3840 HardwareHelper.SendKey(GPAL.VK_RETURN);
3841 }
3842 }
3843
3844 // null is the file handler not having reached an answer yet, which is the only thing worth
3845 // waiting on. true and false are both answers and it shuts the watcher down as it gives one,
3846 // so nothing can change after that.
3847 // DownloadTimeoutInSec bounds how long nothing happens, not how long the file takes. every time
3848 // the bytes on disk change the clock starts over, so a transfer is never cut off while it is
3849 // moving, and a wait where nothing is moving always ends - whether the download never started
3850 // or it died partway. stopping the clock on start instead left no way out of here at all
3851 Stopwatch sinceProgress = Stopwatch.StartNew();
3852 long lastSeen = long.MinValue;
3853
3854 // false is not final. these events arrive many times over one download and an early one can
3855 // conclude nothing while a later one is still resolving the file, so a false here is answered
3856 // by waiting rather than by giving up. what ends this loop is the allowance below
3857 while (null == FileDownloaded || false == FileDownloaded)
3858 {
3859 // NOTE: it is entirely possible, since we do not destroy the filewatcher, that the file could still come in and be handled, i've not seen it, but it's possible
3860 long progress = FileHelper.DownloadProgress(watchedDirectory, fullFilePathToSave);
3861
3862 if (progress != lastSeen)
3863 {
3864 lastSeen = progress;
3865 sinceProgress.Restart();
3866 }
3867 else if (sinceProgress.Elapsed.TotalSeconds > FileHelper.AllowanceFor(watchedDirectory, BrowserSettings))
3868 break;
3869
3870 Thread.Sleep(100); // Prevents 100% CPU usage
3871 }
3872
3874
3875 // but maybe it's a pdf opening in the browser itself, so check to see if that happened and if the url is a pdf file
3876 // of course it is possible the url won't contain .pdf and served up via a header? we might have to chack that
3877 if (false == (bool)(FileDownloaded ?? false) && false == BrowserSettings.OpenPDFExternally && true == fileToSave.Filename.EndsWith(".pdf"))
3878 {
3879 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Testing [{currentUrl2}] for PDF file", this, GPALObjectType.Browser);
3880 if (false == UrlHelper.AreEquivalent(currentUrl, currentUrl2) && true == BrowserHelper.TestUrlForPDF(this, currentUrl2))
3881 {
3882 FileDownloaded = BrowserHelper.DownloadPdfFile(currentUrl2, fullFilePathToSave, this);
3883 noSaveAsDialog = !(bool)FileDownloaded;
3884 }
3885 }
3886
3887 // we're headless, no fallback, if it didn't download it's not going someplace other than where we specified
3888 if (false == (bool)(FileDownloaded ?? false))
3889 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"File not downloaded to [{fullFilePathToSave}].", this, GPALObjectType.Browser);
3890 FileHelper.DiscardAbandonedDownload(BrowserSettings);
3891
3892 }
3893 else if (false == BrowserSettings.UseHeadless) // if we are not headless, we can handle the dialog or direct download
3894 {
3895 ElementHelper.Click(this, selector, webElement, ClickType.LeftClick, ModifierKeys.NONE);
3896
3897 string urlAfterClick = BrowserHelper.GetCurrentUrl(BrowserSettings);
3898
3899 // the persistent selectors belong to the page the click was made on. a click that navigated
3900 // left that page, so there is nothing of ours to look for where we landed
3901 // NOTE: new scenario, some nag modal pops up before the download starts
3902 // define those as persistent selectors and check persistent selectors here after we click to automatically dismiss them
3903 if (true == UrlHelper.AreEquivalent(currentUrl, urlAfterClick))
3904 CheckPersistentSelectors();
3905
3906 // the click landed on the pdf itself, so the browser is showing it and no Save As is ever coming.
3907 // fetching it now is the whole of the work, and skips the wait for a dialog that will not appear.
3908 // OpenPDFExternally is not consulted: a browser honoring it would have prompted or downloaded
3909 // rather than navigated, so arriving here on a pdf url says it did not take
3910 if (false == UrlHelper.AreEquivalent(currentUrl, urlAfterClick)
3911 && true == fileToSave.Filename.EndsWith(".pdf")
3912 && true == BrowserHelper.TestUrlForPDF(this, urlAfterClick))
3913 {
3914 //GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{urlAfterClick}] is the pdf itself, fetching it rather than waiting on a Save As dialog.", this, GPALObjectType.Browser);
3915 FileDownloaded = BrowserHelper.DownloadPdfFile(urlAfterClick, fullFilePathToSave, this);
3916 }
3917
3918 if (false == (bool)(FileDownloaded ?? false) && true == BrowserSettings.PromptForDownload)
3919 if (BrowserType.Chrome == BrowserSettings.BrowserType || BrowserType.Edge == BrowserSettings.BrowserType)
3920 windowOpened = FormHelper.WaitForWindow("Save As", BrowserSettings.WaitForWindowTimeoutInSeconds);
3921 else if (BrowserType.FireFox == BrowserSettings.BrowserType)
3922 // NOTE: that is an elipses character used, not ...
3923 windowOpened = FormHelper.WaitForWindow("Enter name of file to save to…", BrowserSettings.WaitForWindowTimeoutInSeconds);
3924
3925 // nothing below applies once the pdf is already on disk. it is all recovery for a Save As that
3926 // never came, and running it would report a failure for a file we have and then reach for the
3927 // element the navigation took with it
3928 if (true == (bool)(FileDownloaded ?? false))
3929 noSaveAsDialog = false;
3930 // save as window didn't open in the allotted timeout, or we set to no prompt and a direct download
3931 else if (IntPtr.Zero != windowOpened)
3932 {
3933 HardwareHelper.SendString(newFilename);
3934 HardwareHelper.SendKey(GPAL.VK_RETURN);
3935
3936 // is the file save dialog still open? send ENTER again
3937 if (BrowserType.Chrome == BrowserSettings.BrowserType || BrowserType.Edge == BrowserSettings.BrowserType)
3938 windowOpened = FormHelper.WaitForWindow("Save As", 1);
3939 else if (BrowserType.FireFox == BrowserSettings.BrowserType)
3940 windowOpened = FormHelper.WaitForWindow("Enter name of file to save to…", 1); // NOTE: that is an elipses character used, not ...
3941
3942 if (IntPtr.Zero != windowOpened)
3943 HardwareHelper.SendKey(GPAL.VK_RETURN);
3944
3945 string actualDownloadedFilename = null;
3946
3947 actualDownloadedFilename = FileHelper.WaitForDownloadToFinish(newFilename, BrowserSettings.DownloadTimeoutInSec);
3948
3949 FileDownloaded = true != string.IsNullOrEmpty(actualDownloadedFilename);
3950
3951 if (false == FileDownloaded)
3952 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"File not downloaded to [{fullFilePathToSave}]", this, GPALObjectType.Browser);
3953 FileHelper.DiscardAbandonedDownload(BrowserSettings);
3954
3955 }
3956 else
3957 {
3958 if (true == BrowserSettings.PromptForDownload)
3959 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to handle Save As... dialog [{fullFilePathToSave}][{selector.Name}]", BrowserSettings, GPALObjectType.Other);
3960
3961 noSaveAsDialog = true;
3962
3963 // NOTE: maybe it's a pdf opening in the browser itself, so check to see if that happened and if the url is a pdf file
3965
3966 // OpenPDFExternally is not consulted here either. the Save As wait has already come and gone
3967 // with nothing, so whatever the setting asked for is not what the browser did
3968 if (false == (bool)(FileDownloaded ?? false) && true == fileToSave.Filename.EndsWith(".pdf"))
3969 {
3970 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Testing [{currentUrl2}] for PDF file", this, GPALObjectType.Browser);
3971 if (false == UrlHelper.AreEquivalent(currentUrl, currentUrl2) && true == BrowserHelper.TestUrlForPDF(this, currentUrl2))
3972 {
3973 FileDownloaded = BrowserHelper.DownloadPdfFile(currentUrl2, fullFilePathToSave, this);
3974 noSaveAsDialog = !(bool)FileDownloaded;
3975 }
3976 }
3977 }
3978 }
3979
3980 // we didn't get a save as dialog within BrowserSettings.WaitForWindowTimeoutInSeconds which defaults to 30 seconds
3981 // so why? well, the website might have downloaded directly using the current browser default download directory
3982 // so we will attempt to see if a file was downloaded matching the file extension of the GPALFile the user specified
3983 if (true == noSaveAsDialog)
3984 {
3985 string downloadDirectory = FileHelper.GetDefaultDownloadDirectory(this);
3986 string downloadUrl = FileHelper.GetDownloadUrl(webElement);
3987 string filename = Path.GetFileName(downloadUrl) ?? fullFilePathToSave;
3988
3989 string wildcardFilename = "*" + Path.GetExtension(filename);
3990 string systemDownloadedTo = Path.Combine(downloadDirectory, wildcardFilename);
3991 string actualDownloadedFilename = null;
3992
3993 actualDownloadedFilename = FileHelper.WaitForDownloadToFinish(systemDownloadedTo, BrowserSettings.DownloadTimeoutInSec);
3994
3995 FileDownloaded = true != string.IsNullOrEmpty(actualDownloadedFilename);
3996
3997 if (true == FileDownloaded)
3998 {
3999 if (false == fullFilePathToSave.Equals(actualDownloadedFilename))
4000 {
4001 File.Move(actualDownloadedFilename, fullFilePathToSave);
4002 if (false == actualDownloadedFilename.Equals(fullFilePathToSave) && true == File.Exists(actualDownloadedFilename))
4003 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Downloaded [{actualDownloadedFilename}] unable to move to [{fullFilePathToSave}]", null, GPALObjectType.None);
4004 else
4005 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{actualDownloadedFilename}] moved to [{fullFilePathToSave}]", this, GPALObjectType.Browser);
4006 }
4007 }
4008 else
4009 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{filename}] not found in [{downloadDirectory}]", this, GPALObjectType.Browser);
4010 }
4011
4012 if (null != FileHelper.watcher) // we might not have disposed of watcher
4013 {
4014 try
4015 {
4016 FileHelper.watcher.Dispose();
4017 FileHelper.watcher = null;
4018 }
4019 catch { }
4020 }
4021
4022 if (false == BrowserSettings.UseHeadless && BrowserType.Edge == BrowserSettings.BrowserType) // dismiss the 'downloads' popup which can steal keys, clicks or get in the way
4023 {
4024 // KLUDGE: must be a hardware top to dismiss 'downloads' by clicking the browser chrome
4025 BrowserHelper.TopBrowser(this, false, true);
4026 }
4027
4028 if (int.MaxValue != CurrentUOW.WithAllThatMatch && ++rowCount == CurrentUOW.WithAllThatMatch)
4029 return -1;
4030 else
4031 return 0;
4032 }
4033
4034 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4035 {
4036 foreach (Selector selector in CurrentUOW.WithSelectorList)
4037 {
4038 if (SelectorType.Selector != selector.SelectorType)
4039 continue; // we don't look up data literals
4040
4041 ReadOnlyCollection<GPALElement> webelements = ElementHelper.FindWebElements(this, CurrentUOW, selector, out bool matchedAll, out List<GPALElement> matchedElements);
4042 if (null != webelements)
4043 {
4044 // the page these came from is the one to come back to. a download click that navigates
4045 // takes that document with it, and every element still waiting in the list belongs to it
4046 string pageOfTheElements = BrowserHelper.GetCurrentUrl(BrowserSettings);
4047 int matchCount = matchedElements.Count;
4048
4049 for (int elementIdx = 0; elementIdx < matchCount; elementIdx++)
4050 {
4052
4053 // back to where the elements live, and find them again - the ones in hand are gone with
4054 // their document, and asking one of them anything raises an unknown reference
4055 if (false == UrlHelper.AreEquivalent(pageOfTheElements, urlNow))
4056 {
4057 GoTo(pageOfTheElements);
4058 ElementHelper.FindWebElements(this, CurrentUOW, selector, out bool _, out matchedElements);
4059
4060 if (matchedElements.Count != matchCount)
4061 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{selector.Name}] matched [{matchedElements.Count}] on the way back rather than [{matchCount}], so the remaining downloads may not be the ones first found.", this, GPALObjectType.Browser);
4062 }
4063
4064 if (elementIdx >= matchedElements.Count)
4065 break;
4066
4067 // int.MaxValue tells us we have met our .WithAllThatMatch elements to process
4068 if (-1 == DownloadWorkflow(matchedElements[elementIdx], selector))
4069 break;
4070 }
4071 }
4072 }
4073
4074 foreach (string filepath in returnFilename)
4075 {
4076 gPalFileToDownloadTo.ReturnFilenames.Add(filepath); // return the filenames we generated
4077// GPAL.PublishSimpleEvent(GPALEventType.INFO, $"File saved to [{filepath}]");
4078 }
4079 }
4080
4081 GetSetCurrentUrl();
4082
4083 return this;
4084 }
4085
4092 internal void LeftClickAndUpload(GPALFile gPalFile, GPALElement webElement, Selector selector)
4093 {
4094 useEAvars = true;
4095 EAelement = webElement;
4096 EAselector = selector;
4097
4098 LeftClickAndUpload(gPalFile);
4099
4100 }
4109 {
4110 HWND windowOpened = IntPtr.Zero;
4111
4112 if (true == useEAvars)
4113 {
4114 UploadWorkflow(EAelement, EAselector, false); // NOTE: CAVEAT: not sure this makes sense, once again, trying to interpret the concept of gpalfile having more than one file
4115 return this;
4116 }
4117
4118 // NOTE: if you are providing a generic selector, say it finds 3 upload buttons, if you only supply 2 filenames in gg
4119 int UploadWorkflow(GPALElement webElement, Selector selector, bool uploadAllFiles)
4120 {
4121 string fileToUpload = null;
4122
4123 // asked once, here, while the element is still the one we just found. reading it inside the loop
4124 // reads a stale element, because the first SendKeys is what makes it stale.
4125 // the .multiple property rather than the attribute: multiple is a bare boolean attribute carrying
4126 // no value in the markup, and GetAttribute capitalises the name before asking the driver, which is
4127 // enough to miss the special case that would have turned it into "true"
4128 bool sendAllAtOnce = true == UseSelenium
4129 && 1 < gPalFileToUpload.Count
4130 && true == Convert.ToBoolean(BrowserHelper.ExecuteJavaScript(this, "return arguments[0].multiple;", webElement.WebElement) ?? false);
4131
4132 for (int fileCount = 0; fileCount < gPalFileToUpload.Count; fileCount++)
4133 {
4134 fileToUpload = gPalFileToUpload.Filenames[fileCount];
4135
4136 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Uploading [{fileToUpload}]", this, GPALObjectType.Browser);
4137
4138 // while we can deal with the input dialog, why bother? this is cleaner
4139 // if (true == BrowserSettings.UseHeadless || true == BrowserSettings.UseOttoMagic) // headless mode or no prompt for filename, alternate methods to add file to input
4140
4141 // if the user wants to use hardware input, we can do that, otherwise direct is best - but hardware works for selenium headful mode
4142 if (false == BrowserSettings.UseHardware && false == GPAL.GPALSettings.UseHardware && InteractionType.Hardware != selector.InteractionType)
4143 {
4144
4145 if (true == string.IsNullOrEmpty(fileToUpload))
4146 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "For some reason we have an empty filename", gPalFileToUpload, GPALObjectType.Other);
4147
4148 // BOTH OF THESE do not check for completion, just sets download path and clicks
4149 if (true == BrowserSettings.UsePuppeteer)
4150 {
4151 if (true == uploadAllFiles)
4152 {
4153 PuppeteerClient.LeftClickAndUpload(webElement.ElementHandle).UploadFrom(gPalFileToUpload).Execute();
4154 break;
4155 }
4156 PuppeteerClient.LeftClickAndUpload(webElement.ElementHandle).UploadFrom(fileToUpload).Execute();
4157 }
4158 else if (true == BrowserSettings.UseOttoMagic)
4159 MagicHelper.LeftClickAndUpload(webElement.Css, fileToUpload);
4160 else // selenium
4161 {
4162 // chromedriver appends a file on every SendKeys, geckodriver throws on the second one
4163 // instead. all of them take the whole set in one call with the paths newline separated,
4164 // so a multiple input is filled that way and there is nothing left to loop over
4165 string filesToSend = true == sendAllAtOnce ? string.Join("\n", gPalFileToUpload.Filenames) : fileToUpload;
4166
4167 try
4168 {
4169 ((IWebElement)webElement.iWebElement).SendKeys(filesToSend); // NOTE: special case in selenium sendkeys on input[type=files] adds a file
4170 }
4171 catch // stale element
4172 {
4173 ElementHelper.FindWebElements(this, CurrentUOW, selector, out bool _, out List<GPALElement> matchedElements);
4174 webElement = 0 < matchedElements.Count ? matchedElements[0] : null;
4175 webElement?.SendKeys(filesToSend.Replace("\\", "/"));
4176 }
4177
4178 if (true == sendAllAtOnce)
4179 break;
4180 }
4181
4182 Thread.Sleep(250);
4183 }
4184 else if (false == BrowserSettings.UseHeadless) // NOTE: HARDWARE insisted upon for this selector, so it is clickable
4185 {
4186 ElementHelper.Click(this, selector, webElement, ClickType.LeftClick, ModifierKeys.NONE); // click the input[type=file] button
4187
4188 if (BrowserType.Chrome == BrowserSettings.BrowserType || BrowserType.Edge == BrowserSettings.BrowserType)
4189 windowOpened = FormHelper.WaitForWindow("Open", BrowserSettings.WaitForWindowTimeoutInSeconds);
4190 else if (BrowserType.FireFox == BrowserSettings.BrowserType)
4191 // NOTE: that is an elipses character used, not ...
4192 windowOpened = FormHelper.WaitForWindow("File Upload", BrowserSettings.WaitForWindowTimeoutInSeconds);
4193
4194 // save as window didn't open in the allotted timeout, or we set to no prompt and a direct download
4195 if (IntPtr.Zero != windowOpened)
4196 {
4197 int filesToSend = 1;
4198
4199 if (true == webElement.GetAttribute("multiple")?.ToLower().Equals("true"))
4200 filesToSend = gPalFileToUpload.Count;
4201
4202 if (true == uploadAllFiles)
4203 for (int idx = 0; idx < gPalFileToUpload.Count; idx++)
4204 {
4205 fileToUpload = gPalFileToUpload.Filenames[idx];
4206 HardwareHelper.SendString($@"""{fileToUpload}"" ");
4207 }
4208 else
4209 HardwareHelper.SendString($@"""{fileToUpload}"" ");
4210
4211 HardwareHelper.SendKey(GPAL.VK_RETURN);
4212
4213 // is the file save dialog still open? send ENTER again
4214 if (BrowserType.Chrome == BrowserSettings.BrowserType || BrowserType.Edge == BrowserSettings.BrowserType)
4215 windowOpened = FormHelper.WaitForWindow("Open", 1);
4216 else if (BrowserType.FireFox == BrowserSettings.BrowserType)
4217 windowOpened = FormHelper.WaitForWindow("File Upload", 1); // NOTE: that is an elipses character used, not ...
4218
4219 if (IntPtr.Zero != windowOpened)
4220 HardwareHelper.SendKey(GPAL.VK_RETURN);
4221
4222 if (true == uploadAllFiles)
4223 break; // we send all the filenames, so long as they are not too long
4224 }
4225 else
4226 {
4227 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to handle Open File dialog [{fileToUpload}][{selector.Name}] not upoloaded.", BrowserSettings, GPALObjectType.Other);
4228 return -1;
4229 }
4230 }
4231
4232 //if (false == BrowserSettings.UseHeadless && BrowserType.Edge == BrowserSettings.BrowserType) // dismiss the 'downloads' popup which can steal keys, clicks or get in the way
4233 //{
4234 // // KLUDGE: must be a hardware top to dismiss 'downloads' by clicking the browser chrome
4235 // bool tmpTop = GPAL.SeleniumTopsBrowser;
4236 // GPAL.WithSeleniumToppingBrowser(false);
4237 // BrowserHelper.TopBrowser(this);
4238 // GPAL.WithSeleniumToppingBrowser(tmpTop);
4239 //}
4240
4241 // NOTE: CAVEAT: not sure how to use WithAllThatMatch, seems cumbersome?
4242 if (int.MaxValue != CurrentUOW.WithAllThatMatch && fileCount == CurrentUOW.WithAllThatMatch && false == uploadAllFiles)
4243 return -1;
4244 else if (false == uploadAllFiles)
4245 return 0;
4246 }
4247
4248 return -1;
4249 }
4250
4251 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4252 {
4253 foreach (Selector selector in CurrentUOW.WithSelectorList)
4254 {
4255 if (SelectorType.Selector != selector.SelectorType)
4256 continue; // we don't look up data literals
4257
4258 ReadOnlyCollection<GPALElement> webelements = ElementHelper.FindWebElements(this, CurrentUOW, selector, out bool matchedAll, out List<GPALElement> matchedElements);
4259 if (null != webelements)
4260 {
4261 foreach (GPALElement webElement in matchedElements)
4262 {
4263 // int.MaxValue tells us we have met our .WithAllThatMatch elements to process
4264 if (-1 == UploadWorkflow(webElement, selector, 1 == matchedElements.Count))
4265 break;
4266 }
4267 }
4268 }
4269 }
4270
4271 GetSetCurrentUrl();
4272
4273 return this;
4274 }
4275
4286 {
4287 string fullFilePathToSave;
4288 HWND windowOpened = IntPtr.Zero;
4289 List<string> returnFilename = new List<string>();
4290 FileDownloaded = false;
4291
4292 // waitfor did not match if returns false
4293 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4294 {
4295 foreach (Selector selector in CurrentUOW.WithSelectorList)
4296 {
4297 if (SelectorType.Selector != selector.SelectorType)
4298 continue; // we don't look up data literals
4299
4300 ReadOnlyCollection<GPALElement> webelements = ElementHelper.FindWebElements(this, CurrentUOW, selector, out bool matchedAll, out List<GPALElement> matchedElements);
4301 if (null != webelements)
4302 {
4303 foreach (GPALElement webElement in matchedElements)
4304 {
4305 int rowCount = 0;
4306
4307 fullFilePathToSave = FileHelper.CleanUpFileDestination(filenames.Next, out string newFilename, BrowserSettings.DeleteFileBeforeDownload);
4308 returnFilename.Add(fullFilePathToSave); // return the filenames generated
4309
4310 // this will iterate over all selectors in the CurrentUOW and right click each and attempt to download from each
4311 // CAVEAT: this is untested with multiple selector elements
4312
4313 // CAVEAT: KLUDGE: Selenium cannot interact with context menu, so switch to javascript click, then back, altho it's prolly pointless to restore it at this point
4314 Enums.InteractionType interactionType = selector.InteractionType;
4315 if (Enums.InteractionType.Selenium == selector.InteractionType && "GPALElement" != webElement.TagName)
4316 selector.InteractionType = InteractionType.JavaScript;
4317 else if ("GPALElement" != webElement.TagName && false == BrowserSettings.UseHeadless)
4318 selector.InteractionType = InteractionType.Hardware;
4319
4320 if (false == BrowserSettings.UseHeadless && (true == BrowserSettings.UseHardware || true == GPAL.GPALSettings.UseHardware)) // if we are not headless, we will handle the dialog, otherwise set using selenium and click
4321 {
4322 // NOTE: CAVEAT: if DownloadToFile always works, why would we care about using the save as dialog?
4323 bool noSaveAsDialog = false;
4324 string currentUrl = null, currentUrl2 = null;
4325
4326 if (true == BrowserSettings.UseOttoMagic)
4327 currentUrl = MagicHelper.GetCurrentUrl();
4328 else if (true == BrowserSettings.UsePuppeteer)
4329 currentUrl = PuppeteerClient.GetCurrentUrl().Execute();
4330 else
4331 currentUrl = BrowserDriver.Url;
4332
4333 ElementHelper.Click(this, selector, webElement, ClickType.RightClick, ModifierKeys.NONE);
4334
4335 // MS EDGE and context menu is weird under driver control, it seems to need time to settle in because it resets to the top of the context menu
4336 // so we sleep 5 seconds
4337 // we also give it a home key to gobble up cause it's just unpredictable
4338 Thread.Sleep(5000);
4339
4340 // arrow down menu, then enter to select entry
4341 // 2 or 3 arrows down. if it goes too far, it copies the url to the clipboard, so if the save as... doesn't open, we will try with 3 arrow down
4342 HardwareHelper.SendKey(GPAL.VK_HOME); // home key press is first element
4343 Thread.Sleep(150);
4344 HardwareHelper.SendKey(GPAL.VK_DOWN);
4345 Thread.Sleep(150);
4346 HardwareHelper.SendKey(GPAL.VK_DOWN);
4347 Thread.Sleep(150);
4348 HardwareHelper.SendKey(GPAL.VK_DOWN);
4349 Thread.Sleep(150);
4350
4351 if (BrowserType.FireFox == BrowserSettings.BrowserType || BrowserType.Edge == BrowserSettings.BrowserType)
4352 HardwareHelper.SendKey(GPAL.VK_DOWN);
4353 Thread.Sleep(150);
4354 HardwareHelper.SendKey(GPAL.VK_RETURN);
4355
4356 // CAVEAT: KLUDGE - see above, restore to selenium
4357 selector.InteractionType = interactionType;
4358
4359 if (BrowserType.Chrome == BrowserSettings.BrowserType || BrowserType.Edge == BrowserSettings.BrowserType)
4360 windowOpened = FormHelper.WaitForWindow("Save As", BrowserSettings.WaitForWindowTimeoutInSeconds);
4361 else if (BrowserType.FireFox == BrowserSettings.BrowserType)
4362 {
4363 windowOpened = FormHelper.WaitForWindow("Enter name of file to save to…", BrowserSettings.WaitForWindowTimeoutInSeconds); // NOTE: that is an elipses character used, not ...
4364
4365 if (IntPtr.Zero == windowOpened) // could we have a misclick and saving this page? then the dialog title is 'Save As'
4366 {
4367 windowOpened = FormHelper.WaitForWindow("Save As", BrowserSettings.WaitForWindowTimeoutInSeconds);
4368
4369 if (IntPtr.Zero != windowOpened)
4370 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"GPAL thinks a misclick happened and the wrong item is saved. Please verify [{fullFilePathToSave}].", this, GPALObjectType.Browser);
4371 }
4372 }
4373
4374 if (IntPtr.Zero != windowOpened)
4375 {
4376 HardwareHelper.SendString(fullFilePathToSave);
4377 HardwareHelper.SendKey(GPAL.VK_RETURN);
4378
4379 // is the file save dialog still open? send ENTER again
4380 // TODO: make strings configurable
4381 if (BrowserType.Chrome == BrowserSettings.BrowserType || BrowserType.Edge == BrowserSettings.BrowserType)
4382 windowOpened = FormHelper.WaitForWindow("Save As", 5);
4383 else if (BrowserType.FireFox == BrowserSettings.BrowserType)
4384 windowOpened = FormHelper.WaitForWindow("Enter name of file to save to…", 5); // NOTE: that is an elipses character used, not ...
4385
4386 if (IntPtr.Zero != windowOpened)
4387 HardwareHelper.SendKey(GPAL.VK_RETURN);
4388
4389 // wait for file to download
4390 string actualDownloadedFilename2 = null;
4391
4392 actualDownloadedFilename2 = FileHelper.WaitForDownloadToFinish(fullFilePathToSave, BrowserSettings.DownloadTimeoutInSec);
4393
4394 FileDownloaded = true != string.IsNullOrEmpty(actualDownloadedFilename2);
4395
4396 if (BrowserType.Edge == BrowserSettings.BrowserType) // dismiss the 'downloads' popup which can steal keys, clicks or get in the way
4397 {
4398 // KLUDGE: must be a hardware top to dismiss 'downloads' by clicking the browser chrome
4399 BrowserHelper.TopBrowser(this, false, true);
4400 }
4401 }
4402 else
4403 {
4404 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to handle Save As... dialog [{fullFilePathToSave}][{selector.Name}]", BrowserSettings, GPALObjectType.Other);
4405 noSaveAsDialog = true;
4406
4407 // but maybe it's a pdf opening in the browser itself, so check to see if that happened and if the url is a pdf file
4408 // of course it is possible the url won't contain .pdf and served up via a header? we might have to chack that
4409
4410 if (true == BrowserSettings.UseOttoMagic)
4411 currentUrl2 = MagicHelper.GetCurrentUrl();
4412 else if (true == BrowserSettings.UsePuppeteer)
4413 currentUrl2 = PuppeteerClient.GetCurrentUrl().Execute();
4414 else
4415 currentUrl2 = BrowserDriver.Url;
4416
4417 // but maybe it's a pdf opening in the browser itself, so check to see if that happened and if the url is a pdf file
4418 // of course it is possible the url won't contain .pdf and served up via a header? we might have to chack that
4419 if (false == BrowserSettings.OpenPDFExternally && true == fullFilePathToSave.EndsWith(".pdf"))
4420 {
4421 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Testing [{currentUrl2}] for PDF file", this, GPALObjectType.Browser);
4422 if (false == UrlHelper.AreEquivalent(currentUrl, currentUrl2) && true == BrowserHelper.TestUrlForPDF(this, currentUrl2))
4423 {
4424 FileDownloaded = BrowserHelper.DownloadPdfFile(currentUrl2, fullFilePathToSave, this);
4425 noSaveAsDialog = !(bool)FileDownloaded;
4426 }
4427 }
4428 }
4429
4430 // we didn't get a save as dialog within BrowserSettings.WaitForWindowTimeoutInSeconds which defaults to 30 seconds
4431 // so why? well, the website might have downloaded directly using the current browser default download directory
4432 // so we will attempt to see if a file was downloaded matching the file extension of the GPALFile the user specified
4433 if (true == noSaveAsDialog)
4434 {
4435 string downloadDirectory = FileHelper.GetDefaultDownloadDirectory(this);
4436 string downloadUrl = FileHelper.GetDownloadUrl(webElement);
4437 string filename = Path.GetFileName(downloadUrl) ?? fullFilePathToSave;
4438
4439 string wildcardFilename = "*" + Path.GetExtension(filename);
4440 string systemDownloadedTo = Path.Combine(downloadDirectory, wildcardFilename);
4441 string actualDownloadedFilename2 = null;
4442
4443 actualDownloadedFilename2 = FileHelper.WaitForDownloadToFinish(systemDownloadedTo, BrowserSettings.DownloadTimeoutInSec);
4444
4445 FileDownloaded = true != string.IsNullOrEmpty(actualDownloadedFilename2);
4446
4447 if (true == FileDownloaded)
4448 {
4449 if (false == fullFilePathToSave.Equals(actualDownloadedFilename2))
4450 {
4451 File.Move(actualDownloadedFilename2, fullFilePathToSave);
4452 if (false == actualDownloadedFilename2.Equals(fullFilePathToSave) && true == File.Exists(actualDownloadedFilename2))
4453 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Downloaded file [{actualDownloadedFilename2}] unable to move to [{fullFilePathToSave}]", null, GPALObjectType.None);
4454 else
4455 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{actualDownloadedFilename2}] moved to [{fullFilePathToSave}]", this, GPALObjectType.Browser);
4456 }
4457 }
4458 }
4459 }
4460 else // set download destination then click
4461 FileHelper.DownloadToFile(webElement, fullFilePathToSave);
4462 /* selenium navigate the context menu, untested
4463 else
4464 {
4465 SetDownloadFilename(fullFilePath);
4466
4467 // Create an Actions object
4468 Actions actions = new Actions(browserSettings.BrowserDriver);
4469 actions.SendKeys(Keys.Home);
4470
4471 // Simulate pressing the down arrow key multiple times
4472 actions.SendKeys(Keys.ArrowDown);
4473 actions.SendKeys(Keys.ArrowDown);
4474 actions.SendKeys(Keys.ArrowDown);
4475
4476 // If using Firefox, simulate pressing the down arrow key one more time to get to FF command
4477 if (BrowserType.FireFox == BrowserSettings.BrowserType)
4478 actions.SendKeys(Keys.ArrowDown);
4479
4480 // Simulate pressing the return key
4481 actions.SendKeys(Keys.Enter);
4482
4483 // Simulate typing the filename
4484 if (BrowserType.FireFox == browserSettings.BrowserType || BrowserType.Edge == browserSettings.BrowserType)
4485 actions.SendKeys(Keys.ArrowDown);
4486
4487 // Simulate pressing the return key
4488 actions.SendKeys(Keys.Enter).Build().Perform();
4489 }
4490 */
4491
4492 // how many of the found elements are we downloading from?
4493 if (int.MaxValue != CurrentUOW.WithAllThatMatch && ++rowCount == CurrentUOW.WithAllThatMatch)
4494 break;
4495 }
4496 }
4497 else
4498 {
4499 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "Failed to detect Save As open window.", this, GPALObjectType.Browser);
4500 }
4501 }
4502
4503 foreach (string filepath in returnFilename)
4504 {
4505 filenames.ReturnFilenames.Add(filepath); // return the filenames we generated
4506 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"File saved to [{filepath}]");
4507 }
4508 }
4509
4510 return this;
4511 }
4512
4521 {
4522 DatabaseHelper.TokenizeDatabase(CurrentUOW, inputDatabase);
4523 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4524 BrowserHelper.FillInWithTokens(this, CurrentUOW.InputDatabase.Tokens, WriteMode.Append);
4525 return this;
4526 }
4527
4536 {
4537 DatabaseHelper.TokenizeDatabase(CurrentUOW, inputDatabase);
4538 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4539 BrowserHelper.FillInWithTokens(this, CurrentUOW.InputDatabase.Tokens, WriteMode.Insert);
4540 return this;
4541 }
4542
4551 {
4552 DatabaseHelper.TokenizeDatabase(CurrentUOW, inputDatabase);
4553 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4554 BrowserHelper.FillInWithTokens(this, CurrentUOW.InputDatabase.Tokens, WriteMode.Overwrite);
4555 return this;
4556 }
4557
4566 {
4567 ((IGPALFileInternal)inputFile).TokenList = FileHelper.TokenizeFile(CurrentUOW, inputFile);
4568 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4569 foreach (IGPALGrid<string> tokens in ((IGPALFileInternal)inputFile).TokenList)
4570 BrowserHelper.FillInWithTokens(this, tokens, WriteMode.Append);
4571 return this;
4572 }
4573
4582 {
4583 ((IGPALFileInternal)inputFile).TokenList = FileHelper.TokenizeFile(CurrentUOW, inputFile);
4584 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4585 foreach (IGPALGrid<string> tokens in ((IGPALFileInternal)CurrentUOW.InputFile).TokenList)
4586 BrowserHelper.FillInWithTokens(this, tokens, WriteMode.Overwrite);
4587 return this;
4588 }
4589
4598 {
4599 ((IGPALFileInternal)inputFile).TokenList = FileHelper.TokenizeFile(CurrentUOW, inputFile);
4600 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4601 foreach (IGPALGrid<string> tokens in ((IGPALFileInternal)inputFile).TokenList)
4602 BrowserHelper.FillInWithTokens(this, tokens, WriteMode.Insert);
4603 return this;
4604 }
4605
4613 public IAllowBrowserActionOrAnySelector AppendFrom(IGPALGrid<string> inputGrid)
4614 {
4615 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4616 BrowserHelper.FillInWithTokens(this, inputGrid, WriteMode.Append);
4617 return this;
4618 }
4619
4627 public IAllowBrowserActionOrAnySelector FillInFrom(IGPALGrid<string> inputGrid)
4628 {
4629 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4630 BrowserHelper.FillInWithTokens(this, inputGrid, WriteMode.Overwrite);
4631 return this;
4632 }
4633
4641 public IAllowBrowserActionOrAnySelector InsertFrom(IGPALGrid<string> inputGrid)
4642 {
4643 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4644 BrowserHelper.FillInWithTokens(this, inputGrid, WriteMode.Insert);
4645 return this;
4646 }
4647
4654 {
4655 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4656 ElementHelper.FillInFrom(this, text, WriteMode.Append);
4657
4658 return this;
4659 }
4660
4667 {
4668 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4669 ElementHelper.FillInFrom(this, text, WriteMode.Overwrite);
4670
4671 return this;
4672 }
4673
4681 {
4682 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4683 ElementHelper.FillInFrom(this, textToUse, WriteMode.Insert);
4684 return this;
4685 }
4686
4693 {
4694 if (null != selector)
4695 WithSelector(selector);
4696 // waitfor did not match if returns false
4697 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4698 ElementHelper.Focus(this);
4699 return this;
4700 }
4701
4708 {
4709 if (null != selector)
4710 WithSelector(selector);
4711
4712 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4713 ElementHelper.Hide(this);
4714
4715 return this;
4716 }
4717
4720 private string _pendingSetAttributeName;
4721
4730 {
4731 _pendingSetAttributeName = attribute;
4732 return this;
4733 }
4734
4741 {
4742 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4743 ElementHelper.SetAttribute(this, _pendingSetAttributeName, value);
4744
4745 return this;
4746 }
4747
4757 {
4758 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4759 ElementHelper.SetValueFromElement(this, selector);
4760
4761 return this;
4762 }
4763
4773 {
4774 return MoveTo(selector);
4775 }
4776
4787 {
4788 if (null != selector)
4789 WithSelector(selector);
4790
4791 // waitfor did not match if returns false
4792 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4793 ElementHelper.MoveTo(this);
4794 return this;
4795 }
4796
4806 {
4807 ReadOnlyCollection<GPALElement> tmpElems;
4808
4809 if (true == CurrentUOW.ActionCalled)
4810 {
4811 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Action has already been called. This workflow has already run. Continuing.", this, GPALObjectType.Browser);
4812 return this;
4813 }
4814 else
4815 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Workflow starting, you should be careful if you follow this with actions. CallIf handlers will be invoked again.", this, GPALObjectType.Browser);
4816
4817 if (true == WorkflowSetup(WaitTime.Never < CurrentUOW.WaitForInMs, true))
4818 for (int pages = 0; pages < CurrentUOW.PageCount; pages++) // is start workflow is used, then we are using callif/callafter handlers with no main workflow
4819 {
4820 foreach (Selector sel in CurrentUOW.WithSelectorList)
4821 if (SelectorType.Selector == sel.SelectorType)
4822 tmpElems = ElementHelper.FindWebElements(this, CurrentUOW, sel, out bool matchedAll, out List<GPALElement> matchedElems);
4823
4824 if (pages < CurrentUOW.PageCount - 1)
4825 {
4826 if (false == BrowserSettings.AttachedRestApi)
4827 BrowserHelper.TopBrowser(this, false); // ensure the browser window is on top, can use selenium or hardware (mouse click) topping. firefox will only top with hardware
4828 if (false == BrowserHelper.GotoNextPage(this))
4829 {
4830 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No next page, breaking loop. Retrieved [{pages + 1}] out of [{CurrentUOW.PageCount}] pages.", this, GPALObjectType.Browser);
4831 break;
4832 }
4833 }
4834 }
4835
4836 return this;
4837 }
4838 public IAllowBrowserActionOrAnySelector GetElements(out List<GPALElement> elements)
4839 {
4840 elements = new List<GPALElement>();
4841
4842 if (true == CurrentUOW.ActionCalled)
4843 {
4844 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Action has already been called. Not retrieving elements.", this, GPALObjectType.Browser);
4845 return this;
4846 }
4847
4848 if (true == WorkflowSetup(WaitTime.Never < CurrentUOW.WaitForInMs, true))
4849 for (int pages = 0; pages < CurrentUOW.PageCount; pages++)
4850 {
4851 foreach (Selector sel in CurrentUOW.WithSelectorList)
4852 if (SelectorType.Selector == sel.SelectorType)
4853 {
4854 ElementHelper.FindWebElements(this, CurrentUOW, sel, out bool matchedAll, out List<GPALElement> matchedElems);
4855 if (sel.WebSelectorFoundResults != null)
4856 elements.AddRange(sel.WebSelectorFoundResults);
4857 }
4858
4859 if (pages < CurrentUOW.PageCount - 1)
4860 {
4861 BrowserHelper.TopBrowser(this, false);
4862 if (false == BrowserHelper.GotoNextPage(this))
4863 {
4864 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No next page, breaking loop. Retrieved [{pages + 1}] out of [{CurrentUOW.PageCount}] pages.", this, GPALObjectType.Browser);
4865 break;
4866 }
4867 }
4868 }
4869
4870 return this;
4871 }
4899 public IAllowWithHeaderOrFileActions GetGrid(ref IGPALGrid<string> returnGrid)
4900 {
4901 List<ReadOnlyCollection<GPALElement>> columns = new List<ReadOnlyCollection<GPALElement>>();
4902 List<ReadOnlyCollection<GPALElement>> tmpColumns = new List<ReadOnlyCollection<GPALElement>>();
4903 var selectorMeta = new List<(string Name, string AttributeName)>();
4904 List<GPALElement> matchedElems = new List<GPALElement>();
4905 dynamic foundElements = null;
4906
4907 CurrentUOW.GetGridCalled = true;
4908 CurrentUOW.RetGrid.Clear();
4909
4910 // Table-native path (engine-agnostic navigation). When a single selector resolves to a
4911 // <table> or its <tr> rows, the grid is extracted structurally below and we return early.
4912 // Everything else falls through to the unchanged column-per-selector zip.
4913 //if (TryGetGridFromTable(ref returnGrid))
4914 // return this;
4915
4916 // Content-based row dedup, opt-in via .WithDedupeData(true) on the grid the caller passed in.
4917 // seenRows holds a key per row's extracted values. We seed it from the rows already in the caller's
4918 // grid so accumulation across GetGrid calls stays duplicate-free, not just within this one call.
4919 bool useDedupe = (returnGrid as GPALGrid<string>)?.UseDedupe ?? false;
4920 var seenRows = new HashSet<string>();
4921 if (true == useDedupe && null != returnGrid)
4922 foreach (List<string> existingRow in returnGrid)
4923 seenRows.Add(RowDedupeKey(existingRow));
4924 bool capReached = false;
4925
4926 for (int pages = 0; pages < CurrentUOW.PageCount && !capReached; pages++)
4927 {
4928 // check for persistent selectors on each new page
4929 if (!InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
4930 {
4931 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "WaitFor did not find the elements for the grid. Not retrieving anything.", this, GPALObjectType.Browser);
4932 return this;
4933 }
4934
4935 tmpColumns.Clear();
4936 columns.Clear();
4937 CurrentUOW.MatchedRowIndexes.Clear();
4938 selectorMeta.Clear();
4939
4940 BrowserHelper.CheckDocumentReady(this);
4941
4942 // iterate over selectors
4943 foreach (Selector sel in CurrentUOW.WithSelectorList)
4944 {
4945 // cache is no longer used, but maybe the user might wanna look at tham for debugging?
4946 sel.WebSelectorFoundResults = null;
4947 sel.WebSelectorMatchedResults = null;
4948
4949 selectorMeta.Add((sel.Name, sel.AttributeName));
4950
4951 // we have selectors for elements and data/datafunction literals
4952 if (SelectorType.Selector == sel.selectorSettings.SelectorType)
4953 {
4954 bool matchedAll = false;
4955
4956 foundElements = ElementHelper.FindWebElements(this, CurrentUOW, sel, out matchedAll, out matchedElems);
4957
4958 // the only instance where matchedElems does not have what we want is if custom match doesn't return any...
4959 // if nothing matches, then create a psuedo "NOT FOUND" element for the grid
4960 if (matchedElems?.Count > 0)
4961 {
4962 foreach (GPALElement element in matchedElems)
4963 element.AttributeName = sel.AttributeName;
4964
4965 tmpColumns.Add(new ReadOnlyCollection<GPALElement>(matchedElems));
4966 }
4967 //else if (foundElements?.Count > 0) // silly check
4968 // tmpColumns.Add(new ReadOnlyCollection<GPALElement>(foundElements));
4969 else
4970 {
4971 var placeholder = new List<GPALElement>();
4972 int rows = CurrentUOW.RowCount > 0 ? CurrentUOW.RowCount : 0; // set from previous iteration, how many found for that selector?
4973 if (0 < rows)
4974 {
4975 for (int i = 0; i < rows; i++)
4976 placeholder.Add(new GPALElement(new Point(0, 0), new Size(1, 1), $"{GPAL.ErrorPlaceholder} : {sel.Name}", BrowserDriver, "GPALElement") { AttributeName = sel.AttributeName, Browser = this, Selector = sel });
4977 tmpColumns.Add(new ReadOnlyCollection<GPALElement>(placeholder));
4978 }
4979 }
4980
4981 if (matchedElems?.Count > 0)
4982 {
4983 CurrentUOW.ColCount++;
4984
4985 // set rowcount (found elements) for this iteration
4986 if (matchedAll) // found = matched
4987 CurrentUOW.RowCount = Math.Max(CurrentUOW.RowCount, matchedElems.Count);
4988 else if (matchedElems?.Count > 0) // matched match criteria
4989 {
4990 CurrentUOW.PartialMatch = true;
4991 int tmpIdx = 0;
4992 foreach (var elem in matchedElems)
4993 if ((tmpIdx = matchedElems.IndexOf(elem)) != -1 && !CurrentUOW.MatchedRowIndexes.ContainsKey(tmpIdx))
4994 CurrentUOW.MatchedRowIndexes.Add(tmpIdx, sel.AttributeName);
4995 CurrentUOW.RowCount = CurrentUOW.MatchedRowIndexes.Count;
4996 }
4997 else // we found no elements but need placeholders if the workflow specifies WithAllThatMatch (int.MaxValue means all, no end, limited by pages hopefully)
4998 CurrentUOW.RowCount = int.MaxValue != CurrentUOW.WithAllThatMatch ? CurrentUOW.WithAllThatMatch : 0;
4999 }
5000 }
5001 else if (SelectorType.Data == sel.selectorSettings.SelectorType) // use literal data
5002 {
5003 CurrentUOW.ColCount++;
5004 GPALElement tmpElement = GPAL.Element;
5005 tmpElement.Text = sel.SelectorPath;
5006 tmpColumns.Add(new ReadOnlyCollection<GPALElement>(new List<GPALElement>(1) { tmpElement }));
5007 }
5008 else if (SelectorType.DataFunc == sel.selectorSettings.SelectorType) // use literal data provided by function
5009 {
5010 CurrentUOW.ColCount++;
5011 GPALElement tmpElement = GPAL.Element;
5012 tmpElement.Text = sel.selectorSettings.DataFunction();
5013 tmpColumns.Add(new ReadOnlyCollection<GPALElement>(new List<GPALElement>(1) { tmpElement }));
5014 }
5015
5016 // one line per selector instead of one going in and one coming out. Not guarded on finding
5017 // something: a selector that found nothing is the case you most want to see in the log
5018 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Grid for [{sel.Name}] page [{pages + 1}] returned [{CurrentUOW.RowCount}] rows of [{tmpColumns.Count()}] columns", this, GPALObjectType.Browser);
5019 }
5020
5021 if (CurrentUOW.PartialMatch)
5022 {
5023 columns.Clear();
5024 foreach (var colElems in tmpColumns)
5025 {
5026 var newCol = new List<GPALElement>();
5027 // the matched indexes come from one selector, but they are applied to every column, and
5028 // columns are not all the same length yet - a literal data selector is one element, a
5029 // placeholder column can be shorter. an index past the end is always past the end, so
5030 // skipping it just leaves the tail short, and the padding loop below fills it out to
5031 // targetRows with placeholders exactly as it does for every other short column.
5032 foreach (var idx in CurrentUOW.MatchedRowIndexes.Keys)
5033 if (idx < colElems.Count)
5034 newCol.Add(colElems[idx]);
5035 columns.Add(new ReadOnlyCollection<GPALElement>(newCol));
5036 }
5037 }
5038 else
5039 columns = tmpColumns;
5040
5041 // how many elements (rows of data) did we find on this page
5042 int pageRows = 0 < columns.Count ? columns.Max(c => c.Count) : 0;
5043 int targetRows = CurrentUOW.RowCount > 0 ? CurrentUOW.RowCount : pageRows;
5044
5045 // apply WithAllThatMatch per-page cap (only when n fits within one page)
5046 // total-cap and dedup are handled row-by-row during population below
5047 if (int.MaxValue != CurrentUOW.WithAllThatMatch && CurrentUOW.WithAllThatMatch <= pageRows)
5048 targetRows = Math.Min(targetRows, CurrentUOW.WithAllThatMatch);
5049
5050 // how many colums did we get? should be the number of selectors
5051 // now create our columns to iterate, whatever had the most, that is what we will fill all rows to
5052 for (int i = 0; i < columns.Count; i++)
5053 {
5054 var col = columns[i].ToList();
5055 while (col.Count < targetRows) // one of the selectors has more rows than this one, so create a placeholder
5056 {
5057 // for scrollintoview, we need something...
5058 var firstNonEmpty = columns.FirstOrDefault(c => c.Count > 0)?.FirstOrDefault();
5059 var meta = selectorMeta[i];
5060 col.Add(new GPALElement(new Point(0, 0), new Size(1, 1), $"{GPAL.ErrorPlaceholder} : {meta.Name}", BrowserDriver, "GPALElement") { AttributeName = meta.AttributeName, Browser = this, Css = firstNonEmpty?.Css, ElementBackendNodeId = (int)(firstNonEmpty?.ElementBackendNodeId) });
5061 }
5062 columns[i] = new ReadOnlyCollection<GPALElement>(col);
5063 }
5064 CurrentUOW.RowCount = targetRows;
5065
5066 // Populate RetGrid row-major. Dedup (when opted in) is content-based: we key each row by its
5067 // extracted values, so a row is dropped if its data duplicates one already accumulated (seenRows
5068 // is seeded from the caller's grid) or one seen earlier this call. This composes with the
5069 // grid-accumulation contract and needs no per-element attribute hashing.
5070 // WithAllThatMatch total-cap stops accumulation once we have enough unique rows
5071 for (int r = 0; r < CurrentUOW.RowCount && !capReached; r++)
5072 {
5073 var row = new List<string>();
5074 for (int c = 0; c < columns.Count; c++)
5075 {
5076 var elem = columns[c][r];
5077
5078 // whatever item we are scraping, bring it into view
5079 // for UX
5080 if (true == BrowserSettings.ScrollIntoView)
5081 if (false == BrowserSettings.UseHeadless && false == "GPALElement".Equals(elem.TagName))
5082 if (true == UseOttoMagic)
5083 {
5084 if (false == string.IsNullOrEmpty(elem.Css))
5085 MagicHelper.ScrollIntoView(elem.Css);
5086 }
5087 else if (true == UsePuppeteer)
5088 PuppeteerClient.ScrollIntoView(elem).Execute();
5089 else
5090 ElementHelper.ScrollIntoView(this, elem);
5091
5092 if (!string.IsNullOrEmpty(elem.AttributeName))
5093 row.Add(elem.GetAttribute(elem.AttributeName));
5094 else if (null != elem.TagName)
5095 {
5096 switch (elem.TagName?.ToLower())
5097 {
5098 case "a": row.Add(elem.GetAttribute("href")); break;
5099 case "img": row.Add(elem.GetAttribute("src")); break;
5100 default: row.Add(elem.Text); break;
5101 }
5102 }
5103 else // literal data
5104 row.Add(elem.Text);
5105 }
5106
5107 // DEDUPE: skip this row if its data matches one already in the grid or seen this call
5108 if (true == useDedupe && false == seenRows.Add(RowDedupeKey(row)))
5109 continue;
5110
5111 CurrentUOW.RetGrid.AddRow(row);
5112 if (int.MaxValue != CurrentUOW.WithAllThatMatch && CurrentUOW.WithAllThatMatch > pageRows
5113 && CurrentUOW.RetGrid.Count() >= CurrentUOW.WithAllThatMatch)
5114 capReached = true;
5115 }
5116
5117 if (!capReached && pages < CurrentUOW.PageCount - 1)
5118 {
5119 BrowserHelper.TopBrowser(this, false);
5120 if (!BrowserHelper.GotoNextPage(this))
5121 {
5122 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"No next page, breaking loop. Retrieved [{pages + 1}] out of [{CurrentUOW.PageCount}] pages.", this, GPALObjectType.Browser);
5123 break;
5124 }
5125 }
5126 }
5127
5128 // Hand back the results without taking ownership of a grid the caller passed in:
5129 // - null in -> the caller just wants a grid, so give them GPAL's internal one.
5130 // - grid in -> the caller owns it (and may have configured it, e.g. WithDedupeData); add our rows
5131 // into THEIR grid and keep their reference so repeated GetGrid calls can accumulate.
5132 if (null == returnGrid)
5133 returnGrid = (IGPALGrid<string>)CurrentUOW.RetGrid;
5134 else
5135 returnGrid.Add((IGPALGrid<string>)CurrentUOW.RetGrid);
5136
5137 return this;
5138 }
5139
5144 private static string RowDedupeKey(List<string> row)
5145 {
5146 return string.Join("\u001F", row);
5147 }
5148
5149 // ─────────────────────────────────────────────────────────────────────────────────────
5150 // Table-native GetGrid (engine-agnostic). Wedged into GetGrid above; does not touch the
5151 // column-per-selector zip. A single selector that resolves to a <table> (or its <tr> rows)
5152 // is walked structurally with the same ElementHelper.FindWebElements dispatch used
5153 // everywhere else, honoring colspan/rowspan, deriving headers from <th>, filling gaps with
5154 // GPAL.ErrorPlaceholder, and populating CurrentUOW.RetGrid so SaveTo*/pagination just work.
5155 // NOTE: OttoMagic/Puppeteer scope a child query by the parent element's Css, so the table
5156 // and its rows must carry a usable Css for those engines (Selenium scopes by IWebElement).
5157 // NOTE: deeply nested tables are a known limitation (descendant tr/td/th matches inner cells).
5158 // ─────────────────────────────────────────────────────────────────────────────────────
5159 private bool TryGetGridFromTable(ref IGPALGrid<string> returnGrid)
5160 {
5161 // Only a single element-selector request can be table-native. Multiple selectors are the
5162 // column-per-selector zip, and literal Data/DataFunc columns are never tables.
5163 if (1 != CurrentUOW.WithSelectorList.Count
5164 || SelectorType.Selector != CurrentUOW.WithSelectorList[0].selectorSettings.SelectorType)
5165 return false;
5166
5167 Selector sel = CurrentUOW.WithSelectorList[0];
5168
5169 // Probe once to learn the target type. If it is not a <table> or a set of <tr> rows,
5170 // this is not table-native; return false and let the untouched column-zip path run.
5171 bool matchedAll; List<GPALElement> matchedElems;
5172 ReadOnlyCollection<GPALElement> seeds = ElementHelper.FindWebElements(this, CurrentUOW, sel, out matchedAll, out matchedElems);
5173 string tag = (null != seeds && seeds.Count > 0) ? seeds[0].TagName?.ToLowerInvariant() : null;
5174 bool isTable = "table" == tag;
5175 bool isRows = "tr" == tag;
5176 if (!isTable && !isRows)
5177 return false;
5178
5179 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"GetGrid: [{sel.Name}] resolved to <{tag}>; extracting the table structurally.", this, GPALObjectType.Browser);
5180
5181 var seenRows = new HashSet<string>();
5182 List<string> headers = null;
5183 bool capReached = false;
5184 int cap = (int.MaxValue == CurrentUOW.WithAllThatMatch)
5185 ? int.MaxValue : CurrentUOW.WithAllThatMatch;
5186
5187 for (int page = 0; page < CurrentUOW.PageCount && !capReached; page++)
5188 {
5189 if (page > 0)
5190 {
5191 seeds = ElementHelper.FindWebElements(this, CurrentUOW, sel, out matchedAll, out matchedElems);
5192 if (null == seeds || 0 == seeds.Count)
5193 break;
5194 }
5195 BrowserHelper.CheckDocumentReady(this);
5196
5197 // Gather the body row elements for this page (and headers, once).
5198 var bodyRows = new List<GPALElement>();
5199 if (isTable)
5200 {
5201 foreach (GPALElement table in seeds)
5202 {
5203 var headerRows = FindWithin(table, "thead tr");
5204 var tableBody = FindWithin(table, "tbody tr");
5205 if (0 == tableBody.Count && 0 == headerRows.Count)
5206 {
5207 // no thead/tbody: treat a leading all-<th> row as the header
5208 var all = FindWithin(table, "tr");
5209 if (all.Count > 0 && RowIsAllHeader(all[0]))
5210 {
5211 headerRows.Add(all[0]);
5212 tableBody.AddRange(all.Skip(1));
5213 }
5214 else
5215 tableBody.AddRange(all);
5216 }
5217 if (null == headers && headerRows.Count > 0)
5218 {
5219 var hmatrix = BuildTableMatrix(headerRows);
5220 if (hmatrix.Count > 0)
5221 headers = hmatrix[0]; // first header row, span-aligned to the data
5222 }
5223 bodyRows.AddRange(tableBody);
5224 }
5225 }
5226 else // the seeds themselves are the rows
5227 {
5228 bodyRows.AddRange(seeds);
5229 }
5230
5231 // Build a rectangular, span-aware matrix of cell text for these rows.
5232 List<List<string>> matrix = BuildTableMatrix(bodyRows);
5233
5234 foreach (List<string> row in matrix)
5235 {
5236 string key = string.Join("␁", row);
5237 if (!seenRows.Add(key))
5238 continue; // duplicate row from a prior page/scroll pass
5239 CurrentUOW.RetGrid.AddRow(row);
5240 if (CurrentUOW.RetGrid.Count() >= cap)
5241 {
5242 capReached = true;
5243 break;
5244 }
5245 }
5246
5247 if (!capReached && page < CurrentUOW.PageCount - 1)
5248 {
5249 BrowserHelper.TopBrowser(this, false);
5250 if (!BrowserHelper.GotoNextPage(this))
5251 {
5252 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"GetGrid: no next page after page [{page + 1}].", this, GPALObjectType.Browser);
5253 break;
5254 }
5255 }
5256 }
5257
5258 if (null != headers && headers.Count > 0
5259 && (null == CurrentUOW.HeaderList || 0 == CurrentUOW.HeaderList.Count))
5260 CurrentUOW.HeaderList = headers;
5261
5262 returnGrid = (IGPALGrid<string>)CurrentUOW.RetGrid;
5263 return true;
5264 }
5265
5266 // Engine-agnostic scoped query: elements matching css *within* a parent element, using the
5267 // same ElementHelper.FindWebElements dispatch (Selenium/Puppeteer/OttoMagic) as the rest of GPAL.
5268 private List<GPALElement> FindWithin(GPALElement parent, string css)
5269 {
5270 Selector scoped = GPAL.Selector.WithCSS(css).ToGPALObject();
5271 bool matchedAll; List<GPALElement> matchedElems;
5272 ReadOnlyCollection<GPALElement> found = ElementHelper.FindWebElements(this, CurrentUOW, scoped, out matchedAll, out matchedElems, webElement: parent);
5273 return (null != found) ? found.ToList() : new List<GPALElement>();
5274 }
5275
5276 // True when a <tr>'s cells are all <th> (a header row when there is no <thead>).
5277 private bool RowIsAllHeader(GPALElement tr)
5278 {
5279 var cells = FindWithin(tr, "td, th");
5280 if (0 == cells.Count)
5281 return false;
5282 foreach (GPALElement c in cells)
5283 if (!"th".Equals(c.TagName, StringComparison.OrdinalIgnoreCase))
5284 return false;
5285 return true;
5286 }
5287
5288 // Turn <tr> elements into a rectangular grid of cell strings, honoring colspan and rowspan.
5289 // Spanned cells repeat their text across covered positions; structural gaps and short rows
5290 // are filled with GPAL.ErrorPlaceholder so every row has the same column count.
5291 private List<List<string>> BuildTableMatrix(List<GPALElement> rowElems)
5292 {
5293 var matrix = new List<List<string>>();
5294 // pending rowspans: column index -> (remaining future rows, text)
5295 var pending = new Dictionary<int, KeyValuePair<int, string>>();
5296
5297 foreach (GPALElement tr in rowElems)
5298 {
5299 var cells = FindWithin(tr, "td, th");
5300 var row = new List<string>();
5301 int col = 0, ci = 0;
5302
5303 while (ci < cells.Count || pending.Keys.Any(k => k >= col))
5304 {
5305 KeyValuePair<int, string> carry;
5306 if (pending.TryGetValue(col, out carry))
5307 {
5308 row.Add(carry.Value);
5309 int left = carry.Key - 1;
5310 if (left > 0) pending[col] = new KeyValuePair<int, string>(left, carry.Value);
5311 else pending.Remove(col);
5312 col++;
5313 continue;
5314 }
5315
5316 if (ci < cells.Count)
5317 {
5318 GPALElement cell = cells[ci++];
5319 string text = CellText(cell);
5320 int cspan = ParseSpan(cell, "colspan");
5321 int rspan = ParseSpan(cell, "rowspan");
5322 for (int c = 0; c < cspan; c++)
5323 {
5324 row.Add(text);
5325 if (rspan > 1) pending[col] = new KeyValuePair<int, string>(rspan - 1, text);
5326 col++;
5327 }
5328 }
5329 else
5330 {
5331 // a rowspan carry sits further right but there is no cell here: keep alignment
5332 row.Add(GPAL.ErrorPlaceholder);
5333 col++;
5334 }
5335 }
5336
5337 matrix.Add(row);
5338 }
5339
5340 // pad every row to the widest so the grid is rectangular
5341 int width = 0;
5342 foreach (var r in matrix) if (r.Count > width) width = r.Count;
5343 foreach (var r in matrix) while (r.Count < width) r.Add(GPAL.ErrorPlaceholder);
5344
5345 return matrix;
5346 }
5347
5348 private static int ParseSpan(GPALElement cell, string attr)
5349 {
5350 int n;
5351 string v = cell.GetAttribute(attr);
5352 return (int.TryParse(v, out n) && n > 0) ? n : 1;
5353 }
5354
5355 // Cell text read the same way the column-zip path reads cells, so both paths agree.
5356 private string CellText(GPALElement cell)
5357 {
5358 if (!string.IsNullOrEmpty(cell.AttributeName))
5359 return cell.GetAttribute(cell.AttributeName);
5360 switch (cell.TagName?.ToLowerInvariant())
5361 {
5362 case "a": return cell.GetAttribute("href");
5363 case "img": return cell.GetAttribute("src");
5364 default: return cell.Text ?? string.Empty;
5365 }
5366 }
5367
5418 public IAllowCallTemplate WithCallFilter(string urlFragment)
5419 {
5420 BrowserSettings.CallFilter = urlFragment;
5421
5422 return this;
5423 }
5424
5444 {
5445 template = null;
5446
5447 // waiting is pointless where nothing is recording, so this says so now rather than in thirty seconds
5448 if (false == CanCaptureCalls)
5449 {
5450 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"No template: capturing calls is not available on [{BrowserSettings.AutomationEngine}]", this, GPALObjectType.Browser);
5451
5452 return this;
5453 }
5454
5455 CaptureCalls(true);
5456
5457 GPALCall found = null;
5458 Stopwatch waited = Stopwatch.StartNew();
5459
5460 while (null == found && CallTemplateWaitMilliseconds > waited.ElapsedMilliseconds)
5461 {
5462 CaptureCalls(out List<GPALCall> calls);
5463
5464 // the last match, not the last call. the filter narrows what is recorded from here on, but
5465 // anything captured before it was set is still in the list, so it is applied again here
5466 foreach (GPALCall call in calls)
5467 if (true == string.IsNullOrEmpty(BrowserSettings.CallFilter) || true == call.Url?.Contains(BrowserSettings.CallFilter))
5468 found = call;
5469
5470 if (null == found)
5471 System.Threading.Thread.Sleep(250);
5472 }
5473
5474 waited.Stop();
5475
5476 if (null == found)
5477 {
5478 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"No call matching [{BrowserSettings.CallFilter}] in [{waited.ElapsedMilliseconds}]ms, so there is no template", this, GPALObjectType.Browser);
5479
5480 return this;
5481 }
5482
5483 template = (GPALRequest)GPAL.Request
5484 .WithPath(found.Url)
5485 .WithHttpMethod("POST".Equals(found.Method, StringComparison.OrdinalIgnoreCase) ? Enums.HttpVerb.Post : Enums.HttpVerb.Get)
5486 .WithBody(found.PostData)
5487 .WithName(BrowserSettings.CallFilter ?? found.Url);
5488
5489 // a browser sets these for itself and a fetch refuses to be told otherwise, so a template carrying
5490 // them would be a template that cannot be sent
5491 foreach (KeyValuePair<string, string> header in found.Headers)
5492 if (false == BrowserHelper.BrowserOwnedHeader(header.Key))
5493 template.WithHeader($"{header.Key}: {header.Value}");
5494
5495 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Template taken from the page's own [{found.Method}] to [{found.Url}] after [{waited.ElapsedMilliseconds}]ms, with [{template.Headers.Count}] of its headers", this, GPALObjectType.Browser);
5496
5497 return this;
5498 }
5499
5500 // how long to wait for the page to make the call a template is wanted from
5501 private const int CallTemplateWaitMilliseconds = 30_000;
5502
5503 // puppeteer reports requests over CDP, the extension watches with webRequest, and selenium records them
5504 // in the page and needs a driver to inject through. all three can, by different means
5505 private bool CanCaptureCalls => true == BrowserSettings.UsePuppeteer
5506 || true == BrowserSettings.UseOttoMagic
5507 || null != BrowserSettings.BrowserDriver;
5508
5509 public IAllowBrowserActionOrAnySelector CaptureCalls(bool capture = true)
5510 {
5511 // the browser reports its requests over CDP, which is Puppeteer's channel. selenium would need CDP
5512 // wired up the same way and the extension would need its own webRequest listener, so this says so
5513 // rather than recording nothing and looking like a page that asked for nothing
5514 // said once per browser, not once per ask. a workflow waiting for a template asks in a loop, and an
5515 // engine that cannot capture would otherwise say so a hundred times while it waits for nothing
5516 if (false == CanCaptureCalls)
5517 {
5518 if (false == BrowserSettings.CaptureUnavailableReported)
5519 {
5520 BrowserSettings.CaptureUnavailableReported = true;
5521
5522 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Capturing calls is not available on [{BrowserSettings.AutomationEngine}]. It needs a browser that reports its requests, and this one has none started", this, GPALObjectType.Browser);
5523 }
5524
5525 return this;
5526 }
5527
5528 if (true == BrowserSettings.UseOttoMagic)
5529 {
5530 // the extension watches with webRequest, from outside the page. nothing is injected, so this
5531 // survives a navigation on its own and sees the requests no page script issued
5532 BrowserSettings.CaptureCalls = capture;
5533
5534 MagicHelper.CaptureCalls(capture, BrowserSettings.CallFilter);
5535 }
5536 else if (false == BrowserSettings.UsePuppeteer && null != BrowserSettings.BrowserDriver)
5537 {
5538 // selenium has no CDP event stream, so the page records for itself. registered to run on every
5539 // document where chromium allows it, and injected into the page in hand either way, so saying
5540 // this after a navigation still starts recording
5541 BrowserSettings.CaptureCalls = capture;
5542
5543 if (true == capture)
5544 {
5545 if (BrowserType.Chrome == BrowserSettings.BrowserType || BrowserType.Edge == BrowserSettings.BrowserType)
5546 BrowserHelper.SeleniumAddScriptToEvaluateOnNewDocument(BrowserSettings, BrowserHelper.CallRecorderScript);
5547
5548 BrowserHelper.ExecuteJavaScript(this, BrowserHelper.CallRecorderScript);
5549 }
5550 }
5551 else
5552 {
5553 // recorded as a setting first. saying this before going anywhere is the ordinary way to write it,
5554 // and reaching for the communicator now would build one to talk to a browser that has not started
5555 BrowserSettings.CaptureCalls = capture;
5556
5557 // a communicator that exists is a browser that is up, so it can be told now. asking for one that
5558 // does not exist would build it, and the Puppeteer engine has no Selenium driver to test instead
5559 if (true == BrowserSettings.HasPuppeteerCommunicator)
5560 BrowserSettings.PuppeteerCommunicator.CaptureCalls(capture).GetAwaiter().GetResult();
5561 }
5562
5563 return this;
5564 }
5565
5581 public IAllowBrowserActionOrAnySelector CaptureCalls(out List<GPALCall> calls)
5582 {
5583 CaptureCalls(true);
5584
5585 calls = true == BrowserSettings.UsePuppeteer
5586 ? true == BrowserSettings.HasPuppeteerCommunicator
5587 ? new List<GPALCall>(BrowserSettings.PuppeteerCommunicator.CapturedCalls)
5588 : new List<GPALCall>()
5589 : true == BrowserSettings.UseOttoMagic
5590 ? ExtensionRecordedCalls()
5591 : RecordedCalls();
5592
5593 // debug, not info. taking a snapshot is something a workflow does in a loop while it waits, and a
5594 // line per attempt buries the run. the template says the thing worth reading
5595 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Handing back [{calls.Count}] captured calls.", this, GPALObjectType.Browser);
5596
5597 return this;
5598 }
5599
5611 {
5612 if (true == BrowserSettings.UseOttoMagic)
5613 // the recorder keeps recording, and clears what it holds on the way through
5614 MagicHelper.CaptureCalls(BrowserSettings.CaptureCalls, BrowserSettings.CallFilter, true);
5615 else if (true == BrowserSettings.UsePuppeteer)
5616 {
5617 if (true == BrowserSettings.HasPuppeteerCommunicator)
5618 BrowserSettings.PuppeteerCommunicator.ClearCapturedCalls();
5619 }
5620 else if (null != BrowserSettings.BrowserDriver)
5621 // the page holds its own, so the page is where it is emptied
5622 BrowserHelper.ExecuteJavaScript(this, "window.__gpalCalls = []; return true;");
5623
5624 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, "Forgot the calls captured so far.", this, GPALObjectType.Browser);
5625
5626 return this;
5627 }
5628
5629 // webRequest and CDP name the same kinds of request differently: main_frame against Document,
5630 // xmlhttprequest against XHR. a workflow that filters on the type should not have to know which engine
5631 // recorded it, so the extension's names are put into CDP's before anyone sees them
5632 private static string CdpResourceType(string webRequestType)
5633 {
5634 string retVal = webRequestType;
5635
5636 switch (webRequestType)
5637 {
5638 case "main_frame":
5639 case "sub_frame": retVal = "Document"; break;
5640 case "xmlhttprequest": retVal = "XHR"; break;
5641 case "script": retVal = "Script"; break;
5642 case "image":
5643 case "imageset": retVal = "Image"; break;
5644 case "stylesheet": retVal = "Stylesheet"; break;
5645 case "font": retVal = "Font"; break;
5646 case "media": retVal = "Media"; break;
5647 case "websocket": retVal = "WebSocket"; break;
5648 case "ping": retVal = "Ping"; break;
5649 case "csp_report": retVal = "CSPViolationReport"; break;
5650
5651 // webRequest puts both XHR and fetch under xmlhttprequest, where CDP tells them apart. a
5652 // workflow filtering for the site's own api wants both either way, so nothing is lost.
5653 // "other" is the catch-all for what fits nowhere else, and is left saying exactly that
5654 case "other": retVal = "Other"; break;
5655 }
5656
5657 return retVal;
5658 }
5659
5660 // what the extension recorded, read back as the same shape CDP produces. webRequest watches the browser
5661 // rather than the page, so unlike the selenium recorder this holds the documents, scripts and images too
5662 private List<GPALCall> ExtensionRecordedCalls()
5663 {
5664 List<GPALCall> retVal = new List<GPALCall>();
5665
5666 string json = MagicHelper.GetCapturedCalls();
5667
5668 if (true == string.IsNullOrWhiteSpace(json)) return retVal;
5669
5670 json = json.Trim();
5671
5672 // native messaging hands the extension's answer over as a json string, so what arrives here is
5673 // sometimes the object and sometimes that object encoded inside a string. unwrap the second before
5674 // parsing, because the escaped quotes inside it are not something JObject.Parse will take
5675 if (true == json.StartsWith("\""))
5676 json = Newtonsoft.Json.JsonConvert.DeserializeObject<string>(json);
5677
5678 Newtonsoft.Json.Linq.JObject snapshot;
5679
5680 // a snapshot that cannot be read is the one failure that looks exactly like a page that made no
5681 // calls, so it says what it got rather than handing back an empty list
5682 try
5683 {
5684 snapshot = Newtonsoft.Json.Linq.JObject.Parse(json);
5685 }
5686 catch (GPALException)
5687 {
5688 throw;
5689 }
5690 catch (Exception ex)
5691 {
5692 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"The captured calls could not be read. [{json.Length}] characters starting [{json.Substring(0, Math.Min(120, json.Length))}]", this, GPALObjectType.Browser, ex);
5693
5694 return retVal;
5695 }
5696
5697 // the recorder keeps the newest and counts what fell off, so a workflow that finds nothing can tell
5698 // a page that never called it from a page that called too much
5699 int dropped = (int?)snapshot["dropped"] ?? 0;
5700
5701 if (0 < dropped)
5702 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"The recorder dropped [{dropped}] older calls to stay within its limit", this, GPALObjectType.Browser);
5703
5704 foreach (Newtonsoft.Json.Linq.JObject recorded in snapshot["calls"] ?? new Newtonsoft.Json.Linq.JArray())
5705 {
5706 GPALCall call = new GPALCall
5707 {
5708 Url = recorded["url"]?.ToString(),
5709 Method = recorded["method"]?.ToString(),
5710 ResourceType = CdpResourceType(recorded["type"]?.ToString()),
5711 PostData = recorded["postData"]?.ToString(),
5712 Initiator = recorded["initiator"]?.ToString(),
5713 Status = (int?)recorded["status"] ?? 0
5714 };
5715
5716 if (recorded["headers"] is Newtonsoft.Json.Linq.JObject headers)
5717 foreach (KeyValuePair<string, Newtonsoft.Json.Linq.JToken> header in headers)
5718 call.Headers[header.Key] = header.Value?.ToString();
5719
5720 retVal.Add(call);
5721 }
5722
5723 return retVal;
5724 }
5725
5726 // what the page recorded for itself, read back as the same shape CDP produces. the browser's own requests
5727 // for documents, scripts and images never went through fetch or XMLHttpRequest, so they are not here
5728 private List<GPALCall> RecordedCalls()
5729 {
5730 List<GPALCall> retVal = new List<GPALCall>();
5731
5732 if (null == BrowserSettings.BrowserDriver) return retVal;
5733
5734 string json = BrowserHelper.ExecuteJavaScript(this, "return JSON.stringify(window.__gpalCalls || []);");
5735
5736 if (true == string.IsNullOrWhiteSpace(json)) return retVal;
5737
5738 foreach (Newtonsoft.Json.Linq.JObject recorded in Newtonsoft.Json.Linq.JArray.Parse(json))
5739 {
5740 GPALCall call = new GPALCall
5741 {
5742 Url = recorded["url"]?.ToString(),
5743 Method = recorded["method"]?.ToString(),
5744 ResourceType = recorded["type"]?.ToString(),
5745 PostData = recorded["postData"]?.ToString(),
5746 Initiator = "script",
5747 Status = (int?)recorded["status"] ?? 0
5748 };
5749
5750 if (recorded["headers"] is Newtonsoft.Json.Linq.JObject headers)
5751 foreach (KeyValuePair<string, Newtonsoft.Json.Linq.JToken> header in headers)
5752 call.Headers[header.Key] = header.Value?.ToString();
5753
5754 retVal.Add(call);
5755 }
5756
5757 return retVal;
5758 }
5759
5760 public IAllowWithHeaderOrFileActions Fetch(GPALRequest request)
5761 {
5762 CurrentUOW.FetchResults.Clear();
5763
5764 if (null == request)
5765 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "No request given to fetch.", this, GPALObjectType.Browser);
5766 else
5767 BrowserHelper.FetchWithTokens(this, request);
5768
5769 InAction(false);
5770 return this;
5771 }
5779 {
5780 List<string> values = new List<string>();
5781
5782 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Handing back [{CurrentUOW.FetchResults.Count}] fetch responses.", this, GPALObjectType.Browser);
5783
5784 // the array has to parse whatever came back, or saving it was pointless. a json body is spliced in as
5785 // it stands, and anything else, an html deny page or a body carrying the http warning in front of it,
5786 // goes in as a json string. still readable, still an array
5787 foreach (string body in CurrentUOW.FetchResults)
5788 values.Add(true == StringFormatDetector.IsJson(body.Trim()) ? body : Newtonsoft.Json.JsonConvert.SerializeObject(body));
5789
5790 data = "[" + string.Join(",", values) + "]";
5791 return this;
5792 }
5794 {
5795 //GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Saving [{CurrentUOW.RetGrid?.Rows ?? 0}] rows of page data to [{file?.Filename}].", this, GPALObjectType.Browser);
5796
5797 var converter = GPAL.Converter.WithInput(CurrentUOW.RetGrid).WithColumnNames(ResolveHeaderColumns(file));
5798 // Naming headers via WithHeader is clear intent to write them; declare it so the output emits a
5799 // header row unless the output file explicitly turns it off (WithFirstLineIsColumnNames(false)).
5800 if (null != CurrentUOW.HeaderList && 0 < CurrentUOW.HeaderList.Count)
5801 converter.WithFirstLineHasColumnNames(true);
5802 converter.SaveTo(file);
5803 return this;
5804 }
5805
5811 {
5812 //GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Appending [{CurrentUOW.RetGrid?.Rows ?? 0}] rows of page data to [{file?.Filename}].", this, GPALObjectType.Browser);
5813
5814 GPAL.Converter.WithInput(CurrentUOW.RetGrid).WithColumnNames(ResolveHeaderColumns(file)).AppendTo(file);
5815 return this;
5816 }
5817
5818 // Resolve the output column names for a grid save, handed to the Converter as column metadata
5819 // (WithColumnNames), NOT injected as a data row. Precedence, highest first, with a short higher list
5820 // topped up from the next level down:
5821 // 1. CurrentUOW.HeaderList - the workflow's .WithHeader columns (override)
5822 // 2. the output GPALFile's ColumnList - the file's own columns (wildcard/.Next replication of the
5823 // first entry happens at expansion time, so entry 0 is representative here)
5824 // 3. selector names - the ultimate global, always defined
5825 // Whether the names are written is governed by the output file's FirstLineIsColumnNames.
5826 private List<string> ResolveHeaderColumns(GPALFile file)
5827 {
5828 List<string> selectorNames = CurrentUOW.WithSelectorList?.Where(s => null != s.Name).Select(s => s.Name).ToList()
5829 ?? new List<string>();
5830
5831 List<string> fileColumns = new List<string>();
5832 IGPALGrid<string> columnList = ((IGPALFileInternal)file).FileSettings.ColumnList;
5833 if (null != columnList && 0 < columnList.Count() && null != columnList[0])
5834 fileColumns = new List<string>(columnList[0]);
5835
5836 List<string> resolved = (null != CurrentUOW.HeaderList && 0 < CurrentUOW.HeaderList.Count)
5837 ? new List<string>(CurrentUOW.HeaderList)
5838 : new List<string>();
5839 for (int i = resolved.Count; i < fileColumns.Count; i++)
5840 resolved.Add(fileColumns[i]);
5841 for (int i = resolved.Count; i < selectorNames.Count; i++)
5842 resolved.Add(selectorNames[i]);
5843 return resolved;
5844 }
5854 public IAllowAfterWaitFor WaitFor(WaitTime waitForTimeInMs)
5855 {
5856 // we are done waiting on any selectors since we performed an action
5857 // so, we just wait/sleep
5858 if (true == CurrentUOW.ActionCalled)
5859 {
5860 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Sleeping for [{waitForTimeInMs}] ms", this, GPALObjectType.Browser);
5861 Thread.Sleep(waitForTimeInMs);
5862 }
5863 else
5864 CurrentUOW.WaitForInMs = waitForTimeInMs;
5865
5866 return this;
5867 }
5868
5873 public IAllowAfterWaitFor WaitFor(Selector waitForSelector)
5874 {
5875 bool weSetWaitFor = false;
5876 WaitTime safe = WaitTime.Never;
5877
5878 // never (can't wait on a selector 'never') so we will set a time and restore
5879 if (WaitTime.Never == CurrentUOW.WaitForInMs)
5880 {
5881 safe = CurrentUOW.WaitForInMs;
5882 CurrentUOW.WaitForInMs = WaitTime.Immediate;
5883 weSetWaitFor = true;
5884 }
5885
5886 WithSelector(waitForSelector); // this will create a new UOW with waittimeinms = never (or GPAL.WaitFor global setting)
5887 WorkflowSetup(true, true); // this will set CurrentUOW.ActionCalled = true, so this is it's own action
5888
5889 if (true == weSetWaitFor)
5890 CurrentUOW.WaitForInMs = safe;
5891
5892 return this;
5893 }
5894
5900 public IAllowAfterWaitFor WaitFor(ElementState elementState)
5901 {
5902 if (ElementState.NotSet != elementState)
5903 {
5904 CurrentUOW.WaitForElementState = elementState;
5905
5906 if (true == CurrentUOW.ActionCalled) // do this now, we are running the workflow
5907 ElementHelper.WaitFor(this, CurrentUOW.WaitForInMs, out bool matchedAll);
5908 }
5909 else
5910 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "ElementState NOTSET is not a valid wait condition. Please select a valid ElementState.", null, GPALObjectType.None);
5911
5912 return this;
5913 }
5914
5921 {
5922 FormHelper.WaitForWindow(waitForTitle, BrowserSettings.WaitForWindowTimeoutInSeconds);
5923 return this;
5924 }
5925
5932 {
5933 FormHelper.WaitForWindow(waitForTitleRegex, BrowserSettings.WaitForWindowTimeoutInSeconds);
5934 return this;
5935 }
5936
5941 public IAllowBrowserActionOrAnySelector WithPageOrientation(PageOrientation pageOrientation)
5942 {
5943 BrowserSettings.PageOrientation = pageOrientation;
5944 return this;
5945 }
5946 // todo: iterate over UOW selectors
5947 // GPAL.Browser.GoTo("ebay.com").WithSelector(selectorToPrint).PrintToPDF(GPAL.FileFor("filename.pdf")); // print element and contents
5948 // GPAL.Browser.GoTo("ebay.com").PrintToPDF(GPAL.FileFor("filename.pdf")); // print whole page
5955 {
5956 string fullHtml = null;
5957 // If no .withselector/selector set, print the whole page
5958 string selectorPath = string.IsNullOrEmpty(CurrentUOW.CurrentSelector?.SelectorPaths[0].SelectorPath)
5959 ? "html"
5960 : CurrentUOW.CurrentSelector?.SelectorPaths[0].SelectorPath;
5961
5962 if (true == BrowserSettings.UseOttoMagic)
5963 fullHtml = MagicHelper.GetContentAndCss(selectorPath);
5964 else
5965 {
5966 string elementContent = BrowserHelper.ExecuteJavaScriptObj(
5967 $@"var div = document.querySelector('{selectorPath}'); return div.outerHTML;",
5968 BrowserSettings.Browser
5969 ).ToString();
5970
5971 // Inject the div content & CSS used
5972 string cssContent = BrowserHelper.ExecuteJavaScriptObj($@"
5973 function getAllCss() {{
5974 var css = '';
5975 var stylesheets = document.styleSheets;
5976 for (var i = 0; i < stylesheets.length; i++) {{
5977 try {{
5978 var rules = stylesheets[i].cssRules;
5979 for (var j = 0; j < rules.length; j++) {{
5980 css += rules[j].cssText + ' ';
5981 }}
5982 }} catch (e) {{ /* Ignore cross-origin stylesheet errors */ }}
5983 }}
5984 return css;
5985 }}
5986
5987 function getElementCss(element) {{
5988 var css = '';
5989 var style = window.getComputedStyle(element);
5990 for (var i = 0; i < style.length; i++) {{
5991 css += style[i] + ': ' + style.getPropertyValue(style[i]) + '; ';
5992 }}
5993 return css;
5994 }}
5995
5996 var allCss = getAllCss();
5997 var div = document.querySelector('{selectorPath}');
5998 var elementCss = getElementCss(div);
5999
6000 return '<style>' + allCss + ' ' + elementCss + '</style>';
6001 ", BrowserSettings.Browser).ToString();
6002 fullHtml = $@"<html><head>{cssContent}</head><body>{elementContent}</body></html>";
6003 }
6004
6005#if DONOTUSECDP
6006 #region NEW get the browser to print to pdf without CDP
6007 // Step 1: Combine content into full HTML
6008 string outputHtmlPath = $"{gpalFile.Filename}.html";
6009
6010 // Step 2: Save the HTML to a file
6011 File.WriteAllText(outputHtmlPath, fullHtml);
6012
6013 // Step 3: Create JSON configuration file for PDF options
6014 string pdfOptionsPath = $"{gpalFile.Filename}_pdf_options.json";
6015 string pdfOptionsJson = @"{
6016 ""printBackground"": true,
6017 ""landscape"":" + (PageOrientation.Landscape == BrowserSettings.PageOrientation ? "true" : "false")
6018 +@"
6019 ""marginTop"": 0,
6020 ""marginBottom"": 0,
6021 ""marginLeft"": 0,
6022 ""marginRight"": 0
6023 }";
6024 File.WriteAllText(pdfOptionsPath, pdfOptionsJson);
6025
6026 // Step 4: Launch browser to print the HTML file to PDF
6027 List<string> addlArgs = new List<string>
6028 {
6029 "--no-pdf-header-footer",
6030 $"--print-to-pdf=\"{gpalFile.Filename}\"",
6031 $"--print-to-pdf-options=\"{pdfOptionsPath}\""
6032 };
6033
6034 // Use file:// URL to load the local HTML file
6035 string fileUrl = $"file:///{Path.GetFullPath(outputHtmlPath).Replace("\\", "/")}";
6036 System.Diagnostics.Process pdfPrinter = MagicHelper.LaunchBrowser(this, fileUrl, addlArgs);
6037
6038 // Wait for natural exit instead of blind sleep + kill
6039 bool exitedCleanly = pdfPrinter.WaitForExit(30000); // generous timeout, e.g. 30s
6040
6041 if (!exitedCleanly)
6042 {
6043 try
6044 {
6045 if (!pdfPrinter.HasExited)
6046 pdfPrinter.Kill();
6047 }
6048 catch { }
6049
6050 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
6051 "PrintToPDF process did not exit cleanly (timeout)",
6052 BrowserSettings.Browser, GPALObjectType.Browser);
6053 }
6054 else if (pdfPrinter.ExitCode != 0)
6055 {
6056 GPAL.PublishSimpleEvent(GPALEventType.WARNING,
6057 $"PrintToPDF exited with code [{pdfPrinter.ExitCode}]",
6058 BrowserSettings.Browser, GPALObjectType.Browser);
6059 }
6060
6061 // Step 5: Clean up temporary files
6062 try
6063 {
6064 File.Delete(outputHtmlPath);
6065 File.Delete(pdfOptionsPath);
6066 }
6067 catch (GPALException)
6068 {
6069 throw;
6070 }
6071 catch (Exception ex)
6072 {
6073 // Log warning if file deletion fails (e.g., file in use)
6074 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Failed to delete temporary files", BrowserSettings.Browser, GPALObjectType.Browser, ex);
6075 }
6076 #endregion NEW get the browser to print to pdf without CDP
6077#else
6078 #region OLD use CDP to page.PrintToPDF a window
6080 var newWindow = window.open('', '_blank');
6081 newWindow.document.open();
6082 newWindow.document.write('<html><head>' + arguments[0] + '</head><body>' + arguments[1] + '</body></html>');
6083 newWindow.document.close();
6084 newWindow.focus();
6085 ", browserSettings.Browser, new { cssContent, elementContent });
6086
6087 // Wait for the new window to load
6088 System.Threading.Thread.Sleep(2000);
6089
6090 // Switch to the new window
6091 var windows = browserSettings.BrowserDriver.WindowHandles;
6092 browserSettings.BrowserDriver.SwitchTo().Window(windows[windows.Count - 1]);
6093
6094 // Define print settings
6095 var printSettings = new Dictionary<string, object>
6096 {
6097 { "printBackground", true },
6098 { "landscape", false }
6099 };
6100
6101 // Execute the print command
6102 dynamic result = null;
6103
6104 if (BrowserType.Chrome == browserSettings.BrowserType)
6105 result = ((OpenQA.Selenium.Chromium.ChromiumDriver)browserSettings.BrowserDriver).ExecuteCdpCommand("Page.printToPDF", printSettings);
6106 else if (BrowserType.Edge == browserSettings.BrowserType)
6107 result = ((OpenQA.Selenium.Edge.EdgeDriver)browserSettings.BrowserDriver).ExecuteCdpCommand("Page.printToPDF", printSettings);
6108 else
6109 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{browserSettings.BrowserType}] does not support Page.printToPDF", browserSettings.Browser, GPALObjectType.Browser);
6110
6111 var resultDict = result as IDictionary<string, object>;
6112
6113 if (resultDict != null && resultDict.ContainsKey("data"))
6114 {
6115 // Decode the PDF data
6116 var pdfData = Convert.FromBase64String(resultDict["data"].ToString());
6117 // Save the PDF to a file
6118 System.IO.File.WriteAllBytes(gpalFile.Filename, pdfData);
6119 }
6120
6121 browserSettings.BrowserDriver.Close();
6122 windows = browserSettings.BrowserDriver.WindowHandles;
6123 browserSettings.BrowserDriver.SwitchTo().Window(windows[windows.Count - 1]);
6124 #endregion OLD use CDP to page.PrintToPDF a window
6125#endif
6126 return this;
6127 }
6132 /// </summary>
6133 /// <param name="textToSend">Text to type</param>
6134 /// <returns>Fluent interface to write your workflow</returns>
6135 public IAllowBrowserActionOrAnySelector SendString(string textToSend)
6136 {
6137 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
6138 {
6139 ElementHelper.SendString(this, textToSend);
6140 }
6141
6142 return this;
6143 }
6161 public IAllowBrowserActionOrAnySelector PressModifierKey(ModifierKeys modifierKeys)
6162 {
6163 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Pressed [{GetModifierKeys(modifierKeys)}]", this, GPALObjectType.Browser);
6164
6165 if (ModifierKeys.NONE != modifierKeys)
6166 if (false == BrowserSettings.UseHeadless && (true == BrowserSettings.UseHardware || true == GPAL.GPALSettings.UseHardware))
6167 {
6168 HardwareHelper.PressModifierKey(modifierKeys);
6169 }
6170 else if (true == UseOttoMagic)
6171 {
6172 MagicHelper.PressModifierKey(modifierKeys);
6173 }
6174 else if (true == UsePuppeteer || (true == BrowserSettings.UseHeadless && (true == BrowserSettings.UseHardware || true == GPAL.GPALSettings.UseHardware)))
6175 {
6176 PuppeteerClient.PressModifierKey(modifierKeys);
6177 }
6178 else
6179 {
6180 Actions actions = new Actions(BrowserDriver);
6181 bool anyKeySet = false;
6182
6183 if (modifierKeys.HasFlag(ModifierKeys.Alt))
6184 {
6185 actions.KeyDown(Keys.Alt);
6186 anyKeySet = true;
6187 }
6188 if (modifierKeys.HasFlag(ModifierKeys.Control))
6189 {
6190 actions.KeyDown(Keys.Control);
6191 anyKeySet = true;
6192 }
6193 if (modifierKeys.HasFlag(ModifierKeys.Shift))
6194 {
6195 actions.KeyDown(Keys.Shift);
6196 anyKeySet = true;
6197 }
6198 if (modifierKeys.HasFlag(ModifierKeys.Windows))
6199 {
6200 actions.KeyDown(Keys.Meta);
6201 anyKeySet = true;
6203
6204 ModifierKeys unsupported = modifierKeys & (ModifierKeys.Application | ModifierKeys.ScrollLock);
6205 if (ModifierKeys.NONE != unsupported)
6206 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"PressModifierKey: [{GetModifierKeys(unsupported)}] is not supported via Selenium/WebDriver Actions and was ignored.", this, GPALObjectType.Browser);
6207
6208 if (true == anyKeySet)
6209 actions.Perform();
6210 }
6211
6212 return this;
6213 }
6231 public IAllowBrowserActionOrAnySelector ReleaseModifierKey(ModifierKeys modifierKeys)
6232 {
6233 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Released [{GetModifierKeys(modifierKeys)}]", this, GPALObjectType.Browser);
6234
6235 if (ModifierKeys.NONE != modifierKeys)
6236 if (false == BrowserSettings.UseHeadless && (true == BrowserSettings.UseHardware || true == GPAL.GPALSettings.UseHardware))
6237 {
6238 HardwareHelper.ReleaseModifierKeys(modifierKeys);
6239 }
6240 else if (true == UseOttoMagic)
6241 {
6242 MagicHelper.ReleaseModifierKey(modifierKeys);
6243 }
6244 else if (true == UsePuppeteer || (true == BrowserSettings.UseHeadless && (true == BrowserSettings.UseHardware || true == GPAL.GPALSettings.UseHardware)))
6245 {
6246 PuppeteerClient.ReleaseModifierKey(modifierKeys);
6247 }
6248 else
6249 {
6250 Actions actions = new Actions(BrowserDriver);
6251 bool anyKeySet = false;
6252
6253 if (modifierKeys.HasFlag(ModifierKeys.Alt))
6254 {
6255 actions.KeyUp(Keys.Alt);
6256 anyKeySet = true;
6257 }
6258 if (modifierKeys.HasFlag(ModifierKeys.Control))
6259 {
6260 actions.KeyUp(Keys.Control);
6261 anyKeySet = true;
6262 }
6263 if (modifierKeys.HasFlag(ModifierKeys.Shift))
6264 {
6265 actions.KeyUp(Keys.Shift);
6266 anyKeySet = true;
6267 }
6268 if (modifierKeys.HasFlag(ModifierKeys.Windows))
6269 {
6270 actions.KeyUp(Keys.Meta);
6271 anyKeySet = true;
6272 }
6273
6274 ModifierKeys unsupported = modifierKeys & (ModifierKeys.Application | ModifierKeys.ScrollLock);
6275 if (ModifierKeys.NONE != unsupported)
6276 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"ReleaseModifierKey: [{GetModifierKeys(unsupported)}] is not supported via Selenium/WebDriver Actions and was ignored.", this, GPALObjectType.Browser);
6277
6278 if (true == anyKeySet)
6279 actions.Perform();
6280 }
6281
6282 return this;
6283 }
6289 /// </summary>
6290 /// <example>
6291 /// <code>
6292 /// // send CTRL-HOME
6293 /// ...
6294 /// myBrowser
6295 /// .PressModifierKey(ModifierKeys.Control)
6296 /// .SendKey(GPAL.VK_HOME);
6297 /// .WaitFor(150)
6298 /// .ReleaseModifierKey(ModifierKeys.Control)
6299 /// </code>
6300 /// </example>
6301 /// <param name="VKCode">Virtual Keycode</param>
6302 /// <returns>Fluent interface to write your workflow</returns>
6303 public IAllowBrowserActionOrAnySelector SendKey(byte VKCode)
6304 {
6305 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
6307 ElementHelper.SendKey(this, VKCode);
6308 }
6309
6310 return this;
6311 }
6317
6318 public IAllowBrowserActionOrAnySelector ScrollWindowByHorizontal(int scrollAmountInPixels)
6319 {
6320 if (true == UseOttoMagic)
6321 MagicHelper.ScrollWindowByHorizontal(scrollAmountInPixels);
6322 else if (true == UsePuppeteer)
6323 PuppeteerClient.ScrollWindowByHorizontal(scrollAmountInPixels).Execute();
6324 else
6325 BrowserHelper.ExecuteJavaScript(this, $"window.scrollBy({{left: {scrollAmountInPixels}, top: 0, behavior: 'instant'}})");
6326
6327 return this;
6328 }
6334 /// <returns>Fluent interface to write your workflow</returns>
6335 public IAllowBrowserActionOrAnySelector ScrollWindowByVertical(int scrollAmountInPixels)
6336 {
6337 if (true == UseOttoMagic)
6338 MagicHelper.ScrollWindowByVertical(scrollAmountInPixels);
6339 else if (true == UsePuppeteer)
6340 PuppeteerClient.ScrollWindowByVertical(scrollAmountInPixels).Execute();
6341 else
6342 BrowserHelper.ExecuteJavaScript(this, $"window.scrollBy({{left: 0, top: {scrollAmountInPixels}, behavior: 'instant'}})");
6343
6344 return this;
6345 }
6353 {
6354 ElementHelper.ScrollElement(this, scrollAmountInPixels, ScrollTypes.Horizontal);
6355 return this;
6364 {
6365 ElementHelper.ScrollElement(this, scrollAmountInPixels, ScrollTypes.Vertical);
6366 return this;
6367 }
6368
6385 public IAllowBrowserActionOrAnySelector GoToTab(dynamic URLorTabTuple = null)
6386 {
6387 if (true == UseOttoMagic)
6388 {
6389 TabTuple gotoTabTuple = MagicHelper.GotoTab(URLorTabTuple);
6390 if (URLorTabTuple is TabTuple)
6391 UpdateActiveTab(URLorTabTuple);
6392 else
6393 UpdateActiveTab(gotoTabTuple);
6394 }
6395 else if (true == UsePuppeteer)
6396 {
6397 if (URLorTabTuple is GPALUrl url)
6398 PuppeteerClient.GoToTab(url.Url).Execute();
6399 else if (URLorTabTuple is int tabId)
6400 PuppeteerClient.GoToTab(tabId).Execute();
6401 }
6402 else
6404 foreach (var handle in this.BrowserDriver.WindowHandles)
6405 {
6406 string currentUrl = this.BrowserDriver.SwitchTo().Window(handle).Url;
6407 if (currentUrl == URLorTabTuple)
6408 {
6409 this.BrowserDriver.SwitchTo().Window(handle);
6410 BrowserSettings.CurrentURL = URLorTabTuple;
6411
6412 var handles = BrowserDriver.WindowHandles.ToList();
6413 string currentHandle = BrowserDriver.CurrentWindowHandle;
6414
6415 CurrentTabIdx = handles.IndexOf(currentHandle);
6416
6417 return this;
6418 }
6420 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to detect tab with URL [{URLorTabTuple}]", this, GPALObjectType.Browser);
6421 }
6422
6423 return this;
6424 }
6425
6431
6432 public IAllowBrowserActionOrAnySelector DragAndDrop(ModifierKeys modifierKeys)
6433 {
6434 // waitfor did not match if returns false, so we have no element to click
6435 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
6436 {
6437 ElementHelper.DragAndDrop(this, modifierKeys);
6438 }
6439 return this;
6440 }
6441
6442
6449 {
6450 if (null != selector)
6451 WithSelector(selector);
6452
6453 // waitfor did not match if returns false
6454 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
6455 {
6456 ElementHelper.DragAndDrop(this);
6457 }
6458 return this;
6459 }
6460
6467 {
6468 JavaScriptResultObj = BrowserHelper.ExecuteJavaScriptObj(javascript, this);
6469 return this;
6470 }
6471
6477 public IAllowBrowserActionOrAnySelector ExecuteJavaScriptStr(string javascript)
6478 {
6479 JavaScriptResultStr = BrowserHelper.ExecuteJavaScript(this, javascript);
6480 return this;
6481 }
6482
6483 private readonly List<string> _injectedScriptIdentifiers = new List<string>();
6484
6485 // whether this browser has registered a script with whatever is driving it, which decides whether there
6486 // is anything to take away when it closes
6487 private bool _scriptsRegistered = false;
6488 private readonly List<string> _pendingInjectedScripts = new List<string>();
6489
6490 // the override goes on once. after that the answers are written to the page rather than registered again
6491 private bool _dialogRegistered = false;
6492
6493 // the answers and the page they were written to, so nothing is written twice for no reason
6494 private string _dialogPushed = null;
6495
6496 // counts up per registration so the newest override wins wherever the scripts run in
6497 private int _dialogSeq = 0;
6498
6503 private bool EngineReachable
6504 {
6505 get
6506 {
6507 bool retVal;
6508
6509 if (true == UseOttoMagic)
6510 retVal = false == string.IsNullOrEmpty(BrowserSettings.RestApiBaseUrl);
6511 else if (true == UsePuppeteer)
6512 retVal = false == string.IsNullOrEmpty(BrowserSettings.PuppeteerUrl);
6513 else // selenium
6514 retVal = null != BrowserSettings.BrowserDriver;
6515
6516 return retVal;
6517 }
6518 }
6519
6525 private void RegisterInjectedScript(string script)
6526 {
6527 _scriptsRegistered = true;
6528
6529 if (true == UseOttoMagic)
6530 {
6531 MagicHelper.InjectScript(script);
6532 }
6533 else if (true == UsePuppeteer)
6534 {
6535 PuppeteerCommunicator.InjectScript(script, null).GetAwaiter().GetResult();
6536 }
6537 else // selenium
6538 {
6539 var identifier = BrowserHelper.SeleniumAddScriptToEvaluateOnNewDocument(BrowserSettings, script);
6540 if (false == string.IsNullOrEmpty(identifier))
6541 _injectedScriptIdentifiers.Add(identifier);
6542 }
6543 }
6544
6547 /// Called from WorkflowSetup once the engine answers and before the first navigation, so a script queued
6548 /// ahead of .GoTo() still runs on the first document.
6549 /// </summary>
6550 internal void ApplyPendingInjectedScripts()
6551 {
6552 if (0 < _pendingInjectedScripts.Count && true == EngineReachable)
6553 {
6554 foreach (var script in _pendingInjectedScripts)
6555 RegisterInjectedScript(script);
6556
6557 _pendingInjectedScripts.Clear();
6558 }
6559 }
6560
6562
6568 /// over CDP, and the page's own alert, confirm and prompt are untouched, so nothing about it is visible
6569 /// to the page. OttoMagic cannot do that, because an open dialog stops the page thread the extension
6570 /// talks through, so there GPAL replaces those three functions before the page runs and no dialog is
6571 /// ever raised. That replacement is the one thing here a page could notice, which is why it is only
6572 /// done on the engine that has no other way.
6573 /// </summary>
6574 /// <param name="accept">True to accept, false to dismiss.</param>
6575 /// <returns>Fluent interface to write your workflow</returns>
6577 {
6578 // the driver reads this as a capability when it starts, so a browser that is already up was built
6579 // without it and nothing here can change that. said rather than quietly doing nothing, and only
6580 // where the driver is the one answering: naming prompt text moves Selenium into the page, where
6581 // changing the answer works like it does everywhere else
6582 if (true == UseSelenium && null != BrowserSettings.BrowserDriver && false == InPageDialogs)
6583 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Selenium takes this when the browser starts, and this one is already running, so dialogs are not answered. Call WithDialogsAccepted before the first GoTo", this, GPALObjectType.Browser);
6584
6585 BrowserSettings.DialogsAccepted = accept;
6586
6587 ArmDialogHandling();
6588
6589 return this;
6590 }
6591
6593 /// What a prompt hands back when dialogs are accepted. Left unsaid, the prompt's own default stands.
6594 /// </summary>
6595 /// <param name="text">The text to answer a prompt with.</param>
6596 /// <returns>Fluent interface to write your workflow</returns>
6598 {
6599 BrowserSettings.DialogText = text;
6600
6601 // a workflow that says what to answer has said to answer, so this turns it on rather than sitting
6602 // there doing nothing until WithDialogsAccepted is also called
6603 if (null == BrowserSettings.DialogsAccepted)
6604 BrowserSettings.DialogsAccepted = true;
6605
6606 ArmDialogHandling();
6607
6608 return this;
6609 }
6610
6623 {
6624 BrowserSettings.DialogsAccepted = null;
6625 BrowserSettings.DialogText = null;
6626
6627 ArmDialogHandling();
6628
6629 return this;
6630 }
6631
6636 internal void ArmDialogHandling()
6637 {
6638 // still runs with nothing to answer with, because turning it off is something the page has to be
6639 // told about too, and only if it was ever told to start
6640 if (false == EngineReachable || (null == BrowserSettings.DialogsAccepted && false == _dialogRegistered))
6641 return;
6642
6643 if (true == InPageDialogs)
6644 {
6645 if (false == _dialogRegistered && null != BrowserSettings.DialogsAccepted)
6646 {
6647 string script = DialogScript();
6648
6649 _dialogRegistered = true;
6650
6651 // registering covers every document from here on, which before the first navigation is all
6652 // of them. running it directly is for the other case, arming partway through a workflow,
6653 // where the page being stood on has already loaded and would otherwise be the one page not
6654 // covered
6655 InjectScript(script);
6656
6657 if (true == _navigated)
6658 BrowserHelper.ExecuteJavaScript(this, script);
6659
6660 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Answering dialogs in the page, accept [{BrowserSettings.DialogsAccepted}]", this, GPALObjectType.Browser);
6661 }
6662
6663 // every document runs its own copy of the script, which sets the answers back to the ones it was
6664 // registered with, so what is on the page is stale when the workflow changes its mind and stale
6665 // again when the page moves. both are known here without asking the browser anything
6666 string written = DialogAnswers() + "@" + BrowserSettings.CurrentURL;
6667
6668 if (true == _navigated && written != _dialogPushed)
6669 {
6670 _dialogPushed = written;
6671 PushDialogAnswers();
6672 }
6673
6674 // asked once, after the first navigation, because a script that never registered and one that
6675 // registered and never ran look the same from here: both leave the page raising real dialogs,
6676 // and the first one to appear stops the workflow with no idea why
6677 // asked of OttoMagic only, and once. registering there goes through the extension and can be
6678 // refused, which looks from here exactly like a registration that worked. Selenium registers the
6679 // same script over CDP, where there is nothing to refuse it
6680 if (true == UseOttoMagic && true == _navigated && false == _dialogChecked)
6681 {
6682 _dialogChecked = true;
6683
6684 // an expression, not a statement: what OttoMagic runs this through takes the value of the
6685 // last expression, and a top level return is a syntax error there
6686 string seen = BrowserHelper.ExecuteJavaScript(this, "window.__gpalDialogSeq");
6687
6688 if (true == string.IsNullOrEmpty(seen) || "null" == seen)
6689 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"The dialog override is not on the page, so dialogs will stop this workflow. The usual cause is Allow user scripts being off for the extension", this, GPALObjectType.Browser);
6690 }
6691 }
6692 else if (true == UsePuppeteer && null != BrowserSettings.DialogsAccepted && false == _dialogRegistered)
6693 {
6694 // Page.javascriptDialogOpening does not arrive until the domain is on, and the launch paths do
6695 // not turn it on
6696 _dialogRegistered = true;
6697
6698 PuppeteerCommunicator.EnablePageEvents().GetAwaiter().GetResult();
6699
6700 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Answering dialogs over CDP, accept [{BrowserSettings.DialogsAccepted}]", this, GPALObjectType.Browser);
6701 }
6702
6703 // selenium answering yes or no is armed at launch, because UnhandledPromptBehavior is a capability
6704 // on the driver options and nothing after that can change it
6705 }
6706
6716 private bool InPageDialogs => true == UseOttoMagic
6717 || (true == UseSelenium && null != BrowserSettings.DialogText);
6718
6719 // set when a navigation has finished rather than when one has been asked for. BrowserSettings.CurrentURL
6720 // is the url GoTo is heading to and is written before it goes, so it says nothing about what is on screen
6721 private bool _navigated = false;
6722
6723 // whether the override was found in place on the page after the first navigation
6724 private bool _dialogChecked = false;
6725
6731 private void PushDialogAnswers()
6732 {
6733 // only where the override is already installed, so this is never what puts the globals there
6734 BrowserHelper.ExecuteJavaScript(this, "if (window.__gpalDialogSeq) { " + DialogAnswers() + " }");
6735 }
6736
6742 private string DialogAnswers()
6743 {
6744 string accept = null == BrowserSettings.DialogsAccepted
6745 ? "null"
6746 : true == BrowserSettings.DialogsAccepted ? "true" : "false";
6747 string typed = null == BrowserSettings.DialogText ? "null" : JsonConvert.SerializeObject(BrowserSettings.DialogText);
6748
6749 return "window.__gpalDialogAccept = " + accept + "; window.__gpalDialogAnswer = " + typed + ";";
6750 }
6751
6758 private string DialogScript()
6759 {
6760 return
6761 "(function () {" +
6762 " var seq = " + (++_dialogSeq) + ";" +
6763 " if (window.__gpalDialogSeq >= seq) return;" +
6764 " window.__gpalDialogSeq = seq;" +
6765 " " + DialogAnswers() +
6766 " var asText = Function.prototype.toString;" +
6767 " function stand(name, fn) {" +
6768 " Object.defineProperty(fn, 'name', { value: name, configurable: true });" +
6769 // put where the real one is rather than where the spec says it should be. chrome carries
6770 // these as own properties of window, and one defined on Window.prototype sits behind the
6771 // native it was meant to replace: installed, never called. asking first also keeps the
6772 // shape a page would see, since ours ends up in the same place as the one it replaced
6773 " var onto = Object.getOwnPropertyDescriptor(window, name) ? window : Window.prototype;" +
6774 " Object.defineProperty(onto, name," +
6775 " { value: fn, writable: true, enumerable: true, configurable: true });" +
6776 " }" +
6777 // kept so turning it off can hand the page back what it started with, rather than leaving it
6778 // with three functions that answer nothing
6779 " var real = { alert: window.alert, confirm: window.confirm, prompt: window.prompt };" +
6780 " function off() { return null === window.__gpalDialogAccept; }" +
6781 " stand('alert', function (message) {" +
6782 " if (off()) return real.alert.call(window, message);" +
6783 " });" +
6784 " stand('confirm', function (message) {" +
6785 " if (off()) return real.confirm.call(window, message);" +
6786 " return true === window.__gpalDialogAccept;" +
6787 " });" +
6788 " stand('prompt', function (message, preset) {" +
6789 " if (off()) return real.prompt.call(window, message, preset);" +
6790 " if (true !== window.__gpalDialogAccept) return null;" +
6791 " return null !== window.__gpalDialogAnswer ? window.__gpalDialogAnswer : (preset || '');" +
6792 " });" +
6793 // a page that asks what these are reads what it would read of the real ones. it raises the bar
6794 // rather than clearing it: this is the part of the feature a page can still catch out
6795 " Function.prototype.toString = function () {" +
6796 " if (this === window.alert || this === window.confirm || this === window.prompt" +
6797 " || this === Function.prototype.toString)" +
6798 " return 'function ' + this.name + '() { [native code] }';" +
6799 " return asText.call(this);" +
6800 " };" +
6801 "})();";
6802 }
6803
6811 /// <example>
6812 /// <code>
6813 /// browser.GetWindowRectangle(out Rectangle window);
6814 /// </code>
6815 /// </example>
6816 /// <param name="window">Receives the window rectangle in screen coordinates.</param>
6817 /// <returns>Fluent interface to write your workflow</returns>
6818 public IAllowBrowserActionOrAnySelector GetWindowRectangle(out Rectangle window)
6819 {
6820 window = Rectangle.Empty;
6821
6822 if (true == InAction(false))
6823 {
6824 if (true == UsePuppeteer)
6825 window = PuppeteerCommunicator.GetWindowRectangle().GetAwaiter().GetResult();
6826 else
6827 ElementHelper.GetWindowRectangle(this, out window);
6828 }
6829
6830 return this;
6831 }
6832
6836
6841 {
6842 // no browser to inject into until the first .GoTo()/.Get() launches one, so the script is held
6843 // and registered the moment the engine answers, ahead of the first navigation
6844 if (false == EngineReachable)
6845 _pendingInjectedScripts.Add(script);
6846 else
6847 RegisterInjectedScript(script);
6848
6849 return this;
6850 }
6851
6860 {
6861 foreach (var filename in scriptFile.Filenames)
6862 InjectScript(File.ReadAllText(filename));
6863 return this;
6864 }
6865
6866
6871 public IAllowBrowserActionOrAnySelector ClearInjectedScripts()
6872 {
6873 // scripts still waiting on a browser go first, so one queued and then cleared never reaches the page
6874 _pendingInjectedScripts.Clear();
6875
6876 if (true == EngineReachable)
6877 {
6878 if (true == UseOttoMagic)
6879 {
6880 MagicHelper.ClearInjectedScripts();
6881 }
6882 else if (true == UsePuppeteer)
6883 {
6884 PuppeteerCommunicator.ClearInjectedScripts().GetAwaiter().GetResult();
6885 }
6886 else // selenium
6887 {
6888 foreach (var identifier in _injectedScriptIdentifiers)
6890 _injectedScriptIdentifiers.Clear();
6891 }
6892 }
6893 return this;
6894 }
6895
6902 public IAllowBrowserActionOrAnySelector SetRange(int rangeValue)
6903 {
6904 if (true == InAction(WaitTime.Never < CurrentUOW.WaitForInMs))
6905 foreach (Selector selector in CurrentUOW.WithSelectorList)
6906 {
6907 ElementHelper.FindWebElements(this, CurrentUOW, selector, out _, out List<GPALElement> elems);
6908 foreach (GPALElement elem in elems)
6909 {
6910 ElementHelper.ScrollIntoView(this, elem);
6911
6912 if (true == UseOttoMagic)
6913 MagicHelper.SetRange(elem.Css, rangeValue);
6914 else if (true == UsePuppeteer)
6915 PuppeteerClient.SetRange(elem.Css).WithRangeValue(rangeValue).Execute();
6916 else if (true == UseSelenium)
6918 // Set the value and dispatch the change events so the UI updates
6919 string script = @"
6920 arguments[0].value = arguments[1];
6921 arguments[0].dispatchEvent(new Event('input', { bubbles: true }));
6922 arguments[0].dispatchEvent(new Event('change', { bubbles: true }));
6923 ";
6924
6925 BrowserHelper.ExecuteJavaScript(this, script, elem.WebElement, rangeValue);
6927 }
6928 }
6929
6930 return this;
6931 }
6932 #endregion <Browser Actions>
6933 #region <Getters>
6934 private bool _started = false;
6938 internal bool Started
6939 {
6940 get => _started;
6941 set => _started = value;
6942 }
6945
6946 public int ServerResponseCode
6947 {
6948 get => BrowserSettings.ServerResponseCode;
6949 set => BrowserSettings.ServerResponseCode = value;
6950 }
6951
6952
6955 public string CurrentUrl
6956 {
6957 get => BrowserSettings.CurrentURL;
6958 internal set => BrowserSettings.CurrentURL = value;
6959 }
6962 /// </summary>
6963 public bool UseSelenium
6964 {
6965 get => BrowserSettings.UseSelenium;
6966 }
6967
6970 public bool UsePuppeteer
6971 {
6972 get => BrowserSettings.UsePuppeteer;
6973 }
6977 public bool UseOttoMagic
6978 {
6979 get => BrowserSettings.UseOttoMagic;
6980 }
6982
6984 public Process Process
6985 {
6986 get => BrowserSettings.Process;
6987 }
6992 {
6993 get => BrowserSettings.AutomationEngine;
6994 internal set => BrowserSettings.AutomationEngine = value;
6995 }
6999 public bool? FileDownloaded
7000 {
7001 get
7002 {
7003 return BrowserSettings.FileDownloaded;
7004 }
7005
7006 set
7007 {
7008 BrowserSettings.FileDownloaded = value;
7009 }
7010 }
7011
7016 {
7017 get
7018 {
7019 return BrowserSettings.BrowserType;
7020 }
7025 internal UnitOfWork CurrentUOW { get; set; } = null;
7029 internal WorkflowManager WorkflowManager { get; set; }
7033 internal string Name
7034 {
7035 get
7036 {
7037 return BrowserSettings.BrowserName;
7038 }
7039
7040 set
7041 {
7042 BrowserSettings.BrowserName = value;
7043 }
7044 }
7047 /// Only available after the first .GoTo<br/>
7048 /// <b>NOTE:</b> Only for extended Selenium control access. Be careful.
7049 /// </summary>
7050 public IWebDriver BrowserDriver
7051 {
7052 get
7053 {
7054 return BrowserSettings.BrowserDriver;
7055 }
7056
7057 internal set
7058 {
7059 BrowserSettings.BrowserDriver = value;
7060 }
7061 }
7066 public string RestApiBaseUrl
7067 {
7068 get
7069 {
7070 return BrowserSettings.RestApiBaseUrl;
7071 }
7077 {
7078 get
7079 {
7080 return _browserSettings;
7081 }
7082
7083 internal set
7084 {
7085 _browserSettings = value;
7086 }
7087 }
7091 internal string DownloadLocation
7092 {
7093 get
7094 {
7095 return BrowserSettings.DownloadLocation;
7096 }
7097 }
7100 /// </summary>
7102 {
7103 get => BrowserSettings.PuppeteerCommunicator;
7104 internal set => BrowserSettings.PuppeteerCommunicator = value;
7105 }
7106
7107 public bool IsAlive
7109 get
7110 {
7111 // nothing of ours was launched, so ask its GPALRestAPI instead. that exits with the browser
7112 // it belongs to, so an answer means the browser is still there
7113 if (true == BrowserSettings.AttachedRestApi)
7114 return BrowserHelper.RestApiAnswering(BrowserSettings.RestApiBaseUrl);
7115
7116 if (BrowserSettings.Process != null)
7117 return !BrowserSettings.Process.HasExited;
7118 if (BrowserSettings.ServiceDriverPid != 0)
7119 {
7120 try { System.Diagnostics.Process.GetProcessById(BrowserSettings.ServiceDriverPid); return true; }
7121 catch (ArgumentException) { return false; }
7122 }
7123 return true;
7124 }
7125 }
7129 public IPuppeteerClient PuppeteerClient
7130 {
7131 get => BrowserSettings.PuppeteerClient;
7132 internal set => BrowserSettings.PuppeteerClient = value;
7133 }
7138 {
7139 get => (MagicHelper)BrowserSettings.MagicHelper;
7140 }
7141
7142 public IRESTClient OttoMagicClient => MagicHelper.Client;
7143
7149 public IHiddenDesktop HiddenDesktop => true == BrowserSettings.HiddenDesktop
7150 ? new GPALHiddenDesktop(BrowserSettings.HiddenDesktopName)
7151 : null;
7152 #endregion <Getters>
7153 #region <Helpers>
7154 #region <Private>
7161 bool ScrollPage(string direction)
7162 {
7163 try
7164 {
7165 // Validate direction
7166 if (direction != "up" && direction != "down")
7167 {
7168 return false;
7169 }
7170
7171 // JavaScript code with direction parameter
7172 string script = @"
7173 (function(direction) {
7174 function scrollPage(direction) {
7175 try {
7176 const isDown = direction === 'down';
7177 const scrollAmount = isDown ? window.innerHeight : -window.innerHeight;
7178 const keyName = isDown ? 'PageDown' : 'PageUp';
7179 const keyCode = isDown ? 34 : 33;
7180
7181 // Check overflow
7182 const overflow = getComputedStyle(document.documentElement).overflow;
7183
7184 // Try window.scrollBy if overflow is not 'hidden'
7185 const initialScrollY = window.scrollY;
7186 if (overflow !== 'hidden') {
7187 window.scrollBy({ left: 0, top: scrollAmount, behavior: 'instant' });
7188 if (window.scrollY !== initialScrollY) {
7189 return {
7190 status: 'success',
7191 method: 'window.scrollBy',
7192 scrollPosition: window.scrollY,
7193 elementClass: 'window'
7194 };
7195 }
7196 }
7197
7198 // Try scrolling the scrollable element
7199 const scrollable = Array.from(document.querySelectorAll('*')).find(
7200 el => el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY !== 'hidden'
7201 ) || document.body;
7202 const initialScrollTop = scrollable.scrollTop;
7203 scrollable.scrollBy({ left: 0, top: scrollAmount, behavior: 'instant' });
7204 const elementClass = scrollable.className || 'body';
7205
7206 if (scrollable.scrollTop !== initialScrollTop) {
7207 return {
7208 status: 'success',
7209 method: 'element.scrollBy',
7210 scrollPosition: scrollable.scrollTop,
7211 elementClass
7212 };
7213 }
7214
7215 // Fallback to Page Up/Down key press
7216 scrollable.focus();
7217 const event = new KeyboardEvent('keydown', {
7218 key: keyName,
7219 code: keyName,
7220 keyCode,
7221 which: keyCode,
7222 bubbles: true,
7223 cancelable: true
7224 });
7225 scrollable.dispatchEvent(event);
7226
7227 if (scrollable.scrollTop !== initialScrollTop) {
7228 return {
7229 status: 'success',
7230 method: keyName,
7231 scrollPosition: scrollable.scrollTop,
7232 elementClass
7233 };
7234 }
7235
7236 // No scroll method worked
7237 return {
7238 status: 'failed',
7239 message: 'No scroll method worked',
7240 scrollPosition: scrollable.scrollTop,
7241 elementClass
7242 };
7243 } catch (error) {
7244 return {
7245 status: 'error',
7246 message: error.message,
7247 scrollPosition: 0,
7248 elementClass: 'none'
7249 };
7250 }
7251 }
7252 return scrollPage(direction);
7253 })('" + direction + "')";
7254
7255 // Execute the script
7256 var result = BrowserHelper.ExecuteJavaScript(this, script);
7257
7258 // Parse result (adjust based on BrowserHelper's return type)
7259 dynamic response = result; // Assumes result is a dynamic object or JSON
7260 return response.status == "success";
7261 }
7262 catch
7263 {
7264 return false;
7265 }
7266 }
7273 private bool ScrollToPosition(string position)
7274 {
7275 try
7276 {
7277 // Validate position
7278 if (position != "top" && position != "end")
7279 {
7280 return false;
7281 }
7282
7283 // JavaScript code with position parameter
7284 string script = @"
7285 (function(position) {
7286 function scrollToPosition(position) {
7287 try {
7288 const isEnd = position === 'end';
7289 const scrollTarget = isEnd ? document.body.scrollHeight : 0;
7290 const keyName = isEnd ? 'End' : 'Home';
7291 const keyCode = isEnd ? 35 : 36;
7292
7293 // Check overflow
7294 const overflow = getComputedStyle(document.documentElement).overflow;
7295
7296 // Try window.scrollTo if overflow is not 'hidden'
7297 const initialScrollY = window.scrollY;
7298 if (overflow !== 'hidden') {
7299 window.scrollTo({ left: 0, top: scrollTarget, behavior: 'instant' });
7300 if (window.scrollY !== initialScrollY) {
7301 return {
7302 status: 'success',
7303 method: 'window.scrollTo',
7304 scrollPosition: window.scrollY,
7305 elementClass: 'window'
7306 };
7307 }
7308 }
7309
7310 // Try scrolling the scrollable element
7311 const scrollable = Array.from(document.querySelectorAll('*')).find(
7312 el => el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY !== 'hidden'
7313 ) || document.body;
7314 const initialScrollTop = scrollable.scrollTop;
7315 const elementScrollTarget = isEnd ? scrollable.scrollHeight : 0;
7316 scrollable.scrollTo({ left: 0, top: elementScrollTarget, behavior: 'instant' });
7317 const elementClass = scrollable.className || 'body';
7318
7319 if (scrollable.scrollTop !== initialScrollTop) {
7320 return {
7321 status: 'success',
7322 method: 'element.scrollTo',
7323 scrollPosition: scrollable.scrollTop,
7324 elementClass
7325 };
7326 }
7327
7328 // Fallback to Home/End key press
7329 scrollable.focus();
7330 const event = new KeyboardEvent('keydown', {
7331 key: keyName,
7332 code: keyName,
7333 keyCode,
7334 which: keyCode,
7335 bubbles: true,
7336 cancelable: true
7337 });
7338 scrollable.dispatchEvent(event);
7339
7340 if (scrollable.scrollTop !== initialScrollTop) {
7341 return {
7342 status: 'success',
7343 method: keyName,
7344 scrollPosition: scrollable.scrollTop,
7345 elementClass
7346 };
7347 }
7348
7349 // No scroll method worked
7350 return {
7351 status: 'failed',
7352 message: 'No scroll method worked',
7353 scrollPosition: scrollable.scrollTop,
7354 elementClass
7355 };
7356 } catch (error) {
7357 return {
7358 status: 'error',
7359 message: error.message,
7360 scrollPosition: 0,
7361 elementClass: 'none'
7362 };
7363 }
7364 }
7365 return scrollToPosition(position);
7366 })('" + position + "')";
7367
7368 // Execute the script
7369 var result = BrowserHelper.ExecuteJavaScript(this, script);
7370
7371 // Parse result (adjust based on BrowserHelper's return type)
7372 dynamic response = result; // Assumes result is a dynamic object or JSON
7373 return response.status == "success";
7374 }
7375 catch
7376 {
7377 return false;
7378 }
7379 }
7385 private static void SelectorListMaintenanceSweep(UnitOfWork CurrentUOW)
7386 {
7387 for (int idx = 0; idx < CurrentUOW.WithSelectorList.Count; idx++)
7388 if (true == CurrentUOW.WithSelectorList[idx].DeleteMe)
7389 CurrentUOW.WithSelectorList.RemoveAt(idx);
7390
7391 for (int idx = 0; idx < CurrentUOW.InSelectorList.Count; idx++)
7392 if (true == CurrentUOW.InSelectorList[idx].DeleteMe)
7393 CurrentUOW.InSelectorList.RemoveAt(idx);
7394 }
7403 private void CheckPersistentSelectors()
7404 {
7405 UnitOfWork safeUOW = CurrentUOW;
7406
7407 // all we have to do is walk the list, try to find the element and the callif callbacks will do the work
7408 if (0 < (persistentUOW.WithSelectorList?.Count ?? 0))
7409 {
7410 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Checking [{persistentUOW.WithSelectorList?.Count}] persistent selector(s)");
7411
7412 foreach (Selector selector in persistentUOW.WithSelectorList)
7413 {
7414 if (SelectorType.Selector != selector.SelectorType)
7415 continue; // we don't look up data literals
7416
7417 // we cannot use cached results knowing we are a new uow/action
7418 selector.WebSelectorFoundResults = null;
7419 selector.WebSelectorMatchedResults = null;
7420 CurrentUOW.CurrentSelector = selector;
7421
7422 // **************** persistent selector could be in an iframe ****************
7423 if (null != selector.selectorSettings.InFrame)
7424 {
7425 // locate the iframe itself - iframes live in the main document, so the
7426 // persistent (frame-0) query is correct here for all engines.
7427 ElementHelper.FindWebElements(this, persistentUOW, selector.selectorSettings.InFrame, out _, out var iFrameMatches, null, false, true);
7428
7429 if (0 == (iFrameMatches?.Count ?? 0))
7430 continue; // the iframe isn't on the page right now - nothing to check this pass
7431
7432 // Puppeteer/Selenium: actually switch the driver into the iframe so the
7433 // search runs at that frame's document root, then switch back.
7434 if (true == UseOttoMagic)
7435 MagicHelper.SwitchToElement(iFrameMatches[0].Css);
7436 else if(true == UsePuppeteer)
7437 PuppeteerClient.SwitchToFrame(iFrameMatches[0].Css).Execute();
7438 else
7439 BrowserDriver.SwitchTo().Frame((IWebElement)iFrameMatches[0].WebElement);
7440
7441 ElementHelper.FindWebElements(this, persistentUOW, selector, out _, out _, null, true, true);
7442
7443 if (true == UseOttoMagic)
7444 MagicHelper.SwitchToDefaultContent();
7445 else if (true == UsePuppeteer)
7446 PuppeteerClient.SwitchToDefaultContent().Execute();
7447 else
7448 BrowserDriver.SwitchTo().DefaultContent();
7449
7450 // restore the driver to wherever the main workflow's context path left it -
7451 // FindElements only replays CurrentUOW.ContextPath when it is non-empty, so an
7452 // empty ContextPath means the main document is already the correct state.
7453 if (0 < (CurrentUOW.ContextPath?.Count ?? 0))
7454 foreach (UnitOfWork.WebElementWithType contextType in CurrentUOW.ContextPath)
7455 {
7456 if (ElementType.IFrame == contextType.elementType)
7457 {
7458 if (true == UseOttoMagic)
7459 MagicHelper.SwitchToElement(iFrameMatches[0].Css);
7460 else if (true == UsePuppeteer)
7461 PuppeteerClient.SwitchToFrame(contextType.gPalElement.Css).Execute();
7462 else
7463 BrowserDriver.SwitchTo().Frame((IWebElement)contextType.gPalElement.WebElement);
7464 }
7465 else if (ElementType.ShadowRoot == contextType.elementType)
7466 {
7467 if (true == UseOttoMagic)
7468 MagicHelper.SwitchToElement(contextType.gPalElement.Css);
7469 else if (true == UsePuppeteer)
7470 PuppeteerClient.SwitchToShadowRoot(contextType.gPalElement.Css).Execute();
7471 }
7472 }
7473
7474 continue;
7475 }
7476
7477 ElementHelper.FindWebElements(this, persistentUOW, selector, out _, out _, null, true, true);
7478 }
7479 }
7480 CurrentUOW = safeUOW;
7481 }
7486 private bool CheckWaitFor()
7487 {
7488 bool matchFound = true;
7489 List<GPALElement> foundElements = null;
7490
7491 // an earlier action on this same unit of work already waited these selectors in successfully, so
7492 // waiting again is redundant - the page cannot have moved under us, since any selector added after
7493 // an action starts a fresh unit of work. the action still resolves its own elements either way.
7494 if (null != CurrentUOW.WaitForSatisfied)
7495 {
7496 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Selectors for this unit of work already found at [{CurrentUOW.WaitForSatisfied:HH:mm:ss.fff}], skipping redundant wait.", this, GPALObjectType.Browser);
7497 return true;
7498 }
7499
7500 // -1 means wait forever
7501 // -2 never
7502 if (WaitTime.Never < CurrentUOW.WaitForInMs)
7503 {
7504 // we are waiting on selector(s)
7505 foundElements = ElementHelper.WaitFor(this, CurrentUOW.WaitForInMs, out matchFound);
7506
7507 if (0 < foundElements?.Count)
7508 CurrentUOW.WaitForSatisfied = DateTime.Now;
7509 }
7510
7511 return (0 < foundElements?.Count);
7512 }
7521 internal bool WorkflowSetup(bool checkWaitFor, bool doMaintenance, GPALUrl URL = null)
7522 {
7523 // settled before anything is launched, because the engine chosen here decides which of the branches
7524 // below runs
7525 if (true == BrowserSettings.HiddenDesktop && false == BrowserSettings.HiddenDesktopReported)
7526 {
7527 BrowserSettings.HiddenDesktopReported = true;
7528
7529 // SendInput reaches the desktop the user is on and no other, so a hardware engine on a desktop of
7530 // its own would be clicking at nothing. it drives through its protocol instead, and the workflow
7531 // is told which engine it is actually running rather than finding out from the results
7532 if (true == BrowserSettings.UseHardware)
7533 {
7534 AutomationEngine steppedDownTo = HardwareStepDown(BrowserSettings.AutomationEngine);
7535
7536 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{BrowserSettings.AutomationEngine}] cannot drive a hidden desktop, so this browser runs on [{steppedDownTo}]", this, GPALObjectType.Browser);
7537
7538 BrowserSettings.AutomationEngine = steppedDownTo;
7539 }
7540
7541 // a desktop that cannot be made is a browser that opens in front of the user, so the workflow is
7542 // told and this browser runs on the desktop it can have
7543 if (false == Desktops.Ensure(BrowserSettings.HiddenDesktopName))
7544 {
7545 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"No hidden desktop could be made, so this browser opens on the desktop you are working on", this, GPALObjectType.Browser);
7546
7547 BrowserSettings.HiddenDesktop = false;
7548 }
7549 }
7550
7551 // also checked in WithProfileDataDirectory as vital for that operation, but if that is not specified then this is the best location to set this
7552 // NOTE: CAVEAT: it's not clear this is even required here, but we may find a use for it
7553 //if (null == BrowserSettings.ProfileDataDirectory)
7554 // BrowserSettings.ProfileDataDirectory = FileHelper.EnsureDirectoryEndsWithBackslash(BrowserHelper.GetBrowserProfileDirectory(BrowserSettings, true));
7555
7556 // first time init is the only time URL is used
7557 // but due to window tracking, we will always go to https://google.com first then an explicit goto
7558 if (true == UseSelenium)
7559 {
7560 // instatiate a selenium driver and launch chrome headless
7561 if (null == BrowserDriver)
7562 {
7563 if (null == (BrowserDriver = LaunchWhereAsked(() => BrowserHelper.GetBrowserDriver(BrowserSettings))))
7564 return false; // NOTE: CAVEAT: will never return false, getbrowserdiver will now throw an exception and end the workflow if it can't start the browser
7565
7566 // selenium allows runtime setting of prompt for download and for chromium browsers, we can turn it off runtime via CDP
7567 // if we are running under a temp profile (none specified) we write out preferences to handle prompting to save
7568 // but if a profile is loaded and it doesn't have it set in the profile, we have to toggle it based upon the workflow setting
7569 // firefox has no runtime equivalent, so it is set in the profile prefs when the driver is built(BrowserHelper GetBrowserDriver).
7570 if (false == BrowserSettings.PromptForDownload && BrowserType.FireFox != BrowserSettings.BrowserType)
7571 BrowserHelper.AllowDownloads(BrowserSettings, BrowserSettings.DownloadLocation ?? FileHelper.GetDefaultDownloadDirectory(this));
7572 }
7573 }
7574 else if (null == BrowserSettings.Process && false == BrowserSettings.AttachedRestApi) // use browser extension/puppeteer to control browser, not selenium
7575 {
7576 if (true == UseOttoMagic)
7577 // we have to ensure we always open to a page that loads a content page.
7578 BrowserSettings.Process = LaunchWhereAsked(() => MagicHelper.LaunchBrowser(this, URL));
7579 else
7580 BrowserSettings.Process = LaunchWhereAsked(() => Puppeteer.LaunchBrowser(this, URL));
7581 }
7582
7583 // the desktop is asked what is on it, once, after the first launch. a driver that did not inherit the
7584 // desktop starts the browser back on the visible one and reports a clean launch either way, so the
7585 // only thing that tells the two apart is looking at where the window actually went
7586 if (true == BrowserSettings.HiddenDesktop && false == BrowserSettings.HiddenDesktopChecked)
7587 {
7588 BrowserSettings.HiddenDesktopChecked = true;
7589
7590 if (false == Desktops.HasWindows(BrowserSettings.HiddenDesktopName, 10_000))
7591 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Nothing arrived on [{BrowserSettings.HiddenDesktopName}], so this browser is on the desktop you are working on", this, GPALObjectType.Browser);
7592 }
7593
7594 // topping a window means nothing on a desktop nobody is looking at, and the hardware path it can fall
7595 // back to cannot reach one
7596 if (false == BrowserSettings.HiddenDesktop)
7597 BrowserHelper.TopBrowser(this, false); // ensure the browser window is on top, can use selenium or hardware (mouse click) topping. firefox will only top with hardware
7598
7599 CurrentUOW.ActionCalled = true;
7600
7601 if (true == doMaintenance)
7602 {
7603 SelectorListMaintenanceSweep(CurrentUOW); // purge any nodes marked 'deleteme' before iterating list
7604 SelectorListMaintenanceSweep(persistentUOW); // purge any nodes marked 'deleteme' before iterating list
7605 CheckPersistentSelectors(); // check if there are persistent selectors to deal with
7606 }
7607
7608 if (true == checkWaitFor)
7609 CheckWaitFor();
7610
7611 if (true == UsePuppeteer && null == BrowserSettings.PuppeteerUrl)
7612 {
7613 BrowserSettings.PuppeteerUrl = $"http://localhost:{BrowserSettings.DebugPort}";
7614 _ = PuppeteerCommunicator; // init
7615
7616 // Selenium writes download.prompt_for_download as a profile preference on every launch, but the
7617 // puppeteer path only gets it when LaunchBrowser built the profile itself. Setting it here covers
7618 // every profile, and being browser scoped it holds for tabs opened later
7619 if (false == BrowserSettings.PromptForDownload)
7620 {
7621 PuppeteerCommunicator.SetDownloadBehavior(BrowserSettings.DownloadLocation ?? FileHelper.GetDefaultDownloadDirectory(this)).GetAwaiter().GetResult();
7622 BrowserSettings.DownloadBehaviorSet = true;
7623 }
7624 }
7625
7626 // every engine is reachable and GoTo has not navigated yet, so held scripts registered here still
7627 // run on the first document
7628 ApplyPendingInjectedScripts();
7629 ArmDialogHandling();
7630
7631 return true;
7632 }
7633 bool firstRun { get; set; } = true;
7634 bool inAction = false;
7647 internal bool InAction(bool checkWaitFor, bool doMaintenance = true, GPALUrl URL = null)
7648 {
7649 bool retval = true;
7650 if (false == inAction)
7651 {
7652 string saveURL = URL;
7653 inAction = true;
7654 FileDownloaded = false; // any action will clear out this flag, but we can leftclickanddownload and then .WIthSelector(noDataSelector) and preserve it.
7655
7656 //if (true == firstRun && BrowserSettings.StealthType.HasFlag(StealthType.GenerateRealCookies))
7657 // URL = null;
7658 //else
7659 // firstRun = false;
7660
7661 // we have to return true otherwise callIfFound/NotFound are never invoked - waitfor just waits for the element to show up, but doesn't abend the workflow
7662 // getBrowserDriver should not return null now, should throw, so this should always return true
7663 retval = WorkflowSetup(checkWaitFor, doMaintenance, URL);
7664
7665 /*
7666 if (true == firstRun && BrowserSettings.StealthType.HasFlag(StealthType.GenerateRealCookies))
7667 {
7668 firstRun = false;
7669 MagicHelper.LetsDoSomeSurfing(BrowserSettings);
7670 //if (true == MagicHelper.LetsDoSomeSurfing(BrowserSettings)) // stealth type - generate some cookies and history...
7671 // GoTo(saveURL);
7672 }
7673 */
7674 inAction = false;
7675 }
7676 return retval; // NOTE: CAVEAT: useless anymore
7677 }
7695 private void BackOutOfSelfClosedTab(GPALUrl URL, string priorUrl, GPALUrl priorGPALUrl, TabTuple priorTab, string priorHandle)
7696 {
7697 tabsWeOpened--;
7698
7699 // each engine finds its way back the way it already knows how. The tab we came from never navigated,
7700 // so it is still open and still sitting on the url it was on
7701 if (true == UseOttoMagic)
7702 GoToTab(priorTab);
7703 else if (true == UsePuppeteer)
7704 PuppeteerClient.GoToTab(priorGPALUrl).Execute();
7705 else
7706 {
7707 // back to the exact tab we left, by id. It never navigated, so it is still open unless the user
7708 // closed it themselves, in which case the last tab in the list is as good an answer as any
7709 var handles = BrowserDriver.WindowHandles;
7710 string landOn = true == handles.Contains(priorHandle) ? priorHandle : handles[handles.Count - 1];
7711
7712 BrowserDriver.SwitchTo().Window(landOn);
7713 CurrentTabIdx = handles.IndexOf(landOn);
7714 }
7715
7716 BrowserSettings.CurrentURL = priorUrl;
7717 BrowserSettings.CurrentGPALUrl = priorGPALUrl;
7718
7719 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{URL?.Url}] downloaded rather than opening a page, so its tab closed itself. Back on [{priorUrl}].", this, GPALObjectType.Browser);
7720 }
7730 internal int RealTabCount()
7731 {
7732 int realCount;
7733
7734 if (true == UseOttoMagic)
7735 realCount = MagicHelper.TabCount();
7736 else if (true == UsePuppeteer)
7737 realCount = PuppeteerCommunicator.TabCount().GetAwaiter().GetResult();
7738 else
7739 // the plural handles command does not need a live current window, unlike CurrentWindowHandle, so
7740 // this still answers after a tab has closed itself, which is the whole point of asking
7741 realCount = BrowserDriver.WindowHandles.Count;
7742
7743 return realCount;
7744 }
7745 private int GetActiveTabId()
7746 {
7747 foreach(var key in BrowserSettings.TabIdsToURL.Keys.Where(k => BrowserSettings.TabIdsToURL[k].ActiveTab == true))
7748 return key;
7749
7750 return 0;
7751 }
7756 private void UpdateActiveTab(TabTuple activeTabTuple)
7757 {
7758 string priorUrl = null;
7759 if (null != activeTabTuple)
7760 {
7761 // First, update or add the current tabId with its tabInfo
7762 if (BrowserSettings.TabIdsToURL.ContainsKey(activeTabTuple.TabId))
7763 {
7764 priorUrl = BrowserSettings.TabIdsToURL[activeTabTuple.TabId].URL;
7765 BrowserSettings.TabIdsToURL[activeTabTuple.TabId].URL = activeTabTuple.Url ?? BrowserSettings.TabIdsToURL[activeTabTuple.TabId].URL;
7766 BrowserSettings.TabIdsToURL[activeTabTuple.TabId].ActiveTab = true;
7767 }
7768 else
7769 {
7770 priorUrl = activeTabTuple.Url;
7771 BrowserSettings.TabIdsToURL.Add(activeTabTuple.TabId, new TabInfo { URL = activeTabTuple.Url, ActiveTab = true });
7772 }
7773
7774 // Second, set ActiveTab = false for all other tabs
7775 foreach (var key in BrowserSettings.TabIdsToURL.Keys.Where(k => k != activeTabTuple.TabId))
7776 BrowserSettings.TabIdsToURL[key].ActiveTab = false;
7777
7778 if (null != activeTabTuple.Url)
7779 {
7780 URLInfo uRLInfo = new URLInfo { TabId = activeTabTuple.TabId, ActiveTab = true };
7781 // Next, update or add the current URL with its urlInfo
7782 if (BrowserSettings.URLsToTabId.ContainsKey(priorUrl))
7783 {
7784 BrowserSettings.URLsToTabId[activeTabTuple.Url] = uRLInfo;
7785 BrowserSettings.URLsToTabId[activeTabTuple.Url].ActiveTab = true;
7786 if (false == priorUrl.Equals(activeTabTuple.Url))
7787 BrowserSettings.URLsToTabId.Remove(priorUrl);
7788 }
7789 else
7790 BrowserSettings.URLsToTabId.Add(activeTabTuple.Url, new URLInfo { TabId = activeTabTuple.TabId, ActiveTab = true });
7791
7792 // last, set ActiveTab = false for all other tabs
7793 foreach (var key in BrowserSettings.URLsToTabId.Keys.Where(k => k != activeTabTuple.Url))
7794 BrowserSettings.URLsToTabId[key].ActiveTab = false;
7795
7796 BrowserSettings.CurrentURL = activeTabTuple.Url;
7797 // NOTE: add search gpalurllist and setting gpalcurrenturl
7798 }
7799 }
7800 else
7801 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "ActiveTabTuple from last operation is null", this, GPALObjectType.Browser);
7802 }
7803 // NOTE: not implemented. the concept was TabIds for ottomagic, for precise tab switching where the
7804 // server rewrites urls
7812 internal bool TryDecodeNumberToIpAddress(string number, out string ipAddress)
7813 {
7814 bool retVal = false;
7815 uint numberUInt;
7816
7817 ipAddress = null;
7818
7819 if (true == UInt32.TryParse(number, out numberUInt))
7820 {
7821
7822 // Convert number to IPv4 bytes (network byte order)
7823 byte[] bytes = BitConverter.GetBytes(numberUInt);
7824 if (BitConverter.IsLittleEndian)
7825 Array.Reverse(bytes); // Ensure big-endian for IP
7826
7827 // Create IP address from bytes
7828 try
7829 {
7830 var ip = new System.Net.IPAddress(bytes);
7831 ipAddress = ip.ToString(); // e.g., "192.168.1.1"
7832
7833 // Validate: Ensure it's a valid IPv4 address (4 octets, 0-255 range)
7834 var octets = ipAddress.Split('.');
7835 if (octets.Length != 4) return false;
7836 if (octets.All(o => byte.TryParse(o, out byte value) && value <= 255))
7837 {
7838 try
7839 {
7840 var host = System.Net.Dns.GetHostEntry(ip);
7841 // GPAL.PublishSimpleEvent(GPALEventType.INFO, $"[{number}] resolves to [{ip}][{host.HostName}].", this, GPALObjectType.Browser);
7842 retVal = true;
7843 }
7844 catch (GPALException)
7846 throw;
7847 }
7848 catch (Exception ex)
7849 {
7850 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"[{number}][{ip}] doesn't resolve to a hostname.", this, GPALObjectType.Browser, ex);
7851 }
7852 }
7853
7854 }
7855 catch (GPALException)
7856 {
7857 throw;
7858 }
7859 catch (Exception ex)
7860 {
7861 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"[{number}] doesn't resolve to an IP address", this, GPALObjectType.Browser, ex);
7862 }
7863 }
7864
7865 return retVal;
7866 }
7867 #endregion <Private>
7874 public void Close(bool killWebDrivers = false)
7875 {
7876 InAction(false, false);
7877
7878 tabsWeOpened = CurrentTabIdx = 0;
7879 BrowserLaunched = null;
7880
7881 // a script registered through the extension is registered in the profile, not in this browser, and
7882 // stays there once the browser has gone. the next browser on that profile runs it, whatever engine
7883 // drives it and whatever it was asked to do, so what this workflow put there it takes away
7884 if (true == UseOttoMagic && true == _scriptsRegistered)
7886
7887 StopCasting();
7888
7889 // the download directory was pointed at ours for the workflow, and it is the user's browser
7890 if (true == BrowserSettings.DownloadBehaviorSet)
7891 {
7892 BrowserSettings.DownloadBehaviorSet = false;
7893 PuppeteerCommunicator.RestoreDownloadBehavior().GetAwaiter().GetResult();
7894 }
7895
7896 if (true == BrowserSettings.AttachedRestApi)
7897 {
7898 // someone else started it and may still be using it, so the window goes and nothing else
7899 MagicHelper.CloseWindow(BrowserSettings.CurrentURL);
7900 }
7901 else if (1 == GPAL.Browsers.Count || true == killWebDrivers)
7902 {
7903 if (true == UsePuppeteer)
7904 BrowserSettings.PuppeteerCommunicator._isRunning = false;
7905
7906 // kill all removes temp profile directory if it exists
7907 BrowserHelper.KillAllRunningProcesses(killWebDrivers, this);
7908 }
7909 else
7910 {
7911 if (true == UseSelenium)
7912 BrowserDriver.Close();
7913 else if (true == UseOttoMagic && true == IsAlive)
7914 MagicHelper.CloseWindow(BrowserSettings.CurrentURL);
7915 else if (true == UsePuppeteer)
7916 PuppeteerClient.CloseWindow().Execute();
7917 }
7918
7919 // firefox rewrites its prefs.js on the way out the same as chrome does, so this waits for the same
7920 // reason: the process has to be gone first
7921 if (null != BrowserSettings.PreviousFirefoxPreferences)
7922 {
7923 BrowserHelper.WaitForBrowserToExit(BrowserSettings, ProfileRestoreWaitMs);
7924 FirefoxProfileManager.RestoreUserJs(BrowserSettings.ProfileDataDirectory, BrowserSettings.PreviousFirefoxPreferences);
7925 BrowserSettings.PreviousFirefoxPreferences = null;
7926 }
7927
7928 // chrome writes Preferences as it shuts down, so anything we put back while it is still running is
7929 // overwritten. the profile only goes back once the process is gone
7930 if (null != BrowserSettings.PreviousDownloadPreferences)
7931 {
7932 BrowserHelper.WaitForBrowserToExit(BrowserSettings, ProfileRestoreWaitMs);
7933 ChromeProfileManager.RestoreDownloadPreferences(BrowserSettings.ProfileDataDirectory, BrowserSettings.PreviousDownloadPreferences);
7934 BrowserSettings.PreviousDownloadPreferences = null;
7935 }
7936
7937 // a chromium browser hosting an extension's background page does not stop when its last window
7938 // closes; it starts a windowless replacement that holds the profile open. ours started it, so ours
7939 // ends it, rather than leaving it for the next run to fail on
7940 BrowserHelper.EndWindowlessSuccessor(BrowserSettings);
7941
7942 BrowserSettings.Process = null;
7943
7944 GPAL.Browsers.Remove(this);
7945 }
7977 internal void ResolveInteractionType(Selector selector)
7978 {
7979 InteractionType engineDefault = true == UseOttoMagic ? InteractionType.OttoMagic
7980 : true == UsePuppeteer ? InteractionType.Puppeteer
7981 : InteractionType.Selenium;
7982 InteractionType resolved = engineDefault;
7983 bool wantsHardware = InteractionType.Hardware == selector.RequestedInteractionType || true == BrowserSettings.UseHardware || true == GPAL.GPALSettings.UseHardware;
7984
7985 if (true == wantsHardware)
7986 {
7987 if (false == BrowserSettings.UseHeadless)
7988 resolved = InteractionType.Hardware;
7989 else if (InteractionType.Hardware == selector.RequestedInteractionType)
7990 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Selector [{selector.Name}] asked for hardware interaction, which a headless browser has no mouse or keyboard to perform. Using [{engineDefault}].", this, GPALObjectType.Browser);
7991 }
7992 else if (InteractionType.JavaScript == selector.RequestedInteractionType || true == BrowserSettings.UseJavaScript)
7993 // ottomagic already drives the page through injected javascript, so its own interaction is the answer
7994 resolved = true == UseOttoMagic ? InteractionType.OttoMagic : InteractionType.JavaScript;
7995 else if (InteractionType.Selenium == selector.RequestedInteractionType && false == UseSelenium)
7996 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Selector [{selector.Name}] asked for selenium interaction, which the [{AutomationEngine}] engine has no driver for. Using [{engineDefault}].", this, GPALObjectType.Browser);
7997
7998 selector.InteractionType = resolved;
7999 }
8000 public void RemoveCallIfHandlerEverywhere(CallIfDelegate func)
8001 {
8002 // soft delete. the handler stays in the list and gets skipped when the chain runs, which keeps a
8003 // reused selector's handlers intact for the next workflow and keeps us from mutating a list
8004 // CallIfHandlers is in the middle of iterating - this is nearly always called from inside a handler
8005 foreach (Selector selector in CurrentUOW.WithSelectorList)
8006 if (false == selector.RemovedMethods.Contains(func))
8007 selector.RemovedMethods.Add(func);
8008
8009 foreach (Selector selector in CurrentUOW.InSelectorList)
8010 if (false == selector.RemovedMethods.Contains(func))
8011 selector.RemovedMethods.Add(func);
8012
8013 foreach (Selector selector in persistentUOW.WithSelectorList)
8014 if (false == selector.RemovedMethods.Contains(func))
8015 selector.RemovedMethods.Add(func);
8016 }
8027 public bool IsEndOfPage()
8028 {
8029 if (true == UseOttoMagic)
8030 return MagicHelper.IsEndOfPage();
8031 else if (true == UsePuppeteer)
8032 {
8033 try
8034 {
8035 bool tmp = PuppeteerClient.IsEndOfPage().Execute<bool>();
8036 return tmp;
8037 }
8038 catch
8039 {
8040 return true;
8041 }
8042 }
8043 else
8044 {
8045 try
8046 {
8047 // JavaScript code to check if at end of page
8048 string script = @"
8049 return (function() {
8050 try {
8051 const overflow = getComputedStyle(document.documentElement).overflow;
8052 if (overflow !== 'hidden') {
8053 return (window.innerHeight + window.scrollY) >= document.body.scrollHeight;
8054 }
8055 const scrollable = Array.from(document.querySelectorAll('*')).find(
8056 el => el.scrollHeight > el.clientHeight && getComputedStyle(el).overflowY !== 'hidden'
8057 ) || document.body;
8058 return (scrollable.clientHeight + scrollable.scrollTop) >= scrollable.scrollHeight;
8059 } catch {
8060 return false;
8061 }
8062 })()";
8063
8064 // Execute the script
8065 ExecuteJavaScriptObj(script);
8066
8067 // Parse result
8068 if (bool.TryParse(JavaScriptResultObj?.ToString(), out bool val))
8069 {
8070 return val;
8071 }
8072 return false;
8073 }
8074 catch
8075 {
8076 return false;
8077 }
8078 }
8079 }
8080 #endregion <Helpers>
8081 #region Window Handling
8086 public IAllowBrowserActionOrAnySelector NextWindow
8087 {
8088 get
8089 {
8090 InAction(false);
8091 if (true == UseOttoMagic)
8092 {
8093 WindowTuple window = MagicHelper.NextWindow();
8094 foreach (var key in BrowserSettings.WindowIdsToURL.Keys)
8095 BrowserSettings.WindowIdsToURL[key].ActiveWindow = false;
8096 BrowserSettings.WindowIdsToURL[window.WindowId] = new WindowInfo { URL = window.Url, ActiveWindow = true };
8097 BrowserSettings.CurrentURL = window.Url;
8098 }
8099 else if (true == UsePuppeteer)
8100 {
8101 PuppeteerClient.NextWindow().Execute();
8102 }
8103 else // Selenium
8104 {
8105 // Get all tracked windows from your dictionary
8106 var trackedWindows = BrowserSettings.WindowIdsToURL.Keys.ToList();
8107
8108 if (trackedWindows.Count <= 1)
8109 return this; // Nothing to cycle to
8110
8111 // Find the current active one
8112 string currentHandle = trackedWindows.FirstOrDefault(h =>
8113 BrowserSettings.WindowIdsToURL[h].ActiveWindow);
8114
8115 // If none marked active (edge case), fall back to Selenium's current handle
8116 if (string.IsNullOrEmpty(currentHandle))
8117 currentHandle = BrowserDriver.CurrentWindowHandle;
8118
8119 // Find its index in the list
8120 int currentIndex = trackedWindows.IndexOf(currentHandle);
8121 if (currentIndex == -1) currentIndex = 0; // safety
8122
8123 // Calculate next index (cycle)
8124 int nextIndex = (currentIndex + 1) % trackedWindows.Count;
8125 string nextHandle = trackedWindows[nextIndex];
8126
8127 // Switch to it in Selenium
8128 BrowserDriver.SwitchTo().Window(nextHandle);
8129
8130 // Update active flags
8131 foreach (var key in BrowserSettings.WindowIdsToURL.Keys)
8132 BrowserSettings.WindowIdsToURL[key].ActiveWindow = false;
8133
8134 BrowserSettings.WindowIdsToURL[nextHandle].ActiveWindow = true;
8135
8136 // Optional: refresh URL in case page navigated independently
8137 BrowserSettings.WindowIdsToURL[nextHandle].URL = BrowserDriver.Url;
8138
8139 BrowserSettings.CurrentURL = BrowserDriver.Url;
8140 }
8141
8142 GetSetCurrentUrl();
8143 return this;
8144 }
8145 }
8146
8152 public IAllowBrowserActionOrAnySelector OpenWindow(GPALUrl URL)
8153 {
8154 InAction(false);
8155
8156 string urlString = MagicHelper.GetFullUrl(URL?.Url, this, out _areRobotsAllowed);
8157
8158 if (false == AreRobotsAllowed && true == BrowserSettings.ObeyRobotsTxt)
8159 {
8160 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Visiting [{URL?.Url}] is disallowed by robots.txt and your request to honor it. Not going to URL. Workflow will fail.", this, GPALObjectType.Browser);
8161 URL.ForUrl("https://google.com");
8162 }
8163
8164 if (UseOttoMagic)
8165 {
8166 WindowTuple window = MagicHelper.OpenWindow(urlString);
8167
8168 // Deactivate all others
8169 foreach (var key in BrowserSettings.WindowIdsToURL.Keys.ToList())
8170 {
8171 BrowserSettings.WindowIdsToURL[key].ActiveWindow = false;
8172 }
8173
8174 // Add or update the new one
8175 BrowserSettings.WindowIdsToURL[window.WindowId] = new WindowInfo
8176 {
8177 URL = window.Url,
8178 ActiveWindow = true
8179 };
8180
8181 BrowserSettings.CurrentURL = window.Url ?? urlString;
8182 }
8183 else if (UsePuppeteer)
8184 {
8185 PuppeteerClient.OpenWindow(urlString).Execute();
8186 // Assume Puppeteer updates CurrentURL internally, or fetch it later
8187 BrowserSettings.CurrentURL = urlString; // fallback if not updated elsewhere
8188 }
8189 else // Selenium
8190 {
8191 string originalHandle = BrowserDriver.CurrentWindowHandle;
8192
8193 // This creates a new top-level window and switches to it automatically
8194 BrowserDriver.SwitchTo().NewWindow(WindowType.Window);
8195
8196 // Now we're in the new window
8197 string newHandle = BrowserDriver.CurrentWindowHandle;
8198
8199 tabWindows[newHandle] = newHandle; // a window is its own window, and the first tab in it
8200
8201 // Navigate to the desired URL
8202 BrowserHelper.SeleniumGoToUrl(urlString, BrowserSettings);
8203
8204 // Update your internal tracking
8205 foreach (var key in BrowserSettings.WindowIdsToURL.Keys.ToList())
8206 {
8207 BrowserSettings.WindowIdsToURL[key].ActiveWindow = false;
8208 }
8209
8210 BrowserSettings.WindowIdsToURL[newHandle] = new WindowInfo
8211 {
8212 URL = urlString,
8213 ActiveWindow = true
8214 };
8215
8216 BrowserSettings.CurrentURL = BrowserDriver.Url; // should be the url now
8217 }
8218
8219 URL = GetSetCurrentUrl();
8220
8221 // NOTE: use evaluation shortcuiting, if it's not set, don't even check, it has a performance cost
8222 if (true == BrowserSettings.RespectRobotMetaTags && false == UrlHelper.CheckRobotMetaTags(URL, this))
8223 {
8224 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Visiting [{BrowserSettings.CurrentURL}] is disallowed by robot meta tags and your request to honor them..");
8225 URL.ForUrl("https://google.com");
8226
8227 if (true == UseOttoMagic)
8228 {
8229 TabTuple tabTuple = MagicHelper.GoTo(URL);
8230 UpdateActiveTab(tabTuple);
8231 }
8232 else if (true == UsePuppeteer)
8233 {
8235 PuppeteerClient.GoTo(URL).Execute();
8236 }
8237 else
8238 {
8239 BrowserDriver.SwitchTo().DefaultContent();
8240 BrowserHelper.SeleniumGoToUrl(URL, BrowserSettings);
8241 }
8242 }
8243
8244 BrowserSettings.GPALUrlList.Add(URL);
8245
8246 return this;
8247 }
8248
8253 public IAllowBrowserActionOrAnySelector PreviousWindow
8254 {
8255 get
8256 {
8257 InAction(false);
8258
8259 if (UseOttoMagic)
8260 {
8261 WindowTuple window = MagicHelper.PreviousWindow();
8262
8263 foreach (var key in BrowserSettings.WindowIdsToURL.Keys)
8264 BrowserSettings.WindowIdsToURL[key].ActiveWindow = false;
8265
8266 BrowserSettings.WindowIdsToURL[window.WindowId] = new WindowInfo
8267 {
8268 URL = window.Url,
8269 ActiveWindow = true
8270 };
8271
8272 BrowserSettings.CurrentURL = window.Url;
8273 }
8274 else if (UsePuppeteer)
8275 {
8276 PuppeteerClient.PreviousWindow().Execute();
8277 }
8278 else // Selenium
8279 {
8280 // Get all tracked windows from dictionary (preserves insertion order)
8281 var trackedWindows = BrowserSettings.WindowIdsToURL.Keys.ToList();
8282
8283 if (trackedWindows.Count <= 1)
8284 return this; // Nothing to cycle to
8285
8286 // Find the current active one
8287 string currentHandle = trackedWindows.FirstOrDefault(h =>
8288 BrowserSettings.WindowIdsToURL[h].ActiveWindow);
8289
8290 // Fallback to Selenium's current handle if none marked active (safety)
8291 if (string.IsNullOrEmpty(currentHandle))
8292 currentHandle = BrowserDriver.CurrentWindowHandle;
8293
8294 // Find its index
8295 int currentIndex = trackedWindows.IndexOf(currentHandle);
8296 if (currentIndex == -1) currentIndex = 0;
8297
8298 // Calculate previous index (wrap around to the last one if at the beginning)
8299 int previousIndex = (currentIndex - 1 + trackedWindows.Count) % trackedWindows.Count;
8300 string previousHandle = trackedWindows[previousIndex];
8301
8302 // Switch to it in Selenium
8303 BrowserDriver.SwitchTo().Window(previousHandle);
8304
8305 // Update active flags
8306 foreach (var key in BrowserSettings.WindowIdsToURL.Keys)
8307 BrowserSettings.WindowIdsToURL[key].ActiveWindow = false;
8308
8309 BrowserSettings.WindowIdsToURL[previousHandle].ActiveWindow = true;
8310
8311 // Optional: refresh stored URL in case the page changed
8312 BrowserSettings.WindowIdsToURL[previousHandle].URL = BrowserDriver.Url;
8313
8314 BrowserSettings.CurrentURL = BrowserDriver.Url;
8315 }
8316
8317 GetSetCurrentUrl();
8318 return this;
8319 }
8320 }
8321
8327 public IAllowBrowserActionOrAnySelector GoToWindow(dynamic urlOrId)
8328 {
8329 InAction(false);
8330 if (true == UseOttoMagic)
8331 {
8332 string windowId = null;
8333 WindowTuple window = null;
8334
8335 if (urlOrId is GPALUrl url)
8336 {
8337 windowId = BrowserSettings.WindowIdsToURL.FirstOrDefault(x => x.Value.URL.Contains(url.Url) || url.Url.Contains(x.Value.URL)).Key;
8338 BrowserSettings.CurrentGPALUrl = url;
8339 }
8340 else if (urlOrId is string urlStr)
8341 windowId = BrowserSettings.WindowIdsToURL.FirstOrDefault(x => x.Value.URL.Contains(urlStr) || urlStr.Contains(x.Value.URL)).Key;
8342
8343 if (true == string.IsNullOrEmpty(windowId))
8344 window = MagicHelper.GoToWindow(urlOrId);
8345 else
8346 window = MagicHelper.GoToWindow(windowId);
8347
8348 foreach (var key in BrowserSettings.WindowIdsToURL.Keys)
8349 BrowserSettings.WindowIdsToURL[key].ActiveWindow = false;
8350
8351 if (false == BrowserSettings.WindowIdsToURL.ContainsKey(window.WindowId))
8352 BrowserSettings.WindowIdsToURL[window.WindowId] = new WindowInfo { URL = window.Url, ActiveWindow = true };
8353 else
8354 BrowserSettings.WindowIdsToURL[window.WindowId].ActiveWindow = true;
8355
8356 BrowserSettings.CurrentURL = window.Url;
8357 }
8358 else if (true == UsePuppeteer)
8359 {
8360 string windowId = null;
8361 WindowTuple window = null;
8362
8363 if (urlOrId is GPALUrl url)
8364 {
8365 windowId = BrowserSettings.WindowIdsToURL.FirstOrDefault(x => x.Value.URL.Contains(url.Url) || url.Url.Contains(x.Value.URL)).Key;
8366 BrowserSettings.CurrentGPALUrl = url;
8367 }
8368 else if (urlOrId is string urlStr)
8369 windowId = BrowserSettings.WindowIdsToURL.FirstOrDefault(x => x.Value.URL.Contains(urlStr) || urlStr.Contains(x.Value.URL)).Key;
8370
8371 if (true == string.IsNullOrEmpty(windowId))
8372 window = PuppeteerClient.GoToWindow(urlOrId).Execute<WindowTuple>();
8373 else if (true == Int32.TryParse(windowId, out int windowIdInt))
8374 window = PuppeteerClient.GoToWindow(windowIdInt).Execute<WindowTuple>();
8375
8376 foreach (var key in BrowserSettings.WindowIdsToURL.Keys)
8377 BrowserSettings.WindowIdsToURL[key].ActiveWindow = false;
8378
8379 if (false == BrowserSettings.WindowIdsToURL.ContainsKey(window.WindowId))
8380 BrowserSettings.WindowIdsToURL[window.WindowId] = new WindowInfo { URL = window.Url, ActiveWindow = true };
8381 else
8382 BrowserSettings.WindowIdsToURL[window.WindowId].ActiveWindow = true;
8383
8384 BrowserSettings.CurrentURL = window.Url;
8385 }
8386 else if (true == UseSelenium)
8387 {
8388 string theUrl = null;
8389 string targetHandle = null;
8390
8391 if (urlOrId is GPALUrl url)
8392 theUrl = url.Url;
8393
8394 foreach (var handle in BrowserDriver.WindowHandles)
8396 BrowserDriver.SwitchTo().Window(handle);
8397 if (urlOrId is string str && (handle == str || BrowserDriver.Url.Contains(str)))
8398 {
8399 targetHandle = handle;
8400 break;
8401 }
8402 }
8403
8404 if (targetHandle != null)
8405 {
8406 BrowserDriver.SwitchTo().Window(targetHandle);
8407 BrowserSettings.CurrentURL = BrowserDriver.Url;
8408 }
8409 else
8410 {
8411 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"Failed to detect window with URL/ID [{urlOrId}]", this, GPALObjectType.Browser);
8412 }
8413 }
8414
8415 GetSetCurrentUrl();
8416 return this;
8417 }
8418
8424 public IAllowBrowserActionOrAnySelector CloseWindow(dynamic urlOrId = null)
8425 {
8426 // NOTE: CAVEAT: we aren't currently using the id we planned for with ottomagic, but this will cause problems if we do
8427 urlOrId = MagicHelper.GetFullUrl(urlOrId, this, out bool _);
8428
8429 InAction(false);
8430
8431 BrowserSettings.CurrentGPALUrl = null;
8432
8433 if (true == UseOttoMagic)
8434 {
8435 string windowIdToClose = null;
8436
8437 if (urlOrId is GPALUrl url)
8438 {
8439 if (false == string.IsNullOrEmpty(url.Url))
8440 // Find by partial URL match
8441 windowIdToClose = BrowserSettings.WindowIdsToURL
8442 .FirstOrDefault(x => x.Value.URL.Contains(url.Url) || url.Url.Contains(x.Value.URL)) // NOTE: CAVEAT: is doing contains both ways safe?
8443 .Key;
8444 }
8445 else if (urlOrId is string urlStr)
8446 {
8447 if (false == string.IsNullOrEmpty(urlStr))
8448 // Find by partial URL match
8449 windowIdToClose = BrowserSettings.WindowIdsToURL
8450 .FirstOrDefault(x => x.Value.URL.Contains(urlStr) || urlStr.Contains(x.Value.URL)) // NOTE: CAVEAT: is doing contains both ways safe?
8451 .Key;
8452 }
8453 else
8454 {
8455 // Default: close the currently active window
8456 windowIdToClose = BrowserSettings.WindowIdsToURL
8457 .FirstOrDefault(x => true == x.Value.ActiveWindow)
8458 .Key;
8459 }
8460
8461 if (string.IsNullOrEmpty(windowIdToClose))
8462 {
8463 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
8464 $"No window found to close (URL containing [{urlOrId}])", this, GPALObjectType.Browser);
8465 return this;
8466 }
8467
8468 // Let MagicHelper close it and return the new active window
8469 WindowTuple newActiveWindow = MagicHelper.CloseWindow(windowIdToClose);
8470
8471 // Remove from tracking
8472 BrowserSettings.WindowIdsToURL.Remove(windowIdToClose);
8473
8474 // Update active status for the new one (if any windows remain)
8475 if (newActiveWindow != null && !string.IsNullOrEmpty(newActiveWindow.WindowId))
8476 {
8477 foreach (var key in BrowserSettings.WindowIdsToURL.Keys)
8478 BrowserSettings.WindowIdsToURL[key].ActiveWindow = false;
8479
8480 BrowserSettings.WindowIdsToURL[newActiveWindow.WindowId] = new WindowInfo
8481 {
8482 URL = newActiveWindow.Url,
8483 ActiveWindow = true
8484 };
8485
8486 BrowserSettings.CurrentURL = newActiveWindow.Url;
8487 }
8488 else if (BrowserSettings.WindowIdsToURL.Count == 0)
8489 {
8490 BrowserSettings.CurrentURL = null;
8491 }
8492 }
8493 else if (true == UsePuppeteer)
8494 {
8495 // Puppeteer handles its own tracking and current URL update
8496 if (urlOrId is GPALUrl url)
8497 PuppeteerClient.CloseWindow(url).Execute();
8498 else if (urlOrId is string urlStr)
8499 PuppeteerClient.CloseWindow(urlStr).Execute();
8500 else if (urlOrId is int id)
8501 PuppeteerClient.CloseWindow(id).Execute();
8502 else
8503 PuppeteerClient.CloseWindow().Execute();
8504 }
8505 else // Selenium
8506 {
8507 string handleToClose = null;
8508
8509 if (urlOrId is string urlStr)
8510 {
8511 // Search by partial URL match
8512 handleToClose = BrowserSettings.WindowIdsToURL
8513 .FirstOrDefault(x => x.Value.URL.Contains(urlStr) || urlStr.Contains(x.Value.URL))
8514 .Key;
8515 }
8516 else
8517 {
8518 // Default: close the currently active window
8519 handleToClose = BrowserSettings.WindowIdsToURL
8520 .FirstOrDefault(x => x.Value.ActiveWindow)
8521 .Key;
8522
8523 // Fallback to Selenium's current handle if not marked
8524 if (string.IsNullOrEmpty(handleToClose))
8525 handleToClose = BrowserDriver.CurrentWindowHandle;
8526 }
8527
8528 if (string.IsNullOrEmpty(handleToClose) ||
8529 !BrowserSettings.WindowIdsToURL.ContainsKey(handleToClose))
8530 {
8531 GPAL.PublishSimpleEvent(GPALEventType.ERROR,
8532 $"No window found to close (URL containing [{urlOrId}])", this, GPALObjectType.Browser);
8533 return this;
8534 }
8535
8536 // Switch to the target window and close it
8537 BrowserDriver.SwitchTo().Window(handleToClose);
8538 BrowserDriver.Close(); // Closes the current window/tab
8539
8540 // Remove from internal tracking
8541 BrowserSettings.WindowIdsToURL.Remove(handleToClose);
8542
8543 // If no windows left
8544 if (BrowserDriver.WindowHandles.Count == 0)
8545 {
8546 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "No windows remain after closing.", this, GPALObjectType.Browser);
8547 return this;
8548 }
8549
8550 // Determine which window Selenium switched to automatically
8551 string newActiveHandle = BrowserDriver.CurrentWindowHandle;
8552
8553 // Update active flags
8554 foreach (var key in BrowserSettings.WindowIdsToURL.Keys)
8555 BrowserSettings.WindowIdsToURL[key].ActiveWindow = false;
8557 if (BrowserSettings.WindowIdsToURL.ContainsKey(newActiveHandle))
8558 {
8559 BrowserSettings.WindowIdsToURL[newActiveHandle].ActiveWindow = true;
8560 BrowserSettings.WindowIdsToURL[newActiveHandle].URL = BrowserDriver.Url; // refresh
8561 }
8562 else
8563 {
8564 // Fallback: pick first remaining and mark active (shouldn't happen normally)
8565 var firstRemaining = BrowserSettings.WindowIdsToURL.Keys.FirstOrDefault();
8566 if (firstRemaining != null)
8567 {
8568 BrowserSettings.WindowIdsToURL[firstRemaining].ActiveWindow = true;
8569 BrowserDriver.SwitchTo().Window(firstRemaining);
8570 }
8571 }
8572 }
8573
8574 GetSetCurrentUrl();
8575 return this;
8576 }
8577
8578 #endregion Window Handling
8579 #region WithWorkFlow, Run, While, Until, Generated by Grok
8585 public IAllowBrowserActionOrAnySelector Run()
8586 {
8587 WorkflowManager.Run();
8588 return this;
8589 }
8593 /// browser handed to each is this one.
8594 /// </summary>
8595 /// <remarks>
8596 /// The list is what <see cref="While"/> and <see cref="Until(Selector)"/> repeat, which is what makes it
8597 /// worth having: a page of results and a Next button is one workflow run until the button is gone.
8598 /// <br/><br/>
8599 /// To run several workflows at once, each with a browser of its own and possibly a different browser or
8600 /// engine, use <see cref="GPAL.Workflow"/>. This browser runs its workflows sequentially and always has.
8601 /// </remarks>
8602 /// <example>
8603 /// Page through search results, gathering each page, until the Next button is no longer there:
8604 /// <code>
8605 /// browser
8606 /// .GoTo("https://www.example.com/search?q=laptop")
8607 /// .WithWorkflow(b =&gt;
8608 /// {
8609 /// b.WithSelector(GPAL.CssSelector(".result-title"))
8610 /// .WithAllThatMatch()
8611 /// .GetElements(out List&lt;GPALElement&gt; found);
8612 ///
8613 /// results.AddRange(found);
8614 ///
8615 /// b.WithSelector(GPAL.CssSelector("a.next")).LeftClick();
8616 /// })
8617 /// .Until(b =&gt; 0 == b.CurrentUOW.WebSelectorFoundResults.Count);
8618 /// </code>
8619 /// </example>
8620 /// <param name="workflow">The workflow to run. The browser passed in is this browser.</param>
8621 /// <returns>Fluent interface to add another workflow, or to run them</returns>
8622 public IAllowWorkflowExecution WithWorkflow(Action<IBrowser> workflow)
8623 {
8624 return WorkflowManager.WithWorkflow(workflow);
8625 }
8626
8632 public IAllowWorkflowExecution WhileLoopTimeout(int timeoutMs)
8633 {
8634 if (timeoutMs <= 0)
8635 {
8636 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Invalid timeout ([{timeoutMs}]ms) specified. Using default [{BrowserSettings.WhileLoopTimeoutMs}]ms.", this, GPALObjectType.Browser);
8637 }
8638 else
8639 {
8640 BrowserSettings.WhileLoopTimeoutMs = timeoutMs;
8641 }
8642 return this;
8643 }
8644
8650 public IAllowWorkflowExecution WhileLoopMaxIterations(int maxIterations)
8652 if (maxIterations <= 0)
8653 {
8654 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Invalid max iterations ([{maxIterations}]) specified. Using default [{BrowserSettings.WhileLoopMaxIterations}].", this, GPALObjectType.Browser);
8655 }
8656 else
8657 {
8658 BrowserSettings.WhileLoopMaxIterations = maxIterations;
8659 }
8660 return this;
8661 }
8666
8669 public IAllowBrowserActionOrAnySelector While(Func<Browser, bool> predicate)
8670 {
8671 return WorkflowManager.While(predicate);
8672 }
8678
8682 return WorkflowManager.Until(selector);
8683 }
8684
8686
8689 /// <param name="predicate">A function evaluated against the <see cref="Browser"/> after each iteration; the loop stops once it returns true</param>
8690 /// <returns>Fluent interface to write your workflow</returns>
8691 public IAllowBrowserActionOrAnySelector Until(Func<Browser, bool> predicate)
8692 {
8693 return WorkflowManager.Until(predicate);
8694 }
8695 #endregion WithWorkFlow, Run, While, Until, Generated by Grok
8696 #region OCR
8697 #endregion OCR
8698 #region Getters/Setters
8702 public List<string> RobotsTxt
8703 {
8704 get => BrowserSettings.RobotsTxt;
8705 internal set => BrowserSettings.RobotsTxt = value;
8706 }
8710 public object JavaScriptResultObj
8711 {
8712 get => BrowserSettings.JavaScriptResultObj;
8713 internal set => BrowserSettings.JavaScriptResultObj = value;
8714 }
8718 public string JavaScriptResultStr
8719 {
8720 get => BrowserSettings.JavaScriptResultStr;
8721 internal set => BrowserSettings.JavaScriptResultStr = value;
8722 }
8726 public string BrowserVersion
8727 {
8728 get => BrowserSettings.Version;
8729 internal set => BrowserSettings.Version = value;
8730 }
8731
8732 #endregion
8733 #region Helpers
8739 internal static string GetModifierKeys(ModifierKeys modifierKeys)
8740 {
8741 if (modifierKeys == ModifierKeys.NONE)
8742 return "None";
8743
8744 var setFlags = Enum.GetValues(typeof(ModifierKeys))
8745 .Cast<ModifierKeys>()
8746 .Where(flag => flag != ModifierKeys.NONE && modifierKeys.HasFlag(flag))
8747 .Select(flag => flag.ToString());
8748
8749 return string.Join(", ", setFlags);
8750 }
8756 internal string GetSetCurrentUrl()
8757 {
8758 string currentUrl = null;
8759
8760 try
8761 {
8762 if (true == BrowserSettings.UseOttoMagic)
8763 currentUrl = MagicHelper.GetCurrentUrl();
8764 else if (true == UsePuppeteer)
8765 currentUrl = BrowserSettings.Browser.PuppeteerClient.GetCurrentUrl().Execute<dynamic>();
8766 else
8767 // read the url of whatever tab Selenium currently has active - do NOT switch by our own
8768 // CurrentTabIdx, which if stale would switch to the wrong tab and read the wrong url
8769 currentUrl = BrowserDriver.SwitchTo().Window(BrowserDriver.CurrentWindowHandle).Url;
8770
8771 currentUrl = currentUrl?.Trim('"');
8772
8773 if (false == UrlHelper.AreEquivalent(currentUrl, BrowserSettings.CurrentURL))
8774 {
8775 BrowserSettings.CurrentURL = currentUrl;
8776
8777 // invalidate cached elements, we are on a new page
8778 foreach (Selector selector in this.CurrentUOW.WithSelectorList)
8779 {
8780 selector.WebSelectorFoundResults = null;
8781 selector.WebSelectorMatchedResults = null;
8782 }
8783 }
8784 }
8785 catch (GPALException)
8786 {
8787 throw;
8788 }
8789 catch (Exception ex)
8790 {
8791 GPAL.PublishSimpleEvent(GPALEventType.EXCEPTION, $"Unable to get URL, returning [{BrowserSettings.CurrentURL}]", this, GPALObjectType.Browser, ex);
8792 }
8793 return BrowserSettings.CurrentURL;
8794 }
8799 internal void SetReferrer()
8800 {
8801 string message = $"[{BrowserSettings.Referrer}] referrer applied.";
8802 var parms = new Dictionary<string, object>
8803 {
8804 {
8805 "headers", new Dictionary<string, object>
8806 {
8807 { "Referer", BrowserSettings.Referrer } // NOTE: the referrer misspelling is baked into the http/1.0 protocol spec
8808 }
8809 }
8810 };
8811
8812 if (null != BrowserSettings.Referrer)
8813 {
8814 if (true == UsePuppeteer)
8815 PuppeteerClient.OverrideReferrer(BrowserSettings.Referrer).Execute();
8816 else if (true == BrowserSettings.UseOttoMagic)
8817 MagicHelper.OverrideReferrer(BrowserSettings.Referrer);
8818 else if (BrowserType.FireFox != BrowserSettings.BrowserType)
8819 ((OpenQA.Selenium.Chromium.ChromiumDriver)BrowserDriver).ExecuteCdpCommand("Network.setExtraHTTPHeaders", parms);
8820
8821 // clear it, then ignore it.
8822 if (true == string.Empty.Equals(BrowserSettings.Referrer))
8823 {
8824 BrowserSettings.Referrer = null;
8825 message = $"Cleared referrer.";
8826 }
8827
8828 GPAL.PublishSimpleEvent(GPALEventType.INFO, message, this, GPALObjectType.Browser);
8829 }
8830 }
8835 internal void SetUserAgent()
8836 {
8837 string message = $"[{BrowserSettings.OverrideUserAgent}] user agent applied.";
8838 var parms = new Dictionary<string, object> { { "userAgent", BrowserSettings.OverrideUserAgent } };
8839
8840 if (null != BrowserSettings.OverrideUserAgent)
8841 {
8842 if (true == UsePuppeteer)
8843 PuppeteerClient.SetUserAgent(BrowserSettings.OverrideUserAgent).Execute();
8844 else if (true == BrowserSettings.UseOttoMagic)
8845 MagicHelper.SetUserAgent(BrowserSettings.OverrideUserAgent);
8846 else if (BrowserType.FireFox != BrowserSettings.BrowserType)
8847 ((OpenQA.Selenium.Chromium.ChromiumDriver)BrowserDriver).ExecuteCdpCommand("Network.setUserAgentOverride", parms);
8848
8849 // clear it, then ignore it.
8850 if (true == string.Empty.Equals(BrowserSettings.OverrideUserAgent))
8851 {
8852 BrowserSettings.OverrideUserAgent = null;
8853 message = $"Cleared user agent override.";
8854 }
8855
8856 GPAL.PublishSimpleEvent(GPALEventType.INFO, message, this, GPALObjectType.Browser);
8857 }
8858 }
8859 #endregion Helpers
8860 #region Sitep Operations
8866 public IAllowBrowserActionOrAnySelector GetHydratedData(out NextJsHydrationResult hydrationResult)
8867 {
8868 hydrationResult = CachedHydration();
8869 return this;
8870 }
8871
8876 private NextJsHydrationResult CachedHydration()
8877 {
8878 string key = GetSetCurrentUrl();
8879
8880 if (false == string.IsNullOrEmpty(key) && true == _hydrationCache.TryGetValue(key, out NextJsHydrationResult cached))
8881 {
8882 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Hydrated data cache hit for [{UrlHelper.GetOrigin(CurrentUrl)}]", this, GPALObjectType.Browser);
8883 return cached;
8884 }
8885
8886 var result = NextJsHydrationExtractor.Extract(this, GetHydrationSource());
8887
8888 if (false == string.IsNullOrEmpty(key))
8889 _hydrationCache[key] = result;
8890
8891 return result;
8892 }
8901 private string GetHydrationSource()
8902 {
8903 string raw = string.Empty;
8904
8905 if (true == UseOttoMagic)
8906 raw = MagicHelper.GetPageSource();
8907 else if (true == UsePuppeteer)
8908 raw = PuppeteerClient.GetPageSource().Execute();
8909 else
8910 {
8911 try { raw = BrowserDriver.PageSource; }
8912 catch (GPALException)
8913 {
8914 throw;
8915 }
8916 catch (Exception ex)
8917 {
8918 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "Failed to read PageSource for hydration [" + ex.Message + "]", this, GPALObjectType.Browser);
8919 }
8920 }
8921
8922 if (string.IsNullOrWhiteSpace(raw))
8923 return string.Empty;
8924
8925 string peeled = JsonUnescapeToStable(raw.Trim().Trim('\"').Trim('\''));
8926
8927 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Hydrated data created for [{UrlHelper.GetOrigin(CurrentUrl)}]", this, GPALObjectType.Browser);
8928
8929 return Regex.Replace(peeled, @"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]", "");
8930 }
8931
8938 {
8939 NextJsHydrationExtractor.SaveToFile(CachedHydration(), hydrationFile.Filename);
8940
8941 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Saved hydrated data [{hydrationFile.Filename}]", this, GPALObjectType.Browser);
8942
8943 return this;
8944 }
8954 public IAllowBrowserActionOrAnySelector GetLLMDigest(out LLMDigestResult digestResult, string ruleSetName = null)
8955 {
8956 digestResult = CachedDigest(ruleSetName);
8957 return this;
8958 }
8959
8962 /// GetLLMDigest followed by SaveLLMDigest does not fetch and convert the page twice.
8963 /// </summary>
8964 private LLMDigestResult CachedDigest(string ruleSetName)
8965 {
8966 string key = GetSetCurrentUrl() + "|" + (ruleSetName ?? string.Empty);
8967
8968 if (true == _digestCache.TryGetValue(key, out LLMDigestResult cached))
8969 {
8970 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"LLM Digest cache hit for [{UrlHelper.GetOrigin(CurrentUrl)}]", this, GPALObjectType.Browser);
8971 return cached;
8972 }
8973
8974 GetPageSource(out string pageSource);
8975 LLMDigestResult digestResult = HtmlToOptimizedMarkdown.CreateDigest(this, pageSource, ruleSetName);
8976 _digestCache[key] = digestResult;
8977
8978 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"LLM Digest created for [{UrlHelper.GetOrigin(CurrentUrl)}]", this, GPALObjectType.Browser);
8979
8980 return digestResult;
8981 }
8991 public IAllowBrowserActionOrAnySelector SaveLLMDigest(GPALFile llmDigestFile, string ruleSetName = null)
8992 {
8993 HtmlToOptimizedMarkdown.SaveToFile(CachedDigest(ruleSetName), llmDigestFile.Filename);
8994
8995 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Saved LLM Digest [{llmDigestFile.Filename}]", this, GPALObjectType.Browser);
8996
8997 return this;
8998 }
9004 public IAllowBrowserActionOrAnySelector GetPageSource(out string pageSource)
9005 {
9006 pageSource = string.Empty;
9007
9008 if (true == UseOttoMagic)
9009 {
9010 pageSource = MagicHelper.GetPageSource();
9011 }
9012 else if (true == UsePuppeteer)
9013 {
9014 pageSource = PuppeteerClient.GetPageSource().Execute();
9015 }
9016 else
9017 {
9018 try
9019 {
9020 pageSource = BrowserDriver.PageSource;
9021 }
9022 catch (GPALException)
9023 {
9024 throw;
9025 }
9026 catch (Exception ex)
9027 {
9028 GPAL.PublishSimpleEvent(
9029 GPALEventType.ERROR,
9030 "Failed to read page source in Selenium [" + ex.Message + "]",
9031 this,
9032 GPALObjectType.Browser);
9033 }
9034 }
9035
9036 pageSource = NormalizePageSource(pageSource);
9037
9038 Uri currUrl = new Uri(CurrentUrl, UriKind.Absolute);
9039
9040 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Retrieved page source for [{UrlHelper.NormalizePath(currUrl.AbsolutePath)}]", this, GPALObjectType.Browser);
9041
9042 return this;
9043 }
9052 private static string NormalizePageSource(string raw)
9053 {
9054 if (string.IsNullOrWhiteSpace(raw))
9055 return string.Empty;
9056
9057 // JS/JSON-unescape first so the XML-viewer marker (real quotes) is detectable
9058 // regardless of how the engine transported the source (e.g. OttoMagic returns it escaped).
9059 string working = SafeJsUnescape(raw.Trim().Trim('\"').Trim('\''));
9060
9061 if (working.IndexOf("webkit-xml-viewer-source-xml", StringComparison.Ordinal) != -1)
9062 {
9063 string fileSource = ConverterHelper.ExtractRawXmlFromBrowserSource(working);
9064 if (!string.IsNullOrWhiteSpace(fileSource))
9065 {
9066 // Strip control characters only; do NOT HTML-decode, that would corrupt escaped
9067 // entities (e.g. &amp;) that are a genuine part of the recovered file source.
9068 return Regex.Replace(fileSource.Trim(), @"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]", "");
9069 }
9070 }
9071
9072 // Ordinary page: return the honest page source with the standard cleanup.
9073 return SafeCleanPageSource(raw);
9074 }
9080 private static string SafeCleanPageSource(string input)
9081 {
9082 if (string.IsNullOrWhiteSpace(input))
9083 return string.Empty;
9084
9085 string working = input.Trim().Trim('\"').Trim('\'');
9086
9087 // Peel the transport's JSON-string escaping FIRST. OttoMagic sends outerHTML as a JSON string
9088 // (leading quote, \" for attribute quotes, \n for newlines); HtmlDecode must run AFTER this,
9089 // because decoding entities injects raw quotes that make the still-escaped payload un-peelable.
9090 working = SafeJsUnescape(working);
9091
9092 working = System.Web.HttpUtility.HtmlDecode(working);
9093
9094 // Remove control characters only
9095 working = Regex.Replace(working, @"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]", "");
9096
9097 return working.Trim();
9098 }
9099
9105 private static string SafeJsUnescape(string input)
9106 {
9107 if (string.IsNullOrEmpty(input))
9108 return input;
9109
9110 // Step 1: peel the transport's JSON-string escaping. OttoMagic's native messaging hands us the
9111 // source as a JSON string (leading quote, \" for attribute quotes, \n for newlines), sometimes
9112 // even double-escaped. Peel each valid JSON-string layer with a real parser instead of guessing
9113 // with String.Replace. This self-terminates: once the text holds raw quotes/newlines it is no
9114 // longer a JSON string token and parsing stops - that is exactly the decoded page source. For
9115 // Selenium/Puppeteer, whose source is already clean HTML, this is a no-op (peel fails immediately).
9116 string result = JsonUnescapeToStable(input);
9117
9118 // Step 2: ALWAYS run the lenient character-level unescape on the result, so every engine converges
9119 // on identical output. Without this, OttoMagic (whose peel succeeds) would keep the single-level \"
9120 // left in the page's own embedded strings (e.g. __NEXT_DATA__ JSON), while Selenium/Puppeteer
9121 // (whose peel is a no-op) collapse those \" here - producing different source for the same page.
9122
9123 // Basic escapes - but PROTECT newlines and carriage returns so they stay escaped for JSON
9124 result = result.Replace(@"\""", "\"")
9125 .Replace(@"\'", "'")
9126 .Replace(@"\\", "\\") // keep single backslash
9127 .Replace(@"\/", "/");
9128
9129 // Safe unicode only
9130 result = Regex.Replace(result, @"\\u([0-9a-fA-F]{4})", m =>
9131 {
9132 try
9133 {
9134 int code = int.Parse(m.Groups[1].Value, System.Globalization.NumberStyles.HexNumber);
9135 if (code < 0 || code > 0x10FFFF || (code >= 0xD800 && code <= 0xDFFF))
9136 return m.Value; // keep original if invalid
9137 return char.ConvertFromUtf32(code);
9138 }
9139 catch
9140 {
9141 return m.Value;
9142 }
9143 });
9144
9145 // Remove unknown escapes (but protect " \ ' \ / \b \f \t)
9146 result = Regex.Replace(result, @"\\‍([^""'\\/bfnrtu])", "$1");
9147
9148 return result;
9149 }
9150
9159 private static string JsonUnescapeToStable(string input)
9160 {
9161 string current = input;
9162
9163 // Cap the passes: real content peels in 1-2 layers; the cap only guards against pathological input.
9164 for (int pass = 0; pass < 5; pass++)
9165 {
9166 string next = TryJsonStringDecode(current);
9167 if (null == next || next == current)
9168 break;
9169 current = next;
9170 }
9171
9172 return current;
9173 }
9179
9183 private static string TryJsonStringDecode(string s)
9184 {
9185 if (string.IsNullOrEmpty(s))
9186 return null;
9187
9188 try
9189 {
9190 return JsonConvert.DeserializeObject("\"" + s + "\"") as string;
9191 }
9192 catch
9193 {
9194 return null;
9195 }
9197
9203 public IAllowBrowserActionOrAnySelector GetSiteMap(out string sitemapXML)
9204 {
9205 GetPageSource(out sitemapXML);
9206 return this;
9207 }
9208
9209 Dictionary<string, List<string>> siteMaps = new Dictionary<string, List<string>>();
9210 List<string> suggestedSitemaps = new List<string>();
9212 // Result caches so a Save* reuses what a preceding Get* already produced (and so we do not repeatedly
9213 // walk the same site). The sitemap is site-wide, keyed by origin; the digest/hydration results are
9214 // page-level, keyed by the full URL. Instance-scoped, so each browser/engine still does the work once;
9215 // call ClearResultCache() to force a refresh.
9216 private readonly Dictionary<string, List<string>> _sitemapCache = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
9217 private readonly Dictionary<string, LLMDigestResult> _digestCache = new Dictionary<string, LLMDigestResult>(StringComparer.OrdinalIgnoreCase);
9218 private readonly Dictionary<string, NextJsHydrationResult> _hydrationCache = new Dictionary<string, NextJsHydrationResult>(StringComparer.OrdinalIgnoreCase);
9219
9226 {
9227 _sitemapCache.Clear();
9228 _digestCache.Clear();
9229 _hydrationCache.Clear();
9230 GPAL.PublishSimpleEvent(GPALEventType.INFO, "Cleared sitemap/digest/hydration result cache.", this, GPALObjectType.Browser);
9231 return this;
9232 }
9233
9240 public IAllowBrowserActionOrAnySelector GetSiteMapUrls(out List<string> sitemapUrls)
9241 {
9242 sitemapUrls = CachedSitemapUrls();
9243 return this;
9244 }
9245
9250 private List<string> CachedSitemapUrls()
9251 {
9252 string origin = UrlHelper.GetOrigin(CurrentUrl);
9253
9254 if (false == string.IsNullOrEmpty(origin) && true == _sitemapCache.TryGetValue(origin, out List<string> cached))
9255 {
9256 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Sitemap cache hit for [{origin}] ([{cached.Count}] URLs).", this, GPALObjectType.Browser);
9257 return cached;
9258 }
9259
9260 List<string> walked = WalkSitemapUrls();
9261
9262 if (false == string.IsNullOrEmpty(origin))
9263 _sitemapCache[origin] = walked;
9264
9265 return walked;
9266 }
9267
9268 private List<string> WalkSitemapUrls()
9269 {
9270 List<string> sitemapUrls = new List<string>();
9271 string currentUrl = CurrentUrl;
9272 bool isFromRobotsTxt = false;
9273 SitemapExtractor sme = new SitemapExtractor(this);
9274 List<string> potentialSitemapUrls = sme.GetSitemapUrl(currentUrl, out isFromRobotsTxt);
9275 bool urlIsSitemap = false;
9276 IGPALGrid<string> retGrid = new GPALGrid<string>();
9277 int idx = 0;
9278 string pageSource = string.Empty;
9279
9280 do
9281 {
9282 string potentialSitemapUrl = string.Empty;
9283 if (idx < potentialSitemapUrls.Count)
9284 potentialSitemapUrl = potentialSitemapUrls[idx++];
9285
9286 // before we walk to any potential sitemaps, is the current page the user directed us to a sitemap?
9287 // get the source and check if it looks like it, if so, follow it
9288 // if not, try the sitemaps we came up with, which might be from robots.txt
9289 if (false == isFromRobotsTxt)
9290 {
9291 // a .gz current page is a binary download, not a page - fetch + inflate it instead of reading the tab
9292 if (true == SitemapExtractor.IsGzipSitemapUrl(currentUrl))
9293 pageSource = SitemapExtractor.FetchAndInflateGzipSitemap(currentUrl, this);
9294 else
9295 GetPageSource(out pageSource);
9296 }
9297
9298 // IF NOT sitemap in robots.txt, and if the url comtains the literal word "sitemap" treat the current url as a sitemap
9299 // else run a heuristic upon it to see if it looks like one, it's not great...
9300 // NOTE: CAVEAT: if there is a sitemap in robots.txt, the page we are on is not examined to be a sitemap
9301 if (true == isFromRobotsTxt ||
9302 (false == currentUrl.ToLower().Contains("sitemap") && // does url contain 'sitemap' it just might be
9303 false == currentUrl.ToLower().EndsWith(".xml") && // does the url end in .xml, it better be a sitemap
9304 false == SitemapExtractor.IsValidSitemapContent(pageSource) && // no sitemap name, no .xml, last check does it look like a sitemap? 10 links and 2 two end in xml? // NOTE: not sure of the heuristic
9305 false == suggestedSitemaps.Contains(potentialSitemapUrl) // we've already suggestted this and decided against it...
9306 ))
9307 {
9308 // gzipped sitemaps (often the big ones) can't be navigated to - the browser downloads the
9309 // binary and the WebDriver session hangs. Download + inflate directly instead.
9310 if (true == SitemapExtractor.IsGzipSitemapUrl(potentialSitemapUrl))
9311 pageSource = SitemapExtractor.FetchAndInflateGzipSitemap(potentialSitemapUrl, this);
9312 else
9313 {
9314 GoTo(potentialSitemapUrl);
9315 GetPageSource(out pageSource);
9316 }
9317 }
9318 if (false == suggestedSitemaps.Contains(potentialSitemapUrl))
9319 suggestedSitemaps.Add(potentialSitemapUrl);
9320
9321 // a bot-check interstitial (e.g. PerimeterX "Robot or human?") is not a sitemap - recognize it,
9322 // warn, and stop walking this origin rather than parsing the challenge page as xml.
9323 if (true == SitemapExtractor.IsBotCheckPage(pageSource))
9324 {
9325 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"Bot-check interstitial detected at [{currentUrl}], skipping sitemap walk.", this, GPALObjectType.Browser);
9326 break;
9327 }
9328
9329 sitemapUrls = sme.ExtractSitemapUrls(pageSource, Int32.MaxValue, out urlIsSitemap); // NOTE: CAVEAT: hardcoded value - we want them all - we need a setting for this? with all that match?
9330 retGrid.AddRow(sitemapUrls);
9331 string currentUrl2 = string.Empty;
9332
9333 if (true == UseOttoMagic)
9334 currentUrl2 = MagicHelper.GetCurrentUrl();
9335 else if (true == BrowserSettings.UsePuppeteer)
9336 currentUrl2 = PuppeteerClient.GetCurrentUrl().Execute();
9337 else
9338 currentUrl2 = BrowserDriver.Url;
9339
9340 currentUrl2 = currentUrl2.Trim('\"');
9341
9342 // how to break out of the loop?
9343 // 1 - we have no sitemap urls
9344 // 2 - we have a list and have walked all of them and accumulated all of the sitemap information walking all sitemaps
9345 if (0 == potentialSitemapUrls.Count || potentialSitemapUrls.Count == idx)
9346 {
9347 if (false == UrlHelper.AreEquivalent(currentUrl, currentUrl2))
9348 GoTo(currentUrl);
9349 break;
9350 }
9351
9352 } while (true);
9353
9354 sitemapUrls = retGrid.ToList();
9355
9356 return sitemapUrls;
9357 }
9365 public IAllowBrowserActionOrAnySelector SaveSiteMapUrls(GPALFile sitemapUrlsFile)
9366 {
9367 List<string> sitemapUrls = CachedSitemapUrls();
9368
9369 // one URL per row, single "url" column, so the converter can emit any format from the file extension
9370 IGPALGrid<string> grid = new GPALGrid<string>();
9371 foreach (string url in sitemapUrls)
9372 grid.AddRow(new List<string> { url });
9373
9375 .WithInput(grid)
9376 .WithColumnName("url")
9377 .SaveTo(sitemapUrlsFile);
9378
9379 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Saved [{sitemapUrls.Count}] sitemap URLs to [{sitemapUrlsFile.Filename}]", this, GPALObjectType.Browser);
9380
9381 return this;
9382 }
9383 #endregion Sitemap Operations
9384 #region Cast Operations
9388 private string SinkName { get; set; }
9389
9396 public IAllowBrowserActionOrAnySelector CastTab(string sinkName)
9397 {
9398 SinkName = sinkName;
9399 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Casting Tab to [{sinkName}]");
9400
9401 if (true == UsePuppeteer)
9402 PuppeteerClient.CastTab(sinkName).Execute();
9403 else if (true == UseSelenium)
9404 CastTo_Selenium(sinkName, CastMode.Tab);
9405 else if (true == UseOttoMagic)
9406 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "Casting is not available via OttoMagic");
9408 return this;
9409 }
9410
9417 public IAllowBrowserActionOrAnySelector CastDesktop(string sinkName)
9418 {
9419 SinkName = sinkName;
9420 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Casting Desktop to [{sinkName}]");
9421
9422 if (true == UsePuppeteer)
9423 PuppeteerClient.CastDesktop(sinkName).Execute();
9424 else if (true == UseSelenium)
9425 CastTo_Selenium(sinkName, CastMode.Desktop);
9426 else if (true == UseOttoMagic)
9427 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "Casting is not available via OttoMagic");
9428
9429 return this;
9430 }
9431
9436 public IAllowBrowserActionOrAnySelector StopCasting()
9437 {
9438 if (false == string.IsNullOrEmpty(SinkName))
9439 {
9440 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Stopping cast to [{SinkName}]");
9441
9442 if (true == UsePuppeteer)
9443 {
9444 if (null != PuppeteerCommunicator.SinkName)
9445 {
9446 PuppeteerClient.StopCasting().Execute();
9447 PuppeteerCommunicator.SinkName = null;
9448 }
9449 }
9450 else if (true == UseSelenium)
9451 {
9452 var driver = (OpenQA.Selenium.Chromium.ChromiumDriver)BrowserDriver;
9453
9454 var param = new Dictionary<string, object> { ["sinkName"] = SinkName };
9455
9456 if (null != SinkName)
9457 try
9458 {
9459 // 1. Try official stop command
9460 driver.ExecuteCdpCommand("Cast.stopCasting", param);
9461 SinkName = null;
9462 }
9463 catch (Exception ex)
9464 {
9465 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"Cast.stopCasting failed, which it does sometimes", this, GPALObjectType.Browser, ex);
9466 }
9467 }
9468 }
9469
9470 return this;
9471 }
9472
9479 private IAllowBrowserActionOrAnySelector CastTo_Selenium(string sinkName, CastMode mode)
9480 {
9481 int retries = 10;
9482
9483 if (string.IsNullOrEmpty(sinkName))
9484 return this;
9485
9486 ((OpenQA.Selenium.Chromium.ChromiumDriver)BrowserDriver).ExecuteCdpCommand("Cast.enable",
9487 new Dictionary<string, object>());
9488
9489 Thread.Sleep(3_000);
9490 var param = new Dictionary<string, object> { ["sinkName"] = sinkName };
9491
9492 string command = (mode == CastMode.Tab)
9493 ? "Cast.startTabMirroring"
9494 : "Cast.startDesktopMirroring";
9495
9496 while (0 < retries)
9497 {
9498 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Searching for [{sinkName}].");
9499 try
9500 {
9501 ((OpenQA.Selenium.Chromium.ChromiumDriver)BrowserDriver).ExecuteCdpCommand(command, param);
9502
9503 GPAL.PublishSimpleEvent(GPALEventType.INFO,
9504 $"[{sinkName}] found.", this, GPALObjectType.Browser);
9505 break;
9506 }
9507 catch
9508 {
9509 retries--;
9510 }
9511 Thread.Sleep(3_000);
9512 }
9513 return this;
9514 }
9515 #endregion Cast Operations
9516 }
9517}
static string ExecuteJavaScript(IBrowser browser, string executeMe, params object[] args)
Runs a script and hands back what it produced. Selenium's executor is a function body,...
static void FillInWithTokens(Browser browser, IGPALGrid< string > tokens, WriteMode writeMode)
Finds the elements matching the unit of work's selectors, arranges them into a grid of rows and colum...
static void SeleniumRemoveScriptToEvaluateOnNewDocument(BrowserSettings browserSettings, string identifier)
Removes a script previously registered via SeleniumAddScriptToEvaluateOnNewDocument,...
static string FindTab(BrowserSettings browserSettings, string URL)
Searches all open browser window handles for one whose URL matches (or is matched by) URL ,...
static string GetCurrentUrl(BrowserSettings browserSettings)
Returns the current page URL, queried via OttoMagic, Puppeteer, or the Selenium WebDriver depending o...
static void SeleniumGoToUrl(GPALUrl url, BrowserSettings browserSettings)
Navigates the Selenium WebDriver to url and inspects the browser's performance logs (Network....
static bool TestUrlForPDF(Browser browser, string url)
checking the extension of the url, but also calling the URL to see if it returns a pdf response heade...
static void SetHeadlessDownload(BrowserSettings browserSettings, string destination)
Instructs the underlying Chromium driver (via CDP Page.setDownloadBehavior) to send the next download...
static bool GotoNextPage(Browser browser)
Navigates to the next page of results, either by clicking/following the configured "next page" select...
Browser object that contains the fluent methods to create your Browser workflow. Instatiated using G...
Definition Browser.cs:68
IAllowAfterAnySelector WithSelector(string literalData)
Add literal data to a grid when using .GetGrid.
Definition Browser.cs:1149
IAllowStorageOptions WithStorageDomain(string domain)
Specifies the domain to scope the next storage Run* action to. Starts a new StorageAction if the prev...
Definition Browser.cs:1361
IAllowBrowserSettingsOrGoTo WithHiddenDesktop(bool hiddenDesktop)
Run the browser on a Win32 desktop object of its own: a real window, real rendering,...
Definition Browser.cs:362
IAllowBrowserActionOrAnySelector NewTab(GPALUrl url)
Used to specify the next GoTo command should open in a new tab. The new tab is then the active tab....
Definition Browser.cs:3392
IAllowWorkflowExecution WhileLoopMaxIterations(int maxIterations)
Sets the maximum number of iterations a While/Until loop is allowed to run before it is aborted.
Definition Browser.cs:8621
IBrowser ToGPALObject()
Return a GPAL.Browser object so that GPAL.Browser does not have to be cast.
Definition Browser.cs:185
IAllowAfterAnySelector InMainDom()
Specify that GPAL is no longer working in an iFram or shadow dom and reset back to the main document.
Definition Browser.cs:1873
PuppeteerCommunicator PuppeteerCommunicator
Communicator used to send/receive messages to the Puppeteer engine for this Browser.
Definition Browser.cs:7073
IAllowBrowserSettingsOrGoTo WithLoadImages(bool loadImages)
Determine whether to show images on a webpage. .
Definition Browser.cs:527
BrowserSettings BrowserSettings
The settings backing this Browser, including configuration, state, and engine handles.
Definition Browser.cs:7048
IAllowBrowserSettingsOrGoTo WithDownloadLocation(string directory)
Specifies the download location for this browser sessions, overrides the default.
Definition Browser.cs:317
IAllowBrowserActionOrAnySelector DragAndDrop(ModifierKeys modifierKeys)
Left click the element found for Selector(s) defined in the UOW One left click for the first element...
Definition Browser.cs:6403
AutomationEngine AutomationEngine
The automation engine (OttoMagic, Puppeteer, or Selenium) used by this Browser.
Definition Browser.cs:6963
IAllowBrowserActionOrAnySelector FillInFrom(IGPALGrid< string > inputGrid)
FillIn data from the grid overwriting text in the input Selector(s) defined in the UOW One token (on...
Definition Browser.cs:4627
IAllowBrowserActionOrAnySelector Focus(Selector selector)
Focus the current element found via the Selector. Only the first element per Selector found will be f...
Definition Browser.cs:4692
IAllowBrowserSettingsOrGoTo WithWaitOnDocumentReady(int timeoutInMs)
Specifies GPAL will wait for a true in the browser DocumentReady flag before trying to locate element...
Definition Browser.cs:819
IAllowBrowserActionOrAnySelector Get(GPALUrl URL)
Navigate to the URL specified in headless mode. If no headless browser is open, one will be launched...
Definition Browser.cs:2591
string BrowserVersion
The version of the browser being automated.
Definition Browser.cs:8698
IAllowCallTemplate WithCallFilter(string urlFragment)
Save the retrieved grid. The output format and delimiter are determined by the GPALFile (its extensio...
Definition Browser.cs:5418
bool AreRobotsAllowed
True if the current page's robots.txt/meta tags allow automation, as determined by WithRespectRobotMe...
Definition Browser.cs:235
IAllowAllBrowserAndAllSelector Minimize
Minimize the browser to the taskbar.
Definition Browser.cs:2528
IAllowBrowserSettingsOrGoTo WithOverwriteExistingFile(bool overwriteFile)
Overwrite the destination file locally when downloading. If set to false, filenames will have a date...
Definition Browser.cs:508
IAllowBrowserActionOrAnySelector OpenWindow(GPALUrl URL)
Opens a new browser tab/window navigated to URL and switches focus to it.
Definition Browser.cs:8123
IAllowAfterWaitForAndSizeControl WaitForWindowRegex(string waitForTitleRegex)
Wait for a Windows window with title that matches the regex pattern to appear on the desktop....
Definition Browser.cs:5931
IAllowBrowserActionOrAnySelector PrintToPDF(GPALFile gpalFile)
Prints the current selector's element (or the whole page if no selector is set) to a PDF file.
Definition Browser.cs:5954
IAllowBrowserActionOrAnySelector InsertFrom(IGPALGrid< string > inputGrid)
Insert the data from the grid at the beginning of the text in the input Selector(s) defined in the UO...
Definition Browser.cs:4641
IAllowAfterAnySelector InElement(Selector selector)
InElement indicates the root element for following withselector element searches which would use a re...
Definition Browser.cs:1489
void Close(bool killWebDrivers=false)
Close/terminate the browser. This terminates the fluent browser workflow and nothing can be chained ...
Definition Browser.cs:7845
IAllowBrowserActionOrAnySelector PageEnd
Scroll the current tab window to the bottom of the page.
Definition Browser.cs:2367
IAllowBrowserSettingsOrGoTo WithRestApiUrl(string restApiUrl)
Specifies the TCP port number to start the browser, but also connect to this browser session from a c...
Definition Browser.cs:717
IAllowBrowserActionOrAnySelector RightClickAndDownload(GPALFile filenames)
Right click the element(s) found for Selector(s) defined in the UOW, select 'Save as....
Definition Browser.cs:4285
IAllowBrowserActionOrAnySelector PressModifierKey(ModifierKeys modifierKeys)
Press the specified modifer keys (can be ORd) NOTE: If you SendString a mixed case string,...
Definition Browser.cs:6132
IAllowWithHeaderOrFileActions WithHeader(string header)
Specify a column header for output. Headers are output in order defined.
Definition Browser.cs:2168
IAllowBrowserActionOrAnySelector CaptureCalls(out List< GPALCall > calls)
Records what the page asks for and hands back everything recorded so far, so an endpoint can be read ...
Definition Browser.cs:5581
IAllowBrowserActionOrAnySelector GoTo(GPALUrl URL)
Navigate to the URL specified. If no browser is open, one will be launched. If using an already ope...
Definition Browser.cs:2623
IAllowBrowserActionOrAnySelector GoToWindow(dynamic urlOrId)
Switches focus to the browser tab/window matching the given URL or window/tab id.
Definition Browser.cs:8298
delegate CallIfStatus CallAfterFillInDelegate(IBrowser browser, IGPALGrid< string > tokens, int tokenIdx)
Delegate callback for the CallAfterFillIn EventHandler which will be invoked after each row of tokens...
IAllowBrowserActionOrAnySelector Forward
Navigate forward one page.
Definition Browser.cs:2303
IAllowBrowserSettingsOrGoTo WithObeyRobotsTxt(bool trueFalse=false)
Set whether to deny navigating to webpage if denied by robots.txt.
Definition Browser.cs:2566
IAllowBrowserSettingsOrGoTo WithPromptForDownload(bool promptForDownload)
Set to true to always display the SAVE AS dialog box when downloading a file. Very useful with LeftC...
Definition Browser.cs:570
IAllowNetworkIdleSettings WithNetworkIdlePruneMs(int networkIdlePruneMs=3_000)
Specifies the maximum duration to keep network stats before pruning old data The time to wait for new...
Definition Browser.cs:945
IAllowBrowserSettingsOrGoTo WithBrowserType(BrowserType browserType)
Specify which browser to run the workflow in. NOTE: Chrome and Edge act mostly the same,...
Definition Browser.cs:488
IAllowBrowserActionOrAnySelector GetStorageData(out string data)
Retrieves the data read by the most recent .RunGet storage action.
Definition Browser.cs:1474
delegate CallIfStatus CallOnFailDelegate(IBrowser browser, GPALFailure failure, string detail)
Delegate for the CallOnFail handler, invoked when something fails that the workflow,...
IAllowBrowserActionOrAnySelector GetSiteMapUrls(out List< string > sitemapUrls)
Discovers and walks the site's sitemap(s) (via robots.txt, the current page, or heuristic detection),...
Definition Browser.cs:9211
IAllowBrowserSettingsOrGoTo WithBlockPopUps(bool blockPopUp)
Specifies whether to block popups in the browser.
Definition Browser.cs:336
IAllowAfterAnySelector WithSelector(Func< string > dataFunction)
Add literal data to a grid by invoking this function when using .GetGrid.
Definition Browser.cs:1181
IAllowBrowserSettingsOrGoTo WithUseDirectDownload(bool trueFalse)
For use with .RightClickAndDownload, use websockets to directly download what the webelement referenc...
Definition Browser.cs:297
IAllowBrowserSettingsOrGoTo WithNavigationGrace(int graceInMs)
How long a click waits to see whether it started a navigation, before deciding it stayed on the page....
Definition Browser.cs:843
IAllowAfterWaitForAndSizeControl WithWaitForWindowTimeout(int waitTimeInSeconds)
Define the time in seconds to WaitForWindow/Regex.
Definition Browser.cs:2006
IAllowBrowserActionOrAnySelector ContinueAsRESTClient(out IRESTClient client)
Carries on without the browser: hands back a RESTClient holding what this browser has,...
Definition Browser.cs:1201
IAllowBrowserActionOrAnySelector SendKey(byte VKCode)
Type special characters like Enter, Page Up, Page Down, Tab, etc. Use GPAL.VK constants for ease of ...
Definition Browser.cs:6274
IAllowBrowserActionOrAnySelector InsertFrom(GPALDatabase inputDatabase)
Insert the text from the database at the beginning of the input Selector(s) defined in the UOW One t...
Definition Browser.cs:4535
IAllowBrowserActionOrAnySelector ExecuteJavaScriptStr(string javascript)
Executes JavaScript in the browser and stores the result as a string in JavaScriptResultStr.
Definition Browser.cs:6448
IAllowBrowserActionOrAnySelector Back
Navigate back one page.
Definition Browser.cs:2282
IAllowStorageOptions WithStorageStoreName(string storeName)
Specifies the IndexedDB store name to scope the next storage Run* action to. Starts a new StorageActi...
Definition Browser.cs:1418
IAllowBrowserActionOrAnySelector MoveTo(Selector selector=null)
Moves the mouse to the current element found via the Selector. Only the first element per Selector fo...
Definition Browser.cs:4786
IAllowSelectorInFrameBrowserSettingsOrGoto WithPersistentSelector(Selector selector)
Add a persistent selector to the persistent UOW. Persistent selectors are always looked for whenever ...
Definition Browser.cs:2118
IAllowAfterAnySelector CallIfNotFound(Browser.CallIfDelegate callIfNotFound)
Add a Unit of Work level CallIfNotFound handler to be called if any defined selectors DO NOT find/mat...
Definition Browser.cs:1995
IHiddenDesktop HiddenDesktop
The hidden desktop this browser is running on, or null when it is on the desktop you are looking at....
Definition Browser.cs:7120
IAllowBrowserSettingsOrGoTo WithOpenPDFExternally(bool openPDFExternally)
Specifies whether the browser will render PDFs or launch an external program. .
Definition Browser.cs:580
IAllowNetworkIdleSettings WithNetworkIdleTimeoutMs(int networkIdleTimeoutMs=500)
Specifies max time in milliseconds to wait for the network idle before returning false.
Definition Browser.cs:895
IAllowStorageOptions WithStorageKey(string key)
Specifies the key to scope the next storage Run* action to. Starts a new StorageAction if the previou...
Definition Browser.cs:1399
IAllowWithPagesAndGridActions WithNextPageButton(Selector nextPageButtonselector)
Specifies the Next Page button used to retrieve multiple pages of data. Use with ....
Definition Browser.cs:1899
IAllowBrowserActionOrAnySelector ScrollWindowByVertical(int scrollAmountInPixels)
Scroll the browser window horizontally the number of pixels. Negative numbers scroll up....
Definition Browser.cs:6306
IAllowBrowserActionOrAnySelector CloseWindow(dynamic urlOrId=null)
Closes the current browser tab/window, or the one matching the given URL or window/tab id,...
Definition Browser.cs:8395
IAllowBrowserSettingsOrGoTo WithProfileName(string profileName)
Specify the profile name to use under the data directory for this browser automation session NOTE: us...
Definition Browser.cs:1028
IAllowBrowserActionOrAnySelector SaveSiteMapUrls(GPALFile sitemapUrlsFile)
Discovers and walks the site's sitemap(s), then saves the extracted URLs to a file....
Definition Browser.cs:9336
IAllowBrowserActionOrAnySelector ClearResultCache()
Clears the cached sitemap / LLM digest / hydration results for this browser, forcing the next Get*‍/S...
Definition Browser.cs:9196
string RestApiBaseUrl
Where this browser's GPALRestAPI is listening, e.g. http://localhost:3117/, taken from the port it an...
Definition Browser.cs:7038
IAllowAfterAnySelector InShadowDom(Selector shadowDomSelector)
Specify that the following .WithSelectors are contained in an a shadow dom specified by the Selector....
Definition Browser.cs:1747
IAllowBrowserSettingsOrGoTo WithAutomationEngine(AutomationEngine automationEngine)
Selects which automation engine drives the browser: Selenium, OttoMagic, or one of the Puppeteer conn...
Definition Browser.cs:771
IAllowBrowserActionOrAnySelector RightClick(Selector selector=null)
Right click the element found for Selector(s) defined in the UOW One right click for the first eleme...
Definition Browser.cs:3682
IAllowBrowserActionOrAnySelector PageTop
Scroll the current tab window to the top of the page.
Definition Browser.cs:2386
IAllowBrowserActionOrAnySelector ScrollWindowByHorizontal(int scrollAmountInPixels)
Scroll the browser window horizontally the number of pixels. Negative numbers scroll left....
Definition Browser.cs:6289
IAllowBrowserActionOrAnySelector SetValueFrom(Selector selector)
Sets the value of the element(s) matched by the preceding WithSelector call from the value of the ele...
Definition Browser.cs:4756
IAllowBrowserActionOrAnySelector NextTab
Navigate to the next tab. If at the last tab nothing will happen. NOTE: New window is just another ta...
Definition Browser.cs:3320
IAllowWithPagesAndFetch WithTokensFrom(string tokens)
Definition Browser.cs:1948
IAllowAllBrowserAndAllSelector FullScreen
Make the browser window fullscreen, saving the current window size so it can be restored later via Re...
Definition Browser.cs:2459
string CurrentUrl
The URL of the page currently loaded in the browser.
Definition Browser.cs:6927
IAllowBrowserActionOrAnySelector GetHydratedData(out NextJsHydrationResult hydrationResult)
Extracts Next.js hydration data (e.g. NEXT_DATA) embedded in the current page's HTML.
Definition Browser.cs:8837
IAllowBrowserActionOrAnySelector WithDialogsAccepted(bool accept)
Answers the alert, confirm and prompt dialogs the page raises instead of letting them open and stop e...
Definition Browser.cs:6547
List< string > RobotsTxt
The lines of the current site's robots.txt, as parsed by WithRespectRobotMetaTags/WithObeyRobotsTxt.
Definition Browser.cs:8674
IAllowAfterWaitFor WaitFor(WaitTime waitForTimeInMs)
Either wait the amount of time specified or wait UP TO the amount of time specified waiting for eleme...
Definition Browser.cs:5854
bool UseSelenium
True if this Browser is using the Selenium automation engine.
Definition Browser.cs:6935
IAllowBrowserActionOrAnySelector StopCasting()
Stops any active cast (tab or desktop mirroring) started via CastTab or CastDesktop.
Definition Browser.cs:9407
IAllowStorageOptions WithUserDefinedFilter(string userDefined)
Specifies a user-defined filter string passed through to the storage action implementation....
Definition Browser.cs:1456
IAllowBrowserActionOrAnySelector AppendFrom(string text)
Append the given text to the first input in the UOW. Should probably only be used with one selector a...
Definition Browser.cs:4653
IAllowBrowserActionOrAnySelector LeftClickAndDownload(GPALFile gPalFileToDownloadTo)
Left clicks the element(s) found for the current selector(s) and downloads the resulting file to gPal...
Definition Browser.cs:3741
IAllowBrowserSettingsOrGoTo WithHiddenDesktop(string desktopName)
Run the browser on the named Win32 desktop object. Browsers naming the same desktop share it,...
Definition Browser.cs:388
IAllowBrowserActionOrAnySelector ScrollElementVerticalByPixels(int scrollAmountInPixels)
Scroll the element vertically by number of pixels. Negative numbers scroll up. JavaScript only.
Definition Browser.cs:6334
delegate CallIfStatus CallAfterFetchDelegate(IBrowser browser, List< string > results, IGPALGrid< string > tokens, int tokenIdx)
Delegate callback for the CallAfterFetch handler declared on a GPALRequest, invoked after each row of...
IAllowBrowserActionOrAnySelector AppendFrom(GPALFile inputFile)
Append the data from the input file to the end of any input Selectors defined in the UOW One token (...
Definition Browser.cs:4565
IAllowAfterAnySelector WithJavaScript
Specifies to execute javascript on the page to interact with the Selector just defined with ....
Definition Browser.cs:2256
bool IsEndOfPage()
Helper method to check whether the current page is at the bottom.
Definition Browser.cs:7998
IAllowBrowserActionOrAnySelector NextWindow
Switches focus to the next browser tab/window in the window list, wrapping around to the first.
Definition Browser.cs:8058
bool UseOttoMagic
True if this Browser is using the OttoMagic automation engine.
Definition Browser.cs:6949
IAllowAfterAnySelector InFrame(Selector selector)
Specify that the following .WithSelector(s) are contained in an iFrame specified by the Selector....
Definition Browser.cs:1619
IAllowBrowserActionOrAnySelector Run()
Runs the workflows added via WithWorkflow against this browser, in the order they were added....
Definition Browser.cs:8556
IAllowAfterWaitFor WaitFor(ElementState elementState)
Waitfor an element to eneter the specified state. This is an action.
Definition Browser.cs:5900
IAllowBrowserActionOrAnySelector FillInFrom(string text)
Overwrite all text in the first input in the UOW. Should probably only be used with one selector at a...
Definition Browser.cs:4666
IAllowBrowserActionOrAnySelector AppendFrom(IGPALGrid< string > inputGrid)
Append the data from the input grid to the end of any input Selectors defined in the UOW One token (...
Definition Browser.cs:4613
IAllowAfterWaitFor WaitFor(Selector waitForSelector)
Wait for the specified elements(s) defined by withForSelector to be present and defined on-page.
Definition Browser.cs:5873
IAllowBrowserSettingsOrGoTo WithProfileDataDirectory(string profileDataDirectory)
Specify the profile directory to use for this browser automation session NOTE: if both Profile User N...
Definition Browser.cs:968
IAllowBrowserActionOrAnySelector Hover(Selector selector=null)
Fluent alias for MoveTo(Selector). Moving the mouse to an element is a real input-layer event on ever...
Definition Browser.cs:4772
IAllowBrowserSettingsOrGoTo WithUseReferrer(string referrer="")
Set the refererrer to use with any Goto or Get for the rest of the browser sessions....
Definition Browser.cs:1051
IAllowBrowserSettingsOrGoTo WithUserAgentFromBrowser(bool userAgentFromBrowser=true)
Launch a browser purely to read its own user agent, instead of working one out without one....
Definition Browser.cs:1078
IAllowBrowserActionOrAnySelector CastDesktop(string sinkName)
Casts (mirrors) the entire desktop to the named cast sink (e.g. a Chromecast device)....
Definition Browser.cs:9388
IAllowBrowserActionOrAnySelector ClearCapturedCalls()
Forgets everything recorded so far, so what is captured next is what happened next....
Definition Browser.cs:5610
IAllowBrowserActionOrAnySelector GetLLMDigest(out LLMDigestResult digestResult, string ruleSetName=null)
Converts the current page's HTML into an LLM-optimized markdown digest.
Definition Browser.cs:8925
IAllowWorkflowExecution WhileLoopTimeout(int timeoutMs)
Sets the maximum amount of time a While/Until loop is allowed to run before it is aborted.
Definition Browser.cs:8603
IAllowBrowserActionOrAnySelector While(Func< Browser, bool > predicate)
Repeats the workflow added via WithWorkflow for as long as the given predicate returns true,...
Definition Browser.cs:8640
IAllowBrowserActionOrAnySelector Until(Selector selector)
Repeats the workflow added via WithWorkflow until the given selector matches an element on the page,...
Definition Browser.cs:8651
IAllowBrowserActionOrAnySelector AppendFrom(GPALDatabase inputDatabase)
Append data from the database to the end of any input Selectors defined in the UOW One token (one co...
Definition Browser.cs:4520
IAllowBrowserActionOrAnySelector MiddleClick()
Midle click the element found for Selector(s) defined in the UOW One middle click for each element f...
Definition Browser.cs:3625
IAllowBrowserActionOrAnySelector FillInFrom(GPALFile inputFile)
FillIn data from the file overwriting text in the input Selector(s) defined in the UOW One token (on...
Definition Browser.cs:4581
bool? FileDownloaded
True if the most recent download action successfully downloaded a file. Null if no download has been ...
Definition Browser.cs:6971
IAllowBrowserActionOrAnySelector SetRange(int rangeValue)
Sets the value of a range/slider input element matched by the current selector(s) by scrolling it int...
Definition Browser.cs:6873
object JavaScriptResultObj
The result object returned by the most recent ExecuteJavaScriptObj call.
Definition Browser.cs:8682
IAllowBrowserActionOrAnySelector InsertFrom(GPALFile inputFile)
Insert the data from the file at the beginning of the text in the input Selector(s) defined in the UO...
Definition Browser.cs:4597
IAllowBrowserActionOrAnySelector LeftDoubleClick()
Left double click the element found for Selector(s) defined in the UOW One left click for each eleme...
Definition Browser.cs:3608
IAllowBrowserActionOrAnySelector StealthLeftClick(Selector selector)
Definition Browser.cs:3577
int ServerResponseCode
HTTP status code of the most recent page navigation/response.
Definition Browser.cs:6918
IAllowBrowserActionOrAnySelector Hide(Selector selector=null)
Hides the element(s) found for selector (or the current selector(s) if none is given) by setting the...
Definition Browser.cs:4707
IAllowBrowserActionOrAnySelector WithDialogText(string text)
What a prompt hands back when dialogs are accepted. Left unsaid, the prompt's own default stands.
Definition Browser.cs:6568
IAllowAfterAnySelector WithHardware
Specifies to use hardware emulation when interacting with the Selector just defined with ....
Definition Browser.cs:2241
IAllowBrowserSettingsOrGoTo WithDriverLocation(string directory)
Specifies the location where the browser driver executable is loaded from. Default location is the d...
Definition Browser.cs:287
IAllowBrowserSettingsOrGoTo WithUseStealth(StealthType steathType)
Use stealth operations to block bot detection use Runtime.disable before clicking login button Add pe...
Definition Browser.cs:1040
bool UsePuppeteer
True if this Browser is using the Puppeteer automation engine.
Definition Browser.cs:6942
MagicHelper MagicHelper
The MagicHelper used to issue commands when this Browser is using the OttoMagic engine.
Definition Browser.cs:7109
IAllowBrowserActionOrAnySelector ExecuteJavaScriptObj(string javascript)
Executes JavaScript in the browser and stores the raw result object in JavaScriptResultObj.
Definition Browser.cs:6437
IAllowBrowserSettingsOrGoTo WithCredentials(ICredentials credentials)
Add a handler to be called when something fails on this browser that the workflow is in a position to...
Definition Browser.cs:2063
IAllowBrowserActionOrAnySelector GetPageSource(out string pageSource)
Gets the current page's HTML source, cleaned of escape sequences and control characters.
Definition Browser.cs:8975
IAllowBrowserSettingsOrGoTo PersistentCallIfNotFound(CallIfDelegate persistentCallIfNotFound)
Add a persistent CallIfNotFound handler to be called if any defined persistent selectors DO NOT find/...
Definition Browser.cs:2035
int CurrentTabIdx
Index of the currently active browser tab/window.
Definition Browser.cs:222
IAllowBrowserActionOrAnySelector AppendTo(GPALFile file)
Append the retrieved grid. The output format and delimiter are determined by the GPALFile (its extens...
Definition Browser.cs:5810
IPuppeteerClient PuppeteerClient
The Puppeteer client used to issue commands when this Browser is using the Puppeteer engine.
Definition Browser.cs:7101
IAllowBrowserActionOrAnySelector SaveTo(ref string data)
Hand back what .Fetch retrieved, as a JSON array holding one response body per page requested,...
Definition Browser.cs:5778
IAllowBrowserSettingsOrGoTo WithRespectRobotMetaTags(bool trueFalse=false)
Set whether to respect robots-related meta tags (e.g. noindex/nofollow) on the page.
Definition Browser.cs:2576
IAllowBrowserActionOrAnySelector ClearInjectedScripts()
Removes all scripts previously registered via InjectScript(string) or InjectScript(GPALFile).
Definition Browser.cs:6842
IAllowAllBrowserAndAllSelector Restore
Restore the browser to window state, unminimize.
Definition Browser.cs:2424
IAllowAfterWaitForAndSizeControl WaitForWindow(string waitForTitle)
Wait for a Windows window with title to appear on the desktop. Used in conjunction with ....
Definition Browser.cs:5920
IAllowBrowserActionOrAnySelector SelectClick(SelectClickType selectClickType)
Clicks the option element(s) found for the current selector(s) using the given select-click behavior ...
Definition Browser.cs:3555
IAllowBrowserSettingsOrGoTo WithProfileUserName(string profileUserName)
Specify the operating system login username to find the profile directory to use for this browser aut...
Definition Browser.cs:1005
IAllowBrowserActionOrAnySelector GoToTab(dynamic URLorTabTuple=null)
Goto the tab with the specified URL If using OttoMagic, you can use the tabid from browser....
Definition Browser.cs:6356
IAllowBrowserActionOrAnySelector CloseTab(dynamic URLorTabId=null)
Close the current in focus, active tab.
Definition Browser.cs:3054
IAllowWorkflowExecution WithWorkflow(Action< IBrowser > workflow)
Adds a workflow to run against this browser, used with Run, While and Until(Selector)....
Definition Browser.cs:8593
IAllowBrowserSettingsOrGoTo WithUseUserAgent(string userAgent="")
Set the user agent to use with any Goto or Get for the rest of the browser sessions....
Definition Browser.cs:1062
IAllowStorageOptions WithStorageData(string data)
Specifies the data value to write for the next storage .RunSet action. Starts a new StorageAction if ...
Definition Browser.cs:1437
IAllowWithPagesAndFetch WithTokensFrom(GPALDatabase inputDatabase)
Definition Browser.cs:1939
IAllowBrowserActionOrAnySelector PreviousWindow
Switches focus to the previous browser tab/window in the window list, wrapping around to the last.
Definition Browser.cs:8225
IAllowBrowserActionOrAnySelector LeftClick(ModifierKeys modifierKeys)
Left click the element found for Selector(s) defined in the UOW One left click for each element foun...
Definition Browser.cs:3479
IAllowNetworkIdleSettings WithNetworkIdleMaxConnections(int maxConnections=0)
Specifies the maximum duration to keep network stats before pruning old data.
Definition Browser.cs:919
IAllowBrowserActionOrAnySelector PreviousTab
Navigate to the previous tab. If at the first tab nothing will happen.
Definition Browser.cs:3413
IAllowBrowserActionOrAnySelector SetAttribute(string value)
Assigns value to the attribute named by the preceding WithAttribute call, completing the SetAttribut...
Definition Browser.cs:4740
IWebDriver BrowserDriver
The browser driver controlling the current Browser. Only available after the first ....
Definition Browser.cs:7022
IAllowBrowserActionOrAnySelector LeftClick()
Left click the element found for Selector(s) defined in the UOW One left click for the first element...
Definition Browser.cs:3469
IAllowBrowserActionOrAnySelector RunDelete(WebsiteStorageType storageType)
Deletes data from browser storage (cookies, local/session storage, IndexedDB) using the domain/path/k...
Definition Browser.cs:1346
IAllowStorageOptions WithStoragePath(string path)
Specifies the path to scope the next storage Run* action to. Starts a new StorageAction if the previo...
Definition Browser.cs:1380
IAllowBrowserActionOrAnySelector StartWorkflow()
Start a workflow to invoke CallIf handlers (if no other action is being used in the workflow) A Left...
Definition Browser.cs:4805
IAllowWithPagesAndFetch WithTokensFrom(GPALFile inputFile)
Definition Browser.cs:1930
IAllowBrowserSettingsOrGoTo WithUseDebugPipe(bool trueFalse=false)
Use CDP protocol over pipes, should this impply CDP because selenium doesn't support pipe,...
Definition Browser.cs:794
IAllowBrowserActionOrAnySelector ReleaseModifierKey(ModifierKeys modifierKeys)
Release the specified modifer keys (can be ORd) NOTE: If you SendString a mixed case string,...
Definition Browser.cs:6202
IAllowSetAttributeValue WithAttribute(string attribute)
Names the DOM attribute to set on the current selector(s). Must be followed by SetAttribute(string) t...
Definition Browser.cs:4729
IAllowAfterAnySelector CallAfterFillIn(Browser.CallAfterFillInDelegate callAfterFillIn)
Add a handler to call after a row of tokens is consumed and after all inputs are filled in with data....
Definition Browser.cs:1963
IAllowBrowserActionOrAnySelector SendString(string textToSend)
Type in the literal string provided. NOTE: If you press SHIFT then SendString a mixed-case string,...
Definition Browser.cs:6106
IAllowBrowserActionOrAnySelector SaveHydratedData(GPALFile hydrationFile)
Extracts Next.js hydration data (e.g. NEXT_DATA) from the current page and saves it to a file.
Definition Browser.cs:8908
IAllowBrowserActionOrAnySelector GetWindowRectangle(out Rectangle window)
Where this browser's window is and how big it is, in screen coordinates, with its chrome included....
Definition Browser.cs:6789
IAllowBrowserActionOrAnySelector ClearDialogHandling()
Stops answering dialogs, so they open and block again the way they would for a person....
Definition Browser.cs:6593
IAllowBrowserActionOrAnySelector PageUp
Scroll the current tab window up one page.
Definition Browser.cs:2405
IAllowBrowserActionOrAnySelector LeftClick(Selector selector)
Left click the element(s) found for Selector parameter, creates a UOW of 1 element One left click w/...
Definition Browser.cs:3542
IAllowBrowserActionOrAnySelector SaveLLMDigest(GPALFile llmDigestFile, string ruleSetName=null)
Converts the current page's HTML into an LLM-optimized markdown digest and saves it to a file.
Definition Browser.cs:8962
IAllowBrowserActionOrAnySelector CastTab(string sinkName)
Casts (mirrors) the current browser tab to the named cast sink (e.g. a Chromecast device)....
Definition Browser.cs:9367
IAllowAllBrowserAndAllSelector Maximize
Maximize the browser to full-screen.
Definition Browser.cs:2492
IAllowBrowserActionOrAnySelector LeftClickAndUpload(GPALFile gPalFileToUpload)
Left click the input[type=file] or custom control button Headful - enter filename in open file dialog...
Definition Browser.cs:4108
IAllowGetGridAndFillInFrom WithPages(int pageCount)
Specified the number of pages to retrieve when using GetGrid or any .SaveTo[CSV|Excel|File] Must als...
Definition Browser.cs:1911
IAllowBrowserActionOrAnySelector RunSet(WebsiteStorageType storageType)
Writes data to browser storage (cookies, local/session storage, IndexedDB) using the domain/path/key/...
Definition Browser.cs:1332
IAllowBrowserActionOrAnySelector WithExistingBrowser(int port)
Specifies the port to use to connect to an existing session from another workflow started with ....
Definition Browser.cs:667
IAllowBrowserActionOrAnySelector FillInFrom(GPALDatabase inputDatabase)
FillIn data from the database overwriting text in the input Selector(s) defined in the UOW One token...
Definition Browser.cs:4550
IAllowWithHeaderOrFileActions WithGridToSave(IGPALGrid< string > inputGrid)
Specifies a GPALGrid<string> that will be subsequently saved to disk. Headers can be specified inlin...
Definition Browser.cs:620
IAllowBrowserActionOrAnySelector GetSiteMap(out string sitemapXML)
Gets the raw XML/source of the current page, intended for use when the current page is itself a sitem...
Definition Browser.cs:9174
BrowserType BrowserType
Return the current browser type.
Definition Browser.cs:6987
delegate CallIfStatus CallIfDelegate(IBrowser browser, List< IGPALElement > foundElements, List< IGPALElement > matchedElements, Selector selector, bool matchedAll)
Delegate callback for the CallIfFound/CallIfNotFound EventHandlers which will be invoked when a selec...
IAllowBrowserSettingsOrGoTo WithScrollIntoView(bool trueFalse=true)
Scroll each scraped element into view as it is read, so the page visibly follows the workflow....
Definition Browser.cs:549
IAllowBrowserActionOrAnySelector ScrollElementHorizontalByPixels(int scrollAmountInPixels)
Scroll the element horizontally by number of pixels. Negative numbers scroll left....
Definition Browser.cs:6323
string JavaScriptResultStr
The result string returned by the most recent ExecuteJavaScriptStr call.
Definition Browser.cs:8690
IAllowAllBrowserAndAllSelector WithWindowSize(Rectangle windowSize)
Set the default window size for when the browser opens. As well, exiting fullscreen will return to th...
Definition Browser.cs:2204
IAllowBrowserSettingsOrGoTo WithDownloadTimeoutInSec(int seconds)
Sets the maximum time to wait for a download to complete.
Definition Browser.cs:307
IAllowBrowserActionOrAnySelector WithPageOrientation(PageOrientation pageOrientation)
Sets the page orientation (Portrait/Landscape) used by PrintToPDF.
Definition Browser.cs:5941
IAllowWithHeaderOrFileActions GetGrid(ref IGPALGrid< string > returnGrid)
Retrieve the current UOW selectors data into a <string> of columns and rows to use with GPAL or howev...
Definition Browser.cs:4899
IAllowBrowserActionOrAnySelector PageDown
Scroll the current tab window down one page.
Definition Browser.cs:2348
IAllowWithPagesAndGridActions WithInfiniteScroll
Indicates the current page does not have a Next Page button, but an infinite scroll....
Definition Browser.cs:2269
IAllowAfterAnySelector WithSelector(Selector selector)
Add a selector to the current UOW. Selectors make up a Unit of Work (UOW) to perform actions upon....
Definition Browser.cs:1108
IAllowStorageOutData RunGet(WebsiteStorageType storageType)
Reads data from browser storage (cookies, local/session storage, IndexedDB) using the domain/path/key...
Definition Browser.cs:1318
IAllowBrowserActionOrAnySelector InsertFrom(string textToUse)
Insert text at the beginning of the first input in the UOW. Should probably only be used with one sel...
Definition Browser.cs:4680
IAllowBrowserSettingsOrGoTo PersistentCallIfFound(CallIfDelegate persistentCallIfFound)
Add a persistent CallIfFound handler to be called if any defined persistent selectors find/match elem...
Definition Browser.cs:2020
IAllowBrowserActionOrAnySelector Refresh
Refresh the current page.
Definition Browser.cs:2324
IAllowAfterAnySelectorExceptWithAll WithAllThatMatch(int rowCount=int.MaxValue)
Indicates the Selectors refer to/match repeating, multiple elements on the page. Use after all your ...
Definition Browser.cs:2187
IAllowWithPagesAndFetch WithTokensFrom(IGPALGrid< string > inputGrid)
Supply the values .Fetch drops into the numbered tokens of its request, one row per set of requests....
Definition Browser.cs:1923
IAllowAfterAnySelector CallIfFound(Browser.CallIfDelegate callIfFoundDelegate)
Add a Unit of Work level CallIfFound handler to be called if any defined selectors find/match element...
Definition Browser.cs:1979
IAllowBrowserActionOrAnySelector CaptureCallTemplate(out GPALRequest template)
Waits for the page to make a call the filter matches, then hands it back as a request ready to issue....
Definition Browser.cs:5443
IAllowBrowserActionOrAnySelector InjectScript(string script)
Registers a script (raw JS string) to run on every new document load. Callable before the first ....
Definition Browser.cs:6811
Process Process
The OS process hosting the browser, if started and tracked by GPAL.
Definition Browser.cs:6956
IAllowBrowserSettingsOrGoTo WithWaitOnIdleConnection(bool trueFalse=true)
Specifies GPAL will wait for network to be idle on current page before trying to locate elements....
Definition Browser.cs:869
Represents a URL with optional pre-navigation storage cleanup / inspection actions....
Definition GPALUrl.cs:53
IAllowGPALUrlStorageType ForUrl(string url)
Changes (or sets) the target URL for this builder instance.
Definition GPALUrl.cs:106
string Url
Gets the target URL string.
Definition GPALUrl.cs:69
List< StorageAction > Add(StorageAction storageAction)
Adds a fully-configured StorageAction to the list.
Definition GPALUrl.cs:243
void SetUserAgent(string userAgent)
Overrides the browser's user agent string browser.MagicHelper.SetUserAgent("Mozilla/5....
TabTuple GotoTab(dynamic URLorTabId)
Navigates to a tab by URL or tab ID TabTuple tab = browser.MagicHelper.GotoTab("https://example....
void StealthOverrideReferrer()
Overrides referrer stealthily browser.MagicHelper.StealthOverrideReferrer();.
void CaptureCalls(bool capture=true, string urlFragment=null, bool clear=false)
Starts or stops recording what the page asks for. The extension watches with webRequest,...
string GetCapturedCalls()
Everything recorded so far, oldest first. string json = browser.MagicHelper.GetCapturedCalls();.
bool IsEndOfPage()
Checks if the page is at the end bool end = browser.MagicHelper.IsEndOfPage();.
void ScrollWindowByHorizontal(int pixels)
Scrolls the window horizontally by pixels browser.MagicHelper.ScrollWindowByHorizontal(100);.
void PressModifierKey(ModifierKeys modifierKeys)
Press the specified modifier keys.
string GetPageSource()
Get the html source of the current page.
IAllowPuppeteerExecution GoTo(GPALUrl url)
Configure this client to navigate the current tab to the given URL, resolving relative URLs and check...
IAllowPuppeteerExecution CastDesktop(string deviceNameOrId)
Cast the desktop to the specified device name client.CastDesktop("living room").Execute();.
IAllowPuppeteerExecution CastTab(string deviceNameOrId)
Cast the current tab to the specified device name client.CastTab("living room").Execute();.
IAllowPuppeteerExecution SetUserAgent(string userAgent)
Configure this client to override the browser's user agent string for subsequent requests.
IAllowPuppeteerExecution SwitchToDefaultContent()
Configure this client to switch context back to the main document.
async Task StealthOverrideReferrer(string sessionId=null)
Overrides the HTTP Referer header for subsequent requests to "https://www.google.com",...
Represents a single storage-related action to perform before navigating to a URL.
Definition GPALUrl.cs:274
WebAuthType WebAuthType
How the site expects to be told who you are, supplied via .WithWebAuth. Read by a browser this creden...
File-side plumbing behind the fluent chain: writing a unit of work's data out in a delimited format,...
Definition FileHelper.cs:55
static string EnsureDirectoryEndsWithBackslash(string directoryPath)
Adds a trailing backslash to a directory path when it does not already have one, so the path can be c...
static string GetDefaultDownloadDirectory(IBrowser browser)
Returns the download directory this browser will actually use, read out of its own configuration rath...
One request the page made, as reported by the browser while .CaptureCalls was on. Where a GPALRequest...
string Url
The full url requested.
string PostData
The request body, when it had one. Null for a GET.
Dictionary< string, string > Headers
The headers the page sent with it. What its own code added is here, which is what an API expects and ...
string Method
The HTTP method, GET, POST and so on.
Class to define database usage. Currently only used for input from a table, sql or stored procedure....
Pseudo element used in Applications and Browser workflows for image matching and unified automation....
void SendKeys(string text)
Sends keystrokes to the element.
string TagName
HTML tag name of the element.
string GetAttribute(string attributeName)
Gets an attribute value with fallback to internal dictionary.
string AttributeName
Custom attribute name (used internally).
string Css
CSS selector used to locate this element.
dynamic WebElement
The wrapped Selenium IWebElement or internal dynamic element.
Thrown where GPAL deliberately ends the workflow, such as a CallIf handler returning CallIfStatus....
GPAL File object instantied with GPAL.File Used to load tokens into a GPALGrid [rows/columns].
Definition GPALFile.cs:36
List< string > ReturnFilenames
Get the list of filenames saved to (returned).
Definition GPALFile.cs:546
GPALFile Next
Advances an internal cursor and returns the next filename to use. If there is exactly one file and Wi...
Definition GPALFile.cs:407
int Count
The number of filenames in Filenames.
Definition GPALFile.cs:716
List< string > Filenames
Get the list of filenames.
Definition GPALFile.cs:536
string Filename
We have only one file, accessing it.
Definition GPALFile.cs:474
Everything starts here, all the GPAL controls and global settings using fluent syntax are here....
Definition GPAL.cs:49
static IAllowSelectorSettings Selector
Instantiates a new fluent GPAL Selector object used to locate an element. Selectors are for browsers...
Definition GPAL.cs:693
static IAllowConverterInput Converter
New GPAL Convertor.
Definition GPAL.cs:560
static byte VK_RETURN
Enter/Return key virtual keycode (VK) for use with keybd_event.
Definition GPAL.cs:98
static byte VK_DOWN
Down Arrow key virtual keycode (VK) for use with keybd_event.
Definition GPAL.cs:86
static IAllowRequestSettings Request
Start describing an API request for .Fetch to issue from inside the page, so it carries the session t...
Definition GPAL.cs:724
static IAllowRESTEndpoint RESTClient
Instantiate a new fluent RESTClient.
Definition GPAL.cs:914
static void PublishSimpleEvent(GPALEventType gPALEventType, string msg, dynamic gPALObject=null, Enums.GPALObjectType gPALObjectType=GPALObjectType.None, Exception ex=null)
Publish a message to either the information channel or exception channel (if exception passed in) Pub...
Definition GPAL.cs:2406
static byte VK_HOME
Home key virtual keycode (VK) for use with keybd_event.
Definition GPAL.cs:82
Describes an API request for .Fetch to issue from inside the page, so it carries the session the brow...
Fluent REST client for making API calls with a chained interface. Supports defining workflows,...
Definition RESTClient.cs:57
IAllowRESTExecution WithHeader(string name, string value)
Adds an HTTP header to the request client.WithHeader("Authorization", "Bearer YOUR_ACCESS_TOKEN")....
GPAL Selector used to locate Application and Browser elements. Instantiated with GPAL....
Definition Selector.cs:56
string SelectorPath
Returns the first defined selector path string.
Definition Selector.cs:754
InteractionType InteractionType
Defined method to interact with this element. NOTE: This can be overridden in the workflow.
Definition Selector.cs:731
string Name
The name you gave this selector, or one assigned by GPAL [selector1, selector2...] Used in Informati...
Definition Selector.cs:858
Settings for the current selector. Use this only for debugging.
SelectorType SelectorType
Type of selector: A selector for an element, or literal data or a data function for dynamic data not ...
Everything revolves around the Unit of Work. A Unit of Work is defined as one or more selectors betw...
Definition UnitOfWork.cs:39
int WithAllThatMatch
Controls whether the selectors in scope are multiple elements on screen, and to get all Use ....
List< string > HeaderList
Output header list, Defined using .WithHeader or, if not supplied, from the names given to selectors....
List< Selector > InSelectorList
List of iframe or shadow dom selectors. Set using .InFrame() or .InShadowDom() Use ....
Definition UnitOfWork.cs:53
List< Selector > WithSelectorList
List of With selectors Use .WithSelector to add selectors to this list.
Definition UnitOfWork.cs:45
int PageCount
Number of pages to retrieve. Default is 1 page. Use .WithPages() to set this value.
Definition UnitOfWork.cs:80
bool ActionCalled
Has an action been called on the current unit of work? If true, then the next .WithSelector() will c...
Allow Browser Settings, Goto, WaitOnDocumentReady.
What a workflow may say once .WithCallFilter has said which call it is after. Taking a template only ...
IAllowBrowserActionOrAnySelector SetAttribute(string value)
Directly sets a DOM attribute on the found element(s) via element.setAttribute(attribute,...