feat(artist): resolve patreon + subscribestar display names at add-time (#130 step 5)
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m41s

Parity with pixiv (operator ask): the extension add now resolves the real
display name for our other native platforms too, not just the URL handle.
patreon_resolver.resolve_display_name reads the campaigns API's
attributes.name; SubscribeStarClient.resolve_display_name pulls the creator
name off the profile page (og:title, else the <title> stripped of the
SubscribeStar suffix). extension_service._resolve_artist_name dispatches per
platform (pixiv=token, patreon/subscribestar=cookies via get_cookies_path),
best-effort in an executor, falling back to the readable URL handle on any
failure. Still all curator core — the extension is unchanged (sends only the
URL). gallery-dl platforms keep the handle (readable, no native client).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
This commit is contained in:
2026-07-04 22:37:50 -04:00
parent 0c4b8aef8c
commit 0963bf0db3
6 changed files with 187 additions and 30 deletions
@@ -273,6 +273,30 @@ def _parse_ss_datetime(text: str) -> str | None:
return None
_OG_TITLE_RE = re.compile(
r'<meta[^>]+property=["\']og:title["\'][^>]+content=["\']([^"\']+)["\']',
re.IGNORECASE,
)
_TITLE_RE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
# Trailing " | SubscribeStar" / " on SubscribeStar" the profile <title> carries.
_SS_TITLE_SUFFIX_RE = re.compile(
r"\s*[|·]\s*SubscribeStar.*$|\s+on\s+SubscribeStar.*$", re.IGNORECASE
)
def _extract_creator_name(html: str) -> str | None:
"""The creator's display name from a SubscribeStar profile page: prefer the
og:title meta (it's the bare creator name), else the <title> with the
SubscribeStar suffix stripped. None when neither yields anything (#130)."""
m = _OG_TITLE_RE.search(html)
name = unescape(m.group(1)).strip() if m else ""
if not name:
t = _TITLE_RE.search(html)
raw = unescape(t.group(1)).strip() if t else ""
name = _SS_TITLE_SUFFIX_RE.sub("", raw).strip()
return name or None
class SubscribeStarClient:
"""Synchronous SubscribeStar HTML-scrape read client. Construct with a path
to a Netscape cookies.txt (the same file CredentialService.get_cookies_path
@@ -604,6 +628,23 @@ class SubscribeStarClient:
current = next_href
first_page = False
# -- display name -------------------------------------------------------
def resolve_display_name(self, campaign_id: str) -> str | None:
"""The creator's display name from their profile page, used to name the
Artist at add-time (#130). `campaign_id` is the creator URL. None on any
failure — the caller falls back to the URL handle. Sync: run in an
executor."""
base, slug = _split_creator_url(campaign_id)
if not slug:
return None
self._session.headers["Referer"] = f"{base}/"
try:
html = self._feed_html(f"{base}/{slug}")
except SubscribeStarAPIError:
return None
return _extract_creator_name(html)
# -- verify ------------------------------------------------------------
def verify_auth(self, campaign_id: str) -> tuple[bool | None, str]: