Merge branch 'SciSharp:master' into master

This commit is contained in:
hchen2020 2024-09-04 09:28:05 -05:00 committed by GitHub
commit 329aa37e5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 347 additions and 29 deletions

3
.gitignore vendored
View file

@ -293,4 +293,5 @@ logs
wwwroot
appsettings.Production.json
*.csproj.user
env/
env/
FodyWeavers.*

View file

@ -36,6 +36,7 @@
<PackageReference Include="System.Text.Json" Version="8.0.4" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
<PackageReference Include="Rougamo.Fody" Version="4.0.0" />
</ItemGroup>
</Project>

View file

@ -4,6 +4,6 @@ namespace BotSharp.Abstraction.Browsing;
public interface IWebPageResponseHook
{
void OnDataFetched(MessageInfo message, string url, string postData, string responsData);
T? GetResponse<T>(MessageInfo message, string url, string? queryParameter = null);
void OnDataFetched(MessageInfo message, WebPageResponseData response);
T? GetResponse<T>(MessageInfo message, WebPageResponseFilter filter);
}

View file

@ -12,6 +12,11 @@ public class ElementActionArgs
public string? PressKey { get; set; }
/// <summary>
/// Locator option
/// </summary>
public bool FirstIfMultipleFound { get; set; } = false;
/// <summary>
/// Wait time in seconds
/// </summary>

View file

@ -26,6 +26,7 @@ public class ElementLocatingArgs
public bool Parent { get; set; }
public bool FailIfMultiple { get; set; }
public bool IgnoreIfNotFound { get; set; }
/// <summary>
/// Draw outline around the element

View file

@ -1,6 +1,8 @@
using BotSharp.Abstraction.Infrastructures;
namespace BotSharp.Abstraction.Browsing.Models;
public class MessageInfo
public class MessageInfo : ICacheKey
{
public string AgentId { get; set; } = null!;
public string UserId { get; set; } = null!;
@ -8,4 +10,7 @@ public class MessageInfo
public string? MessageId { get; set; }
public string? TaskId { get; set; }
public string StepId { get; set; } = Guid.NewGuid().ToString();
public string GetCacheKey()
=> $"{nameof(MessageInfo)}-{ContextId}";
}

View file

@ -15,10 +15,25 @@ public class PageActionArgs
/// This value has to be set to true if you want to get the page XHR/ Fetch responses
/// </summary>
public bool OpenNewTab { get; set; } = false;
public bool EnableResponseCallback { get; set; } = false;
/// <summary>
/// Exclude urls for XHR/ Fetch responses
/// </summary>
public string[]? ExcludeResponseUrls { get; set; }
/// <summary>
/// Only include urls for XHR/ Fetch responses
/// </summary>
public string[]? IncludeResponseUrls { get; set; }
/// <summary>
/// If set to true, the response will be stored in memory
/// </summary>
public bool ResponseInMemory { get; set; } = false;
public List<WebPageResponseData>? ResponseContainer { get; set; }
public bool UseExistingPage { get; set; } = false;
public bool WaitForNetworkIdle { get; set; } = true;

View file

