diff --git a/.gitignore b/.gitignore index 40b2916..6c134a3 100644 --- a/.gitignore +++ b/.gitignore @@ -15,5 +15,13 @@ profiles/ *.suo *.sln.docstates +## Local IDE debug configs — contain machine-specific paths +.vscode/ +src/ModelPublisher.Cli/Properties/launchSettings.json + ## dotnet *.nupkg + +## Node.js (stealth launcher dependencies) +node_modules/ +package-lock.json diff --git a/CLAUDE.md b/CLAUDE.md index 80eefb5..17c7a65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,3 +108,30 @@ dotnet run --project src\ModelPublisher.Cli -- "C:\Users\chris\Downloads\Models\ ## Browser profiles Persistent Chromium sessions stored in `profiles/{platformKey}/` relative to working directory. Gitignored — contains login cookies. On first run per platform, the browser pauses for manual login. + +## Cloudflare / bot detection +Cloudflare detects Playwright's CDP protocol and blocks access even when a human clicks "I'm human". This affects MakerWorld and Patreon. Two mitigations are in place in `BrowserContextFactory`: +- `--disable-blink-features=AutomationControlled` launch arg +- Init script removing `navigator.webdriver`, spoofing canvas/WebGL/plugins + +When these aren't enough, use `--profile-path` to run with the real Brave profile (which has `cf_clearance` cookies from normal browsing). **Brave must be fully closed first.** + +```bash +# For publishing: +dotnet run --project src\ModelPublisher.Cli -- "manifest.json" --platforms makerworld --profile-path "C:\Users\chris\AppData\Local\BraveSoftware\Brave-Browser\User Data\Profile 1" + +# For codegen: +dotnet run --project src\ModelPublisher.Cli -- codegen makerworld https://makerworld.com/en/my/models/publish?type=original --profile-path "C:\Users\chris\AppData\Local\BraveSoftware\Brave-Browser\User Data\Profile 1" +``` + +Google OAuth is blocked in all automated browsers (Playwright injects CDP which Google detects). Log into Cloudflare-protected platforms using email/password, not Google, during the AuthGuard pause. + +## Running Playwright codegen +`npx playwright codegen` does NOT work from Claude Code's bash shell (node not on PATH, GUI can't open in background). Always run codegen manually from a Windows terminal. + +**For sites without Cloudflare / bot detection** — use the .NET playwright.ps1 (built output): +```powershell +pwsh "C:\Source\ModelPublisher\src\ModelPublisher.Cli\bin\Debug\net10.0\playwright.ps1" codegen https://example.com +``` + +**For sites with Cloudflare / Google auth (e.g. Patreon)** — codegen won't work (Chromium is blocked). Instead: open the page in the regular Brave browser, interact with the form, and use DevTools (F12) to inspect elements. Paste HTML snippets into the conversation to extract selectors. diff --git a/ModelPublisher.sln b/ModelPublisher.sln index 6b859ea..077f8bb 100644 --- a/ModelPublisher.sln +++ b/ModelPublisher.sln @@ -11,8 +11,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ModelPublisher.Core.Tests", "tests\ModelPublisher.Core.Tests\ModelPublisher.Core.Tests.csproj", "{B3713944-A5ED-4A22-81E6-646CA6369288}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" -EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU diff --git a/README.md b/README.md index 15bc40a..ead6222 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Automates publishing 3D printable models across multiple platforms using Playwri | `printables` | Printables.com | Free + Premium | Working | | `makerworld` | MakerWorld.com | Free | Stub — needs codegen selectors | | `cults3d` | Cults3D.com | Free | Stub — needs codegen selectors | -| `thangs` | Thangs.com | Free + Premium | Stub — needs codegen selectors | +| `thangs` | Thangs.com | Free + Premium | Working | | `makeronline` | Maker.online | Free | Stub — needs codegen selectors | | `patreon` | Patreon.com | Free + Premium | Stub — clipboard paste approach, needs verification | @@ -43,9 +43,12 @@ pwsh src/ModelPublisher.Cli/bin/Debug/net10.0/playwright.ps1 install chromium For platforms marked as stubs, use Playwright's codegen tool to capture live selectors, then fill them in to the publisher class: ```bash -pwsh src/ModelPublisher.Cli/bin/Debug/net10.0/playwright.ps1 codegen --target csharp https://www.makerworld.com/upload +# Close Brave first, then run: +dotnet run --project src\ModelPublisher.Cli -- codegen --profile-path "C:\Users\chris\AppData\Local\BraveSoftware\Brave-Browser\User Data\Profile 1" ``` +The `--profile-path` option lets you reuse your real Brave session (already logged in). Brave must be fully closed before running this command. + ## Release Workflow 1. Design, print, and photograph your model. @@ -69,25 +72,33 @@ dotnet run --project src/ModelPublisher.Cli -- releases/my-model-name/manifest.j # Publish to specific platforms only dotnet run --project src/ModelPublisher.Cli -- releases/my-model-name/manifest.json --platforms printables makerworld -# From C:\Source\ModelPublisher: -dotnet run --project src/ModelPublisher.Cli -- "C:\Source\ModelPublisher\releases\my-model-name\manifest.json" +# Use your real Brave profile to bypass Cloudflare (close Brave first) +dotnet run --project src/ModelPublisher.Cli -- releases/my-model-name/manifest.json --platforms makerworld --profile-path "C:\Users\chris\AppData\Local\BraveSoftware\Brave-Browser\User Data\Profile 1" ``` -## Human-in-the-loop steps - -Every platform pauses before the final publish action and waits for you to press Enter. This lets you: -- Catch any mis-filled fields -- Select categories or subcategories not covered by automation -- Handle CAPTCHAs if they appear - -Patreon additionally requires manual access tier selection due to the complexity of their UI. - ## Auth On first run per platform, if you're not logged in, the browser will pause and prompt you to log in manually. After logging in, press Enter in the terminal. The session is saved to `profiles//` and reused on subsequent runs. The `profiles/` directory is gitignored. Do not commit it — it contains your session cookies. +### Cloudflare-protected platforms (MakerWorld, Patreon) + +Cloudflare detects Playwright's CDP protocol at the network level. Use `--stealth` to launch via [playwright-extra](https://github.com/berstend/puppeteer-extra) with the stealth plugin, which patches the browser to remove automation markers: + +```bash +# Stealth mode (bypasses Cloudflare bot detection) +dotnet run --project src\ModelPublisher.Cli -- "manifest.json" --platforms makerworld --stealth + +# Stealth + real Brave profile (best combination — close Brave first) +dotnet run --project src\ModelPublisher.Cli -- "manifest.json" --platforms makerworld --stealth --profile-path "C:\Users\chris\AppData\Local\BraveSoftware\Brave-Browser\User Data\Profile 1" + +# Stealth codegen (for inspecting selectors on Cloudflare-protected sites) +dotnet run --project src\ModelPublisher.Cli -- codegen makerworld https://makerworld.com/en/my/models/publish?type=original --stealth +``` + +Requires Node.js and `npm install` in the project root (one-time setup). + ## Adding a new platform 1. Create `src/ModelPublisher.Core/Platforms/MyNewPublisher.cs` implementing `IPlatformPublisher`. diff --git a/package.json b/package.json new file mode 100644 index 0000000..5f39b49 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "modelpublisher", + "version": "1.0.0", + "description": "[![.NET](https://github.com/TheCraftyMaker/ModelPublisher/actions/workflows/dotnet.yml/badge.svg)](https://github.com/TheCraftyMaker/ModelPublisher/actions/workflows/dotnet.yml) [![CodeQL Advanced](https://github.com/TheCraftyMaker/ModelPublisher/actions/workflows/codeql.yml/badge.svg)](https://github.com/TheCraftyMaker/ModelPublisher/actions/workflows/codeql.yml) [![Dependabot Updates](https://github.com/TheCraftyMaker/ModelPublisher/actions/workflows/dependabot/dependabot-updates/badge.svg)](https://github.com/TheCraftyMaker/ModelPublisher/actions/workflows/dependabot/dependabot-updates)", + "main": "index.js", + "directories": { + "doc": "docs", + "test": "tests" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/TheCraftyMaker/ModelPublisher.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "bugs": { + "url": "https://github.com/TheCraftyMaker/ModelPublisher/issues" + }, + "homepage": "https://github.com/TheCraftyMaker/ModelPublisher#readme", + "dependencies": { + "playwright": "^1.58.2", + "playwright-extra": "^4.3.6", + "puppeteer-extra-plugin-stealth": "^2.11.2" + } +} diff --git a/src/ModelPublisher.Cli/Program.cs b/src/ModelPublisher.Cli/Program.cs index 6703f3b..fec6995 100644 --- a/src/ModelPublisher.Cli/Program.cs +++ b/src/ModelPublisher.Cli/Program.cs @@ -16,10 +16,22 @@ }; platformsOption.Aliases.Add("-p"); +var profilePathOption = new Option("--profile-path") +{ + Description = "Use a custom browser profile directory (e.g. your real Brave profile) to bypass Cloudflare. Close Brave first." +}; + +var stealthOption = new Option("--stealth") +{ + Description = "Launch via playwright-extra stealth plugin to bypass Cloudflare bot detection." +}; + var rootCommand = new RootCommand("ModelPublisher — automates 3D model publishing across platforms.") { manifestArg, - platformsOption + platformsOption, + profilePathOption, + stealthOption }; rootCommand.SetAction(async (parseResult, ct) => @@ -32,10 +44,14 @@ .LeftJustified() .Color(Color.Cyan1)); + var profilePath = parseResult.GetValue(profilePathOption); + var stealth = parseResult.GetValue(stealthOption); var command = new PublishCommand(); return await command.ExecuteAsync( manifest.FullName, platforms.Length > 0 ? platforms : null, + profilePath, + stealth, ct ); }); @@ -43,21 +59,38 @@ // codegen — opens Brave with the platform's persistent profile and pauses for inspection var codegenPlatformArg = new Argument("platform") { Description = "Platform key (e.g. thangs)" }; var codegenUrlArg = new Argument("url") { Description = "URL to navigate to" }; +var codegenProfileOption = new Option("--profile-path") +{ + Description = "Override the profile directory (e.g. point to your real Brave profile while it's closed)." +}; +var codegenStealthOption = new Option("--stealth") +{ + Description = "Use playwright-extra stealth plugin (for Cloudflare-protected sites)." +}; + var codegenCommand = new Command("codegen", "Open Brave with a platform profile for selector inspection") { codegenPlatformArg, - codegenUrlArg + codegenUrlArg, + codegenProfileOption, + codegenStealthOption }; codegenCommand.SetAction(async (parseResult, ct) => { var platformKey = parseResult.GetValue(codegenPlatformArg)!; var url = parseResult.GetValue(codegenUrlArg)!; + var profilePath = parseResult.GetValue(codegenProfileOption); + var stealth = parseResult.GetValue(codegenStealthOption); AnsiConsole.MarkupLine($"[cyan]Opening Brave with profile '[bold]{platformKey}[/]' at {Markup.Escape(url)}[/]"); + if (stealth) AnsiConsole.MarkupLine("[yellow]Stealth mode enabled (playwright-extra)[/]"); + if (profilePath != null) AnsiConsole.MarkupLine($"[yellow]Using custom profile: {Markup.Escape(profilePath)}[/]"); AnsiConsole.MarkupLine("[yellow]Use the Playwright Inspector to inspect selectors. Close the browser when done.[/]"); using var playwright = await Playwright.CreateAsync(); - var context = await BrowserContextFactory.GetPersistentContextAsync(playwright, platformKey); + var context = stealth + ? await BrowserContextFactory.GetStealthContextAsync(playwright, platformKey, profilePath) + : await BrowserContextFactory.GetPersistentContextAsync(playwright, platformKey, profilePathOverride: profilePath); var page = await context.NewPageAsync(); await page.GotoAsync(url); await page.PauseAsync(); diff --git a/src/ModelPublisher.Core/Platforms/MakerWorldPublisher.cs b/src/ModelPublisher.Core/Platforms/MakerWorldPublisher.cs index 7aafee5..f4d0ab2 100644 --- a/src/ModelPublisher.Core/Platforms/MakerWorldPublisher.cs +++ b/src/ModelPublisher.Core/Platforms/MakerWorldPublisher.cs @@ -17,59 +17,30 @@ public class MakerWorldPublisher : IPlatformPublisher { public string PlatformKey => "makerworld"; public string PlatformName => "MakerWorld"; - + public bool IsFreeOnly => true; public bool SupportsMarkdown => true; - public string Disclaimer => ""; + public string Disclaimer => GetDisclaimer(); - public async Task PublishFreeAsync(ReleaseManifest manifest, IPage page, CancellationToken ct = default) + public async Task PublishFreeAsync(ReleaseManifest manifest, IPage page, + CancellationToken ct = default) { try { - await page.GotoAsync("https://makerworld.com/en/create"); - - await AuthGuard.EnsureLoggedInAsync(page, PlatformName, async p => - { - return await p.Locator(".user-avatar, [data-testid='user-avatar'], .avatar-wrapper") - .First.IsVisibleAsync(); - }, ct); - - AnsiConsole.MarkupLine($"[cyan][[{PlatformName}]][/] Filling model details..."); - - // TODO: Replace selectors after running codegen - await page.Locator("input[name='title'], input[placeholder*='title' i]").First.FillAsync(manifest.Title); - - var descEditor = page.Locator("textarea[name='description'], [contenteditable='true']").First; - await descEditor.ClickAsync(); - await descEditor.FillAsync(manifest.GetDescription(this)); - - // Tags - foreach (var tag in manifest.Tags) - { - var tagInput = page.Locator("input[placeholder*='tag' i]").First; - await tagInput.FillAsync(tag); - await tagInput.PressAsync("Enter"); - await page.WaitForTimeoutAsync(300); - } - - // Model file upload - AnsiConsole.MarkupLine($"[cyan][[{PlatformName}]][/] Uploading model file..."); - var modelInput = page.Locator("input[type='file']").First; - // await modelInput.SetInputFilesAsync(manifest.ResolveFilePath(manifest.Files.Model)); - await page.WaitForLoadStateAsync(LoadState.NetworkIdle); - - // Photos - AnsiConsole.MarkupLine($"[cyan][[{PlatformName}]][/] Uploading photos..."); - var photoInput = page.Locator("input[type='file'][accept*='image']").First; - await FileUploadHelper.UploadSequentialAsync(page, photoInput, - manifest.Files.Photos.Select(manifest.ResolveFilePath), PlatformName); - - AnsiConsole.MarkupLine($"[yellow][[{PlatformName}]][/] Review the form in the browser. Press [green]Enter[/] to publish..."); - await Task.Run(() => Console.ReadLine(), ct); - - await page.Locator("button:has-text('Publish'), button[type='submit']").First.ClickAsync(); - await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + await page.GotoAsync("https://makerworld.com/en/my/models/publish?type=original"); + + await AuthGuard + .EnsureLoggedInAsync(page, PlatformName, + async p => await p + .Locator("img[src='https://public-cdn.bblmw.com/avatar/d59a9d40-0c79-11ee-9a50-b1cd743d2b1a." + + "jpg?x-oss-process=image/resize,w_60/format,webp']").First + .IsVisibleAsync(), ct); + + // Step 1: Specific platform print profile(s) + await UploadFiles(manifest, page); + + return new PublishResult(PlatformName, true, page.Url, null); } @@ -83,4 +54,63 @@ public Task PublishPremiumAsync(ReleaseManifest manifest, IPage p { throw new NotImplementedException(); } -} + + private async Task UploadFiles(ReleaseManifest manifest, IPage page) + { + // Step 1: Specific platform print profile(s) + var config = manifest.GetPlatformConfig(PlatformKey); + if (config != null && config.PrintProfiles.Any()) + { + AnsiConsole.MarkupLine($"[cyan][[{PlatformName}]][/] Uploading MakerWorld specific profile..."); + + await page + .GetByRole(AriaRole.Radio, new() { Name = "Yes (earn extra points reward)" }) + .CheckAsync(); + + var profileInput = page.Locator("input[type='file'][accept='.3mf']").First; + + var profilePath = config.PrintProfiles.Select(manifest.ResolveFilePath).First(); + + await FileUploadHelper.UploadSequentialAsync(page, profileInput, [profilePath], PlatformName, networkIdleTimeoutMs: 10_000); + } + else + { + AnsiConsole.MarkupLine( + $"[cyan][[{PlatformName}]][/] No MakerWorld specific profile found. Skipping..."); + + await page + .GetByRole(AriaRole.Radio, new() { Name = "I have STL/CAD files or other types of 3MF files" }) + .CheckAsync(); + } + + // Step 2: Model files + AnsiConsole.MarkupLine($"[cyan][[{PlatformName}]][/] Uploading model files..."); + + var pathsToExclude = new List(); + if (config != null && config.PrintProfiles.Any()) + { + pathsToExclude.AddRange(config.PrintProfiles.Select(manifest.ResolveFilePath)); + } + + var nonProfileModelFiles = manifest.Files.Models + .Select(manifest.ResolveFilePath) + .Except(pathsToExclude); + + var modelInput = page.Locator( + "input[type='file'][accept='.3ds, .amf, .blend, .dwg, .dxf, .f3d, .f3z, .factory, .fcstd, .iges, .ipt, .obj, .ply, .py, .rsdoc, .scad, .shape, .shapr, .skp, .sldasm, .sldprt, .slvs, .step, .stl, .stp, .studio3, .zip, .3mf, .stpz, .fcstd']").First; + + await FileUploadHelper.UploadSequentialAsync(page, modelInput, nonProfileModelFiles, PlatformName, networkIdleTimeoutMs: 10_000); + + await page + .GetByRole(AriaRole.Button, new() { Name = "Next Step" }) + .ClickAsync(); + } + + private static string GetDisclaimer() + { + return "_ALL The Crafty Maker designs are protected by Copyright Law. By downloading, YOU HAVE NO RIGHT " + + "to sell any digital files or reproductions from those files. If you want a commercial license to " + + "LEGALLY SELL 3D prints, you'll need a [The Crafty Seller Patreon](https://patreon.com/TheCraftyMaker) " + + "subscription._"; + } +} \ No newline at end of file diff --git a/src/ModelPublisher.Core/Platforms/PatreonPublisher.cs b/src/ModelPublisher.Core/Platforms/PatreonPublisher.cs index cae236a..9f79740 100644 --- a/src/ModelPublisher.Core/Platforms/PatreonPublisher.cs +++ b/src/ModelPublisher.Core/Platforms/PatreonPublisher.cs @@ -38,13 +38,10 @@ public async Task PublishFreeAsync(ReleaseManifest manifest, IPag { try { - await page.GotoAsync("https://www.patreon.com/posts/create"); + await page.GotoAsync("https://www.patreon.com/c/TheCraftyMaker"); - await AuthGuard.EnsureLoggedInAsync(page, PlatformName, async p => - { - return await p.Locator("[data-tag='user-avatar'], .UserAvatar, [aria-label*='profile' i]") - .First.IsVisibleAsync(); - }, ct); + await AuthGuard.EnsureLoggedInAsync(page, PlatformName, + async p => await p.Locator("[data-tag='account-menu-toggle-switcher']").First.IsVisibleAsync(), ct); AnsiConsole.MarkupLine($"[cyan][[{PlatformName}]][/] Filling post details..."); diff --git a/src/ModelPublisher.Core/Platforms/PrintablesPublisher.cs b/src/ModelPublisher.Core/Platforms/PrintablesPublisher.cs index e834e06..dd38188 100644 --- a/src/ModelPublisher.Core/Platforms/PrintablesPublisher.cs +++ b/src/ModelPublisher.Core/Platforms/PrintablesPublisher.cs @@ -25,12 +25,9 @@ public async Task PublishFreeAsync(ReleaseManifest manifest, IPag { await page.GotoAsync("https://www.printables.com/model/create"); - await AuthGuard.EnsureLoggedInAsync(page, PlatformName, async p => - { - // Logged-in indicator: avatar or user menu visible - return await p.Locator("[data-cy='user-menu'], .user-avatar, [aria-label='User menu']") - .First.IsVisibleAsync(); - }, ct); + await AuthGuard + .EnsureLoggedInAsync(page, PlatformName, + async p => await p.Locator("[data-cy='user-menu'], .user-avatar, [aria-label='User menu']").First.IsVisibleAsync(), ct); // Step 1: Model files AnsiConsole.MarkupLine($"[cyan][[{PlatformName}]][/] Uploading model file..."); diff --git a/src/ModelPublisher.Core/PublishCommand.cs b/src/ModelPublisher.Core/PublishCommand.cs index d94f991..7f3e802 100644 --- a/src/ModelPublisher.Core/PublishCommand.cs +++ b/src/ModelPublisher.Core/PublishCommand.cs @@ -28,6 +28,8 @@ public class PublishCommand public async Task ExecuteAsync( string manifestPath, string[]? onlyPlatforms, + string? profilePathOverride = null, + bool stealth = false, CancellationToken ct = default) { if (!File.Exists(manifestPath)) @@ -88,8 +90,9 @@ public async Task ExecuteAsync( var tasks = runnablePublishers.Select(async x => { - await using var context = await BrowserContextFactory.GetPersistentContextAsync( - playwright, x.Publisher.PlatformKey); + await using var context = stealth + ? await BrowserContextFactory.GetStealthContextAsync(playwright, x.Publisher.PlatformKey, profilePathOverride) + : await BrowserContextFactory.GetPersistentContextAsync(playwright, x.Publisher.PlatformKey, profilePathOverride: profilePathOverride); var page = await context.NewPageAsync(); diff --git a/src/ModelPublisher.Core/Shared/BrowserContextFactory.cs b/src/ModelPublisher.Core/Shared/BrowserContextFactory.cs index 872c294..bbe527f 100644 --- a/src/ModelPublisher.Core/Shared/BrowserContextFactory.cs +++ b/src/ModelPublisher.Core/Shared/BrowserContextFactory.cs @@ -1,3 +1,6 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; using Microsoft.Playwright; namespace ModelPublisher.Core.Shared; @@ -12,18 +15,164 @@ public static class BrowserContextFactory public static async Task GetPersistentContextAsync( IPlaywright playwright, string platformKey, - bool headless = false) + bool headless = false, + string? profilePathOverride = null) { var profilePath = Path.GetFullPath(Path.Combine("profiles", platformKey)); Directory.CreateDirectory(profilePath); - return await playwright.Chromium.LaunchPersistentContextAsync(profilePath, new() + if (profilePathOverride != null) + { + // Copy session cookies + encryption key from the real Brave profile into the platform profile. + // This lets us bypass Cloudflare using cf_clearance cookies from normal browsing. + // DPAPI decryption works because we're the same Windows user. + var profileName = Path.GetFileName(profilePathOverride); + var userDataDir = Path.GetDirectoryName(profilePathOverride)!; + var isNamedProfile = profileName.StartsWith("Profile ", StringComparison.OrdinalIgnoreCase) || profileName == "Default"; + var sourceProfileDir = isNamedProfile ? profilePathOverride : profilePathOverride; + var sourceUserDataDir = isNamedProfile ? userDataDir : profilePathOverride; + + // Local State holds the AES key used to decrypt cookies (itself DPAPI-protected) + var localStateSrc = Path.Combine(sourceUserDataDir, "Local State"); + if (File.Exists(localStateSrc)) + { + File.Copy(localStateSrc, Path.Combine(profilePath, "Local State"), overwrite: true); + Console.WriteLine($"[BrowserContextFactory] Copied Local State from {localStateSrc}"); + } + else Console.WriteLine($"[BrowserContextFactory] Local State not found at {localStateSrc}"); + + // Cookies + var cookiesSrc = Path.Combine(sourceProfileDir, "Network", "Cookies"); + if (File.Exists(cookiesSrc)) + { + var cookiesDest = Path.Combine(profilePath, "Default", "Network"); + Directory.CreateDirectory(cookiesDest); + File.Copy(cookiesSrc, Path.Combine(cookiesDest, "Cookies"), overwrite: true); + Console.WriteLine($"[BrowserContextFactory] Copied Cookies from {cookiesSrc}"); + } + else Console.WriteLine($"[BrowserContextFactory] Cookies not found at {cookiesSrc}"); + } + + var context = await playwright.Chromium.LaunchPersistentContextAsync(profilePath, new() { Headless = headless, SlowMo = 80, ExecutablePath = @"C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe", ViewportSize = new ViewportSize { Width = 1400, Height = 900 }, - UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + Permissions = ["geolocation"], + Args = ["--disable-blink-features=AutomationControlled"] }); + + // Remove automation markers and spoof fingerprints to evade Cloudflare bot detection + await context.AddInitScriptAsync(""" + // 1. Remove webdriver flag + Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); + delete navigator.__proto__.webdriver; + + // 2. Restore chrome object + window.chrome = { runtime: {} }; + + // 3. Realistic languages + Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] }); + + // 4. Realistic plugins + Object.defineProperty(navigator, 'plugins', { get: () => [ + { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format' }, + { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '' } + ]}); + + // 5. Canvas fingerprint noise + const origToDataURL = HTMLCanvasElement.prototype.toDataURL; + HTMLCanvasElement.prototype.toDataURL = function(type) { + const ctx = this.getContext('2d'); + if (ctx) { + try { + const imageData = ctx.getImageData(0, 0, this.width, this.height); + for (let i = 0; i < imageData.data.length; i += 4) { + imageData.data[i] += Math.floor(Math.random() * 3) - 1; + } + ctx.putImageData(imageData, 0, 0); + } catch {} + } + return origToDataURL.call(this, type); + }; + + // 6. WebGL fingerprint spoofing + const getParameter = WebGLRenderingContext.prototype.getParameter; + WebGLRenderingContext.prototype.getParameter = function(parameter) { + if (parameter === 37445) return 'Intel Inc.'; + if (parameter === 37446) return 'Intel Iris OpenGL Engine'; + return getParameter.call(this, parameter); + }; + """); + + return context; + } + + /// + /// Launches Brave via the stealth-launcher.js Node.js script (playwright-extra + stealth plugin) + /// and connects to it over CDP. Use this for Cloudflare-protected platforms. + /// + public static async Task GetStealthContextAsync( + IPlaywright playwright, + string platformKey, + string? profilePathOverride = null) + { + var port = GetFreePort(); + // stealth-launcher.js lives in the project root; during dotnet run the CWD is the project root + var launcherPath = Path.GetFullPath(Path.Combine( + AppContext.BaseDirectory, "..", "..", "..", "..", "..", "stealth-launcher.js")); + var nodeExe = @"C:\Program Files\nodejs\node.exe"; + + // Use the real Brave profile if provided, otherwise fall back to the persistent platform profile + var effectiveProfilePath = profilePathOverride + ?? Path.GetFullPath(Path.Combine("profiles", platformKey)); + Directory.CreateDirectory(Path.GetFullPath(Path.Combine("profiles", platformKey))); + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = nodeExe, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + } + }; + // Use ArgumentList so .NET handles quoting — avoids newline/space issues with paths + process.StartInfo.ArgumentList.Add(launcherPath); + process.StartInfo.ArgumentList.Add(port.ToString()); + process.StartInfo.ArgumentList.Add(effectiveProfilePath); + process.Start(); + + // Wait for the READY signal from the launcher + bool ready = false; + while (!ready) + { + var line = await process.StandardOutput.ReadLineAsync(); + if (line == null) break; + if (line.StartsWith("READY:")) + ready = true; + } + + if (!ready) + { + var stderr = await process.StandardError.ReadToEndAsync(); + throw new InvalidOperationException($"stealth-launcher did not emit a READY signal. stderr: {stderr}"); + } + + var browser = await playwright.Chromium.ConnectOverCDPAsync($"http://localhost:{port}"); + return browser.Contexts.FirstOrDefault() + ?? await browser.NewContextAsync(); + } + + private static int GetFreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; } } diff --git a/src/ModelPublisher.Core/Shared/FileUploadHelper.cs b/src/ModelPublisher.Core/Shared/FileUploadHelper.cs index 14443c5..d7a9175 100644 --- a/src/ModelPublisher.Core/Shared/FileUploadHelper.cs +++ b/src/ModelPublisher.Core/Shared/FileUploadHelper.cs @@ -27,7 +27,8 @@ public static async Task UploadSequentialAsync( IPage page, ILocator fileInput, IEnumerable filePaths, - string platformName) + string platformName, + int networkIdleTimeoutMs = 0) { foreach (var path in filePaths) { @@ -35,10 +36,19 @@ public static async Task UploadSequentialAsync( throw new FileNotFoundException($"Upload file not found: {path}"); AnsiConsole.MarkupLine($" [dim]Uploading {Path.GetFileName(path)}...[/] to {platformName}"); - + await fileInput.SetInputFilesAsync(path); - - await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + + var loadStateOptions = networkIdleTimeoutMs > 0 + ? new PageWaitForLoadStateOptions { Timeout = networkIdleTimeoutMs } + : null; + + try + { + await page.WaitForLoadStateAsync(LoadState.NetworkIdle, loadStateOptions); + } + catch (TimeoutException) { /* SPA keeps background requests open — best-effort wait */ } + await page.WaitForTimeoutAsync(300); } } diff --git a/stealth-launcher.js b/stealth-launcher.js new file mode 100644 index 0000000..bc3a5ec --- /dev/null +++ b/stealth-launcher.js @@ -0,0 +1,59 @@ +'use strict'; +// Launches Brave with playwright-extra stealth plugin and exposes a CDP endpoint. +// .NET connects to it via ConnectOverCDPAsync to run publisher code without bot detection. +// +// Usage: node stealth-launcher.js [profilePath] +// profilePath: e.g. "C:\Users\...\Brave-Browser\User Data\Profile 1" +// If it ends in "Profile N" or "Default", splits into userDataDir + --profile-directory. +// Otherwise used directly as userDataDir (e.g. our own profiles/makerworld dir). + +const { chromium } = require('playwright-extra'); +const StealthPlugin = require('puppeteer-extra-plugin-stealth'); +chromium.use(StealthPlugin()); + +const path = require('path'); + +const cdpPort = parseInt(process.argv[2] || '9222', 10); +const profilePath = process.argv[3] || null; + +(async () => { + const commonArgs = [ + `--remote-debugging-port=${cdpPort}`, + '--disable-blink-features=AutomationControlled', + '--no-first-run', + '--no-default-browser-check', + ]; + + const launchOptions = { + headless: false, + executablePath: 'C:\\Program Files\\BraveSoftware\\Brave-Browser\\Application\\brave.exe', + args: commonArgs, + }; + + let context; + + if (profilePath) { + const profileName = path.basename(profilePath); + const isNamedProfile = /^Profile \d+$/i.test(profileName) || profileName === 'Default'; + const userDataDir = isNamedProfile ? path.dirname(profilePath) : profilePath; + + if (isNamedProfile) { + launchOptions.args = [...commonArgs, `--profile-directory=${profileName}`]; + } + + // Must use launchPersistentContext — playwright-extra blocks --user-data-dir as a launch arg + context = await chromium.launchPersistentContext(userDataDir, launchOptions); + } else { + const browser = await chromium.launch(launchOptions); + context = browser.contexts()[0] || await browser.newContext(); + } + + // Signal ready — .NET already knows the port + process.stdout.write(`READY:${cdpPort}\n`); + + // Exit when the context/browser closes + context.on('close', () => process.exit(0)); +})().catch(err => { + process.stderr.write(`stealth-launcher error: ${err.message}\n`); + process.exit(1); +});