1426 lines
57 KiB
Python
1426 lines
57 KiB
Python
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 global de-dup (process-local): reduce short-term duplicates without extra DB writes.
|
|
_RECENT_LOCK = Lock()
|
|
_RECENT_IMAGES: deque[tuple[float, int]] = deque()
|
|
_RECENT_AUTHORS: deque[tuple[float, int]] = deque()
|
|
|
|
|
|
def _prune_recent(now: float, *, window_s: float, max_images: int, max_authors: int) -> None:
|
|
cutoff = float(now) - float(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(max_images):
|
|
_RECENT_IMAGES.popleft()
|
|
while len(_RECENT_AUTHORS) > int(max_authors):
|
|
_RECENT_AUTHORS.popleft()
|
|
|
|
|
|
def _get_recent_sets(now: float, *, window_s: float, max_images: int, max_authors: int) -> tuple[set[int], set[int]]:
|
|
with _RECENT_LOCK:
|
|
_prune_recent(now, window_s=float(window_s), max_images=int(max_images), max_authors=int(max_authors))
|
|
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,
|
|
window_s: float,
|
|
max_images: int,
|
|
max_authors: int,
|
|
) -> 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, window_s=float(window_s), max_images=int(max_images), max_authors=int(max_authors))
|
|
_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_bool(value: Any) -> bool | None:
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, int) and value in (0, 1):
|
|
return bool(value)
|
|
if isinstance(value, str):
|
|
v = value.strip().lower()
|
|
if v in {"true", "1", "yes", "y", "on"}:
|
|
return True
|
|
if v in {"false", "0", "no", "n", "off"}:
|
|
return False
|
|
return None
|
|
|
|
|
|
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),
|
|
# Newness/trending knobs:
|
|
# - freshness_half_life_days: used by the freshness decay term (see scoring).
|
|
# - velocity_smooth_days: smoothing for bookmark velocity denominator (age_days + smooth).
|
|
"freshness_half_life_days": 21.0,
|
|
"velocity_smooth_days": 2.0,
|
|
"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 _parse_recommendation_overrides_from_query(query_params: Any) -> tuple[dict[str, Any], list[str]]:
|
|
qp = query_params or {}
|
|
used: list[str] = []
|
|
overrides: dict[str, Any] = {}
|
|
|
|
def _get_str(name: str) -> str | None:
|
|
try:
|
|
raw = qp.get(name)
|
|
except Exception:
|
|
raw = None
|
|
if raw is None:
|
|
return None
|
|
s = str(raw).strip()
|
|
if not s:
|
|
return None
|
|
used.append(name)
|
|
return s
|
|
|
|
def _get_float(name: str) -> float | None:
|
|
try:
|
|
raw = qp.get(name)
|
|
except Exception:
|
|
raw = None
|
|
if raw is None:
|
|
return None
|
|
v = _as_float(raw, default=float("nan"))
|
|
if not math.isfinite(float(v)):
|
|
raise ApiError(code=ErrorCode.BAD_REQUEST, message=f"Unsupported {name}", status_code=400)
|
|
used.append(name)
|
|
return float(v)
|
|
|
|
# Be strict for rec_* prefixes to avoid silent typos.
|
|
try:
|
|
keys = list(qp.keys())
|
|
except Exception:
|
|
keys = []
|
|
for k in keys:
|
|
if not isinstance(k, str):
|
|
continue
|
|
if k.startswith("rec_w_"):
|
|
weight_key = k.removeprefix("rec_w_")
|
|
if weight_key not in _DEFAULT_SCORE_WEIGHTS:
|
|
raise ApiError(code=ErrorCode.BAD_REQUEST, message=f"Unsupported {k}", status_code=400)
|
|
if k.startswith("rec_m_"):
|
|
mult_key = k.removeprefix("rec_m_")
|
|
if mult_key not in _DEFAULT_RECOMMENDATION["multipliers"]:
|
|
raise ApiError(code=ErrorCode.BAD_REQUEST, message=f"Unsupported {k}", status_code=400)
|
|
|
|
pick_mode_s = _get_str("rec_pick_mode")
|
|
if pick_mode_s is not None:
|
|
candidate = pick_mode_s.strip().lower()
|
|
if candidate not in {"best", "weighted"}:
|
|
raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported rec_pick_mode", status_code=400)
|
|
overrides["pick_mode"] = candidate
|
|
|
|
temperature_v = _get_float("rec_temperature")
|
|
if temperature_v is not None:
|
|
overrides["temperature"] = float(max(0.05, min(float(temperature_v), 100.0)))
|
|
|
|
freshness_half_life_v = _get_float("rec_fresh_half_life_days")
|
|
if freshness_half_life_v is not None:
|
|
overrides["freshness_half_life_days"] = float(max(0.1, min(float(freshness_half_life_v), 3650.0)))
|
|
|
|
velocity_smooth_v = _get_float("rec_velocity_smooth_days")
|
|
if velocity_smooth_v is not None:
|
|
overrides["velocity_smooth_days"] = float(max(0.0, min(float(velocity_smooth_v), 3650.0)))
|
|
|
|
score_overrides: dict[str, float] = {}
|
|
for key in _DEFAULT_SCORE_WEIGHTS.keys():
|
|
v = _get_float(f"rec_w_{key}")
|
|
if v is None:
|
|
continue
|
|
score_overrides[key] = float(max(-100.0, min(float(v), 100.0)))
|
|
if score_overrides:
|
|
overrides["score_weights"] = score_overrides
|
|
|
|
mult_overrides: dict[str, float] = {}
|
|
for key in _DEFAULT_RECOMMENDATION["multipliers"].keys():
|
|
v = _get_float(f"rec_m_{key}")
|
|
if v is None:
|
|
continue
|
|
mult_overrides[key] = float(max(0.0, min(float(v), 100.0)))
|
|
if mult_overrides:
|
|
overrides["multipliers"] = mult_overrides
|
|
|
|
return overrides, used
|
|
|
|
|
|
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)
|
|
dedup_enabled_setting = True
|
|
dedup_window_s = 20.0 * 60.0
|
|
dedup_max_images = 5000
|
|
dedup_max_authors = 2000
|
|
dedup_strict = False
|
|
dedup_image_penalty = 8.0
|
|
dedup_author_penalty = 2.5
|
|
dedup_raw = random_defaults.get("dedup")
|
|
if isinstance(dedup_raw, dict):
|
|
v = _as_bool(dedup_raw.get("enabled"))
|
|
if v is not None:
|
|
dedup_enabled_setting = bool(v)
|
|
|
|
window_raw = dedup_raw.get("window_s")
|
|
if window_raw is not None:
|
|
try:
|
|
dedup_window_s = float(max(0.0, min(float(window_raw), 24.0 * 60.0 * 60.0)))
|
|
except Exception:
|
|
pass
|
|
|
|
max_images_raw = dedup_raw.get("max_images")
|
|
if max_images_raw is not None:
|
|
try:
|
|
dedup_max_images = int(max(1, min(int(max_images_raw), 200_000)))
|
|
except Exception:
|
|
pass
|
|
|
|
max_authors_raw = dedup_raw.get("max_authors")
|
|
if max_authors_raw is not None:
|
|
try:
|
|
dedup_max_authors = int(max(1, min(int(max_authors_raw), 200_000)))
|
|
except Exception:
|
|
pass
|
|
|
|
v = _as_bool(dedup_raw.get("strict"))
|
|
if v is not None:
|
|
dedup_strict = bool(v)
|
|
|
|
image_pen_raw = dedup_raw.get("image_penalty")
|
|
if image_pen_raw is not None:
|
|
try:
|
|
v = float(image_pen_raw)
|
|
if math.isfinite(v):
|
|
dedup_image_penalty = float(max(0.0, min(v, 1000.0)))
|
|
except Exception:
|
|
pass
|
|
|
|
author_pen_raw = dedup_raw.get("author_penalty")
|
|
if author_pen_raw is not None:
|
|
try:
|
|
v = float(author_pen_raw)
|
|
if math.isfinite(v):
|
|
dedup_author_penalty = float(max(0.0, min(v, 1000.0)))
|
|
except Exception:
|
|
pass
|
|
|
|
anti_repeat_enabled = bool(dedup_enabled_setting) and 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(),
|
|
window_s=float(dedup_window_s),
|
|
max_images=int(dedup_max_images),
|
|
max_authors=int(dedup_max_authors),
|
|
)
|
|
|
|
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)
|
|
|
|
rec_overrides, rec_override_keys = _parse_recommendation_overrides_from_query(getattr(request, "query_params", None))
|
|
if rec_overrides:
|
|
recommendation_source = "query"
|
|
# Shallow merge: keep unspecified runtime defaults, override only what user passes.
|
|
recommendation_obj = dict(recommendation_obj)
|
|
for k, v in rec_overrides.items():
|
|
if k in {"score_weights", "multipliers"}:
|
|
base_raw = recommendation_obj.get(k)
|
|
base = dict(base_raw) if isinstance(base_raw, dict) else {}
|
|
if isinstance(v, dict):
|
|
base.update(v)
|
|
recommendation_obj[k] = base
|
|
else:
|
|
recommendation_obj[k] = v
|
|
|
|
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)))
|
|
|
|
freshness_half_life_days_default = float(_DEFAULT_RECOMMENDATION["freshness_half_life_days"])
|
|
freshness_half_life_days = freshness_half_life_days_default
|
|
if "freshness_half_life_days" in recommendation_obj:
|
|
v = _as_float(recommendation_obj.get("freshness_half_life_days"), default=float("nan"))
|
|
if math.isfinite(float(v)):
|
|
freshness_half_life_days = float(max(0.1, min(float(v), 3650.0)))
|
|
|
|
velocity_smooth_days_default = float(_DEFAULT_RECOMMENDATION["velocity_smooth_days"])
|
|
velocity_smooth_days = velocity_smooth_days_default
|
|
if "velocity_smooth_days" in recommendation_obj:
|
|
v = _as_float(recommendation_obj.get("velocity_smooth_days"), default=float("nan"))
|
|
if math.isfinite(float(v)):
|
|
velocity_smooth_days = float(max(0.0, min(float(v), 3650.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),
|
|
"dedup_enabled": bool(dedup_enabled_setting),
|
|
"dedup_window_s": float(dedup_window_s),
|
|
"dedup_max_images": int(dedup_max_images),
|
|
"dedup_max_authors": int(dedup_max_authors),
|
|
"dedup_strict": bool(dedup_strict),
|
|
"dedup_image_penalty": float(dedup_image_penalty),
|
|
"dedup_author_penalty": float(dedup_author_penalty),
|
|
"time_boost_enabled": bool(time_boost_enabled),
|
|
"recommendation_source": recommendation_source,
|
|
"recommendation_query_overrides": list(rec_override_keys or []),
|
|
"freshness_half_life_days": float(freshness_half_life_days),
|
|
"velocity_smooth_days": float(velocity_smooth_days),
|
|
}
|
|
|
|
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 bool(recent_image_ids) and not bool(dedup_strict):
|
|
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 bool(recent_image_ids) and not bool(dedup_strict):
|
|
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)
|
|
|
|
# 时间因素:
|
|
# - freshness: 对“老图”做衰减(loss),避免老热门长期统治(可通过权重/半衰期调节强度)。
|
|
# - bookmark_velocity: 提升“成长速度快”的作品,避免只看总收藏数。
|
|
#
|
|
# 默认使用 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:
|
|
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:
|
|
# log(decay) = - age/half_life, i.e. a true time-decay loss factor.
|
|
freshness_contrib = (-1.0) * float(freshness_w) * (float(age_days) / float(freshness_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:
|
|
# 平滑,避免“刚发布/刚补全”的极端值。
|
|
denom = float(age_days) + float(velocity_smooth_days)
|
|
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 -= float(dedup_image_penalty)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
uid = getattr(image, "user_id", None)
|
|
if uid is not None and int(uid) in recent_author_ids:
|
|
logit -= float(dedup_author_penalty)
|
|
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,
|
|
window_s=float(dedup_window_s),
|
|
max_images=int(dedup_max_images),
|
|
max_authors=int(dedup_max_authors),
|
|
)
|
|
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,
|
|
window_s=float(dedup_window_s),
|
|
max_images=int(dedup_max_images),
|
|
max_authors=int(dedup_max_authors),
|
|
)
|
|
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,
|
|
},
|
|
)
|