@ -0,0 +1,10 @@
namespace BotSharp.Abstraction.Browsing.Models;
public class WebPageResponseData
{
public string Url { get; set; } = null!;
public string PostData { get; set; } = null!;
public string ResponseData { get; set; } = null!;
public bool ResponseInMemory { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

View file

@ -0,0 +1,7 @@
namespace BotSharp.Abstraction.Browsing.Models;
public class WebPageResponseFilter
{
public string Url { get; set; } = null!;
public string[]? QueryParameters { get; set; }
}

View file

@ -0,0 +1,6 @@
namespace BotSharp.Abstraction.Infrastructures;
public interface ICacheKey
{
string GetCacheKey();
}

View file

@ -0,0 +1,8 @@
namespace BotSharp.Abstraction.Infrastructures;
public interface ICacheService
{
Task<T?> GetAsync<T>(string key);
Task<object> GetAsync(string key, Type type);
Task SetAsync<T>(string key, T value, TimeSpan? expiry);
}

View file

@ -0,0 +1,82 @@
using BotSharp.Abstraction.Infrastructures;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Rougamo;
using Rougamo.Context;
namespace BotSharp.Core.Infrastructures;
public class SharpCacheAttribute : MoAttribute
{
public static IServiceProvider Services { get; set; } = null!;
private int _minutes;
public SharpCacheAttribute(int minutes = 60)
{
_minutes = minutes;
}
public override void OnEntry(MethodContext context)
{
var settings = Services.GetRequiredService<SharpCacheSettings>();
if (!settings.Enabled)
{
return;
}
var cache = Services.GetRequiredService<ICacheService>();
var key = GetCacheKey(settings, context);
var value = cache.GetAsync(key, context.TaskReturnType).Result;
if (value != null)
{
context.ReplaceReturnValue(this, value);
}
}
public override void OnSuccess(MethodContext context)
{
var settings = Services.GetRequiredService<SharpCacheSettings>();
if (!settings.Enabled)
{
return;
}
var httpContext = Services.GetRequiredService<IHttpContextAccessor>();
if (httpContext.HttpContext.Response.Headers["Cache-Control"].ToString().Contains("no-store"))
{
return;
}
var cache = Services.GetRequiredService<ICacheService>();
if (context.ReturnValue != null)
{
var key = GetCacheKey(settings, context);
cache.SetAsync(key, context.ReturnValue, new TimeSpan(0, _minutes, 0)).Wait();
}
}
private string GetCacheKey(SharpCacheSettings settings, MethodContext context)
{
var key = settings.Prefix + "-" + context.Method.Name;
foreach (var arg in context.Arguments)
{
if (arg is null)
{
key += "-" + "<NULL>";
}
else if (arg is ICacheKey withCacheKey)
{
key += "-" + withCacheKey.GetCacheKey();
}
else
{
key += "-" + arg.ToString();
}
}
return key;
}
}

View file

@ -0,0 +1,7 @@
namespace BotSharp.Abstraction.Infrastructures;
public class SharpCacheSettings
{
public bool Enabled { get; set; } = false;
public string Prefix { get; set; } = "cache";
}

View file

@ -1,6 +1,8 @@
using BotSharp.Abstraction.Infrastructures;
namespace BotSharp.Abstraction.Utilities;
public class Pagination
public class Pagination : ICacheKey
{
private int _page;
private int _size;
@ -39,6 +41,9 @@ public class Pagination
}
public bool ReturnTotal { get; set; } = true;
public string GetCacheKey()
=> $"{nameof(Pagination)}_{_page}_{_size}_{Sort}_{Order}";
}
public class PagedItems<T>

View file

@ -46,10 +46,13 @@
</PropertyGroup>
<ItemGroup>
<Compile Remove="packages\**" />
<Compile Remove="Planning\**" />
<Compile Remove="Translation\Models\**" />
<EmbeddedResource Remove="packages\**" />
<EmbeddedResource Remove="Planning\**" />
<EmbeddedResource Remove="Translation\Models\**" />
<None Remove="packages\**" />
<None Remove="Planning\**" />
<None Remove="Translation\Models\**" />
</ItemGroup>

View file

@ -1,12 +1,13 @@
using BotSharp.Abstraction.Functions;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Configuration;
using BotSharp.Abstraction.Functions;
using BotSharp.Core.Plugins;
using BotSharp.Abstraction.Settings;
using BotSharp.Abstraction.Options;
using BotSharp.Abstraction.Messaging.JsonConverters;
using BotSharp.Abstraction.Users.Settings;
using BotSharp.Abstraction.Interpreters.Settings;
using BotSharp.Abstraction.Infrastructures;
namespace BotSharp.Core;
@ -22,8 +23,15 @@ public static class BotSharpCoreExtensions
services.AddScoped<ISettingService, SettingService>();
services.AddScoped<IUserService, UserService>();
services.AddSingleton<DistributedLocker>();
// Register cache service
var cacheSettings = new SharpCacheSettings();
config.Bind("SharpCache", cacheSettings);
services.AddSingleton(x => cacheSettings);
services.AddSingleton<ICacheService, CacheService>();
RegisterPlugins(services, config);
ConfigureBotSharpOptions(services, configOptions);
@ -61,6 +69,9 @@ public static class BotSharpCoreExtensions
app.ApplicationServices.GetRequiredService<PluginLoader>().Configure(app);
// Set root services for SharpCacheAttribute
SharpCacheAttribute.Services = app.ApplicationServices;
return app;
}

View file

