From 86513736165e9bcc98b76db1b2b000eca9140a54 Mon Sep 17 00:00:00 2001 From: Christof Lauriers Date: Sun, 22 Mar 2026 17:03:04 +0100 Subject: [PATCH 1/2] Implement ThangsPublisher with verified selectors - Navigate to /mythangs, use URL-based auth check (Google OAuth blocks automated browsers) - Upload model files via file input with terms acceptance label click - Fill title, description, tags, photos via data-testid selectors from codegen session - Set audience to Public sharing and license via dropdown - Human review pause before save (matching Printables pattern) - Add `codegen` CLI subcommand for future selector inspection sessions - Update CLAUDE.md with Slopwatch section Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 6 + src/ModelPublisher.Cli/Program.cs | 28 +++++ .../Platforms/PatreonPublisher.cs | 4 +- .../Platforms/PrintablesPublisher.cs | 13 +- .../Platforms/ThangsPublisher.cs | 112 +++++++++++++----- 5 files changed, 120 insertions(+), 43 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d8552fc..9ffec95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,6 +79,12 @@ dotnet run --project src\ModelPublisher.Cli -- "C:\Users\chris\Downloads\Models\ - **Spectre.Console**: any string containing `[` or `]` from user data must be wrapped in `Markup.Escape()`. - **System.CommandLine 2.0.3**: `SetAction` + `ParseResult.GetValue` only — old `Handler` API removed. +## Slopwatch +- Installed globally: `dotnet tool install --global Slopwatch.Cmd` (v0.4.0) +- Baseline initialized at `.slopwatch/baseline.json` (0 pre-existing issues on master) +- Run after code changes: `powershell.exe -Command "cd 'C:\Source\ModelPublisher'; slopwatch analyze -d ."` +- Detects: disabled tests, empty catch blocks, warning suppression, arbitrary delays, NoWarn in csproj, CPM bypass + ## GitHub workflow - Repo: https://github.com/TheCraftyMaker/ModelPublisher - `master` is protected — PRs required, no direct pushes, enforce_admins=true diff --git a/src/ModelPublisher.Cli/Program.cs b/src/ModelPublisher.Cli/Program.cs index c3c7cec..6703f3b 100644 --- a/src/ModelPublisher.Cli/Program.cs +++ b/src/ModelPublisher.Cli/Program.cs @@ -1,5 +1,7 @@ using System.CommandLine; +using Microsoft.Playwright; using ModelPublisher.Core; +using ModelPublisher.Core.Shared; using Spectre.Console; var manifestArg = new Argument("manifest") @@ -38,4 +40,30 @@ ); }); +// 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 codegenCommand = new Command("codegen", "Open Brave with a platform profile for selector inspection") +{ + codegenPlatformArg, + codegenUrlArg +}; +codegenCommand.SetAction(async (parseResult, ct) => +{ + var platformKey = parseResult.GetValue(codegenPlatformArg)!; + var url = parseResult.GetValue(codegenUrlArg)!; + + AnsiConsole.MarkupLine($"[cyan]Opening Brave with profile '[bold]{platformKey}[/]' at {Markup.Escape(url)}[/]"); + 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 page = await context.NewPageAsync(); + await page.GotoAsync(url); + await page.PauseAsync(); + await context.CloseAsync(); + return 0; +}); +rootCommand.Add(codegenCommand); + return await rootCommand.Parse(args).InvokeAsync(); \ No newline at end of file diff --git a/src/ModelPublisher.Core/Platforms/PatreonPublisher.cs b/src/ModelPublisher.Core/Platforms/PatreonPublisher.cs index 69e4587..be26448 100644 --- a/src/ModelPublisher.Core/Platforms/PatreonPublisher.cs +++ b/src/ModelPublisher.Core/Platforms/PatreonPublisher.cs @@ -38,9 +38,7 @@ public async Task PublishFreeAsync(ReleaseManifest manifest, IPag { try { - // TODO: use config.FreePost and config.AccessTierId when Patreon automation is implemented - var config = manifest.GetPlatformConfig(PlatformKey) ?? new PatreonConfig(); - _ = config; + // TODO: use manifest.GetPlatformConfig(PlatformKey) for FreePost and AccessTierId when Patreon automation is implemented await page.GotoAsync("https://www.patreon.com/posts/create"); diff --git a/src/ModelPublisher.Core/Platforms/PrintablesPublisher.cs b/src/ModelPublisher.Core/Platforms/PrintablesPublisher.cs index 9a64d9b..7bdbf56 100644 --- a/src/ModelPublisher.Core/Platforms/PrintablesPublisher.cs +++ b/src/ModelPublisher.Core/Platforms/PrintablesPublisher.cs @@ -45,27 +45,18 @@ await FileUploadHelper.UploadSequentialAsync( AnsiConsole.MarkupLine($"[cyan][[{PlatformName}]][/] Filling model details..."); // Step 2: Model Title - await page - .GetByRole(AriaRole.Textbox, new() { Name = "Model name (required)" }) - .ClickAsync(); - await page .GetByRole(AriaRole.Textbox, new() { Name = "Model name (required)" }) .FillAsync(manifest.Title); // Step 3: Model Summary - await page - .GetByRole(AriaRole.Textbox, new() { Name = "Summary (required)" }) - .ClickAsync(); - await page .GetByRole(AriaRole.Textbox, new() { Name = "Summary (required)" }) .FillAsync(manifest.Title); // Step 4: Model Category - // await page.GetByRole(AriaRole.Button, new() { Name = "Main category (required)" }).ClickAsync(); - // await page.GetByRole(AriaRole.Button, new() { Name = manifest.Category }).ClickAsync(); - + // TODO: Manually for now + // Step 5: Tags await page .GetByRole(AriaRole.Textbox, new() { Name = "Additional tags" }) diff --git a/src/ModelPublisher.Core/Platforms/ThangsPublisher.cs b/src/ModelPublisher.Core/Platforms/ThangsPublisher.cs index 3bdc96d..edbd8f6 100644 --- a/src/ModelPublisher.Core/Platforms/ThangsPublisher.cs +++ b/src/ModelPublisher.Core/Platforms/ThangsPublisher.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using Microsoft.Playwright; using ModelPublisher.Core.Models; using ModelPublisher.Core.Shared; @@ -21,51 +22,97 @@ public class ThangsPublisher : IPlatformPublisher public bool IsFreeOnly => false; public bool SupportsMarkdown => true; - public string Disclaimer => ""; + public string Disclaimer => GetDisclaimer(); public async Task PublishFreeAsync(ReleaseManifest manifest, IPage page, CancellationToken ct = default) { try { - await page.GotoAsync("https://thangs.com/designer/upload"); + await page.GotoAsync("https://thangs.com/mythangs"); - await AuthGuard.EnsureLoggedInAsync(page, PlatformName, async p => - { - return await p.Locator("[data-testid='user-menu'], .user-avatar, nav .avatar") - .First.IsVisibleAsync(); - }, ct); - - AnsiConsole.MarkupLine($"[cyan][[{PlatformName}]][/] Filling model details..."); + await AuthGuard.EnsureLoggedInAsync(page, PlatformName, + p => Task.FromResult(p.Url.Contains("mythangs")), ct); + + await page + .GetByRole(AriaRole.Button, new() { Name = "Add new" }) + .ClickAsync(); + + await page + .GetByTestId("action-upload-models") + .ClickAsync(); + + // Step 1: Model files + AnsiConsole.MarkupLine($"[cyan][[{PlatformName}]][/] Uploading model file..."); + + var modelFileInput = page.Locator("input[multiple]").First; + modelFileInput.FocusAsync(); - // TODO: Replace selectors after running codegen - await page.Locator("input[name='name'], input[placeholder*='model name' i]").First.FillAsync(manifest.Title); - await page.Locator("textarea[name='description'], [placeholder*='description' i]").First.FillAsync(manifest.GetDescription(this)); + await FileUploadHelper.UploadSequentialAsync( + page, modelFileInput, manifest.Files.Models.Select(manifest.ResolveFilePath), PlatformName); - // Tags + await page.GetByTestId("upload-mode-collection").ClickAsync(); + + await page.Locator("label[for='terms-acceptance']").ClickAsync(); + + await page + .GetByTestId("file-selector-buttons-upload-files") + .ClickAsync(); + + // Step 2: Model Title + await page + .GetByTestId("model-upload-name-input") + .ClearAsync(); + + await page + .GetByTestId("model-upload-name-input") + .FillAsync(manifest.Title); + + // Step 3: Model Description + await page + .GetByTestId("model-upload-description-input") + .FillAsync(manifest.GetDescription(this)); + + // Step 4: Model Category + // TODO: Manually for now + + // Step 5: Tags foreach (var tag in manifest.Tags) { - var tagInput = page.Locator("input[placeholder*='tag' i], .tags-input input").First; - await tagInput.FillAsync(tag); - await tagInput.PressAsync("Enter"); + await page + .GetByTestId("cy_tag_input") + .FillAsync(tag); + + await page + .GetByTestId("cy_tag_input") + .PressAsync("Enter"); + await page.WaitForTimeoutAsync(300); } + + // Step 6: Upload photos + AnsiConsole.MarkupLine($"[cyan][[{PlatformName}]][/] Uploading photos..."); - // Model file - AnsiConsole.MarkupLine($"[cyan][[{PlatformName}]][/] Uploading model file..."); - // await page.Locator("input[type='file']").First - // .SetInputFilesAsync(manifest.ResolveFilePath(manifest.Files.Model)); - await page.WaitForLoadStateAsync(LoadState.NetworkIdle); + var photoInput = page.Locator("input[multiple][accept*='.jpg']").First; + await FileUploadHelper.UploadSequentialAsync( + page, photoInput, manifest.Files.PhotosOrdered(coverFirst: false).Select(manifest.ResolveFilePath), PlatformName); - // 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); + // Step 7: Audience + await page.Locator("[class*='Audience_VisibilityItem']").First.ClickAsync(); + await page.GetByText("Public sharing").ClickAsync(); + + // Step 7: License + await page.GetByRole(AriaRole.Button, new() { Name = "Select a license" }).ClickAsync(); + await page.GetByText("by-nc-sa_4_0.txt").First.ClickAsync(); - AnsiConsole.MarkupLine($"[yellow][[{PlatformName}]][/] Review the form in the browser. Press [green]Enter[/] to publish..."); - await Task.Run(() => Console.ReadLine(), ct); + // Step 8: Human review before publishing + 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:has-text('Upload'), button[type='submit']").First.ClickAsync(); + // Step 9: Save + await page.GetByTestId("save-model-details").ClickAsync(); + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); return new PublishResult(PlatformName, true, page.Url, null); @@ -80,4 +127,11 @@ public Task PublishPremiumAsync(ReleaseManifest manifest, IPage p { throw new NotImplementedException(); } + + 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 Companion subscription._"; + } } From 8e935fd3c3cf1673363ce4e9a0f978f5316867cd Mon Sep 17 00:00:00 2001 From: Christof Lauriers Date: Sun, 22 Mar 2026 17:05:26 +0100 Subject: [PATCH 2/2] Refactor `ThangsPublisher.PublishFreeAsync` for code clarity and consistency: - Removed unused `System.Text.RegularExpressions` import. - Fixed spacing and indentation issues. - Added missing calls to `async` methods. --- .../Platforms/ThangsPublisher.cs | 47 ++++++++++--------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/src/ModelPublisher.Core/Platforms/ThangsPublisher.cs b/src/ModelPublisher.Core/Platforms/ThangsPublisher.cs index edbd8f6..5c6f1e9 100644 --- a/src/ModelPublisher.Core/Platforms/ThangsPublisher.cs +++ b/src/ModelPublisher.Core/Platforms/ThangsPublisher.cs @@ -1,4 +1,3 @@ -using System.Text.RegularExpressions; using Microsoft.Playwright; using ModelPublisher.Core.Models; using ModelPublisher.Core.Shared; @@ -23,8 +22,9 @@ public class ThangsPublisher : IPlatformPublisher public bool SupportsMarkdown => true; 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 { @@ -32,74 +32,75 @@ public async Task PublishFreeAsync(ReleaseManifest manifest, IPag await AuthGuard.EnsureLoggedInAsync(page, PlatformName, p => Task.FromResult(p.Url.Contains("mythangs")), ct); - + await page .GetByRole(AriaRole.Button, new() { Name = "Add new" }) .ClickAsync(); - + await page .GetByTestId("action-upload-models") .ClickAsync(); - + // Step 1: Model files AnsiConsole.MarkupLine($"[cyan][[{PlatformName}]][/] Uploading model file..."); - + var modelFileInput = page.Locator("input[multiple]").First; - modelFileInput.FocusAsync(); + await modelFileInput.FocusAsync(); await FileUploadHelper.UploadSequentialAsync( page, modelFileInput, manifest.Files.Models.Select(manifest.ResolveFilePath), PlatformName); await page.GetByTestId("upload-mode-collection").ClickAsync(); - + await page.Locator("label[for='terms-acceptance']").ClickAsync(); - + await page .GetByTestId("file-selector-buttons-upload-files") .ClickAsync(); - + // Step 2: Model Title await page .GetByTestId("model-upload-name-input") .ClearAsync(); - + await page .GetByTestId("model-upload-name-input") .FillAsync(manifest.Title); - + // Step 3: Model Description await page .GetByTestId("model-upload-description-input") .FillAsync(manifest.GetDescription(this)); - + // Step 4: Model Category // TODO: Manually for now - + // Step 5: Tags foreach (var tag in manifest.Tags) { await page .GetByTestId("cy_tag_input") .FillAsync(tag); - + await page .GetByTestId("cy_tag_input") .PressAsync("Enter"); - + await page.WaitForTimeoutAsync(300); } - + // Step 6: Upload photos AnsiConsole.MarkupLine($"[cyan][[{PlatformName}]][/] Uploading photos..."); var photoInput = page.Locator("input[multiple][accept*='.jpg']").First; await FileUploadHelper.UploadSequentialAsync( - page, photoInput, manifest.Files.PhotosOrdered(coverFirst: false).Select(manifest.ResolveFilePath), PlatformName); + page, photoInput, manifest.Files.PhotosOrdered(coverFirst: false).Select(manifest.ResolveFilePath), + PlatformName); // Step 7: Audience await page.Locator("[class*='Audience_VisibilityItem']").First.ClickAsync(); await page.GetByText("Public sharing").ClickAsync(); - + // Step 7: License await page.GetByRole(AriaRole.Button, new() { Name = "Select a license" }).ClickAsync(); await page.GetByText("by-nc-sa_4_0.txt").First.ClickAsync(); @@ -107,12 +108,12 @@ await FileUploadHelper.UploadSequentialAsync( // Step 8: Human review before publishing AnsiConsole.MarkupLine( $"[yellow][[{PlatformName}]][/] Review the form in the browser. Press [green]Enter[/] to publish..."); - + await Task.Run(Console.ReadLine, ct); // Step 9: Save await page.GetByTestId("save-model-details").ClickAsync(); - + await page.WaitForLoadStateAsync(LoadState.NetworkIdle); return new PublishResult(PlatformName, true, page.Url, null); @@ -134,4 +135,4 @@ private static string GetDisclaimer() "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 Companion subscription._"; } -} +} \ No newline at end of file