Files
Random-image-api/backend/app/api/public/random.py
T

1202 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import asyncio
from collections import deque
import random
import os
import math
import time
from datetime import datetime, timedelta, timezone
from threading import Lock
from typing import Any
from fastapi import APIRouter, BackgroundTasks, Query, Request
from fastapi.responses import RedirectResponse
from app.core.errors import ApiError, ErrorCode
from app.core.http_stream import stream_url
from app.core.imgproxy import build_signed_processing_url, load_imgproxy_config_from_settings
from app.core.pximg_reverse_proxy import (
normalize_pximg_mirror_host,
normalize_pximg_proxy,
pick_pximg_mirror_host_for_request,
rewrite_pximg_to_mirror,
)
from app.core.proxy_routing import select_proxy_uri_for_url
from app.core.runtime_settings import load_runtime_config
from app.core.time import iso_utc_ms
from app.db.images_mark import mark_image_failure, mark_image_ok
from app.db.tags_get import get_tag_names_for_image
from app.db.random_pick import pick_random_image, pick_random_images
from app.db.session import create_sessionmaker
from app.jobs.enqueue import enqueue_opportunistic_hydrate_metadata
router = APIRouter()
_MAX_TAG_FILTERS = 50
_MAX_TAG_OR_TERMS = 20
_MAX_TAG_TOTAL_TERMS = 200
# Best-effort anti-repeat (process-local): reduce short-term duplicates without extra DB writes.
_RECENT_LOCK = Lock()
_RECENT_WINDOW_S = 20.0 * 60.0
_RECENT_MAX_IMAGES = 600
_RECENT_MAX_AUTHORS = 200
_RECENT_IMAGES: deque[tuple[float, int]] = deque()
_RECENT_AUTHORS: deque[tuple[float, int]] = deque()
def _prune_recent(now: float) -> None:
cutoff = float(now) - float(_RECENT_WINDOW_S)
while _RECENT_IMAGES and float(_RECENT_IMAGES[0][0]) < cutoff:
_RECENT_IMAGES.popleft()
while _RECENT_AUTHORS and float(_RECENT_AUTHORS[0][0]) < cutoff:
_RECENT_AUTHORS.popleft()
while len(_RECENT_IMAGES) > int(_RECENT_MAX_IMAGES):
_RECENT_IMAGES.popleft()
while len(_RECENT_AUTHORS) > int(_RECENT_MAX_AUTHORS):
_RECENT_AUTHORS.popleft()
def _get_recent_sets(now: float) -> tuple[set[int], set[int]]:
with _RECENT_LOCK:
_prune_recent(now)
recent_images = {int(image_id) for _t, image_id in _RECENT_IMAGES}
recent_authors = {int(user_id) for _t, user_id in _RECENT_AUTHORS}
return recent_images, recent_authors
def _record_recent(*, now: float, image_id: int, user_id: int | None) -> None:
try:
image_id_i = int(image_id)
except Exception:
return
if image_id_i <= 0:
return
user_id_i: int | None
try:
user_id_i = int(user_id) if user_id is not None else None
except Exception:
user_id_i = None
with _RECENT_LOCK:
_prune_recent(now)
_RECENT_IMAGES.append((float(now), int(image_id_i)))
if user_id_i is not None and user_id_i > 0:
_RECENT_AUTHORS.append((float(now), int(user_id_i)))
def _as_nonneg_int(value: Any) -> int:
if value is None:
return 0
if isinstance(value, bool):
return 0
try:
i = int(value)
except Exception:
return 0
return i if i > 0 else 0
_DEFAULT_SCORE_WEIGHTS: dict[str, float] = {
"bookmark": 4.0,
"view": 0.5,
"comment": 2.0,
"pixels": 1.0,
"bookmark_rate": 3.0,
# 适度提升“新鲜感”和“成长速度”,避免老热门长期统治。
# - freshness: 越新越加分(指数衰减,默认半衰期在代码里固定为 21 天)
# - bookmark_velocity: 收藏增长率(收藏数 / 存在天数)的对数项
"freshness": 1.0,
"bookmark_velocity": 1.2,
}
_DEFAULT_RECOMMENDATION: dict[str, Any] = {
"pick_mode": "weighted",
"temperature": 1.0,
"score_weights": dict(_DEFAULT_SCORE_WEIGHTS),
"multipliers": {
"ai": 1.0,
"non_ai": 1.0,
"unknown_ai": 1.0,
"illust": 1.0,
"manga": 1.0,
"ugoira": 1.0,
"unknown_illust_type": 1.0,
},
}
def _as_float(value: Any, *, default: float) -> float:
if value is None:
return float(default)
if isinstance(value, bool):
return float(default)
try:
return float(value)
except Exception:
return float(default)
def _quality_score(image: Any, *, weights: dict[str, float] | None = None) -> float:
w = weights or _DEFAULT_SCORE_WEIGHTS
w_bookmark = float(w.get("bookmark", _DEFAULT_SCORE_WEIGHTS["bookmark"]))
w_view = float(w.get("view", _DEFAULT_SCORE_WEIGHTS["view"]))
w_comment = float(w.get("comment", _DEFAULT_SCORE_WEIGHTS["comment"]))
w_pixels = float(w.get("pixels", _DEFAULT_SCORE_WEIGHTS["pixels"]))
w_bookmark_rate = float(w.get("bookmark_rate", _DEFAULT_SCORE_WEIGHTS["bookmark_rate"]))
bookmark_count = _as_nonneg_int(getattr(image, "bookmark_count", None))
view_count = _as_nonneg_int(getattr(image, "view_count", None))
comment_count = _as_nonneg_int(getattr(image, "comment_count", None))
width = _as_nonneg_int(getattr(image, "width", None))
height = _as_nonneg_int(getattr(image, "height", None))
pixels = width * height if width > 0 and height > 0 else 0
rate_term = 0.0
if view_count > 0:
bookmark_rate_per_mille = (float(bookmark_count) / float(view_count)) * 1000.0
rate_term = math.log1p(max(0.0, bookmark_rate_per_mille))
score = (
float(w_bookmark) * math.log1p(bookmark_count)
+ float(w_view) * math.log1p(view_count)
+ float(w_comment) * math.log1p(comment_count)
+ float(w_pixels) * math.log1p(float(pixels) / 1_000_000.0)
+ float(w_bookmark_rate) * rate_term
)
return float(score)
@router.get("/random")
async def random_image(
request: Request,
background_tasks: BackgroundTasks,
format: str = "image",
redirect: int = 0,
attempts: int | None = None,
seed: str | None = None,
strategy: str | None = None,
quality_samples: int | None = None,
r18: int = 0,
r18_strict: int | None = None,
ai_type: str = "any",
illust_type: str = "any",
orientation: str = "any",
layout: str | None = None,
adaptive: int = 0,
pixiv_cat: int = 0,
pximg_mirror_host: str | None = None,
proxy: str | None = None,
min_width: int = 0,
min_height: int = 0,
min_pixels: int = 0,
min_bookmarks: int = 0,
min_views: int = 0,
min_comments: int = 0,
included_tags: list[str] | None = Query(default=None),
excluded_tags: list[str] | None = Query(default=None),
user_id: int | None = None,
illust_id: int | None = None,
created_from: str | None = None,
created_to: str | None = None,
) -> Any:
if format not in {"image", "json", "simple_json"}:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported format", status_code=400)
if redirect not in {0, 1}:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported redirect", status_code=400)
seed_norm = (seed or "").strip()
if seed is not None and not seed_norm:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported seed", status_code=400)
if seed_norm and len(seed_norm) > 128:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported seed", status_code=400)
ai_type_raw = (ai_type or "any").strip().lower()
ai_type_i: int | None = None
if ai_type_raw in {"", "any"}:
ai_type_i = None
elif ai_type_raw in {"0", "1"}:
ai_type_i = int(ai_type_raw)
else:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported ai_type", status_code=400)
illust_type_raw = (illust_type or "any").strip().lower()
illust_type_i: int | None = None
if illust_type_raw in {"", "any"}:
illust_type_i = None
elif illust_type_raw in {"0", "illust", "illustration"}:
illust_type_i = 0
elif illust_type_raw in {"1", "manga"}:
illust_type_i = 1
elif illust_type_raw in {"2", "ugoira"}:
illust_type_i = 2
else:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported illust_type", status_code=400)
if r18 not in {0, 1, 2}:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported r18", status_code=400)
if adaptive not in {0, 1}:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported adaptive", status_code=400)
if pixiv_cat not in {0, 1}:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported pixiv_cat", status_code=400)
pximg_mirror_host_override: str | None = None
if pximg_mirror_host is not None:
raw = str(pximg_mirror_host or "").strip()
if raw:
pximg_mirror_host_override = normalize_pximg_mirror_host(raw)
if pximg_mirror_host_override is None:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported pximg_mirror_host", status_code=400)
layout_source = "orientation"
raw_layout = orientation
if layout is not None:
layout_source = "layout"
raw_layout = layout
layout_norm = (raw_layout or "").strip().lower()
alias_map = {"vertical": "portrait", "horizontal": "landscape"}
layout_norm = alias_map.get(layout_norm, layout_norm)
orientation_map = {"any": None, "portrait": 1, "landscape": 2, "square": 3}
if layout_norm not in orientation_map:
raise ApiError(
code=ErrorCode.BAD_REQUEST,
message="Unsupported layout" if layout_source == "layout" else "Unsupported orientation",
status_code=400,
)
min_width_i = int(min_width)
min_height_i = int(min_height)
min_pixels_i = int(min_pixels)
min_bookmarks_i = int(min_bookmarks)
min_views_i = int(min_views)
min_comments_i = int(min_comments)
if min_width_i < 0 or min_height_i < 0 or min_pixels_i < 0:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported min_*", status_code=400)
if min_bookmarks_i < 0 or min_views_i < 0 or min_comments_i < 0:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported min_*", status_code=400)
# 自适应:在用户没有显式指定的情况下,根据设备类型设置默认的方向/分辨率门槛。
# 注意:不会覆盖用户显式传入的 orientation/layout/min_*。
if int(adaptive) == 1:
qp = request.query_params
orientation_explicit = ("layout" in qp) or ("orientation" in qp)
min_explicit = ("min_width" in qp) or ("min_height" in qp) or ("min_pixels" in qp)
ch_mobile = (request.headers.get("sec-ch-ua-mobile") or request.headers.get("Sec-CH-UA-Mobile") or "").strip()
if ch_mobile == "?1":
is_mobile = True
elif ch_mobile == "?0":
is_mobile = False
else:
ua = (request.headers.get("user-agent") or request.headers.get("User-Agent") or "").lower()
is_mobile = any(x in ua for x in ("mobi", "android", "iphone", "ipad", "ipod"))
if not orientation_explicit and layout_norm == "any":
layout_norm = "portrait" if is_mobile else "landscape"
if not min_explicit and min_width_i == 0 and min_height_i == 0 and min_pixels_i == 0:
min_pixels_i = 1_000_000 if is_mobile else 2_000_000
def _parse_tag_filters(values: list[str] | None) -> list[str]:
"""
Tag filters support "AND of groups" where each query param is one group.
Examples:
- included_tags=girl&included_tags=boy -> girl AND boy
- included_tags=girl|boy -> girl OR boy
- included_tags=girl|boy&included_tags=white|black -> (girl OR boy) AND (white OR black)
"""
out: list[str] = []
seen: set[str] = set()
for raw in values or []:
expr = str(raw or "").strip()
if not expr or expr in seen:
continue
seen.add(expr)
out.append(expr)
return out
included = _parse_tag_filters(included_tags)
excluded = _parse_tag_filters(excluded_tags)
def _validate_tag_filters(values: list[str]) -> None:
total_terms = 0
for expr in values:
parts: list[str] = []
seen_terms: set[str] = set()
for part in str(expr).split("|"):
term = part.strip()
if not term or term in seen_terms:
continue
seen_terms.add(term)
parts.append(term)
if len(parts) > _MAX_TAG_OR_TERMS:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Too many tag terms in a group", status_code=400)
total_terms += len(parts)
if total_terms > _MAX_TAG_TOTAL_TERMS:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Too many tag terms", status_code=400)
if len(included) > _MAX_TAG_FILTERS or len(excluded) > _MAX_TAG_FILTERS:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Too many tag filters", status_code=400)
_validate_tag_filters(included)
_validate_tag_filters(excluded)
if user_id is not None and int(user_id) <= 0:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported user_id", status_code=400)
if illust_id is not None and int(illust_id) <= 0:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported illust_id", status_code=400)
def _normalize_iso_utc(value: str) -> str:
raw = (value or "").strip()
if not raw:
raise ValueError("empty datetime")
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
dt = datetime.fromisoformat(raw)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
dt = dt.astimezone(timezone.utc).replace(microsecond=0)
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
created_from_norm: str | None = None
created_to_norm: str | None = None
try:
if created_from is not None:
created_from_norm = _normalize_iso_utc(created_from)
if created_to is not None:
created_to_norm = _normalize_iso_utc(created_to)
except Exception:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported created_*", status_code=400)
if created_from_norm is not None and created_to_norm is not None:
if created_from_norm > created_to_norm:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="created_from > created_to", status_code=400)
def _no_match_error() -> ApiError:
applied_filters: dict[str, Any] = {
"r18": r18,
"r18_strict": int(r18_strict),
"ai_type": ai_type_raw,
"illust_type": illust_type_raw,
"adaptive": int(adaptive),
"orientation": layout_norm,
"min_width": int(min_width_i),
"min_height": int(min_height_i),
"min_pixels": int(min_pixels_i),
"min_bookmarks": int(min_bookmarks_i),
"min_views": int(min_views_i),
"min_comments": int(min_comments_i),
}
if included:
applied_filters["included_tags"] = included
if excluded:
applied_filters["excluded_tags"] = excluded
if user_id is not None:
applied_filters["user_id"] = int(user_id)
if illust_id is not None:
applied_filters["illust_id"] = int(illust_id)
if created_from_norm is not None:
applied_filters["created_from"] = created_from_norm
if created_to_norm is not None:
applied_filters["created_to"] = created_to_norm
suggestions: list[str] = ["运行元数据补全任务以提升元数据覆盖率"]
if r18 == 0 and int(r18_strict) == 1:
suggestions.append("将 r18_strict=0 以允许未知 x_restrict(冷启动阶段更容易命中)")
if layout_norm != "any":
suggestions.append("将 orientation=any(取消方向限制)")
if int(min_width_i) > 0 or int(min_height_i) > 0 or int(min_pixels_i) > 0:
suggestions.append("降低 min_width/min_height/min_pixels(放宽分辨率门槛)")
if int(min_bookmarks_i) > 0 or int(min_views_i) > 0 or int(min_comments_i) > 0:
suggestions.append("降低 min_bookmarks/min_views/min_comments(放宽热度门槛)")
if included:
suggestions.append("放宽 included_tags(减少必须包含的标签)")
if excluded:
suggestions.append("放宽 excluded_tags(减少必须排除的标签)")
if user_id is not None:
suggestions.append("移除 user_id 过滤")
if illust_id is not None:
suggestions.append("移除 illust_id 过滤")
if ai_type_i is not None:
suggestions.append("将 ai_type=any(取消 AI 限制)")
if illust_type_i is not None:
suggestions.append("将 illust_type=any(取消作品类型限制)")
if created_from_norm is not None or created_to_norm is not None:
suggestions.append("扩大 created_from/created_to 时间范围")
if int(adaptive) == 1:
suggestions.append("若自适应导致过滤过严,可尝试 adaptive=0 或显式设置 min_*")
return ApiError(
code=ErrorCode.NO_MATCH,
message="没有匹配的图片。",
status_code=404,
details={"hints": {"applied_filters": applied_filters, "suggestions": suggestions}},
)
engine = request.app.state.engine
Session = create_sessionmaker(engine)
runtime = await load_runtime_config(engine)
proxy_override: str | None = None
if proxy is not None:
raw = str(proxy or "").strip()
if raw:
proxy_override = normalize_pximg_proxy(raw, extra_hosts=runtime.image_proxy_extra_pximg_mirror_hosts)
if proxy_override is None:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported proxy", status_code=400)
mirror_host_override = proxy_override or pximg_mirror_host_override
use_pixiv_cat = bool(runtime.image_proxy_use_pixiv_cat) or int(pixiv_cat) == 1 or proxy_override is not None
runtime_mirror_host = str(getattr(runtime, "image_proxy_pximg_mirror_host", "") or "").strip() or "i.pixiv.cat"
mirror_host = mirror_host_override or (
pick_pximg_mirror_host_for_request(headers=request.headers, fallback_host=runtime_mirror_host)
if use_pixiv_cat
else runtime_mirror_host
)
async def _best_effort(fn, *args, timeout_s: float = 1.5, **kwargs) -> None: # type: ignore[no-untyped-def]
try:
await asyncio.wait_for(fn(*args, **kwargs), timeout=float(timeout_s))
except Exception:
pass
random_defaults = runtime.random_defaults if isinstance(runtime.random_defaults, dict) else {}
attempts_source = "query"
attempts_i = 3
if attempts is not None:
try:
attempts_i = int(attempts)
except Exception as exc:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported attempts", status_code=400) from exc
else:
raw = random_defaults.get("default_attempts")
if raw is None:
attempts_source = "fallback"
attempts_i = 3
else:
attempts_source = "runtime"
try:
attempts_i = int(raw)
except Exception:
attempts_i = 3
if attempts_i < 1 or attempts_i > 10:
if attempts_source == "query":
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported attempts", status_code=400)
attempts_source = "fallback"
attempts_i = 3
attempts = int(attempts_i)
r18_strict_source = "query"
r18_strict_i = 1
if r18_strict is not None:
try:
r18_strict_i = int(r18_strict)
except Exception as exc:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported r18_strict", status_code=400) from exc
else:
raw = random_defaults.get("default_r18_strict")
if raw is None:
r18_strict_source = "fallback"
r18_strict_i = 1
elif isinstance(raw, bool):
r18_strict_source = "runtime"
r18_strict_i = 1 if raw else 0
else:
r18_strict_source = "runtime"
try:
r18_strict_i = int(raw)
except Exception:
r18_strict_i = 1
if r18_strict_i not in {0, 1}:
if r18_strict_source == "query":
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported r18_strict", status_code=400)
r18_strict_source = "fallback"
r18_strict_i = 1
r18_strict = int(r18_strict_i)
fail_cooldown_source = "runtime"
fail_cooldown_ms = random_defaults.get("fail_cooldown_ms")
try:
fail_cooldown_ms_i = int(fail_cooldown_ms) if fail_cooldown_ms is not None else None
except Exception:
fail_cooldown_ms_i = None
if fail_cooldown_ms_i is None:
fail_cooldown_source = "fallback"
cooldown_s_raw = (os.environ.get("RANDOM_FAIL_COOLDOWN_SECONDS") or "600").strip()
try:
cooldown_s = int(cooldown_s_raw)
except Exception:
cooldown_s = 600
cooldown_s = max(0, min(int(cooldown_s), 24 * 60 * 60))
fail_cooldown_ms_i = int(cooldown_s) * 1000
fail_cooldown_ms_i = max(0, min(int(fail_cooldown_ms_i), 24 * 60 * 60 * 1000))
request_now = datetime.now(timezone.utc)
fail_cooldown_before = (
iso_utc_ms(request_now - timedelta(milliseconds=int(fail_cooldown_ms_i)))
if int(fail_cooldown_ms_i) > 0
else None
)
def _parse_iso_dt(value: str | None) -> datetime | None:
raw = str(value or "").strip()
if not raw:
return None
try:
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
dt = datetime.fromisoformat(raw)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
except Exception:
return None
def _age_days(dt: datetime | None) -> float | None:
if dt is None:
return None
try:
secs = float((request_now - dt).total_seconds())
except Exception:
return None
if not math.isfinite(secs):
return None
return max(0.0, secs / 86400.0)
pick_kwargs: dict[str, Any] = {
"r18": r18,
"r18_strict": bool(r18_strict),
"ai_type": ai_type_i,
"illust_type": illust_type_i,
"orientation": orientation_map[layout_norm],
"min_width": int(min_width_i),
"min_height": int(min_height_i),
"min_pixels": int(min_pixels_i),
"min_bookmarks": int(min_bookmarks_i),
"min_views": int(min_views_i),
"min_comments": int(min_comments_i),
"included_tags": included,
"excluded_tags": excluded,
"user_id": user_id,
"illust_id": illust_id,
"created_from": created_from_norm,
"created_to": created_to_norm,
"fail_cooldown_before": fail_cooldown_before,
}
rng = random.Random(seed_norm) if seed_norm else random
time_boost_enabled = not bool(seed_norm)
anti_repeat_enabled = bool(time_boost_enabled) and user_id is None and illust_id is None
recent_image_ids: set[int] = set()
recent_author_ids: set[int] = set()
if anti_repeat_enabled:
recent_image_ids, recent_author_ids = _get_recent_sets(time.monotonic())
strategy_raw = (strategy or "").strip().lower()
strategy_source = "query"
if not strategy_raw:
strategy_source = "runtime"
strategy_raw = str(random_defaults.get("strategy") or "").strip().lower()
if not strategy_raw:
strategy_source = "fallback"
strategy_raw = "quality"
if strategy_raw not in {"quality", "random"}:
if strategy_source == "query":
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported strategy", status_code=400)
strategy_source = "fallback"
strategy_raw = "quality"
strategy_norm = strategy_raw
quality_samples_i: int
quality_samples_source = "query"
if quality_samples is not None:
try:
quality_samples_i = int(quality_samples)
except Exception as exc:
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported quality_samples", status_code=400) from exc
else:
raw = random_defaults.get("quality_samples")
if raw is None:
quality_samples_source = "fallback"
quality_samples_i = 12
else:
quality_samples_source = "runtime"
try:
quality_samples_i = int(raw)
except Exception:
quality_samples_i = 12
if quality_samples_i < 1 or quality_samples_i > 1000:
if quality_samples_source == "query":
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported quality_samples", status_code=400)
quality_samples_source = "fallback"
quality_samples_i = 12
quality_samples_base = int(quality_samples_i)
quality_samples_multiplier = 1
if quality_samples is None and strategy_norm == "quality" and bool(time_boost_enabled):
strictness = 0
strictness += 3 * int(len(included))
strictness += 2 * int(len(excluded))
if int(min_bookmarks_i) > 0:
strictness += 2
if int(min_views_i) > 0:
strictness += 1
if int(min_comments_i) > 0:
strictness += 1
if int(min_pixels_i) > 0:
strictness += 1
if int(min_width_i) > 0 or int(min_height_i) > 0:
strictness += 1
if ai_type_i is not None:
strictness += 1
if illust_type_i is not None:
strictness += 1
if orientation_map[layout_norm] is not None:
strictness += 1
if created_from_norm is not None or created_to_norm is not None:
strictness += 1
if int(r18) == 1:
strictness += 1
if bool(anti_repeat_enabled):
strictness += 1
if strictness >= 9:
quality_samples_multiplier = 4
elif strictness >= 6:
quality_samples_multiplier = 3
elif strictness >= 3:
quality_samples_multiplier = 2
else:
quality_samples_multiplier = 1
quality_samples_i = min(1000, int(max(1, int(quality_samples_base) * int(quality_samples_multiplier))))
quality_samples_scaled = bool(quality_samples_i != quality_samples_base)
recommendation_raw = random_defaults.get("recommendation")
recommendation_source = "fallback"
recommendation_obj: dict[str, Any] = {}
if isinstance(recommendation_raw, dict):
recommendation_source = "runtime"
recommendation_obj = dict(recommendation_raw)
pick_mode_raw = str(recommendation_obj.get("pick_mode") or _DEFAULT_RECOMMENDATION["pick_mode"]).strip().lower()
if pick_mode_raw not in {"best", "weighted"}:
pick_mode_raw = str(_DEFAULT_RECOMMENDATION["pick_mode"])
temperature_raw = _as_float(recommendation_obj.get("temperature"), default=float(_DEFAULT_RECOMMENDATION["temperature"]))
temperature = float(max(0.05, min(float(temperature_raw), 100.0)))
score_weights_raw = recommendation_obj.get("score_weights")
score_weights_obj = score_weights_raw if isinstance(score_weights_raw, dict) else {}
score_weights: dict[str, float] = {}
for key, default_value in _DEFAULT_SCORE_WEIGHTS.items():
v = _as_float(score_weights_obj.get(key), default=float(default_value))
score_weights[key] = float(max(-100.0, min(float(v), 100.0)))
multipliers_default = _DEFAULT_RECOMMENDATION["multipliers"]
multipliers_raw = recommendation_obj.get("multipliers")
multipliers_obj = multipliers_raw if isinstance(multipliers_raw, dict) else {}
multipliers: dict[str, float] = {}
for key, default_value in multipliers_default.items():
v = _as_float(multipliers_obj.get(key), default=float(default_value))
multipliers[key] = float(max(0.0, min(float(v), 100.0)))
debug_base = {
"attempts": int(attempts_i),
"attempts_source": attempts_source,
"r18_strict": int(r18_strict_i),
"r18_strict_source": r18_strict_source,
"fail_cooldown_ms": int(fail_cooldown_ms_i),
"fail_cooldown_source": fail_cooldown_source,
"strategy": strategy_norm,
"strategy_source": strategy_source,
"quality_samples": int(quality_samples_i),
"quality_samples_base": int(quality_samples_base),
"quality_samples_multiplier": int(quality_samples_multiplier),
"quality_samples_scaled": bool(quality_samples_scaled),
"quality_samples_source": quality_samples_source,
"anti_repeat_enabled": bool(anti_repeat_enabled),
"time_boost_enabled": bool(time_boost_enabled),
"recommendation_source": recommendation_source,
}
async def _pick_with_strategy(
*,
session: Any,
exclude_image_ids: list[int] | None = None,
) -> tuple[Any, dict[str, Any]] | tuple[None, dict[str, Any]]:
if strategy_norm == "random":
base_exclude = list(exclude_image_ids or [])
exclude_set: set[int] = set(int(x) for x in base_exclude)
if bool(anti_repeat_enabled) and recent_image_ids:
exclude_set.update(int(x) for x in recent_image_ids)
image = await pick_random_image(session, r=rng.random(), exclude_image_ids=list(exclude_set), **pick_kwargs)
if image is None and bool(anti_repeat_enabled) and recent_image_ids:
image = await pick_random_image(session, r=rng.random(), exclude_image_ids=base_exclude, **pick_kwargs)
if image is None:
return None, {**debug_base, "attempts_used": 1, "picked_by": "random_key"}
return image, {**debug_base, "attempts_used": 1, "picked_by": "random_key"}
def _multiplier_for_image(image: Any) -> float:
m = 1.0
ai = getattr(image, "ai_type", None)
if ai == 1:
m *= float(multipliers.get("ai", 1.0))
elif ai == 0:
m *= float(multipliers.get("non_ai", 1.0))
else:
m *= float(multipliers.get("unknown_ai", 1.0))
it = getattr(image, "illust_type", None)
if it == 0:
m *= float(multipliers.get("illust", 1.0))
elif it == 1:
m *= float(multipliers.get("manga", 1.0))
elif it == 2:
m *= float(multipliers.get("ugoira", 1.0))
else:
m *= float(multipliers.get("unknown_illust_type", 1.0))
if not math.isfinite(float(m)) or float(m) <= 0.0:
return 0.0
return float(m)
base_exclude_set: set[int] = set(int(x) for x in exclude_image_ids or [])
exclude_set: set[int] = set(base_exclude_set)
if bool(anti_repeat_enabled) and recent_image_ids:
exclude_set.update(int(x) for x in recent_image_ids)
# candidates: (image, score_total, multiplier, logit, score_base, freshness_contrib, velocity_contrib)
candidates: list[tuple[Any, float, float, float, float, float, float]] = []
# 批量抽样:一次性取 N 个候选(必要时 wrap-around 再取一次),避免 N 次 DB 循环查询。
# 若用户把某些类别倍率设为 0(例如 manga=0),直接在 SQL 抽样阶段剔除,减少无效候选。
ai_allowed: set[int | None] = set()
if float(multipliers.get("ai", 1.0)) > 0.0:
ai_allowed.add(1)
if float(multipliers.get("non_ai", 1.0)) > 0.0:
ai_allowed.add(0)
if float(multipliers.get("unknown_ai", 1.0)) > 0.0:
ai_allowed.add(None)
illust_allowed: set[int | None] = set()
if float(multipliers.get("illust", 1.0)) > 0.0:
illust_allowed.add(0)
if float(multipliers.get("manga", 1.0)) > 0.0:
illust_allowed.add(1)
if float(multipliers.get("ugoira", 1.0)) > 0.0:
illust_allowed.add(2)
if float(multipliers.get("unknown_illust_type", 1.0)) > 0.0:
illust_allowed.add(None)
if not ai_allowed or not illust_allowed:
return None, {
**debug_base,
"attempts_used": 1,
"picked_by": "quality_weighted" if pick_mode_raw == "weighted" else "quality_best",
"candidates_drawn": 0,
"candidates_accepted": 0,
"quality_pick_mode": pick_mode_raw,
"quality_temperature": float(temperature),
}
images = await pick_random_images(
session,
r=rng.random(),
limit=int(quality_samples_i),
exclude_image_ids=list(exclude_set),
ai_type_allowed=ai_allowed,
illust_type_allowed=illust_allowed,
**pick_kwargs,
)
if not images and bool(anti_repeat_enabled) and recent_image_ids:
images = await pick_random_images(
session,
r=rng.random(),
limit=int(quality_samples_i),
exclude_image_ids=list(base_exclude_set),
ai_type_allowed=ai_allowed,
illust_type_allowed=illust_allowed,
**pick_kwargs,
)
drawn = int(len(images))
accepted = 0
for image in images:
score_base = _quality_score(image, weights=score_weights)
# 时间因素(适度):默认使用 created_at_pixiv;若缺失则 freshness 退化到 added_at,
# 以便新导入/新补全的图片也能获得“新鲜感”提升。
freshness_w = float(score_weights.get("freshness", 0.0)) if bool(time_boost_enabled) else 0.0
velocity_w = float(score_weights.get("bookmark_velocity", 0.0)) if bool(time_boost_enabled) else 0.0
freshness_contrib = 0.0
velocity_contrib = 0.0
if freshness_w != 0.0:
# 半衰期固定为 21 天:足够提升新图,但不会让老图完全失去竞争力。
half_life_days = 21.0
dt_created = _parse_iso_dt(getattr(image, "created_at_pixiv", None))
if dt_created is None:
dt_created = _parse_iso_dt(getattr(image, "added_at", None))
age_days = _age_days(dt_created)
if age_days is not None:
try:
freshness_contrib = float(freshness_w) * math.exp(-float(age_days) / float(half_life_days))
except Exception:
freshness_contrib = 0.0
if velocity_w != 0.0:
dt_created = _parse_iso_dt(getattr(image, "created_at_pixiv", None))
age_days = _age_days(dt_created)
bookmark_count = _as_nonneg_int(getattr(image, "bookmark_count", None))
if age_days is not None and bookmark_count > 0:
try:
# +2 天平滑,避免“刚发布/刚补全”的极端值。
denom = float(age_days) + 2.0
velocity_term = math.log1p(float(bookmark_count) / max(1.0, denom))
velocity_contrib = float(velocity_w) * float(velocity_term)
except Exception:
velocity_contrib = 0.0
score = float(score_base) + float(freshness_contrib) + float(velocity_contrib)
multiplier = _multiplier_for_image(image)
if multiplier <= 0.0:
continue
logit = float(score) / float(temperature) + math.log(float(multiplier))
if bool(anti_repeat_enabled):
try:
if int(getattr(image, "id", 0) or 0) in recent_image_ids:
logit -= 8.0
except Exception:
pass
try:
uid = getattr(image, "user_id", None)
if uid is not None and int(uid) in recent_author_ids:
logit -= 2.5
except Exception:
pass
candidates.append(
(
image,
float(score),
float(multiplier),
float(logit),
float(score_base),
float(freshness_contrib),
float(velocity_contrib),
)
)
accepted += 1
if not candidates:
return None, {
**debug_base,
"attempts_used": 1,
"picked_by": "quality_weighted" if pick_mode_raw == "weighted" else "quality_best",
"candidates_drawn": int(drawn),
"candidates_accepted": int(accepted),
"quality_pick_mode": pick_mode_raw,
"quality_temperature": float(temperature),
}
if pick_mode_raw == "best":
picked = max(candidates, key=lambda x: x[3])
picked_by = "quality_best"
else:
max_logit = max(x[3] for x in candidates)
weights = [math.exp(float(x[3]) - float(max_logit)) for x in candidates]
total = float(sum(weights))
if not math.isfinite(total) or total <= 0.0:
picked = max(candidates, key=lambda x: x[3])
picked_by = "quality_best"
else:
r = float(rng.random()) * total
idx = 0
for i, w in enumerate(weights):
r -= float(w)
if r <= 0:
idx = i
break
picked = candidates[int(max(0, min(idx, len(candidates) - 1)))]
picked_by = "quality_weighted"
best_image, best_score, best_multiplier, _best_logit, best_base, best_fresh, best_vel = picked
return (
best_image,
{
**debug_base,
"attempts_used": 1,
"picked_by": picked_by,
"candidates_drawn": int(drawn),
"candidates_accepted": int(accepted),
"quality_pick_mode": pick_mode_raw,
"quality_temperature": float(temperature),
"quality_score": float(best_score),
"quality_score_base": float(best_base),
"quality_score_freshness": float(best_fresh),
"quality_score_bookmark_velocity": float(best_vel),
"quality_multiplier": float(best_multiplier),
},
)
def _needs_opportunistic_hydrate(image: Any) -> bool:
return (
getattr(image, "width", None) is None
or getattr(image, "height", None) is None
or getattr(image, "x_restrict", None) is None
or getattr(image, "ai_type", None) is None
or getattr(image, "illust_type", None) is None
or getattr(image, "user_id", None) is None
or getattr(image, "bookmark_count", None) is None
or getattr(image, "view_count", None) is None
or getattr(image, "comment_count", None) is None
)
if format in {"json", "simple_json"} or (format == "image" and redirect == 1):
tags: list[str] = []
debug: dict[str, Any] = {}
async with Session() as session:
image, debug = await _pick_with_strategy(session=session)
if image is None:
raise _no_match_error()
if format == "json":
tags = await get_tag_names_for_image(session, image_id=image.id)
if bool(anti_repeat_enabled):
try:
_record_recent(
now=time.monotonic(),
image_id=int(image.id),
user_id=int(image.user_id) if getattr(image, "user_id", None) is not None else None,
)
except Exception:
pass
if _needs_opportunistic_hydrate(image):
background_tasks.add_task(
_best_effort,
enqueue_opportunistic_hydrate_metadata,
engine,
illust_id=int(image.illust_id),
reason="random",
timeout_s=2.5,
)
if format == "image" and redirect == 1:
qp: list[tuple[str, str]] = []
if proxy_override is not None:
qp.append(("proxy", str(proxy_override)))
else:
if int(pixiv_cat) == 1:
qp.append(("pixiv_cat", "1"))
if pximg_mirror_host_override is not None:
qp.append(("pximg_mirror_host", str(pximg_mirror_host_override)))
qs = ("?" + "&".join([f"{k}={v}" for k, v in qp])) if qp else ""
resp = RedirectResponse(
url=f"/i/{image.id}.{image.ext}{qs}",
status_code=302,
headers={"Cache-Control": "no-store"},
)
try:
if getattr(resp, "background", None) is None:
resp.background = background_tasks
except Exception:
pass
return resp
origin_url = None if runtime.hide_origin_url_in_public_json else image.original_url
imgproxy_url = None
try:
cfg = load_imgproxy_config_from_settings(request.app.state.settings)
except Exception:
cfg = None
if cfg is not None:
try:
if runtime.hide_origin_url_in_public_json:
base = str(getattr(request, "base_url", "") or "").rstrip("/")
source_url = f"{base}/i/{image.id}.{image.ext}"
else:
source_url = str(image.original_url)
imgproxy_url = build_signed_processing_url(cfg, source_url=source_url, extension=str(image.ext))
except Exception:
imgproxy_url = None
if format == "simple_json":
return {
"ok": True,
"code": "OK",
"request_id": getattr(getattr(request, "state", None), "request_id", None) or "req_unknown",
"data": {
"image": {
"id": str(image.id),
"illust_id": str(image.illust_id),
"page_index": image.page_index,
"ext": image.ext,
"width": image.width,
"height": image.height,
"x_restrict": image.x_restrict,
"ai_type": image.ai_type,
"illust_type": getattr(image, "illust_type", None),
"bookmark_count": getattr(image, "bookmark_count", None),
"view_count": getattr(image, "view_count", None),
"comment_count": getattr(image, "comment_count", None),
"user": {
"id": str(image.user_id) if image.user_id is not None else None,
"name": image.user_name,
},
},
"urls": {
"proxy": f"/i/{image.id}.{image.ext}",
"origin": origin_url,
"imgproxy": imgproxy_url,
},
"debug": {
**debug,
},
},
}
return {
"ok": True,
"code": "OK",
"request_id": getattr(getattr(request, "state", None), "request_id", None) or "req_unknown",
"data": {
"image": {
"id": str(image.id),
"illust_id": str(image.illust_id),
"page_index": image.page_index,
"ext": image.ext,
"width": image.width,
"height": image.height,
"x_restrict": image.x_restrict,
"ai_type": image.ai_type,
"illust_type": getattr(image, "illust_type", None),
"bookmark_count": getattr(image, "bookmark_count", None),
"view_count": getattr(image, "view_count", None),
"comment_count": getattr(image, "comment_count", None),
"user": {
"id": str(image.user_id) if image.user_id is not None else None,
"name": image.user_name,
},
"title": image.title,
"created_at_pixiv": image.created_at_pixiv,
},
"tags": tags,
"urls": {
"proxy": f"/i/{image.id}.{image.ext}",
"origin": origin_url,
"imgproxy": imgproxy_url,
"legacy_single": f"/{image.illust_id}.{image.ext}",
"legacy_multi": f"/{image.illust_id}-{image.page_index + 1}.{image.ext}",
},
"debug": {
**debug,
},
},
}
tried_ids: set[int] = set()
last_error: ApiError | None = None
attempts_i = int(attempts)
runtime_stream = runtime
for _ in range(attempts_i):
async with Session() as session:
image, _debug = await _pick_with_strategy(session=session, exclude_image_ids=list(tried_ids))
if image is None:
break
image_id = int(image.id)
origin_url = str(image.original_url)
source_url = rewrite_pximg_to_mirror(origin_url, mirror_host=mirror_host) if use_pixiv_cat else origin_url
illust_id_for_hydrate = int(image.illust_id)
needs_hydrate = _needs_opportunistic_hydrate(image)
should_mark_ok = image.last_ok_at is None or image.last_error_code is not None
user_id_for_recent = int(image.user_id) if getattr(image, "user_id", None) is not None else None
transport = getattr(request.app.state, "httpx_transport", None)
proxy_uri = None
if not use_pixiv_cat:
picked = await select_proxy_uri_for_url(
engine,
request.app.state.settings,
runtime_stream,
url=origin_url,
)
if picked is not None:
proxy_uri = picked.uri
try:
resp = await stream_url(
source_url,
transport=transport,
proxy=proxy_uri,
cache_control="no-store",
range_header=request.headers.get("Range"),
)
if bool(anti_repeat_enabled):
try:
_record_recent(now=time.monotonic(), image_id=int(image_id), user_id=user_id_for_recent)
except Exception:
pass
if should_mark_ok:
background_tasks.add_task(_best_effort, mark_image_ok, engine, image_id=image_id, now=iso_utc_ms(), timeout_s=1.5)
if needs_hydrate:
background_tasks.add_task(
_best_effort,
enqueue_opportunistic_hydrate_metadata,
engine,
illust_id=illust_id_for_hydrate,
reason="random",
timeout_s=2.5,
)
try:
if getattr(resp, "background", None) is None:
resp.background = background_tasks
except Exception:
pass
return resp
except ApiError as exc:
if exc.code in {
ErrorCode.UPSTREAM_STREAM_ERROR,
ErrorCode.UPSTREAM_403,
ErrorCode.UPSTREAM_404,
ErrorCode.UPSTREAM_RATE_LIMIT,
}:
await _best_effort(
mark_image_failure,
engine,
image_id=image_id,
now=iso_utc_ms(),
error_code=exc.code.value,
error_message=exc.message,
timeout_s=1.5,
)
tried_ids.add(image_id)
last_error = exc
continue
raise
if last_error is None:
raise _no_match_error()
raise ApiError(
code=ErrorCode.UPSTREAM_STREAM_ERROR,
message="多次尝试后上游请求仍失败。",
status_code=502,
details={
"attempts_used": len(tried_ids),
"last_upstream_code": last_error.code.value,
},
)