@ -0,0 +1,74 @@
using BotSharp.Abstraction.Infrastructures;
using Newtonsoft.Json;
using StackExchange.Redis;
namespace BotSharp.Core.Infrastructures;
public class CacheService : ICacheService
{
private readonly BotSharpDatabaseSettings _settings;
private static ConnectionMultiplexer redis = null!;
public CacheService(BotSharpDatabaseSettings settings)
{
_settings = settings;
}
public async Task<T?> GetAsync<T>(string key)
{
if (string.IsNullOrEmpty(_settings.Redis))
{
return default;
}
if (redis == null)
{
redis = ConnectionMultiplexer.Connect(_settings.Redis);
}
var db = redis.GetDatabase();
var value = await db.StringGetAsync(key);
if (value.HasValue)
{
return JsonConvert.DeserializeObject<T>(value);
}
return default;
}
public async Task<object> GetAsync(string key, Type type)
{
if (string.IsNullOrEmpty(_settings.Redis))
{
return default;
}
if (redis == null)
{
redis = ConnectionMultiplexer.Connect(_settings.Redis);
}
var db = redis.GetDatabase();
var value = await db.StringGetAsync(key);
if (value.HasValue)
{
return JsonConvert.DeserializeObject(value, type);
}
return default;
}
public async Task SetAsync<T>(string key, T value, TimeSpan? expiry)
{
if (string.IsNullOrEmpty(_settings.Redis))
{
return;
}
var db = redis.GetDatabase();
await db.StringSetAsync(key, JsonConvert.SerializeObject(value), expiry);
}
}

View file

@ -6,47 +6,54 @@ namespace BotSharp.Core.Infrastructures;
public class DistributedLocker
{
private readonly BotSharpDatabaseSettings _settings;
private static ConnectionMultiplexer connection;
public DistributedLocker(BotSharpDatabaseSettings settings)
{
_settings = settings;
}
public async Task Lock(string resource, Func<Task> action, int timeoutInSeconds = 30)
public async Task<T> Lock<T>(string resource, Func<Task<T>> action, int timeoutInSeconds = 30)
{
await ConnectToRedis();
var timeout = TimeSpan.FromSeconds(timeoutInSeconds);
var connection = await ConnectionMultiplexer.ConnectAsync(_settings.Redis);
var @lock = new RedisDistributedLock(resource, connection.GetDatabase());
await using (var handle = await @lock.TryAcquireAsync(timeout))
{
if (handle != null)
{
await action();
}
else
if (handle == null)
{
Serilog.Log.Logger.Error($"Acquire lock for {resource} failed due to after {timeout}s timeout.");
}
return await action();
}
}
public async Task Lock(string resource, Action action, int timeoutInSeconds = 30)
public async Task<T> Lock<T>(string resource, Func<T> action, int timeoutInSeconds = 30)
{
await ConnectToRedis();
var timeout = TimeSpan.FromSeconds(timeoutInSeconds);
var connection = await ConnectionMultiplexer.ConnectAsync(_settings.Redis);
var @lock = new RedisDistributedLock(resource, connection.GetDatabase());
await using (var handle = await @lock.TryAcquireAsync(timeout))
{
if (handle != null)
{
action();
}
else
if (handle == null)
{
Serilog.Log.Logger.Error($"Acquire lock for {resource} failed due to after {timeout}s timeout.");
}
return action();
}
}
private async Task ConnectToRedis()
{
if (connection == null)
{
connection = await ConnectionMultiplexer.ConnectAsync(_settings.Redis);
}
}
}

View file

