"""Native Pixiv client — the Pixiv adapter's read path. Pixiv has a real (if unofficial) API: the mobile app API gallery-dl drives (`PixivAppAPI`). Per the downloader ground rule — gallery-dl is the known-working base — this client mirrors gallery-dl 1.32.5's request profile EXACTLY: the same iOS app headers on every request, the same OAuth refresh-token dance against oauth.secure.pixiv.net (X-Client-Time + X-Client-Hash), and the same `/v1/user/illusts` walk paginated by `next_url`. Deviating from that profile is how the SubscribeStar/Patreon spikes broke, so any change here should be diffed against gallery-dl's extractor first. Feed shape (characterized from gallery-dl 1.32.5, extractor/pixiv.py): - `GET /v1/user/illusts?user_id=` returns `{"illusts": [work...], "next_url": "https://app-api...?user_id=..&offset=30" | null}`. - Pagination: re-issue the SAME endpoint with `next_url`'s query params. The query string doubles as our resumable page cursor (re-fetching it re-serves the same page — the ingest-core resume contract). - A work carries id/title/type(illust|manga|ugoira)/caption(HTML)/ create_date(ISO+09:00)/tags[{name,translated_name}]/user/page_count/ x_restrict/series/total_view/total_bookmarks/meta_single_page/meta_pages. - Files: multi-page → meta_pages[].image_urls.original; single page → meta_single_page.original_image_url; ugoira → `/v1/ugoira/metadata` zip (600x600 → 1920x1080 URL swap, gallery-dl's default non-original mode). `campaign_id` for Pixiv is the numeric user id (extracted from the source URL by `user_id_from_url` — no network resolver needed). Gated works: pixiv serves a `https://s.pximg.net/common/images/limit_*.png` placeholder as the "original" when a work is blocked for this account (sanity-level filter, my-pixiv lock, deleted). gallery-dl's fallback for those is a web-AJAX scrape that needs PHPSESSID browser cookies — FC stores only the OAuth refresh token, so (exactly like our previous gallery-dl configuration, which warned "No PHPSESSID cookie set") those works are skipped, via the post_is_gated seam. Auth failures are loud (rotate the refresh token); a response missing the fields we depend on is DRIFT (update this client). FC runs on a plain-HTTP homelab; nothing here uses a secure-context Web API. """ from __future__ import annotations import hashlib import logging import time from collections.abc import Iterator from dataclasses import dataclass from datetime import UTC, datetime from urllib.parse import parse_qsl, urlsplit import requests from ..utils.paths import safe_ext from .native_ingest_common import ( _MAX_429_RETRIES, NativeAuthError, NativeDriftError, NativeIngestError, make_session, retry_after_seconds, ) log = logging.getLogger(__name__) _TIMEOUT_SECONDS = 30.0 _API_ROOT = "https://app-api.pixiv.net" _OAUTH_URL = "https://oauth.secure.pixiv.net/auth/token" # gallery-dl's public Pixiv-app credentials (PixivAppAPI, also pixivpy's) — # these identify the official iOS app to the API, NOT the operator; the # operator's identity is the OAuth refresh token. _CLIENT_ID = "MOBrBDS8blbauoSck0ZfDbtuzpyT" _CLIENT_SECRET = "lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj" _HASH_SECRET = ( "28c1fdd170a5204386cb1313c7077b34" "f83e4aaf4aa829ce78c231e05b0bae2c" ) # The exact header set gallery-dl 1.32.5 installs on its session — the proven # app-API request profile. The Referer also unlocks i.pximg.net media GETs # (403 without it), so the downloader reuses this constant. PIXIV_APP_HEADERS = { "App-OS": "ios", "App-OS-Version": "16.7.2", "App-Version": "7.19.1", "User-Agent": "PixivIOSApp/7.19.1 (iOS 16.7.2; iPhone12,8)", "Referer": "https://app-api.pixiv.net/", } # Placeholder image prefix pixiv serves instead of a blocked work's original # (limit_sanity_level / limit_mypixiv / limit_unknown variants). _LIMIT_URL = "https://s.pximg.net/common/images/limit_" # The app API reports rate-limiting as an error MESSAGE (often on HTTP 403), # not only as HTTP 429. gallery-dl sleeps 300s in-walk; sleeping that long # inside our time-boxed chunk would eat the whole budget, so we surface it as # a typed 429 and let download_service's cooldown machinery honor the wait. _RATE_LIMIT_RETRY_AFTER = 300.0 _TITLE_MAX = 50 # gallery-dl pixiv filename template: {title[:50]} _RATINGS = {0: "General", 1: "R-18", 2: "R-18G"} class PixivAPIError(NativeIngestError): """Base for native Pixiv client failures. status_code / retry_after are inherited from NativeIngestError.""" class PixivAuthError(PixivAPIError, NativeAuthError): """Auth failure — missing/expired/revoked OAuth refresh token. Fix = rotate the credential (Settings → Credentials → Pixiv), not update the client. Maps to error_type 'auth_error'.""" class PixivDriftError(PixivAPIError, NativeDriftError): """A response did not match the shape this client depends on (missing `illusts`, un-parseable JSON where JSON was promised). Fail loud so the run flags 'the Pixiv app API changed' instead of silently importing nothing. Maps to API_DRIFT.""" @dataclass class MediaItem: """One resolved downloadable file belonging to a Pixiv work. Fields mirror the other native clients' MediaItem so the downloader and ledger are structurally the same. Pixiv original URLs carry no content hash, so `filehash` is always None and the ledger keys on `:` where media_id is `p` (page) or `ugoira` (the frame zip) — stable across URL-shape drift. """ url: str filename: str kind: str filehash: str | None post_id: str media_id: str def user_id_from_url(url: str) -> str | None: """The numeric pixiv user id from a source URL, or None. Handles the modern forms FC accepts as sources (https://www.pixiv.net/users/, /en/users/) plus the legacy member.php?id=. This IS the campaign id — no network resolver. """ parts = urlsplit(url or "") if "pixiv.net" not in parts.netloc: return None segs = [s for s in parts.path.split("/") if s] if segs and segs[0] == "en": segs = segs[1:] if len(segs) >= 2 and segs[0] == "users" and segs[1].isdigit(): return segs[1] if segs and segs[0] == "member.php": qid = dict(parse_qsl(parts.query)).get("id", "") if qid.isdigit(): return qid return None def _work_filename(work: dict, num: int, url: str) -> str: """gallery-dl layout parity: `{id}_{title[:50]}_{num:>02}.{extension}` (the downloader sanitizes the final segment).""" title = work.get("title") title50 = (title if isinstance(title, str) else "")[:_TITLE_MAX] ext = safe_ext(urlsplit(url).path.rsplit("/", 1)[-1]) return f"{work.get('id')}_{title50}_{num:02d}{ext}" class PixivClient: """Synchronous Pixiv app-API read client. Construct with the operator's OAuth refresh token (the same token-type Credential the gallery-dl path consumed as `extractor.pixiv.refresh-token`).""" def __init__( self, refresh_token: str | None, *, request_sleep: float = 0.0, max_retries: int = _MAX_429_RETRIES, session: requests.Session | None = None, ): self.refresh_token = refresh_token self._request_sleep = request_sleep or 0.0 self._max_retries = max_retries # No cookies — the app API authenticates via the Bearer token _login # installs. make_session still supplies the retry/UA plumbing; the # extra_headers overwrite its browser UA with the app profile. self._session = ( session if session is not None else make_session(None, extra_headers=PIXIV_APP_HEADERS) ) self._authed_user: dict = {} # Monotonic deadline after which the access token must be refreshed; # 0 forces a refresh on first use. self._token_deadline = 0.0 # -- auth ---------------------------------------------------------------- def _login(self) -> None: """Exchange the refresh token for a Bearer access token (gallery-dl's `_login_impl`, including the X-Client-Time/X-Client-Hash pair the endpoint validates). No-op while the current token is still fresh.""" if time.monotonic() < self._token_deadline: return if not self.refresh_token: raise PixivAuthError( "No Pixiv refresh token configured — add the OAuth refresh " "token as the Pixiv credential (token type)." ) # gallery-dl stamps naive-UTC with a literal +00:00 suffix. now = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S+00:00") headers = { "X-Client-Time": now, "X-Client-Hash": hashlib.md5( (now + _HASH_SECRET).encode() ).hexdigest(), } data = { "client_id": _CLIENT_ID, "client_secret": _CLIENT_SECRET, "grant_type": "refresh_token", "refresh_token": self.refresh_token, "get_secure_url": "1", } try: resp = self._session.post( _OAUTH_URL, data=data, headers=headers, timeout=_TIMEOUT_SECONDS, ) except requests.RequestException as exc: raise PixivAPIError(f"Pixiv OAuth request failed: {exc}") from exc if resp.status_code >= 400: raise PixivAuthError( "Pixiv rejected the refresh token (HTTP " f"{resp.status_code}) — rotate the Pixiv credential.", status_code=resp.status_code, ) try: payload = resp.json()["response"] access = payload["access_token"] except (ValueError, KeyError, TypeError) as exc: raise PixivDriftError( f"Pixiv OAuth response shape changed: {exc}" ) from exc self._authed_user = payload.get("user") or {} self._session.headers["Authorization"] = f"Bearer {access}" # expires_in is 3600 today; refresh 60s early so a long walk never # rides an expiring token into a spurious 400. expires_in = payload.get("expires_in") lifetime = float(expires_in) if isinstance(expires_in, (int, float)) else 3600.0 self._token_deadline = time.monotonic() + max(60.0, lifetime - 60.0) # -- request ------------------------------------------------------------- def _call(self, endpoint: str, params: dict) -> dict: """Authenticated app-API GET → parsed JSON body, with the shared 429 backoff and the loud auth/drift/rate-limit mapping.""" self._login() if self._request_sleep > 0: time.sleep(self._request_sleep) url = _API_ROOT + endpoint attempt = 0 while True: try: resp = self._session.get( url, params=params, timeout=_TIMEOUT_SECONDS ) except requests.RequestException as exc: raise PixivAPIError( f"Pixiv request failed ({endpoint}): {exc}" ) from exc if resp.status_code == 429 and attempt < self._max_retries: attempt += 1 delay = retry_after_seconds(resp, attempt) log.warning( "Pixiv 429 (%s) — backing off %.1fs (retry %d/%d)", endpoint, delay, attempt, self._max_retries, ) time.sleep(delay) continue break try: body = resp.json() except ValueError as exc: raise PixivDriftError( f"Pixiv returned non-JSON for {endpoint} " f"(HTTP {resp.status_code})" ) from exc error = body.get("error") if isinstance(body, dict) else None message = "" if isinstance(error, dict): message = str( error.get("user_message") or error.get("message") or "" ) # Rate limiting first: the app API reports it as an error MESSAGE # (often on HTTP 403), which must not be mistaken for an auth failure. if resp.status_code == 429 or "rate limit" in message.lower(): raise PixivAPIError( f"Pixiv rate limit hit ({endpoint}): {message or 'HTTP 429'}", status_code=429, retry_after=_RATE_LIMIT_RETRY_AFTER, ) if resp.status_code in (400, 401, 403): # Invalid/expired access token surfaces as 400 invalid_grant-style # errors on the app API; 401/403 are straight auth rejections. raise PixivAuthError( f"Pixiv rejected the request ({endpoint}, HTTP " f"{resp.status_code}): {message or 'auth rejected'} — " "rotate the Pixiv refresh token.", status_code=resp.status_code, ) if resp.status_code >= 400: raise PixivAPIError( f"Pixiv API error ({endpoint}, HTTP {resp.status_code}): " f"{message or 'unknown error'}", status_code=resp.status_code, ) if error: # HTTP 200 carrying an error object — unexpected, but never # silently treat it as data. raise PixivAPIError( f"Pixiv API error ({endpoint}): {message or error}", status_code=resp.status_code, ) return body # -- normalization ------------------------------------------------------- @staticmethod def _normalize(work: dict) -> dict: """Wrap an app-API work in the `{"id", "attributes", ...}` post shape the platform-agnostic core and shared helpers read. The raw work rides along under `_work` for extract_media / the post record.""" title = work.get("title") caption = work.get("caption") wtype = work.get("type") return { "id": work.get("id"), "attributes": { "title": title if isinstance(title, str) else "", "content": caption if isinstance(caption, str) else "", "published_at": work.get("create_date"), "post_type": wtype if isinstance(wtype, str) else "illust", }, "_work": work, } # -- post-first seams ---------------------------------------------------- @staticmethod def post_record_key(post: dict) -> tuple[str, str] | None: """`(ledger_key, post_id)` gating post-record capture through the seen ledger (`post:` synthetic key), or None when the work has no id.""" pid = post.get("id") pid = str(pid) if pid is not None else "" if not pid: return None return (f"post:{pid}", pid) @staticmethod def post_meta(post: dict) -> dict: attrs = post.get("attributes") or {} return {"title": attrs.get("title") or None, "date": attrs.get("published_at")} @staticmethod def post_is_gated(post: dict) -> bool: """True when this account cannot fetch the work's real files: pixiv substitutes a `limit_*` placeholder for the original (sanity-level filter / my-pixiv lock / deleted), or zeroes the author (deleted account). Mirrors #874 semantics: gated content leaves NO trace — a placeholder thumbnail and an empty stub would only pollute the archive. (gallery-dl's PHPSESSID web-scrape fallback for these is out of scope: FC holds no pixiv browser cookies — module docstring.)""" work = post.get("_work") or {} user = work.get("user") or {} if not user.get("id"): return True if work.get("meta_pages"): return False single = work.get("meta_single_page") or {} original = single.get("original_image_url") return isinstance(original, str) and original.startswith(_LIMIT_URL) # -- media --------------------------------------------------------------- def extract_media(self, post: dict, included_index: dict) -> list[MediaItem]: """Resolve a work's downloadable files (gallery-dl's `_extract_files`): multi-page originals, the single-page original, or the ugoira frame zip. `included_index` is unused (pixiv works are self-contained).""" work = post.get("_work") or {} pid = str(post.get("id") or "") if not pid or self.post_is_gated(post): return [] if work.get("type") == "ugoira": return self._ugoira_media(work, pid) meta_pages = work.get("meta_pages") or [] if meta_pages: items = [] for num, page in enumerate(meta_pages): urls = page.get("image_urls") or {} url = urls.get("original") if not isinstance(url, str) or not url: continue items.append( MediaItem( url=url, filename=_work_filename(work, num, url), kind="image", filehash=None, post_id=pid, media_id=f"p{num}", ) ) return items single = work.get("meta_single_page") or {} url = single.get("original_image_url") if not isinstance(url, str) or not url or url.startswith(_LIMIT_URL): return [] return [ MediaItem( url=url, filename=_work_filename(work, 0, url), kind="image", filehash=None, post_id=pid, media_id="p0", ) ] def _ugoira_meta(self, work: dict, pid: str) -> dict | None: """Fetch + memoize the ugoira metadata (frames + zip urls) for a work. Idempotent and cached on the work dict, so the post record and the media extraction share ONE `/v1/ugoira/metadata` call regardless of which runs first (the core writes the post record BEFORE it extracts media). Returns None — and caches the miss — on a non-auth failure (matching gallery-dl's downgrade); auth failures stay loud.""" if "_ugoira_meta" in work: return work["_ugoira_meta"] try: body = self._call("/v1/ugoira/metadata", {"illust_id": pid}) meta = body["ugoira_metadata"] except PixivAuthError: raise except (PixivAPIError, KeyError, TypeError) as exc: log.warning("Pixiv ugoira metadata failed for %s: %s", pid, exc) work["_ugoira_meta"] = None return None work["_ugoira_meta"] = meta # Frame delays: a future ugoira→video conversion needs the timings (the # zip alone has none), so the post record captures them. work["_ugoira_frames"] = meta.get("frames") or [] return meta def fetch_ugoira_frames(self, post: dict) -> None: """Populate `post['_work']['_ugoira_frames']` for an ugoira post (no-op otherwise). The core writes the post record BEFORE extract_media, so without this the frame timings would never reach the record; this fetches (and memoizes, so extract_media reuses it) the metadata. Injected into the downloader by the ingester, mirroring Patreon's content_fetcher. Auth errors propagate; other failures leave frames unset.""" work = post.get("_work") or {} if work.get("type") != "ugoira": return pid = str(post.get("id") or "") if pid: self._ugoira_meta(work, pid) def _ugoira_media(self, work: dict, pid: str) -> list[MediaItem]: """The ugoira frame zip (gallery-dl's default non-original mode): `/v1/ugoira/metadata` → zip_urls.medium with the 600x600→1920x1080 swap. A metadata failure downgrades to 'no media' with a warning (matching gallery-dl) instead of failing the walk — except auth failures, which stay loud.""" meta = self._ugoira_meta(work, pid) if meta is None: return [] try: zip_url = meta["zip_urls"]["medium"] except (KeyError, TypeError) as exc: log.warning("Pixiv ugoira zip url missing for %s: %s", pid, exc) return [] url = zip_url.replace("_ugoira600x600", "_ugoira1920x1080", 1) return [ MediaItem( url=url, filename=_work_filename(work, 0, url), kind="ugoira", filehash=None, post_id=pid, media_id="ugoira", ) ] # -- iteration ----------------------------------------------------------- def iter_posts( self, campaign_id: str, cursor: str | None = None ) -> Iterator[tuple[dict, dict, str | None]]: """Yield (post, {}, page_cursor) for every work in the user's feed. `campaign_id` is the numeric pixiv user id. `cursor` is the query string of the app API's `next_url` (offset pagination); None fetches page 1. The yielded `page_cursor` is the cursor that FETCHED this work's page, so the core checkpoints a value that re-serves the same page on resume (the shared cursor contract).""" if not str(campaign_id or "").isdigit(): raise PixivDriftError( f"Pixiv campaign id must be a numeric user id, got " f"{campaign_id!r}" ) current = cursor while True: page_cursor = current if current is None: params: dict = {"user_id": campaign_id} else: params = dict(parse_qsl(current)) data = self._call("/v1/user/illusts", params) works = data.get("illusts") if not isinstance(works, list): raise PixivDriftError( "Pixiv user-illusts response had no 'illusts' list " f"(keys: {sorted(data)[:8]})" ) for work in works: if not isinstance(work, dict): continue yield self._normalize(work), {}, page_cursor next_url = data.get("next_url") if not next_url: return current = str(next_url).rpartition("?")[2] # -- verify -------------------------------------------------------------- def verify_auth(self) -> tuple[bool | None, str]: """Cheap credential probe: run the OAuth refresh (the thing that fails when the token is bad) without walking any feed.""" try: self._token_deadline = 0.0 # force a real refresh self._login() except PixivAuthError as exc: return False, f"Pixiv rejected the credential — {exc}" except PixivAPIError as exc: return None, f"Couldn't verify (network/HTTP issue): {exc}" account = self._authed_user.get("account") or self._authed_user.get("name") suffix = f" as {account}" if account else "" return True, f"Credentials valid — Pixiv OAuth refresh succeeded{suffix}." def rating_label(x_restrict) -> str | None: """Human rating from pixiv's x_restrict (0/1/2) — written into the post record so the archive keeps the R-18 flag without the reader needing to know pixiv's numeric scheme.""" if isinstance(x_restrict, bool) or not isinstance(x_restrict, int): return None return _RATINGS.get(x_restrict)