Diff -- /var/www/vhosts/webstls.com/httpdocs/MODS/WebSTLs/ImportAdapters.pm
Diff

/var/www/vhosts/webstls.com/httpdocs/MODS/WebSTLs/ImportAdapters.pm

added on WebSTLs (webstls.com) at 2026-07-01 22:26:38

Added
+1416
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to f740ca407ac5
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1package MODS::WebSTLs::ImportAdapters;
2#======================================================================
3# WebSTLs -- marketplace -> WebSTLs model importers.
4#
5# Per-platform adapters that pull a seller's existing creations from
6# a marketplace and turn them into draft `models` rows on WebSTLs.
7# Parallel to MessageAdapters.pm, but for whole listings instead of
8# comments.
9#
10# Entry point used by marketplaces.cgi:
11# $imp->import_for_user($db, $dbh, $DB, $uid, $platform_slug)
12# -> { ok=>1, imported=>N, skipped=>N, errors=>[...] }
13# or { ok=>0, error=>'...' }
14#
15# Each per-platform adapter implements:
16# $a->is_configured($acct) -> 1/0 (has the creds we need)
17# $a->fetch_creations($ctx) -> arrayref of normalized creations
18# or { error=>'...' }
19#
20# Normalized creation hash:
21# {
22# external_id => 'platform-side id (string)',
23# title => 'Display name',
24# description => 'Long text',
25# price_cents => 0, # 0 = free; non-zero = paid
26# currency => 'USD',
27# hero_image_url => 'https://...', # cover image (downloaded)
28# file_urls => [ {name, url}, ... ],# STL/3MF/etc. (downloaded best-effort)
29# permalink => 'https://cults3d.com/...',
30# tags => 'a,b,c', # optional, comma-separated
31# }
32#
33# Files are best-effort: if the platform gates downloads behind a
34# purchase token, those entries just produce an "errors" list line in
35# the result, and the draft model row + cover image still land.
36#======================================================================
37use strict;
38use warnings;
39use HTTP::Tiny;
40use JSON::PP;
41use MIME::Base64 qw(encode_base64);
42use Digest::SHA qw(sha256_hex);
43
44my %ADAPTERS = (
45 cults3d => 'MODS::WebSTLs::ImportAdapters::Cults3D',
46 cults => 'MODS::WebSTLs::ImportAdapters::Cults3D',
47 thangs => 'MODS::WebSTLs::ImportAdapters::Thangs',
48);
49
50# Per-installation upload root. Mirrors upload.cgi save_uploaded_files
51# so imported files land in the same folder layout the rest of the
52# upload/edit/serve plumbing already expects.
53my $BASE_DIR = '/var/www/vhosts/webstls.com/httpdocs/uploads';
54
55sub new {
56 my ($class) = @_;
57 my $self = {
58 ua => HTTP::Tiny->new(
59 agent => 'WebSTLs/1.0',
60 # Per-request timeout. Each download (detail page or
61 # image) gets at most this long before bailing. Lowered
62 # from 30s -- a single stuck fetch should not be allowed
63 # to push the whole batch into the nginx 504 zone. Most
64 # legitimate fetches finish in 1-3s; anything past 15s
65 # is almost certainly the CDN stalling on us.
66 timeout => 15,
67 verify_SSL => 1,
68 ),
69 };
70 return bless $self, $class;
71}
72
73sub _esc {
74 my $s = shift; $s = '' unless defined $s;
75 $s =~ s/\\/\\\\/g; $s =~ s/'/''/g;
76 return $s;
77}
78
79# --------------------------------------------------------------
80# Currency -> USD conversion.
81#
82# We store all prices on our side in USD cents regardless of what
83# the source marketplace returned. That way storefront cards,
84# listing pages, sales reports and checkout never need per-row
85# currency-aware logic.
86#
87# Rates are fetched once per process from open.er-api.com (free,
88# no API key required) and cached in a module-level hash so the
89# 60-model import loop doesn't fire 60 separate HTTP requests.
90# If the fetch fails for any reason, we fall back to baked-in
91# rates that get the import unstuck without crashing; sellers
92# can fix individual prices in the model editor afterward.
93# --------------------------------------------------------------
94my %_FX_RATES; # currency code -> USD multiplier
95my $_FX_FETCHED_AT = 0; # epoch of last successful fetch
96my %_FX_FALLBACK = (
97 USD => 1.00,
98 EUR => 1.08, # ~typical mid-rate; refreshes on first live fetch
99 GBP => 1.26,
100 CAD => 0.73,
101 AUD => 0.65,
102 JPY => 0.0067,
103 CHF => 1.12,
104 SEK => 0.094,
105 NOK => 0.090,
106 DKK => 0.145,
107 PLN => 0.25,
108 BRL => 0.20,
109 MXN => 0.056,
110 INR => 0.012,
111);
112
113sub _fx_load {
114 # 6-hour cache window in-process. CGI processes are short-lived
115 # but Apache mod_cgid may reuse them; cap to 6h to stay fresh.
116 return if %_FX_RATES && (time - $_FX_FETCHED_AT) < 21600;
117
118 my $ua = HTTP::Tiny->new(timeout => 5, agent => 'WebSTLs/1.0');
119 my $res = $ua->request('GET', 'https://open.er-api.com/v6/latest/USD');
120 if ($res->{success}) {
121 my $json;
122 eval { $json = decode_json($res->{content} || '{}'); 1 } or do { $json = {}; };
123 my $rates = ref($json->{rates}) eq 'HASH' ? $json->{rates} : {};
124 if (keys %$rates) {
125 # API returns USD->X. We need X->USD = 1 / (USD->X).
126 %_FX_RATES = ();
127 foreach my $code (keys %$rates) {
128 my $r = $rates->{$code};
129 next unless $r && $r > 0;
130 $_FX_RATES{uc $code} = 1 / $r;
131 }
132 $_FX_RATES{USD} = 1.0;
133 $_FX_FETCHED_AT = time;
134 return;
135 }
136 }
137 # Fetch failed -- seed with the bundled fallback table.
138 %_FX_RATES = %_FX_FALLBACK unless %_FX_RATES;
139 $_FX_FETCHED_AT = time;
140}
141
142sub _convert_to_usd_cents {
143 my ($cents, $currency) = @_;
144 return 0 unless $cents && $cents =~ /^\d+$/;
145 $currency = uc(defined $currency ? $currency : '');
146 return $cents if !$currency || $currency eq 'USD';
147 _fx_load();
148 my $rate = $_FX_RATES{$currency} // $_FX_FALLBACK{$currency};
149 return 0 unless $rate && $rate > 0;
150 return int($cents * $rate + 0.5);
151}
152
153sub _adapter_for {
154 my ($self, $slug) = @_;
155 my $klass = $ADAPTERS{lc($slug || '')};
156 return undef unless $klass;
157 return $klass->new($self->{ua});
158}
159
160sub _slug {
161 my $s = lc(shift // '');
162 $s =~ s/[^a-z0-9]+/-/g;
163 $s =~ s/^-+|-+$//g;
164 return substr($s, 0, 120);
165}
166
167# Find a slug not already taken by this user. Imported titles often
168# collide with prior uploads ("Cool Dragon" already exists as a
169# draft from last week), so we suffix -2, -3, ... until uq_models_user_slug
170# is happy.
171sub _unique_slug {
172 my ($db, $dbh, $DB, $uid, $base) = @_;
173 $base ||= 'imported';
174 my $try = $base;
175 my $n = 1;
176 while (1) {
177 my $row = $db->db_readwrite($dbh, qq~
178 SELECT id FROM `${DB}`.models
179 WHERE user_id='$uid' AND slug='~ . _esc($try) . qq~'
180 LIMIT 1
181 ~, $ENV{SCRIPT_NAME}, __LINE__);
182 return $try unless ($row && $row->{id});
183 $n++;
184 $try = substr($base, 0, 115) . "-$n";
185 return $try if $n > 999; # give up after a thousand collisions
186 }
187}
188
189# Download a URL to disk; returns { ok, path, size, ext } or { error }.
190# Used for cover images + best-effort file downloads.
191sub _download_to {
192 my ($self, $url, $dest_path) = @_;
193 return { error => 'no url' } unless $url;
194 my $res = $self->{ua}->mirror($url, $dest_path);
195 if (!$res->{success}) {
196 return { error => "HTTP " . ($res->{status} || '?') . " " . ($res->{reason} || '') };
197 }
198 my $size = -s $dest_path;
199 return { error => 'empty response' } unless $size && $size > 0;
200 my $ext = '';
201 $ext = lc($1) if $dest_path =~ /\.([a-z0-9]{2,5})$/i;
202 return { ok => 1, path => $dest_path, size => $size, ext => $ext };
203}
204
205# Has marketplace_accounts table arrived yet on this install?
206sub _has_accounts_table {
207 my ($self, $db, $dbh, $DB) = @_;
208 my $r = $db->db_readwrite($dbh, qq~
209 SELECT COUNT(*) AS n FROM information_schema.tables
210 WHERE table_schema='$DB' AND table_name='marketplace_accounts'
211 ~, $ENV{SCRIPT_NAME}, __LINE__);
212 return ($r && $r->{n}) ? 1 : 0;
213}
214
215# Are the source_platform / source_external_id columns on models
216# present yet? Without them we'd lose the dedup signal across re-runs,
217# so we refuse to import until the migration is run.
218sub _has_source_columns {
219 my ($self, $db, $dbh, $DB) = @_;
220 my $r = $db->db_readwrite($dbh, qq~
221 SELECT COUNT(*) AS n FROM information_schema.columns
222 WHERE table_schema='$DB' AND table_name='models'
223 AND column_name='source_external_id'
224 ~, $ENV{SCRIPT_NAME}, __LINE__);
225 return ($r && $r->{n}) ? 1 : 0;
226}
227
228# Storefront id for this user. Imports land on the seller's storefront
229# the same way native uploads do once published, but draft imports
230# don't auto-list (mirrors upload.cgi behavior).
231sub _storefront_for {
232 my ($self, $db, $dbh, $DB, $uid) = @_;
233 my $r = $db->db_readwrite($dbh, qq~
234 SELECT id FROM `${DB}`.storefronts WHERE user_id='$uid' LIMIT 1
235 ~, $ENV{SCRIPT_NAME}, __LINE__);
236 return ($r && $r->{id}) ? $r->{id} : 0;
237}
238
239#-------------------------------------------------------------------
240# Manual paste-import. For platforms (e.g. Thangs) behind Cloudflare
241# bot protection that plain HTTP can't pass. The user pastes a list of
242# their model URLs (one per line) and we create draft rows from those.
243#
244# Differences from import_for_user:
245# - No HTTP fetch at all -- we only parse the strings the user gives.
246# - No images / files (we can't reach the platform to download them).
247# - title is derived from the URL slug; user fills the rest in via
248# the normal draft-edit flow.
249# - Dedup still applies via source_platform + source_external_id.
250#-------------------------------------------------------------------
251sub import_manual_for_user {
252 my ($self, $db, $dbh, $DB, $uid, $slug, $urls_text) = @_;
253 $uid = '' unless defined $uid;
254 $uid =~ s/[^0-9]//g;
255 return { ok => 0, error => 'bad_user' } unless length $uid;
256 $slug = lc($slug || ''); $slug =~ s/[^a-z0-9]//g;
257 return { ok => 0, error => 'bad_platform' } unless length $slug;
258
259 return { ok => 0, error => 'no_accounts_table' }
260 unless $self->_has_accounts_table($db, $dbh, $DB);
261 return { ok => 0, error => 'no_source_columns' }
262 unless $self->_has_source_columns($db, $dbh, $DB);
263
264 # We don't require an adapter for manual mode -- the platform might
265 # be 100% Cloudflare-blocked (Thangs). We do still require the user
266 # to be "connected" so the tile state stays consistent.
267 my $acct = $db->db_readwrite($dbh, qq~
268 SELECT id, platform, status FROM `${DB}`.marketplace_accounts
269 WHERE user_id='$uid' AND platform='~ . _esc($slug) . qq~'
270 LIMIT 1
271 ~, $ENV{SCRIPT_NAME}, __LINE__);
272 return { ok => 0, error => 'not_connected' }
273 unless $acct && $acct->{id} && ($acct->{status} || '') eq 'connected';
274
275 # Parse the textarea: one URL per line, skip blanks/comments.
276 my @lines = split /\r?\n/, ($urls_text || '');
277 my @urls;
278 foreach my $line (@lines) {
279 $line =~ s/^\s+|\s+$//g;
280 next unless length $line;
281 next if $line =~ /^#/;
282 # Be forgiving: accept "https://..." or "thangs.com/..." or just
283 # the path portion. Normalize to a full URL.
284 my $u = $line;
285 $u = "https://$u" if $u =~ m{^[a-z0-9.\-]+\.[a-z]{2,}/}i && $u !~ m{^https?://};
286 push @urls, $u if $u =~ m{^https?://}i;
287 }
288 return { ok => 0, error => 'no_urls' } unless @urls;
289
290 my $sid = $self->_storefront_for($db, $dbh, $DB, $uid);
291 my $imported = 0;
292 my $skipped = 0;
293 my @errors;
294
295 foreach my $url (@urls) {
296 # Pull the last non-empty path segment as the external id /
297 # slug. Works for /3d-model/<slug>, /m/<id>, /model/<slug>, etc.
298 my $external_id = '';
299 if ($url =~ m{://[^/]+/(.+?)(?:[?#]|$)}) {
300 my $path = $1;
301 $path =~ s{/$}{};
302 my @segs = split m{/}, $path;
303 $external_id = $segs[-1] if @segs;
304 }
305 # URL-decode BEFORE sanitizing. Otherwise "%20" in a Thangs slug
306 # like "Inspired%20by%20GoBots" survives as the literal chars
307 # "20" embedded in the slug, and the humanized title comes out
308 # as "Inspired 20By 20Gobots" -- ugly and wrong.
309 $external_id =~ s/%([0-9A-Fa-f]{2})/chr(hex($1))/ge;
310 $external_id =~ s/[^A-Za-z0-9._\-]/_/g;
311 unless (length $external_id) {
312 push @errors, "skipped (no slug): $url";
313 next;
314 }
315
316 # Dedup on (user_id, source_platform, source_external_id) BUT
317 # only against active rows -- trashed / archived / purged rows
318 # are treated as gone so the user can delete-and-re-pull.
319 my $ext_sql = _esc($external_id);
320 my $dup = $db->db_readwrite($dbh, qq~
321 SELECT id FROM `${DB}`.models
322 WHERE user_id='$uid'
323 AND source_platform='~ . _esc($slug) . qq~'
324 AND source_external_id='$ext_sql'
325 AND purged_at IS NULL
326 AND status NOT IN ('removed','archived')
327 LIMIT 1
328 ~, $ENV{SCRIPT_NAME}, __LINE__);
329 if ($dup && $dup->{id}) { $skipped++; next; }
330
331 # Humanize the slug into a title: "cool-thing-1234" -> "Cool
332 # Thing 1234". User can rewrite when they edit the draft.
333 my $title = $external_id;
334 $title =~ s/[-_]/ /g;
335 $title =~ s/\b(\w)/\U$1/g;
336
337 my $base_slug = _slug($external_id);
338 $base_slug = 'imported' unless length $base_slug;
339 my $slug_local = _unique_slug($db, $dbh, $DB, $uid, $base_slug);
340
341 my $title_sql = _esc($title);
342 my $slug_sql = _esc($slug_local);
343 my $permalink_sql = _esc($url);
344
345 $db->db_readwrite($dbh, qq~
346 INSERT INTO `${DB}`.models SET
347 user_id = '$uid',
348 title = '$title_sql',
349 slug = '$slug_sql',
350 tagline = '',
351 description = '',
352 base_price_cents = '0',
353 currency = 'USD',
354 status = 'draft',
355 visibility = 'private',
356 source_platform = '~ . _esc($slug) . qq~',
357 source_external_id = '$ext_sql',
358 source_url = '$permalink_sql',
359 created_at = NOW(),
360 updated_at = NOW()
361 ~, $ENV{SCRIPT_NAME}, __LINE__);
362
363 $imported++;
364 }
365
366 $db->db_readwrite($dbh, qq~
367 UPDATE `${DB}`.marketplace_accounts
368 SET last_used_at=NOW(), last_error=NULL
369 WHERE id='$acct->{id}'
370 ~, $ENV{SCRIPT_NAME}, __LINE__);
371
372 return {
373 ok => 1,
374 imported => $imported,
375 skipped => $skipped,
376 errors => \@errors,
377 };
378}
379
380#-------------------------------------------------------------------
381# Public entry point.
382#-------------------------------------------------------------------
383sub import_for_user {
384 my ($self, $db, $dbh, $DB, $uid, $slug) = @_;
385 $uid = '' unless defined $uid;
386 $uid =~ s/[^0-9]//g;
387 return { ok => 0, error => 'bad_user' } unless length $uid;
388 $slug = lc($slug || ''); $slug =~ s/[^a-z0-9]//g;
389 return { ok => 0, error => 'bad_platform' } unless length $slug;
390
391 return { ok => 0, error => 'no_accounts_table' }
392 unless $self->_has_accounts_table($db, $dbh, $DB);
393 return { ok => 0, error => 'no_source_columns' }
394 unless $self->_has_source_columns($db, $dbh, $DB);
395
396 my $adapter = $self->_adapter_for($slug);
397 return { ok => 0, error => 'no_adapter' } unless $adapter;
398
399 # Load the connected account row.
400 my $acct = $db->db_readwrite($dbh, qq~
401 SELECT id, platform, access_token, account_handle, metadata, status
402 FROM `${DB}`.marketplace_accounts
403 WHERE user_id='$uid' AND platform='~ . _esc($slug) . qq~'
404 LIMIT 1
405 ~, $ENV{SCRIPT_NAME}, __LINE__);
406 return { ok => 0, error => 'not_connected' }
407 unless $acct && $acct->{id} && ($acct->{status} || '') eq 'connected';
408 # NOTE: access_token presence is checked by the adapter's
409 # is_configured() -- scrape-based adapters (Cults3D) only need the
410 # account_handle, while API-based adapters need both.
411 return { ok => 0, error => 'not_configured_for_adapter' }
412 unless $adapter->is_configured($acct);
413
414 # Pull the creation list from the platform.
415 my $result = eval { $adapter->fetch_creations({ account => $acct, ua => $self->{ua} }) };
416 if ($@) { return { ok => 0, error => "fetch_died: $@" }; }
417 return $result if ref($result) eq 'HASH' && $result->{error};
418
419 my @items = ref($result) eq 'ARRAY' ? @$result : ();
420 return { ok => 1, imported => 0, skipped => 0, total_available => 0, errors => [], note => 'no_creations_returned' }
421 unless @items;
422 my $total_available = scalar @items;
423
424 my $sid = $self->_storefront_for($db, $dbh, $DB, $uid);
425
426 my $imported = 0;
427 my $skipped = 0;
428 my @errors;
429
430 # Per-call cap: each Pull only inserts up to this many NEW models
431 # before stopping early. History:
432 # 25 -> 15 (added detail-page enrichment, ~3x per-model cost)
433 # 15 -> 5 (still tripped 504 on models with large galleries)
434 # 5 -> 3 (still tripped 504 -- Cults3D CDN can be slow enough
435 # that 3 models with full galleries blow past 60s)
436 # 3 -> 1 (one model per HTTP request -- bulletproof; the auto-
437 # loop in the banner just runs more iterations. ~12s
438 # per round trip × 60 models = ~12 min for a full
439 # library, but it NEVER times out mid-flight.)
440 my $MAX_PER_CALL = 1;
441 my $more_available = 0;
442
443 # Adapter-specific enrichment hook. Adapters that implement
444 # enrich_one() get a callback per-creation right before insert, so
445 # they can fetch detail-page data only for the models that survive
446 # dedup (cheaper than enriching everything up front).
447 my $can_enrich = $adapter->can('enrich_one') ? 1 : 0;
448
449 foreach my $c (@items) {
450 next unless $c && $c->{external_id};
451
452 if ($imported >= $MAX_PER_CALL) {
453 $more_available = 1;
454 last;
455 }
456
457 my $ext_id = _esc($c->{external_id});
458 # Dedup: skip if we already imported this exact creation AND it
459 # still exists as an active model row. Trashed (status='removed'),
460 # archived, or purged rows are treated as gone -- re-import
461 # creates a fresh draft so "delete from WebSTLs, re-pull from
462 # the platform" works the way users expect. _unique_slug below
463 # finds a non-colliding slug if the old row's slug is still
464 # taken in the unique index.
465 my $dup = $db->db_readwrite($dbh, qq~
466 SELECT id FROM `${DB}`.models
467 WHERE user_id='$uid'
468 AND source_platform='~ . _esc($slug) . qq~'
469 AND source_external_id='$ext_id'
470 AND purged_at IS NULL
471 AND status NOT IN ('removed','archived')
472 LIMIT 1
473 ~, $ENV{SCRIPT_NAME}, __LINE__);
474 if ($dup && $dup->{id}) { $skipped++; next; }
475
476 # Enrich the creation with detail-page data (description,
477 # hi-res hero image, gallery, tags). Only runs for models that
478 # pass dedup -- no point fetching detail for the 50 we'd skip.
479 # Failures are swallowed; the model still imports with the
480 # listing-page data alone.
481 if ($can_enrich) {
482 eval { $adapter->enrich_one($c); 1 };
483 }
484
485 my $title = $c->{title} || 'Imported model';
486 my $description = $c->{description} || '';
487 my $tagline = substr($description, 0, 180); # short version for cards
488 my $category = $c->{category} || '';
489 my $price_cents = ($c->{price_cents} && $c->{price_cents} =~ /^\d+$/) ? $c->{price_cents} : 0;
490 my $currency = ($c->{currency} && $c->{currency} =~ /^[A-Z]{3}$/) ? $c->{currency} : 'USD';
491
492 # We always store prices in USD on our side regardless of the
493 # source platform's currency. Convert here so the rest of the
494 # platform (storefront cards, listing detail, sales reports,
495 # checkout) can assume USD without per-row currency logic.
496 if ($currency ne 'USD' && $price_cents > 0) {
497 my $usd_cents = _convert_to_usd_cents($price_cents, $currency);
498 if ($usd_cents > 0) {
499 $price_cents = $usd_cents;
500 $currency = 'USD';
501 }
502 }
503
504 my $permalink = $c->{permalink} || '';
505
506 my $base_slug = _slug($c->{slug} || $title);
507 $base_slug = 'imported' unless length $base_slug;
508 my $slug_local = _unique_slug($db, $dbh, $DB, $uid, $base_slug);
509
510 my $title_sql = _esc($title);
511 my $description_sql = _esc($description);
512 my $tagline_sql = _esc($tagline);
513 my $slug_sql = _esc($slug_local);
514 my $permalink_sql = _esc($permalink);
515 my $category_sql = _esc(substr($category, 0, 80));
516
517 $db->db_readwrite($dbh, qq~
518 INSERT INTO `${DB}`.models SET
519 user_id = '$uid',
520 title = '$title_sql',
521 slug = '$slug_sql',
522 tagline = '$tagline_sql',
523 description = '$description_sql',
524 base_price_cents = '$price_cents',
525 currency = '$currency',
526 category = '$category_sql',
527 status = 'draft',
528 visibility = 'private',
529 source_platform = '~ . _esc($slug) . qq~',
530 source_external_id = '$ext_id',
531 source_url = '$permalink_sql',
532 created_at = NOW(),
533 updated_at = NOW()
534 ~, $ENV{SCRIPT_NAME}, __LINE__);
535
536 my $row = $db->db_readwrite($dbh, qq~
537 SELECT id FROM `${DB}`.models
538 WHERE user_id='$uid' AND slug='$slug_sql'
539 ORDER BY id DESC LIMIT 1
540 ~, $ENV{SCRIPT_NAME}, __LINE__);
541 my $model_id = ($row && $row->{id}) ? $row->{id} : 0;
542 unless ($model_id) {
543 push @errors, "insert failed for '$title'";
544 next;
545 }
546
547 # Filesystem layout matches upload.cgi save_uploaded_files so
548 # img.cgi / models.cgi / the storefront keep finding them.
549 my $store_dir = "$BASE_DIR/models/$sid";
550 my $dest_dir = "$store_dir/$model_id";
551 foreach my $d ("$BASE_DIR", "$BASE_DIR/models", $store_dir, $dest_dir) {
552 unless (-d $d) { mkdir $d, 0755; }
553 }
554
555 # ---- Cover + gallery images ----
556 # Build a single ordered list: cover first (sort_order=0,
557 # is_primary=1, type=hero), then any gallery images from the
558 # detail page (sort_order=N, type=gallery). model_images is
559 # the canonical store; we also set models.hero_image_url to
560 # /img.cgi?m=<first image id> so cards and storefront render.
561 my @image_urls;
562 push @image_urls, $c->{hero_image_url} if $c->{hero_image_url};
563 if (ref($c->{gallery_image_urls}) eq 'ARRAY') {
564 foreach my $g (@{ $c->{gallery_image_urls} }) {
565 next unless $g && $g ne ($c->{hero_image_url} || '');
566 push @image_urls, $g;
567 }
568 }
569
570 # Walk the image list, downloading each one. The FIRST SUCCESS-
571 # FULLY downloaded image is promoted to hero (is_primary=1,
572 # type=hero) and we set models.hero_image_url to point at it.
573 # The old version used "first attempted" -- if the Cults3D CDN
574 # 404'd the cover but a gallery image succeeded, the model
575 # ended up with downloaded images but a NULL hero_image_url,
576 # so My Models / storefront cards rendered the "NO COVER YET"
577 # placeholder.
578 my $order = 0;
579 my $promoted_to_hero = 0;
580 foreach my $iurl (@image_urls) {
581 my $img_ext = 'jpg';
582 $img_ext = lc($1) if $iurl =~ /\.(jpe?g|png|webp|gif)(?:\?|$)/i;
583 $img_ext = 'jpg' if $img_ext eq 'jpeg';
584 my $id_part = substr(sha256_hex($iurl . $model_id . $order . time()), 0, 16);
585 my $dest_name = "$id_part.$img_ext";
586 my $dest_path = "$dest_dir/$dest_name";
587 my $dl = $self->_download_to($iurl, $dest_path);
588 unless ($dl->{ok}) {
589 push @errors, "$title: image " . ($order + 1) . " -- " . $dl->{error};
590 $order++;
591 next;
592 }
593 my $rel_key = "uploads/models/$sid/$model_id/$dest_name";
594 my $rel_sql = _esc($rel_key);
595 my $is_first_success = $promoted_to_hero ? 0 : 1;
596 my $itype = $is_first_success ? 'hero' : 'gallery';
597 my $is_primary = $is_first_success ? 1 : 0;
598 $db->db_readwrite($dbh, qq~
599 INSERT INTO `${DB}`.model_images SET
600 model_id = '$model_id',
601 image_type = '$itype',
602 storage_key = '$rel_sql',
603 sort_order = '$order',
604 is_primary = '$is_primary',
605 created_at = NOW()
606 ~, $ENV{SCRIPT_NAME}, __LINE__);
607 if ($is_first_success) {
608 my $ir = $db->db_readwrite($dbh, qq~
609 SELECT id FROM `${DB}`.model_images
610 WHERE model_id='$model_id' AND storage_key='$rel_sql'
611 ORDER BY id DESC LIMIT 1
612 ~, $ENV{SCRIPT_NAME}, __LINE__);
613 if ($ir && $ir->{id}) {
614 my $url = "/img.cgi?m=$ir->{id}";
615 $db->db_readwrite($dbh, qq~
616 UPDATE `${DB}`.models
617 SET hero_image_url='$url'
618 WHERE id='$model_id' LIMIT 1
619 ~, $ENV{SCRIPT_NAME}, __LINE__);
620 $promoted_to_hero = 1;
621 }
622 }
623 $order++;
624 }
625
626 # ---- Tags ----
627 # Detail-page enrichment populates $c->{tags} as a comma-
628 # separated string (e.g. "gobot,gobots,toy,1980s"). Each one
629 # becomes a model_tags row. INSERT IGNORE skips duplicates if
630 # the platform sent the same tag twice.
631 if ($c->{tags}) {
632 foreach my $tag (split /,/, $c->{tags}) {
633 $tag =~ s/^\s+|\s+$//g;
634 next unless length $tag;
635 next if length($tag) > 64;
636 my $tag_sql = _esc($tag);
637 $db->db_readwrite($dbh, qq~
638 INSERT IGNORE INTO `${DB}`.model_tags
639 SET model_id='$model_id', tag='$tag_sql'
640 ~, $ENV{SCRIPT_NAME}, __LINE__);
641 }
642 }
643
644 # ---- Model files (best-effort) ----
645 # Platforms gate file downloads behind purchase tokens for paid
646 # creations, so plenty of these will 403. Each failure becomes
647 # one line in the errors report; the draft row still survives.
648 my $files = $c->{file_urls};
649 if (ref($files) eq 'ARRAY') {
650 foreach my $f (@$files) {
651 next unless $f && $f->{url};
652 my $orig = $f->{name} || 'file.stl';
653 $orig =~ s/.*[\\\/]//;
654 $orig =~ s/[^A-Za-z0-9._-]/_/g;
655 my $ext = 'stl';
656 $ext = lc($1) if $orig =~ /\.(stl|obj|step|stp|3mf|zip|rar)$/i;
657 my $id_part = substr(sha256_hex($f->{url} . $model_id . time() . rand()), 0, 16);
658 my $dest_name = "$id_part.$ext";
659 my $dest_path = "$dest_dir/$dest_name";
660 my $dl = $self->_download_to($f->{url}, $dest_path);
661 if ($dl->{ok}) {
662 my $rel_key = "uploads/models/$sid/$model_id/$dest_name";
663 my $rel_sql = _esc($rel_key);
664 my $orig_sql = _esc($orig);
665 my $size_n = $dl->{size} + 0;
666 $db->db_readwrite($dbh, qq~
667 INSERT INTO `${DB}`.model_files SET
668 model_id = '$model_id',
669 filename = '$orig_sql',
670 file_type = '$ext',
671 file_size_bytes = '$size_n',
672 storage_provider = 'local',
673 storage_key = '$rel_sql',
674 checksum_sha256 = '~ . ('0' x 64) . qq~',
675 scan_status = 'pending',
676 uploaded_at = NOW()
677 ~, $ENV{SCRIPT_NAME}, __LINE__);
678 } else {
679 push @errors, "$title: $orig -- " . $dl->{error};
680 }
681 }
682 }
683
684 $imported++;
685 }
686
687 # Account bookkeeping (same fields MessageAdapters uses).
688 $db->db_readwrite($dbh, qq~
689 UPDATE `${DB}`.marketplace_accounts
690 SET last_used_at=NOW(), last_error=NULL
691 WHERE id='$acct->{id}'
692 ~, $ENV{SCRIPT_NAME}, __LINE__);
693
694 return {
695 ok => 1,
696 imported => $imported,
697 skipped => $skipped,
698 total_available => $total_available,
699 errors => \@errors,
700 more_available => $more_available,
701 };
702}
703
704
705# =====================================================================
706# Cults3D adapter
707# =====================================================================
708# This adapter scrapes the seller's PUBLIC profile page at
709# https://cults3d.com/en/users/<URL handle>/3d-models
710# rather than calling Cults3D's GraphQL API. Reasons:
711# - Cults3D's GraphQL credentials are not self-service; sellers have
712# to request them via the platform's Discord #api-help channel.
713# - The public profile lists every creation the seller has published,
714# which is exactly what "Pull in your models" needs.
715# - Cults3D explicitly says the API does not allow direct file
716# downloads anyway ("files remain hosted on Cults for legal
717# reasons"), so the GraphQL path wouldn't have given us STLs.
718#
719# Auth: NONE. We only fetch the seller's own public profile pages, so
720# marketplace_accounts.access_token is ignored by this adapter (the
721# field is still stored against future push-side adapters that need
722# real Cults3D API creds).
723# =====================================================================
724package MODS::WebSTLs::ImportAdapters::Cults3D;
725use strict;
726use warnings;
727use JSON::PP;
728use MIME::Base64 qw(encode_base64);
729
730my $BASE_URL = 'https://cults3d.com';
731my $MAX_PAGES = 20; # safety stop -- 20 paginated GraphQL batches
732
733sub new { my $c = shift; return bless { ua => shift }, $c; }
734
735# Tiny HTML entity decoder for og:description / title content. Only
736# covers the common ones; Cults3D's text is plain enough that this is
737# sufficient.
738sub _html_decode {
739 my $s = shift; $s = '' unless defined $s;
740 $s =~ s/&amp;/&/g;
741 $s =~ s/&quot;/"/g;
742 $s =~ s/&#39;/'/g;
743 $s =~ s/&apos;/'/g;
744 $s =~ s/&lt;/</g;
745 $s =~ s/&gt;/>/g;
746 $s =~ s/&nbsp;/ /g;
747 $s =~ s/&#(\d+);/chr($1)/ge;
748 return $s;
749}
750
751# API mode. We need account_handle (= Cults3D username, used as the
752# Basic-auth username) AND access_token (= API key from
753# cults3d.com/en/api/keys). The legacy HTML-scrape path required only
754# the handle; the GraphQL path needs both.
755sub is_configured {
756 my ($self, $acct) = @_;
757 return ($acct && $acct->{account_handle} && $acct->{access_token}) ? 1 : 0;
758}
759
760# Fetch one URL with a polite User-Agent. Returns body string or undef.
761# Kept around for any future need to download images by URL (the API
762# returns image URLs but doesn't pipe the bytes through).
763sub _get {
764 my ($self, $url) = @_;
765 my $res = $self->{ua}->request('GET', $url, {
766 headers => {
767 'Accept' => 'text/html,application/xhtml+xml',
768 'Accept-Language' => 'en-US,en;q=0.9',
769 },
770 });
771 return undef unless $res->{success};
772 return $res->{content};
773}
774
775# POST a GraphQL query to the Cults3D endpoint with HTTP Basic auth.
776# Returns ($data, undef) on success or (undef, $err_string) on failure.
777# The Cults3D API returns 200 with `{"errors":[...]}` for query errors,
778# so we check both transport-level and GraphQL-level failures.
779sub _graphql {
780 my ($self, $handle, $token, $query, $variables) = @_;
781 my $payload = encode_json({
782 query => $query,
783 variables => $variables || {},
784 });
785 my $basic = encode_base64("$handle:$token", '');
786 my $res = $self->{ua}->request('POST', "$BASE_URL/graphql", {
787 headers => {
788 'Authorization' => "Basic $basic",
789 'Content-Type' => 'application/json',
790 'Accept' => 'application/json',
791 },
792 content => $payload,
793 });
794 unless ($res->{success}) {
795 my $status = $res->{status} || '?';
796 my $reason = $res->{reason} || '';
797 my $body = $res->{content} || '';
798 # Surface 401/403 explicitly so the seller knows it's an auth
799 # issue (wrong username / expired key) vs a connectivity blip.
800 if ($status == 401 || $status == 403) {
801 return (undef, "Cults3D API rejected the credentials (HTTP $status). Re-check the username (Cults3D URL handle, case-sensitive) and the API key from cults3d.com/en/api/keys.");
802 }
803 if ($status == 429) {
804 return (undef, "Cults3D API rate limit hit (HTTP 429). Their cap is roughly 60 requests / 30 seconds and 500 / day -- wait a moment and re-run.");
805 }
806 my $snippet = substr($body, 0, 200);
807 return (undef, "Cults3D API HTTP $status $reason. Body: $snippet");
808 }
809 my $json;
810 eval { $json = decode_json($res->{content} || '{}'); 1 } or do {
811 return (undef, "Cults3D API returned non-JSON response. Body: " . substr($res->{content} || '', 0, 200));
812 };
813 if (ref($json->{errors}) eq 'ARRAY' && @{ $json->{errors} }) {
814 my @msgs = map { $_->{message} || 'unknown error' } @{ $json->{errors} };
815 return (undef, "Cults3D GraphQL error: " . join(' | ', @msgs));
816 }
817 return ($json->{data} || {}, undef);
818}
819
820# Pull every creation off the seller's public profile. Returns the
821# arrayref of normalized hashes the dispatcher in
822# MODS::WebSTLs::ImportAdapters expects, or { error => '...' } on a
823# fatal failure (e.g. handle 404s).
824sub fetch_creations {
825 my ($self, $ctx) = @_;
826 my $acct = $ctx->{account};
827 return { error => 'not_configured' } unless $self->is_configured($acct);
828
829 my $handle = $acct->{account_handle} || '';
830 my $token = $acct->{access_token} || '';
831 return { error => 'no_handle' } unless length $handle;
832 return { error => "Cults3D API key missing. Generate one at cults3d.com/en/api/keys and paste it into the Cults3D connection settings -- the GraphQL API requires it." }
833 unless length $token;
834
835 # Stash the account so enrich_one() (called per-model right before
836 # insert) can reuse the same credentials without the dispatcher
837 # threading them through.
838 $self->{_last_acct} = $acct;
839
840 # Cults3D's GraphQL API exposes the authenticated user's creations
841 # via myself.creationsBatch(limit, offset). We page in batches of
842 # 50 and stop when results.length < limit (last page).
843 my $per_page = 50;
844 my $offset = 0;
845 my %seen;
846 my @out;
847 for (my $i = 0; $i < $MAX_PAGES; $i++) {
848 my $query = <<"GQL";
849{
850 myself {
851 creationsBatch(limit: $per_page, offset: $offset) {
852 total
853 results {
854 identifier
855 name(locale: EN)
856 url(locale: EN)
857 illustrationImageUrl
858 tags(locale: EN)
859 visibility
860 }
861 }
862 }
863}
864GQL
865 my ($data, $err) = $self->_graphql($handle, $token, $query);
866 return { error => $err } if $err;
867
868 my $batch = ($data && $data->{myself} && $data->{myself}{creationsBatch})
869 || { results => [] };
870 my $results = $batch->{results} || [];
871 last unless ref($results) eq 'ARRAY' && @$results;
872
873 foreach my $c (@$results) {
874 my $id = $c->{identifier};
875 next unless defined $id && length $id;
876 next if $seen{$id}++;
877 # Skip creations the seller hid on Cults3D. Their
878 # CreationVisibilityEnum has three values:
879 # PUBLIC -- live on cults3d.com (we want these)
880 # DEACTIVATED -- the "OFFLINE" tag in their UI; seller
881 # pulled it from the marketplace
882 # SECRET -- unlisted / private link only; seller
883 # explicitly chose not to make it public
884 # Importing DEACTIVATED or SECRET would silently re-surface
885 # work the seller deliberately took down.
886 my $vis = uc($c->{visibility} || '');
887 next if $vis eq 'DEACTIVATED' || $vis eq 'SECRET';
888 # Pull a category slug out of the URL for the draft. The
889 # url field looks like https://cults3d.com/en/3d-model/<cat>/<slug>
890 my $url = $c->{url} || '';
891 my $cat_slug = '';
892 my $slug = '';
893 if ($url =~ m{/3d-model/([^/]+)/([^/?#]+)}) {
894 $cat_slug = $1;
895 $slug = $2;
896 }
897 my $title = $c->{name} || $slug || 'Imported model';
898 $title =~ s/^\s+|\s+$//g;
899
900 # Tags(locale: EN) returns an array of strings.
901 my $tags = '';
902 if (ref($c->{tags}) eq 'ARRAY') {
903 my %seen_tag;
904 my @t;
905 foreach my $name (@{ $c->{tags} }) {
906 next unless defined $name && length $name;
907 my $clean = $name; $clean =~ s/^\s+|\s+$//g;
908 next if $seen_tag{lc $clean}++;
909 push @t, $clean;
910 last if @t >= 20;
911 }
912 $tags = join(',', @t);
913 }
914
915 push @out, {
916 external_id => $id,
917 title => $title,
918 description => '', # filled by enrich_one
919 slug => $slug,
920 category => $cat_slug,
921 price_cents => 0, # filled by enrich_one
922 currency => 'USD',
923 hero_image_url => $c->{illustrationImageUrl} || '',
924 file_urls => [],
925 permalink => $url,
926 tags => $tags,
927 _visibility => $c->{visibility} || '',
928 };
929 }
930
931 # Last page when we got fewer than $per_page results.
932 last if scalar(@$results) < $per_page;
933 $offset += $per_page;
934 }
935
936 return \@out;
937}
938
939# Called by the dispatcher (import_for_user) for each creation that
940# survives the dedup check, just before insert. Runs a per-creation
941# GraphQL query to pull description, gallery preview images, and the
942# list price (none of which are on the cheap list query above).
943# Mutates $c in place. Best-effort: if the API call fails, the
944# creation still imports with the listing data we already have.
945sub enrich_one {
946 my ($self, $c) = @_;
947 return unless $c && $c->{slug};
948
949 my $acct = $self->{_last_acct};
950 return unless $acct && $acct->{account_handle} && $acct->{access_token};
951
952 # Cults3D's GraphQL root `creation` query takes `slug`, not the
953 # opaque identifier. Slug comes from the listing query (we already
954 # parsed it out of the permalink URL in fetch_creations).
955 my $slug = $c->{slug};
956 $slug =~ s/\\/\\\\/g;
957 $slug =~ s/"/\\"/g;
958 # `illustrations` is the user-uploaded gallery (the full-size
959 # photos the creator put on the page). `blueprints` is something
960 # else entirely -- tiny STL/file preview thumbnails (2-9 KB each).
961 # Earlier import used `blueprints` by mistake and saved those tiny
962 # preview strips as the model's gallery; corrected here.
963 my $query = <<"GQL";
964{
965 creation(slug: "$slug") {
966 identifier
967 description(locale: EN)
968 price { cents currency }
969 illustrations { imageUrl position }
970 }
971}
972GQL
973 my ($data, $err) = $self->_graphql(
974 $acct->{account_handle}, $acct->{access_token}, $query,
975 );
976 return if $err;
977
978 my $cr = $data && $data->{creation};
979 return unless $cr;
980
981 if (my $desc = $cr->{description}) {
982 $desc =~ s/^\s+|\s+$//g;
983 $c->{description} = $desc if length $desc;
984 }
985 if (ref($cr->{price}) eq 'HASH' && defined $cr->{price}{cents}) {
986 my $cents = int($cr->{price}{cents});
987 $c->{price_cents} = $cents if $cents >= 0;
988 $c->{currency} = $cr->{price}{currency} if $cr->{price}{currency};
989 }
990 if (ref($cr->{illustrations}) eq 'ARRAY') {
991 # Sort by position so the gallery shows in the seller's
992 # intended order (Front, Back, Slicer, ...) rather than
993 # whatever order the API returns.
994 my @sorted = sort {
995 ($a->{position} // 9999) <=> ($b->{position} // 9999)
996 } grep { ref($_) eq 'HASH' && $_->{imageUrl} } @{ $cr->{illustrations} };
997
998 my %seen;
999 $seen{ $c->{hero_image_url} || '' } = 1;
1000 my @gallery;
1001 foreach my $b (@sorted) {
1002 my $u = $b->{imageUrl} || '';
1003 next unless $u =~ m{^https?://};
1004 next if $seen{$u}++;
1005 push @gallery, $u;
1006 last if @gallery >= 8;
1007 }
1008 $c->{gallery_image_urls} = \@gallery if @gallery;
1009 }
1010}
1011
1012# Walk the HTML, pulling out every unique /en/3d-model/<cat>/<slug> card
1013# and a title + thumbnail in its immediate neighborhood. Adds to
1014# $seen{} and pushes to @$out. Returns count of NEW cards found on this
1015# page so the caller can stop paginating when a page is empty / repeats.
1016sub _parse_page {
1017 my ($self, $html, $seen, $out) = @_;
1018 return 0 unless defined $html && length $html;
1019 my $added = 0;
1020
1021 # Pass 1: find every distinct creation slug on the page.
1022 # Pattern: /en/3d-model/<category>/<slug> (alnum + hyphens).
1023 my @slugs;
1024 while ($html =~ m{href=["']/en/3d-model/([a-z0-9\-]+)/([a-z0-9\-]+)(?:["'?#])}gi) {
1025 my $cat = $1;
1026 my $slug = $2;
1027 my $key = "$cat/$slug";
1028 next if $seen->{$key}++;
1029 push @slugs, { cat => $cat, slug => $slug, key => $key };
1030 }
1031 return 0 unless @slugs;
1032
1033 # Pass 2: for each slug, snip out a FORWARD-ONLY window so we can
1034 # scrape its title + thumbnail + price without bleeding into the
1035 # neighboring card's data.
1036 #
1037 # Earlier this used a back+forward window, but Cults3D cards live
1038 # adjacent in the HTML and the backward portion would reach into
1039 # the PREVIOUS card's <img alt="..."> tag -- the regex then matched
1040 # that as "the current card's title". Result: 60 drafts imported
1041 # with each card's title attached to the next card's image.
1042 # Forward-only is safe because the card structure is always
1043 # <a href="...slug"><img alt="Title" src="..."></a> ... price
1044 # i.e., the title image and price are always AFTER the href.
1045 foreach my $s (@slugs) {
1046 my $href_pat = quotemeta("/en/3d-model/$s->{cat}/$s->{slug}");
1047 my $window = '';
1048 if ($html =~ m{href=["']$href_pat[^"']*["'][^>]*>(.{0,1500})}s) {
1049 $window = $1 || '';
1050 }
1051
1052 # Title: prefer alt text on a nearby <img alt="...">. Falls back
1053 # to slug humanization so the draft has a reasonable label even
1054 # if the markup shifts on Cults3D's end.
1055 my $title = '';
1056 if ($window =~ m{<img\b[^>]*\balt=["']([^"']+)["']}i) {
1057 $title = $1;
1058 $title =~ s/\s+/ /g; $title =~ s/^\s+|\s+$//g;
1059 }
1060 if (!length $title) {
1061 $title = $s->{slug};
1062 $title =~ s/-/ /g;
1063 $title =~ s/\b(\w)/\U$1/g;
1064 }
1065
1066 # Thumbnail: first reasonable image src in the window. Cults3D
1067 # serves card thumbs from fbi.cults3d.com -- be permissive.
1068 my $img = '';
1069 if ($window =~ m{<img\b[^>]*\bsrc=["']([^"']+)["']}i) {
1070 $img = $1;
1071 } elsif ($window =~ m{\bdata-src=["']([^"']+)["']}i) {
1072 $img = $1;
1073 }
1074 # Defensively skip 1x1 placeholders and inline data URIs.
1075 $img = '' if $img =~ /^data:/;
1076 $img = '' if $img =~ /sprite|placeholder|spinner/i;
1077 # Normalize protocol-relative + relative URLs.
1078 if (length $img) {
1079 $img = "https:$img" if $img =~ m{^//};
1080 $img = "$BASE_URL$img" if $img =~ m{^/};
1081 }
1082
1083 # Price: look for "US$ 4.95" / "$4.95" / "Free" near this card.
1084 my $price_cents = 0;
1085 if ($window =~ /(?:US\$|\$|USD)\s*([0-9]+(?:\.[0-9]{1,2})?)/i) {
1086 $price_cents = int($1 * 100 + 0.5);
1087 }
1088
1089 # external_id: stable per-creation key the dispatcher uses for
1090 # dedup. We don't have Cults3D's numeric ID without scraping the
1091 # detail page, so use category/slug -- safe because Cults3D
1092 # creation URLs are immutable once published.
1093 push @$out, {
1094 external_id => $s->{key},
1095 title => $title,
1096 description => '', # detail-page-only; left for the draft edit
1097 slug => $s->{slug},
1098 price_cents => $price_cents,
1099 currency => 'USD',
1100 hero_image_url => $img,
1101 file_urls => [], # Cults3D API explicitly forbids file downloads
1102 permalink => "$BASE_URL/en/3d-model/$s->{cat}/$s->{slug}",
1103 tags => '',
1104 };
1105 $added++;
1106 }
1107 return $added;
1108}
1109
1110
1111# =====================================================================
1112# Thangs adapter
1113# =====================================================================
1114# Scrapes the seller's PUBLIC designer page at
1115# https://thangs.com/designer/<URL handle>
1116# Same approach as Cults3D: pull-only, no auth needed, files are NOT
1117# downloaded (Thangs gates file access behind their Sync desktop app
1118# anyway -- they have no equivalent of a public file URL we could hit).
1119#
1120# Two key differences from Cults3D:
1121# 1. Thangs runs on Next.js, which ships the page's data as a JSON
1122# blob in a <script id="__NEXT_DATA__"> tag at the bottom of the
1123# HTML. We try to parse that first -- it's structured and gives
1124# us the canonical fields. If the JSON shape shifts, we fall
1125# through to regex-on-HTML like the Cults3D scraper.
1126# 2. Thangs aggressively filters User-Agents -- empty / generic UAs
1127# get a 403. We send a real browser UA + Accept headers so the
1128# request looks like Chrome and Thangs serves the HTML.
1129# =====================================================================
1130package MODS::WebSTLs::ImportAdapters::Thangs;
1131use strict;
1132use warnings;
1133use JSON::PP;
1134
1135my $BASE_URL = 'https://thangs.com';
1136my $MAX_PAGES = 20; # safety stop -- Thangs paginates ~24/page, 480 max
1137
1138sub new { my $c = shift; return bless { ua => shift }, $c; }
1139
1140sub is_configured {
1141 my ($self, $acct) = @_;
1142 return ($acct && $acct->{account_handle}) ? 1 : 0;
1143}
1144
1145# Browser-like headers. Thangs sits behind Cloudflare so the first
1146# bounce was likely from CF's bot fight mode. This expanded set adds
1147# the Sec-Fetch-* and Sec-Ch-Ua hints that real Chrome sends -- CF
1148# scores requests partly on whether these are present and consistent.
1149# If CF has full JS-challenge mode on, no amount of headers helps and
1150# we just have to accept defeat.
1151sub _browser_headers {
1152 my ($referer) = @_;
1153 my $h = {
1154 'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
1155 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
1156 'Accept-Language' => 'en-US,en;q=0.9',
1157 # Intentionally NO Accept-Encoding: let HTTP::Tiny decide
1158 # based on whether decompression modules are installed.
1159 # Advertising gzip without being able to decode it would hand
1160 # the parser unreadable bytes.
1161 'Sec-Ch-Ua' => '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"',
1162 'Sec-Ch-Ua-Mobile' => '?0',
1163 'Sec-Ch-Ua-Platform' => '"Windows"',
1164 'Sec-Fetch-Dest' => 'document',
1165 'Sec-Fetch-Mode' => 'navigate',
1166 'Sec-Fetch-Site' => $referer ? 'same-origin' : 'none',
1167 'Sec-Fetch-User' => '?1',
1168 'Upgrade-Insecure-Requests' => '1',
1169 };
1170 $h->{Referer} = $referer if $referer;
1171 return $h;
1172}
1173
1174# GET helper that surfaces the actual HTTP status when it fails, so
1175# the caller can tell a Cloudflare 403 / 1020 (real block) from a 404
1176# (wrong handle) from a network error. Returns
1177# ({ body => '...' }) on 2xx
1178# ({ error => 'msg', status => N }) on failure.
1179sub _get {
1180 my ($self, $url, $referer) = @_;
1181 my $res = $self->{ua}->request('GET', $url, { headers => _browser_headers($referer) });
1182 if (!$res->{success}) {
1183 my $status = $res->{status} || 0;
1184 my $reason = $res->{reason} || '';
1185 # Status 599 in HTTP::Tiny == internal client error (DNS, TLS
1186 # handshake, no route). Surface it differently from a real
1187 # HTTP response so the user knows it's not just a 403.
1188 my $kind = ($status == 599) ? 'network/TLS'
1189 : ($status == 403 || $status == 1020) ? 'bot-blocked'
1190 : ($status == 404) ? 'not-found'
1191 : "HTTP $status";
1192 return { error => "$kind ($status $reason)" };
1193 }
1194 return { body => $res->{content} };
1195}
1196
1197sub fetch_creations {
1198 my ($self, $ctx) = @_;
1199 my $acct = $ctx->{account};
1200 return { error => 'not_configured' } unless $self->is_configured($acct);
1201
1202 my $handle = $acct->{account_handle} || '';
1203 return { error => 'no_handle' } unless length $handle;
1204
1205 # Try both URL forms on first call. Thangs serves both
1206 # /designer/<handle> -> profile landing
1207 # /designer/<handle>/3d-models -> explicit models tab
1208 # and one may pass CF while the other doesn't (CF rules can be
1209 # path-specific). Fall through to the next if the first errors.
1210 my @candidate_paths = ("/designer/$handle/3d-models", "/designer/$handle");
1211
1212 my %seen;
1213 my @out;
1214 my $first_error;
1215 my $first_status_clue = '';
1216
1217 PATH: foreach my $base_path (@candidate_paths) {
1218 for (my $page = 1; $page <= $MAX_PAGES; $page++) {
1219 my $url = "$BASE_URL$base_path";
1220 $url .= "?page=$page" if $page > 1;
1221 # Send a Referer on page 2+ to look more like real
1222 # navigation through paginated results.
1223 my $referer = ($page > 1) ? "$BASE_URL$base_path" : undef;
1224 my $r = $self->_get($url, $referer);
1225 if ($r->{error}) {
1226 if ($page == 1) {
1227 $first_error ||= $r->{error};
1228 $first_status_clue = $r->{error};
1229 next PATH; # try the other URL form
1230 }
1231 last; # mid-pagination error -- stop and return what we have
1232 }
1233 my $html = $r->{body};
1234 my $found = $self->_parse_page($html, \%seen, \@out);
1235 last unless $found;
1236 }
1237 last if @out; # got results from this path, done
1238 }
1239
1240 if (!@out && $first_error) {
1241 my $hint;
1242 if ($first_error =~ /^bot-blocked/) {
1243 $hint = "Thangs's Cloudflare bot filter rejected the request even with browser headers. Plain HTTP can't get past JS-challenge bot protection -- we'd need a different approach (e.g. a headless browser, or wait for Thangs to publish a creator API).";
1244 } elsif ($first_error =~ /^not-found/) {
1245 $hint = "Check the URL handle: open thangs.com/designer/<handle> in a browser and confirm your profile loads. Handle is case-sensitive.";
1246 } elsif ($first_error =~ /^network/) {
1247 $hint = "Couldn't reach thangs.com at all -- possible TLS / network issue on the server.";
1248 } else {
1249 $hint = "Unexpected response. Check the handle, then if it's correct we likely need a different fetch strategy.";
1250 }
1251 return { error => "Thangs fetch failed: $first_error. $hint" };
1252 }
1253
1254 return \@out;
1255}
1256
1257# Try the structured Next.js JSON first. If that yields nothing,
1258# regex-scrape the rendered HTML for the same data.
1259sub _parse_page {
1260 my ($self, $html, $seen, $out) = @_;
1261 return 0 unless defined $html && length $html;
1262
1263 my $added = $self->_parse_next_data($html, $seen, $out);
1264 return $added if $added > 0;
1265 return $self->_parse_html($html, $seen, $out);
1266}
1267
1268# Walk the Next.js page-state JSON. The structure changes between
1269# Thangs deployments, so we recursively scan for any array of objects
1270# that have the marker fields (id + name/title + an image-ish field).
1271sub _parse_next_data {
1272 my ($self, $html, $seen, $out) = @_;
1273
1274 my $json_blob;
1275 if ($html =~ m{<script[^>]*id=["']__NEXT_DATA__["'][^>]*>(.*?)</script>}s) {
1276 $json_blob = $1;
1277 }
1278 return 0 unless $json_blob;
1279
1280 my $data;
1281 eval { $data = decode_json($json_blob); 1 } or return 0;
1282
1283 my @found;
1284 _walk_for_models($data, \@found);
1285
1286 my $added = 0;
1287 foreach my $m (@found) {
1288 my $id = $m->{id} || $m->{modelId} || $m->{slug} || '';
1289 $id = "$id" if defined $id;
1290 next unless length $id;
1291 my $key = "thangs:$id";
1292 next if $seen->{$key}++;
1293
1294 my $title = $m->{name} || $m->{title} || '';
1295 my $slug = $m->{slug} || $id;
1296 my $img = $m->{previewImageUrl}
1297 || $m->{thumbnailUrl}
1298 || $m->{imageUrl}
1299 || $m->{image}
1300 || '';
1301 my $price = 0;
1302 if (defined $m->{price} && $m->{price} =~ /^\s*([0-9]+(?:\.[0-9]+)?)\s*$/) {
1303 $price = int($1 * 100 + 0.5);
1304 } elsif (defined $m->{priceCents} && $m->{priceCents} =~ /^\d+$/) {
1305 $price = int($m->{priceCents});
1306 }
1307 my $desc = $m->{description} || $m->{shortDescription} || '';
1308
1309 push @$out, {
1310 external_id => $id,
1311 title => $title,
1312 description => $desc,
1313 slug => $slug,
1314 price_cents => $price,
1315 currency => 'USD',
1316 hero_image_url => $img,
1317 file_urls => [], # Thangs gates file downloads via Sync; none available
1318 permalink => $m->{url} ? ($m->{url} =~ m{^/} ? "$BASE_URL$m->{url}" : $m->{url})
1319 : "$BASE_URL/3d-model/$slug",
1320 tags => '',
1321 };
1322 $added++;
1323 }
1324 return $added;
1325}
1326
1327# Recursively scan a JSON tree for arrays of model-shaped objects.
1328# Heuristic: an array entry is a "model" if it has an id and a name or
1329# title field. We don't trust array keys/paths because Next.js renames
1330# them between releases ("models", "creations", "items", "data", ...).
1331sub _walk_for_models {
1332 my ($node, $sink) = @_;
1333 if (ref($node) eq 'ARRAY') {
1334 my $looks_model_array = 0;
1335 foreach my $item (@$node) {
1336 if (ref($item) eq 'HASH'
1337 && ($item->{id} || $item->{modelId})
1338 && ($item->{name} || $item->{title})) {
1339 $looks_model_array = 1;
1340 last;
1341 }
1342 }
1343 if ($looks_model_array) {
1344 foreach my $item (@$node) {
1345 if (ref($item) eq 'HASH'
1346 && ($item->{id} || $item->{modelId})
1347 && ($item->{name} || $item->{title})) {
1348 push @$sink, $item;
1349 }
1350 }
1351 }
1352 # Recurse anyway in case there are nested model arrays elsewhere.
1353 foreach my $item (@$node) { _walk_for_models($item, $sink); }
1354 } elsif (ref($node) eq 'HASH') {
1355 foreach my $v (values %$node) { _walk_for_models($v, $sink); }
1356 }
1357}
1358
1359# Fallback: scrape the raw HTML. Look for /3d-model/... or /m/... or
1360# /model/... hrefs and a neighborhood window for title + image.
1361sub _parse_html {
1362 my ($self, $html, $seen, $out) = @_;
1363 my @slugs;
1364 while ($html =~ m{href=["'](/(?:3d-model|m|model)/([a-zA-Z0-9\-_]+))(?:["'?#])}g) {
1365 my $path = $1;
1366 my $slug = $2;
1367 my $key = "thangs:$slug";
1368 next if $seen->{$key}++;
1369 push @slugs, { slug => $slug, path => $path };
1370 }
1371 return 0 unless @slugs;
1372
1373 my $added = 0;
1374 foreach my $s (@slugs) {
1375 my $href_pat = quotemeta($s->{path});
1376 my $window = '';
1377 if ($html =~ m{(.{0,600})href=["']$href_pat(.{0,600})}s) {
1378 $window = ($1 || '') . ($2 || '');
1379 }
1380
1381 my $title = '';
1382 if ($window =~ m{<img\b[^>]*\balt=["']([^"']+)["']}i) {
1383 $title = $1; $title =~ s/\s+/ /g; $title =~ s/^\s+|\s+$//g;
1384 }
1385 $title ||= do { my $t = $s->{slug}; $t =~ s/-/ /g; $t =~ s/\b(\w)/\U$1/g; $t };
1386
1387 my $img = '';
1388 if ($window =~ m{<img\b[^>]*\bsrc=["']([^"']+)["']}i) { $img = $1; }
1389 elsif ($window =~ m{\bdata-src=["']([^"']+)["']}i) { $img = $1; }
1390 $img = '' if $img =~ /^data:|sprite|placeholder|spinner/i;
1391 $img = "https:$img" if $img =~ m{^//};
1392 $img = "$BASE_URL$img" if $img =~ m{^/};
1393
1394 my $price = 0;
1395 if ($window =~ /(?:US\$|\$|USD)\s*([0-9]+(?:\.[0-9]{1,2})?)/i) {
1396 $price = int($1 * 100 + 0.5);
1397 }
1398
1399 push @$out, {
1400 external_id => $s->{slug},
1401 title => $title,
1402 description => '',
1403 slug => $s->{slug},
1404 price_cents => $price,
1405 currency => 'USD',
1406 hero_image_url => $img,
1407 file_urls => [],
1408 permalink => "$BASE_URL$s->{path}",
1409 tags => '',
1410 };
1411 $added++;
1412 }
1413 return $added;
1414}
1415
14161;
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help