Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
27 changes: 27 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 0 additions & 2 deletions ModelPublisher.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 24 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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 <platformKey> <url> --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.
Expand All @@ -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/<platform>/` 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`.
Expand Down
30 changes: 30 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
39 changes: 36 additions & 3 deletions src/ModelPublisher.Cli/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,22 @@
};
platformsOption.Aliases.Add("-p");

var profilePathOption = new Option<string?>("--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<bool>("--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) =>
Expand All @@ -32,32 +44,53 @@
.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
);
});

// codegen <platformKey> <url> — opens Brave with the platform's persistent profile and pauses for inspection
var codegenPlatformArg = new Argument<string>("platform") { Description = "Platform key (e.g. thangs)" };
var codegenUrlArg = new Argument<string>("url") { Description = "URL to navigate to" };
var codegenProfileOption = new Option<string?>("--profile-path")
{
Description = "Override the profile directory (e.g. point to your real Brave profile while it's closed)."
};
var codegenStealthOption = new Option<bool>("--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();
Expand Down
124 changes: 77 additions & 47 deletions src/ModelPublisher.Core/Platforms/MakerWorldPublisher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PublishResult> PublishFreeAsync(ReleaseManifest manifest, IPage page, CancellationToken ct = default)
public async Task<PublishResult> 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);
}
Expand All @@ -83,4 +54,63 @@ public Task<PublishResult> 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<PlatformConfig>(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<string>();
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._";
}
}
9 changes: 3 additions & 6 deletions src/ModelPublisher.Core/Platforms/PatreonPublisher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,10 @@ public async Task<PublishResult> 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...");

Expand Down
Loading
Loading