added on WebSTLs (webstls.com) at 2026-07-01 22:26:38
| 1 | package 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 | #====================================================================== | |
| 37 | use strict; | |
| 38 | use warnings; | |
| 39 | use HTTP::Tiny; | |
| 40 | use JSON::PP; | |
| 41 | use MIME::Base64 qw(encode_base64); | |
| 42 | use Digest::SHA qw(sha256_hex); | |
| 43 | ||
| 44 | my %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. | |
| 53 | my $BASE_DIR = '/var/www/vhosts/webstls.com/httpdocs/uploads'; | |
| 54 | ||
| 55 | sub 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 | ||
| 73 | sub _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 | # -------------------------------------------------------------- | |
| 94 | my %_FX_RATES; # currency code -> USD multiplier | |
| 95 | my $_FX_FETCHED_AT = 0; # epoch of last successful fetch | |
| 96 | my %_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 | ||
| 113 | sub _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 | ||
| 142 | sub _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 | ||
| 153 | sub _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 | ||
| 160 | sub _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. | |
| 171 | sub _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. | |
| 191 | sub _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? | |
| 206 | sub _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. | |
| 218 | sub _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). | |
| 231 | sub _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 | #------------------------------------------------------------------- | |
| 251 | sub 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 | #------------------------------------------------------------------- | |
| 383 | sub 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 | # ===================================================================== | |
| 724 | package MODS::WebSTLs::ImportAdapters::Cults3D; | |
| 725 | use strict; | |
| 726 | use warnings; | |
| 727 | use JSON::PP; | |
| 728 | use MIME::Base64 qw(encode_base64); | |
| 729 | ||
| 730 | my $BASE_URL = 'https://cults3d.com'; | |
| 731 | my $MAX_PAGES = 20; # safety stop -- 20 paginated GraphQL batches | |
| 732 | ||
| 733 | sub 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. | |
| 738 | sub _html_decode { | |
| 739 | my $s = shift; $s = '' unless defined $s; | |
| 740 | $s =~ s/&/&/g; | |
| 741 | $s =~ s/"/"/g; | |
| 742 | $s =~ s/'/'/g; | |
| 743 | $s =~ s/'/'/g; | |
| 744 | $s =~ s/</</g; | |
| 745 | $s =~ s/>/>/g; | |
| 746 | $s =~ s/ / /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. | |
| 755 | sub 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). | |
| 763 | sub _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. | |
| 779 | sub _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). | |
| 824 | sub 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 | } | |
| 864 | GQL | |
| 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. | |
| 945 | sub 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 | } | |
| 972 | GQL | |
| 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. | |
| 1016 | sub _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 | # ===================================================================== | |
| 1130 | package MODS::WebSTLs::ImportAdapters::Thangs; | |
| 1131 | use strict; | |
| 1132 | use warnings; | |
| 1133 | use JSON::PP; | |
| 1134 | ||
| 1135 | my $BASE_URL = 'https://thangs.com'; | |
| 1136 | my $MAX_PAGES = 20; # safety stop -- Thangs paginates ~24/page, 480 max | |
| 1137 | ||
| 1138 | sub new { my $c = shift; return bless { ua => shift }, $c; } | |
| 1139 | ||
| 1140 | sub 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. | |
| 1151 | sub _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. | |
| 1179 | sub _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 | ||
| 1197 | sub 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. | |
| 1259 | sub _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). | |
| 1271 | sub _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", ...). | |
| 1331 | sub _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. | |
| 1361 | sub _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 | ||
| 1416 | 1; |