@ -187,7 +187,7 @@ public class UserService : IUserService
{
new Claim(JwtRegisteredClaimNames.NameId, user.Id),
new Claim(JwtRegisteredClaimNames.UniqueName, user.UserName),
new Claim(JwtRegisteredClaimNames.Email, user.Email),
new Claim(JwtRegisteredClaimNames.Email, user?.Email ?? string.Empty),
new Claim(JwtRegisteredClaimNames.GivenName, user?.FirstName ?? string.Empty),
new Claim(JwtRegisteredClaimNames.FamilyName, user?.LastName ?? string.Empty),
new Claim("source", user.Source),

View file

@ -43,6 +43,11 @@ public class PlaywrightInstance : IDisposable
}
}
if (!string.IsNullOrEmpty(pattern))
{
return null;
}
return _contexts[contextId].Pages.LastOrDefault();
}
@ -117,7 +122,12 @@ public class PlaywrightInstance : IDisposable
return _contexts[ctxId];
}
public async Task<IPage> NewPage(MessageInfo message, string[]? excludeResponseUrls = null)
public async Task<IPage> NewPage(MessageInfo message,
bool enableResponseCallback = false,
bool responseInMemory = false,
List<WebPageResponseData>? responseContainer = null,
string[]? excludeResponseUrls = null,
string[]? includeResponseUrls = null)
{
var context = await GetContext(message.ContextId);
var page = await context.NewPageAsync();
@ -127,13 +137,19 @@ public class PlaywrightInstance : IDisposable
var js = @"Object.defineProperties(navigator, {webdriver:{get:()=>false}});";
await page.AddInitScriptAsync(js);
if (!enableResponseCallback)
{
return page;
}
page.Response += async (sender, e) =>
{
if (e.Status != 204 &&
e.Headers.ContainsKey("content-type") &&
e.Headers["content-type"].Contains("application/json") &&
(e.Request.ResourceType == "fetch" || e.Request.ResourceType == "xhr") &&
(excludeResponseUrls == null || !excludeResponseUrls.Any(url => e.Url.ToLower().Contains(url))))
(excludeResponseUrls == null || !excludeResponseUrls.Any(url => e.Url.ToLower().Contains(url))) &&
(includeResponseUrls == null || includeResponseUrls.Any(url => e.Url.ToLower().Contains(url))))
{
Serilog.Log.Information($"{e.Request.Method}: {e.Url}");
JsonElement? json = null;
@ -148,10 +164,23 @@ public class PlaywrightInstance : IDisposable
Serilog.Log.Warning($"Response status: {e.Status} {e.StatusText}, OK: {e.Ok}");
}
var result = new WebPageResponseData
{
Url = e.Url.ToLower(),
PostData = e.Request?.PostData ?? string.Empty,
ResponseData = JsonSerializer.Serialize(json),
ResponseInMemory = responseInMemory
};
if (responseContainer != null && responseInMemory)
{
responseContainer.Add(result);
}
var webPageResponseHooks = _services.GetServices<IWebPageResponseHook>();
foreach (var hook in webPageResponseHooks)
{
hook.OnDataFetched(message, e.Url.ToLower(), e.Request?.PostData ?? string.Empty, JsonSerializer.Serialize(json));
hook.OnDataFetched(message, result);
}
}
catch (ObjectDisposedException ex)
@ -160,7 +189,7 @@ public class PlaywrightInstance : IDisposable
}
catch (Exception ex)
{
Serilog.Log.Error(ex.ToString());
Serilog.Log.Error($"{e.Url}\r\n" + ex.ToString());
}
}
};

View file

@ -13,11 +13,24 @@ public partial class PlaywrightWebDriver
ILocator locator = page.Locator(result.Selector);
var count = await locator.CountAsync();
if (count == 0)
{
Serilog.Log.Error($"Element not found: {result.Selector}");
return;
}
else if (count > 1)
{
if(!action.FirstIfMultipleFound)
{
Serilog.Log.Error($"Multiple eElements were found: {result.Selector}");
return;
}
else
{
locator = page.Locator(result.Selector).First;// 匹配到多个时取第一个否则当await locator.ClickAsync();匹配到多个就会抛异常。
}
}
if (action.Action == BroswerActionEnum.Click)
{

View file

@ -10,7 +10,11 @@ public partial class PlaywrightWebDriver
{
var page = args.UseExistingPage ?
_instance.GetPage(message.ContextId, pattern: args.Url) :
await _instance.NewPage(message, excludeResponseUrls: args.ExcludeResponseUrls);
await _instance.NewPage(message, enableResponseCallback: args.EnableResponseCallback,
responseInMemory: args.ResponseInMemory,
responseContainer: args.ResponseContainer,
excludeResponseUrls: args.ExcludeResponseUrls,
includeResponseUrls: args.IncludeResponseUrls);
if (args.UseExistingPage && page != null && page.Url == args.Url)
{
@ -23,7 +27,22 @@ public partial class PlaywrightWebDriver
if (args.UseExistingPage && args.OpenNewTab && page != null && page.Url == "about:blank")
{
page = await _instance.NewPage(message, excludeResponseUrls: args.ExcludeResponseUrls);
page = await _instance.NewPage(message,
enableResponseCallback: args.EnableResponseCallback,
responseInMemory: args.ResponseInMemory,
responseContainer: args.ResponseContainer,
excludeResponseUrls: args.ExcludeResponseUrls,
includeResponseUrls: args.IncludeResponseUrls);
}
if (page == null)
{
page = await _instance.NewPage(message,
enableResponseCallback: args.EnableResponseCallback,
responseInMemory: args.ResponseInMemory,
responseContainer: args.ResponseContainer,
excludeResponseUrls: args.ExcludeResponseUrls,
includeResponseUrls: args.IncludeResponseUrls);
}
var response = await page.GotoAsync(args.Url, new PageGotoOptions

View file

@ -68,8 +68,17 @@ public partial class PlaywrightWebDriver
if (count == 0)
{
result.Message = $"Can't locate element by keyword {location.Text}";
_logger.LogError(result.Message);
if (location.IgnoreIfNotFound)
{
result.Message = $"Can't locate element by keyword {location.Text} and Ignored";
_logger.LogWarning(result.Message);
}
else
{
result.Message = $"Can't locate element by keyword {location.Text}";
_logger.LogError(result.Message);
}
}
else if (count == 1)
{