|
| 1 | +import html |
| 2 | +import re |
| 3 | +from urllib.parse import quote, urljoin |
| 4 | + |
| 5 | +from curl_cffi.requests.exceptions import RequestException |
| 6 | + |
| 7 | +from user_scanner.core.impersonate import impersonate_request, impersonate_validate |
| 8 | +from user_scanner.core.result import Result |
| 9 | + |
| 10 | +SIGNUP_URL = "https://profile.threadless.com/artist/shops/" |
| 11 | +CSRF_RE = re.compile(r"name=['\"]csrfmiddlewaretoken['\"]\s+value=['\"]([^'\"]+)") |
| 12 | + |
| 13 | + |
| 14 | +def validate_threadless(user: str) -> Result: |
| 15 | + show_url = f"https://www.threadless.com/@{user}" |
| 16 | + |
| 17 | + def process(page): |
| 18 | + token = CSRF_RE.search(page.text) |
| 19 | + if page.status_code != 200 or not token: |
| 20 | + return Result.error( |
| 21 | + f"Could not read Threadless signup form (HTTP {page.status_code})" |
| 22 | + ) |
| 23 | + |
| 24 | + response = impersonate_request( |
| 25 | + SIGNUP_URL, |
| 26 | + "POST", |
| 27 | + headers={"x-requested-with": "XMLHttpRequest"}, |
| 28 | + data={ |
| 29 | + "validate": "true", |
| 30 | + "username": user, |
| 31 | + "csrfmiddlewaretoken": token.group(1), |
| 32 | + }, |
| 33 | + ) |
| 34 | + if response.status_code != 200: |
| 35 | + return Result.error( |
| 36 | + f"Unexpected Threadless response: HTTP {response.status_code}" |
| 37 | + ) |
| 38 | + |
| 39 | + match response.json(): |
| 40 | + case {"is_valid": False}: |
| 41 | + extra, media = _profile(user) |
| 42 | + return Result.taken(extra=extra, media=media) |
| 43 | + case {"is_valid": True}: |
| 44 | + return Result.available() |
| 45 | + case _: |
| 46 | + return Result.error("Unexpected Threadless response") |
| 47 | + |
| 48 | + return impersonate_validate( |
| 49 | + SIGNUP_URL, |
| 50 | + process, |
| 51 | + show_url=show_url, |
| 52 | + allow_redirects=True, |
| 53 | + ) |
| 54 | + |
| 55 | + |
| 56 | +def _profile(user: str) -> tuple[dict, dict]: |
| 57 | + try: |
| 58 | + response = impersonate_request( |
| 59 | + f"https://www.threadless.com/@{quote(user, safe='')}", |
| 60 | + allow_redirects=True, |
| 61 | + ) |
| 62 | + except RequestException: |
| 63 | + return {}, {} |
| 64 | + |
| 65 | + if response.status_code != 200: |
| 66 | + return {}, {} |
| 67 | + |
| 68 | + document = response.text |
| 69 | + username = _text(r'var userName = "([^"]+)"', document) |
| 70 | + if not username or username.casefold() != user.casefold(): |
| 71 | + return {}, {} |
| 72 | + |
| 73 | + shop = _text(r'<div class="artist-shop">(.*?)</div>', document) or "" |
| 74 | + shop_url = _text(r'<a href="([^"]+)" title="Visit my Artist Shop"', shop) |
| 75 | + links = re.findall( |
| 76 | + r'<a href="(https?://[^"]+)" target="_blank" title="[^"]+" rel="nofollow"', |
| 77 | + document, |
| 78 | + ) |
| 79 | + website = _text( |
| 80 | + r'<div class="website">(?:(?!</div>).)*?<a href="([^"]+)"', document |
| 81 | + ) |
| 82 | + shop_preview = _text(r'<img src="([^"]+)"', shop) |
| 83 | + return ( |
| 84 | + { |
| 85 | + "id": _text(r"var profileId = (\d+);", document), |
| 86 | + "name": _text(r'<span class="name">\s*([^<]+)', document), |
| 87 | + "bio": _text(r'<div class="cover-statement">\s*<p>(.*?)</p>', document), |
| 88 | + "location": _text( |
| 89 | + r'<div class="location">\s*<h3>Location</h3>\s*([^<]+)', document |
| 90 | + ), |
| 91 | + "following": _text(r'/following"[^>]*>\s*<span>(\d+)</span>', document), |
| 92 | + "followers": _text(r'/followers"[^>]*>\s*<span>(\d+)</span>', document), |
| 93 | + "member_since": _text(r"Member since ([^<]+)", document), |
| 94 | + "artist_shop": urljoin("https://www.threadless.com", shop_url) |
| 95 | + if shop_url |
| 96 | + else None, |
| 97 | + "artist_shop_slug": _text(r'var artistSlug = "([^"]+)";', document), |
| 98 | + "artist_shop_name": _text( |
| 99 | + r'title="Visit my Artist Shop"[^>]*>\s*([^<]+)</a>', |
| 100 | + shop, |
| 101 | + ), |
| 102 | + "website": website, |
| 103 | + "links": ", ".join(map(html.unescape, links)), |
| 104 | + "threads_started": _number(r"([\d,]+) threads started", document), |
| 105 | + "designs_submitted": _number(r"([\d,]+) designs submitted", document), |
| 106 | + "designs_scored": _number(r"([\d,]+) designs scored", document), |
| 107 | + "avg_score_given": _text(r"Avg Score Given:\s*([\d.]+)", document), |
| 108 | + "is_printed_artist": _flag("isPrintedArtist", document), |
| 109 | + "is_submitted_artist": _flag("isSubmittedArtist", document), |
| 110 | + "is_shop_owner": _flag("isShopOwner", document), |
| 111 | + "facebook_connected": _flag("fbConnected", document), |
| 112 | + "is_hifiver": _flag("isHifiver", document), |
| 113 | + "is_shop_published": _flag("isShopPublished", document), |
| 114 | + "open_submissions": _flag("openSubs", document), |
| 115 | + "is_alumni": "is-alumni" in document, |
| 116 | + }, |
| 117 | + { |
| 118 | + "avatar": _text(r'<meta property="og:image" content="([^"]+)"', document), |
| 119 | + "cover": _text( |
| 120 | + r'<header id="header" style="background-image: url\([\'\"]([^\'\"]+)', |
| 121 | + document, |
| 122 | + ), |
| 123 | + "artist_shop_preview": shop_preview, |
| 124 | + }, |
| 125 | + ) |
| 126 | + |
| 127 | + |
| 128 | +def _text(pattern: str, document: str) -> str | None: |
| 129 | + match = re.search(pattern, document, re.DOTALL) |
| 130 | + return html.unescape(match.group(1)).strip() if match else None |
| 131 | + |
| 132 | + |
| 133 | +def _number(pattern: str, document: str) -> int | None: |
| 134 | + value = _text(pattern, document) |
| 135 | + return int(value.replace(",", "")) if value else None |
| 136 | + |
| 137 | + |
| 138 | +def _flag(name: str, document: str) -> bool | None: |
| 139 | + value = _text(rf"var {name} = (true|false);", document) |
| 140 | + return value == "true" if value else None |
0 commit comments