switched to new way
This commit is contained in:
parent
f22d182c8f
commit
4a65c8e17b
96 changed files with 4991 additions and 10025 deletions
11
src/Poe2Trade.Trade/Poe2Trade.Trade.csproj
Normal file
11
src/Poe2Trade.Trade/Poe2Trade.Trade.csproj
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Playwright" Version="1.49.0" />
|
||||
<ProjectReference Include="..\Poe2Trade.Core\Poe2Trade.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
30
src/Poe2Trade.Trade/Selectors.cs
Normal file
30
src/Poe2Trade.Trade/Selectors.cs
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
namespace Poe2Trade.Trade;
|
||||
|
||||
public static class Selectors
|
||||
{
|
||||
public const string LiveSearchButton =
|
||||
"button.livesearch-btn, button:has-text(\"Activate Live Search\")";
|
||||
|
||||
public const string ListingRow =
|
||||
".resultset .row, [class*=\"result\"]";
|
||||
|
||||
public static string ListingById(string id) => $"[data-id=\"{id}\"]";
|
||||
|
||||
public const string TravelToHideoutButton =
|
||||
"button:has-text(\"Travel to Hideout\"), button:has-text(\"Visit Hideout\"), a:has-text(\"Travel to Hideout\"), [class*=\"hideout\"]";
|
||||
|
||||
public const string WhisperButton =
|
||||
".whisper-btn, button[class*=\"whisper\"], [data-tooltip=\"Whisper\"], button:has-text(\"Whisper\")";
|
||||
|
||||
public const string ConfirmDialog =
|
||||
"[class*=\"modal\"], [class*=\"dialog\"], [class*=\"confirm\"]";
|
||||
|
||||
public const string ConfirmYesButton =
|
||||
"button:has-text(\"Yes\"), button:has-text(\"Confirm\"), button:has-text(\"OK\"), button:has-text(\"Accept\")";
|
||||
|
||||
public const string ConfirmNoButton =
|
||||
"button:has-text(\"No\"), button:has-text(\"Cancel\"), button:has-text(\"Decline\")";
|
||||
|
||||
public const string ResultsContainer =
|
||||
".resultset, [class*=\"results\"]";
|
||||
}
|
||||
296
src/Poe2Trade.Trade/TradeMonitor.cs
Normal file
296
src/Poe2Trade.Trade/TradeMonitor.cs
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
using System.Text.Json;
|
||||
using Microsoft.Playwright;
|
||||
using Poe2Trade.Core;
|
||||
using Serilog;
|
||||
|
||||
namespace Poe2Trade.Trade;
|
||||
|
||||
public class TradeMonitor : IAsyncDisposable
|
||||
{
|
||||
private IBrowserContext? _context;
|
||||
private readonly Dictionary<string, IPage> _pages = new();
|
||||
private readonly HashSet<string> _pausedSearches = new();
|
||||
private readonly AppConfig _config;
|
||||
|
||||
private const string StealthScript = """
|
||||
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
||||
Object.defineProperty(navigator, 'plugins', {
|
||||
get: () => [
|
||||
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer' },
|
||||
{ name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai' },
|
||||
{ name: 'Native Client', filename: 'internal-nacl-plugin' },
|
||||
],
|
||||
});
|
||||
Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
|
||||
delete window.__playwright;
|
||||
delete window.__pw_manual;
|
||||
if (!window.chrome) window.chrome = {};
|
||||
if (!window.chrome.runtime) window.chrome.runtime = { id: undefined };
|
||||
const originalQuery = window.navigator.permissions?.query;
|
||||
if (originalQuery) {
|
||||
window.navigator.permissions.query = (params) => {
|
||||
if (params.name === 'notifications')
|
||||
return Promise.resolve({ state: Notification.permission });
|
||||
return originalQuery(params);
|
||||
};
|
||||
}
|
||||
""";
|
||||
|
||||
public event Action<string, List<string>, IPage>? NewListings;
|
||||
|
||||
public TradeMonitor(AppConfig config)
|
||||
{
|
||||
_config = config;
|
||||
}
|
||||
|
||||
public async Task Start(string? dashboardUrl = null)
|
||||
{
|
||||
Log.Information("Launching Playwright browser (stealth mode)...");
|
||||
|
||||
var playwright = await Playwright.CreateAsync();
|
||||
_context = await playwright.Chromium.LaunchPersistentContextAsync(
|
||||
_config.BrowserUserDataDir,
|
||||
new BrowserTypeLaunchPersistentContextOptions
|
||||
{
|
||||
Headless = false,
|
||||
ViewportSize = null,
|
||||
Args = [
|
||||
"--disable-blink-features=AutomationControlled",
|
||||
"--disable-features=AutomationControlled",
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--disable-infobars",
|
||||
],
|
||||
IgnoreDefaultArgs = ["--enable-automation"],
|
||||
});
|
||||
|
||||
await _context.AddInitScriptAsync(StealthScript);
|
||||
|
||||
if (dashboardUrl != null)
|
||||
{
|
||||
var pages = _context.Pages;
|
||||
if (pages.Count > 0)
|
||||
await pages[0].GotoAsync(dashboardUrl);
|
||||
else
|
||||
await (await _context.NewPageAsync()).GotoAsync(dashboardUrl);
|
||||
Log.Information("Dashboard opened: {Url}", dashboardUrl);
|
||||
}
|
||||
|
||||
Log.Information("Browser launched (stealth active)");
|
||||
}
|
||||
|
||||
public async Task AddSearch(string tradeUrl)
|
||||
{
|
||||
if (_context == null) throw new InvalidOperationException("Browser not started");
|
||||
|
||||
var searchId = ExtractSearchId(tradeUrl);
|
||||
if (_pages.ContainsKey(searchId))
|
||||
{
|
||||
Log.Information("Search already open: {SearchId}", searchId);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.Information("Adding trade search: {Url} ({SearchId})", tradeUrl, searchId);
|
||||
|
||||
var page = await _context.NewPageAsync();
|
||||
_pages[searchId] = page;
|
||||
|
||||
await page.GotoAsync(tradeUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
|
||||
await Helpers.Sleep(2000);
|
||||
|
||||
page.WebSocket += (_, ws) => HandleWebSocket(ws, searchId, page);
|
||||
|
||||
try
|
||||
{
|
||||
var liveBtn = page.Locator(Selectors.LiveSearchButton).First;
|
||||
await liveBtn.ClickAsync(new LocatorClickOptions { Timeout = 5000 });
|
||||
Log.Information("Live search activated: {SearchId}", searchId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Log.Warning("Could not click Activate Live Search: {SearchId}", searchId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task PauseSearch(string searchId)
|
||||
{
|
||||
_pausedSearches.Add(searchId);
|
||||
if (_pages.TryGetValue(searchId, out var page))
|
||||
{
|
||||
await page.CloseAsync();
|
||||
_pages.Remove(searchId);
|
||||
}
|
||||
Log.Information("Search paused: {SearchId}", searchId);
|
||||
}
|
||||
|
||||
public async Task<bool> ClickTravelToHideout(IPage page, string? itemId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (itemId != null)
|
||||
{
|
||||
var row = page.Locator(Selectors.ListingById(itemId));
|
||||
if (await WaitForVisible(row, 5000))
|
||||
{
|
||||
var travelBtn = row.Locator(Selectors.TravelToHideoutButton).First;
|
||||
if (await WaitForVisible(travelBtn, 3000))
|
||||
{
|
||||
await travelBtn.ClickAsync();
|
||||
Log.Information("Clicked Travel to Hideout for item {ItemId}", itemId);
|
||||
await HandleConfirmDialog(page);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var btn = page.Locator(Selectors.TravelToHideoutButton).First;
|
||||
await btn.ClickAsync(new LocatorClickOptions { Timeout = 5000 });
|
||||
Log.Information("Clicked Travel to Hideout");
|
||||
await HandleConfirmDialog(page);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to click Travel to Hideout");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<(IPage Page, List<TradeItem> Items)> OpenScrapPage(string tradeUrl)
|
||||
{
|
||||
if (_context == null) throw new InvalidOperationException("Browser not started");
|
||||
|
||||
var page = await _context.NewPageAsync();
|
||||
var items = new List<TradeItem>();
|
||||
|
||||
page.Response += async (_, response) =>
|
||||
{
|
||||
if (!response.Url.Contains("/api/trade2/fetch/")) return;
|
||||
try
|
||||
{
|
||||
var body = await response.TextAsync();
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
if (doc.RootElement.TryGetProperty("result", out var results) &&
|
||||
results.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var r in results.EnumerateArray())
|
||||
items.Add(ParseTradeItem(r));
|
||||
}
|
||||
}
|
||||
catch { /* Response may not be JSON */ }
|
||||
};
|
||||
|
||||
await page.GotoAsync(tradeUrl, new PageGotoOptions { WaitUntil = WaitUntilState.NetworkIdle });
|
||||
await Helpers.Sleep(2000);
|
||||
Log.Information("Scrap page opened: {Url} ({Count} items)", tradeUrl, items.Count);
|
||||
return (page, items);
|
||||
}
|
||||
|
||||
public string ExtractSearchId(string url)
|
||||
{
|
||||
var cleaned = System.Text.RegularExpressions.Regex.Replace(url, @"/live/?$", "");
|
||||
var parts = cleaned.Split('/');
|
||||
return parts.Length > 0 ? parts[^1] : url;
|
||||
}
|
||||
|
||||
public static TradeItem ParseTradeItem(JsonElement r)
|
||||
{
|
||||
var id = r.GetProperty("id").GetString() ?? "";
|
||||
int w = 1, h = 1, stashX = 0, stashY = 0;
|
||||
var account = "";
|
||||
|
||||
if (r.TryGetProperty("item", out var item))
|
||||
{
|
||||
if (item.TryGetProperty("w", out var wProp)) w = wProp.GetInt32();
|
||||
if (item.TryGetProperty("h", out var hProp)) h = hProp.GetInt32();
|
||||
}
|
||||
if (r.TryGetProperty("listing", out var listing))
|
||||
{
|
||||
if (listing.TryGetProperty("stash", out var stash))
|
||||
{
|
||||
if (stash.TryGetProperty("x", out var sx)) stashX = sx.GetInt32();
|
||||
if (stash.TryGetProperty("y", out var sy)) stashY = sy.GetInt32();
|
||||
}
|
||||
if (listing.TryGetProperty("account", out var acc) &&
|
||||
acc.TryGetProperty("name", out var accName))
|
||||
account = accName.GetString() ?? "";
|
||||
}
|
||||
return new TradeItem(id, w, h, stashX, stashY, account);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (var page in _pages.Values)
|
||||
await page.CloseAsync();
|
||||
_pages.Clear();
|
||||
if (_context != null)
|
||||
{
|
||||
await _context.CloseAsync();
|
||||
_context = null;
|
||||
}
|
||||
Log.Information("Trade monitor stopped");
|
||||
}
|
||||
|
||||
private void HandleWebSocket(IWebSocket ws, string searchId, IPage page)
|
||||
{
|
||||
if (!ws.Url.Contains("/api/trade") || !ws.Url.Contains("/live/"))
|
||||
return;
|
||||
|
||||
Log.Information("WebSocket connected for live search: {SearchId}", searchId);
|
||||
|
||||
ws.FrameReceived += (_, frame) =>
|
||||
{
|
||||
if (_pausedSearches.Contains(searchId)) return;
|
||||
try
|
||||
{
|
||||
var payload = frame.Text ?? "";
|
||||
using var doc = JsonDocument.Parse(payload);
|
||||
if (doc.RootElement.TryGetProperty("new", out var newItems) &&
|
||||
newItems.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var ids = newItems.EnumerateArray()
|
||||
.Select(e => e.GetString()!)
|
||||
.Where(s => s != null)
|
||||
.ToList();
|
||||
if (ids.Count > 0)
|
||||
{
|
||||
Log.Information("New listings: {SearchId} ({Count} items)", searchId, ids.Count);
|
||||
NewListings?.Invoke(searchId, ids, page);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* Not all frames are JSON */ }
|
||||
};
|
||||
|
||||
ws.Close += (_, _) => Log.Warning("WebSocket closed: {SearchId}", searchId);
|
||||
}
|
||||
|
||||
private async Task HandleConfirmDialog(IPage page)
|
||||
{
|
||||
await Helpers.Sleep(500);
|
||||
try
|
||||
{
|
||||
var confirmBtn = page.Locator(Selectors.ConfirmYesButton).First;
|
||||
if (await WaitForVisible(confirmBtn, 2000))
|
||||
{
|
||||
await confirmBtn.ClickAsync();
|
||||
Log.Information("Confirmed dialog");
|
||||
}
|
||||
}
|
||||
catch { /* No dialog */ }
|
||||
}
|
||||
|
||||
private static async Task<bool> WaitForVisible(ILocator locator, int timeoutMs)
|
||||
{
|
||||
try
|
||||
{
|
||||
await locator.WaitForAsync(new LocatorWaitForOptions
|
||||
{
|
||||
State = WaitForSelectorState.Visible,
|
||||
Timeout = timeoutMs
|
||||
});
|
||||
return true;
|
||||
}
|
||||
catch (TimeoutException) { return false; }
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue