From 53e08901ddbcbd6e9268a6108307c90523990206 Mon Sep 17 00:00:00 2001 From: inliver233 Date: Tue, 24 Feb 2026 03:44:38 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=80=91=E5=B8=83=E6=B5=81?= =?UTF-8?q?=E6=98=BE=E7=A4=BA,=E4=BC=98=E5=8C=96docs=E5=92=8Cstatus?= =?UTF-8?q?=E6=98=BE=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/public/docs_page.py | 34 ++++- backend/app/api/public/images.py | 38 +++-- backend/app/api/public/status_page.py | 45 +----- backend/app/api/public/wtf_page.py | 205 +++++++++++++------------- 4 files changed, 164 insertions(+), 158 deletions(-) diff --git a/backend/app/api/public/docs_page.py b/backend/app/api/public/docs_page.py index 75ec475..36ee5a3 100644 --- a/backend/app/api/public/docs_page.py +++ b/backend/app/api/public/docs_page.py @@ -22,6 +22,10 @@ def _build_docs_html(*, base_url: str) -> str: "img_pixiv_cat": u("/random?pixiv_cat=1"), "img_pixiv_re": u("/random?pixiv_cat=1&pximg_mirror_host=re"), "img_redirect": u("/random?redirect=1"), + "status": u("/status"), + "status_json": u("/status.json"), + "wtf": u("/wtf"), + "wtf_r18": u("/wtf?r18=1"), "json_full": u("/random?format=json"), "json_simple": u("/random?format=simple_json"), "r18_only": u("/random?r18=1"), @@ -35,9 +39,7 @@ def _build_docs_html(*, base_url: str) -> str: "quality_strong": u( "/random?strategy=quality&quality_samples=200&min_pixels=2000000&min_bookmarks=100&illust_type=illust&ai_type=0" ), - "complex": u( - "/random?format=json&r18=1&illust_type=illust&orientation=portrait&min_pixels=2500000&min_bookmarks=25&min_comments=50&included_tags=loli" - ), + "complex": u("/random?r18=1&illust_type=illust&orientation=portrait&min_pixels=2500000&min_bookmarks=2&min_comments=5&included_tags=loli"), "tags_api": u("/tags"), "authors_api": u("/authors"), "swagger": u("/api/docs"), @@ -250,6 +252,8 @@ def _build_docs_html(*, base_url: str) -> str:

质量优先随机 + 强力筛选,支持标签/热度/分辨率/R18/AI/作品类型等组合查询。

/random 直接出图 + /wtf 瀑布流 + /status 运行状态 format=json 返回 JSON strategy=random 纯随机 /api/docs Swagger @@ -294,8 +298,24 @@ def _build_docs_html(*, base_url: str) -> str:
+
+

5) 状态页 / 瀑布流

+

/status 为公开仪表盘:展示 API 状态、图库概览、/random 请求统计;/status.json 为机器可读 JSON。

+
{examples["status"]}
+{examples["status_json"]}
+

/wtf 为瀑布流:参数与 /random 完全一致(例如 r18/标签/分辨率/热度等),页面内部会自动加上 adaptive=1 与 redirect=1 来做屏幕适配与稳定链接。

+
{examples["wtf"]}
+{examples["wtf_r18"]}
+
+

筛选语法速记

+
+ 标签 AND / OR 速记: + AND 用“重复参数”(例如 included_tags=a&included_tags=b), + OR 用同一参数里的 |(例如 included_tags=a|b)。 + 如果你的客户端不方便输入 |,也可以写成 %7C。 +
@@ -340,12 +360,16 @@ def _build_docs_html(*, base_url: str) -> str: - + @@ -390,7 +414,7 @@ def _build_docs_html(*, base_url: str) -> str:

复杂示例(组合查询)

-

示例:R18 + 插画 + 竖图 + 250万像素以上 + 收藏≥25 + 评论≥50 + 标签包含 loli,并返回 JSON。

+

示例:R18 + 插画 + 竖图 + 250万像素以上 + 收藏≥2 + 评论≥5 + 标签包含 loli(直接出图)。

{examples["complex"]}
diff --git a/backend/app/api/public/images.py b/backend/app/api/public/images.py index 4895333..0b483c0 100644 --- a/backend/app/api/public/images.py +++ b/backend/app/api/public/images.py @@ -32,6 +32,8 @@ 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 @router.get("/images") @@ -87,22 +89,40 @@ async def list_images( if min_width < 0 or min_height < 0 or min_pixels < 0: raise ApiError(code=ErrorCode.BAD_REQUEST, message="Unsupported min_*", status_code=400) - def _parse_tags(values: list[str] | None) -> list[str]: + def _parse_tag_filters(values: list[str] | None) -> list[str]: out: list[str] = [] seen: set[str] = set() for raw in values or []: - for part in str(raw).split("|"): - name = part.strip() - if not name or name in seen: - continue - seen.add(name) - out.append(name) + expr = str(raw or "").strip() + if not expr or expr in seen: + continue + seen.add(expr) + out.append(expr) return out - included = _parse_tags(included_tags) - excluded = _parse_tags(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) + + included = _parse_tag_filters(included_tags) + excluded = _parse_tag_filters(excluded_tags) 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) diff --git a/backend/app/api/public/status_page.py b/backend/app/api/public/status_page.py index abf66cb..5875b5c 100644 --- a/backend/app/api/public/status_page.py +++ b/backend/app/api/public/status_page.py @@ -48,33 +48,6 @@ async def _query_gallery_stats(engine) -> dict[str, Any]: (await conn.exec_driver_sql("SELECT COUNT(*) FROM images WHERE status=1 AND ai_type IS NULL;")).scalar_one() ) - tag_id = ( - await conn.exec_driver_sql( - "SELECT id FROM tags WHERE name = ? COLLATE NOCASE LIMIT 1;", - ("loli",), - ) - ).scalar_one_or_none() - if tag_id is None: - loli_count = 0 - loli_found = False - else: - loli_found = True - loli_count = int( - ( - await conn.exec_driver_sql( - """ -SELECT COUNT(DISTINCT it.image_id) -FROM image_tags it -JOIN images i ON i.id = it.image_id -WHERE it.tag_id = ? AND i.status = 1; -""".strip(), - (int(tag_id),), - ) - ).scalar_one() - ) - - non_loli_count = max(0, int(images_enabled) - int(loli_count)) - return { "gallery": { "images_total": images_total, @@ -85,7 +58,6 @@ WHERE it.tag_id = ? AND i.status = 1; "breakdown": { "r18": {"r18": r18_count, "safe": safe_count, "unknown": r18_unknown}, "ai": {"ai": ai_count, "non_ai": non_ai_count, "unknown": ai_unknown}, - "loli": {"loli": loli_count, "non_loli": non_loli_count, "tag_found": bool(loli_found)}, }, } @@ -109,6 +81,7 @@ def _build_status_html(*, base_url: str, status_code: int, payload: dict[str, An gallery = payload.get("gallery") if isinstance(payload.get("gallery"), dict) else {} images_total = _clamp_int(gallery.get("images_total")) + images_enabled = _clamp_int(gallery.get("images_enabled")) illust_total = _clamp_int(gallery.get("illust_total")) authors_total = _clamp_int(gallery.get("authors_total")) @@ -124,7 +97,7 @@ def _build_status_html(*, base_url: str, status_code: int, payload: dict[str, An json_url = u("/status.json") docs_url = u("/docs") random_url = u("/random") - admin_url = u("/admin") + wtf_url = u("/wtf") return f""" @@ -377,7 +350,7 @@ def _build_status_html(*, base_url: str, status_code: int, payload: dict[str, An @@ -388,7 +361,7 @@ def _build_status_html(*, base_url: str, status_code: int, payload: dict[str, An
总图片数
{images_total}
images
总作品数
{illust_total}
DISTINCT illust_id
总作者数
{authors_total}
DISTINCT user_id
-
更多统计
—
+
可用图片数
{images_enabled}
status=1
@@ -410,7 +383,6 @@ def _build_status_html(*, base_url: str, status_code: int, payload: dict[str, An @@ -481,15 +453,6 @@ def _build_status_html(*, base_url: str, status_code: int, payload: dict[str, An ]); return; }} - if (metric === "loli") {{ - const l = bd.loli || {{}}; - note.textContent = (l.tag_found === false) ? "(tags 表中未找到 loli 标签)" : "(标签:loli)"; - renderPie([ - {{ label: "萝莉", value: l.loli || 0, color: "#c07046" }}, - {{ label: "非萝莉", value: l.non_loli || 0, color: "#1f7a56" }}, - ]); - return; - }} // default: r18 note.textContent = "(x_restrict)"; const r = bd.r18 || {{}}; diff --git a/backend/app/api/public/wtf_page.py b/backend/app/api/public/wtf_page.py index 83cceae..ad3dc61 100644 --- a/backend/app/api/public/wtf_page.py +++ b/backend/app/api/public/wtf_page.py @@ -64,7 +64,7 @@ def _build_wtf_html(*, base_url: str) -> str: a:hover {{ text-decoration: underline; }} .wrap {{ - max-width: 1180px; + max-width: 980px; margin: 0 auto; padding: 22px 16px 44px; }} @@ -138,29 +138,18 @@ def _build_wtf_html(*, base_url: str) -> str: .feed {{ margin-top: 14px; - column-width: 320px; - column-gap: 14px; - }} - @media (max-width: 520px) {{ - .feed {{ column-width: auto; column-count: 1; column-gap: 0; }} + display: flex; + flex-direction: column; + gap: 14px; }} .item {{ - break-inside: avoid; - margin: 0 0 14px; border: 1px solid var(--border); background: var(--card); border-radius: 14px; overflow: hidden; box-shadow: 0 10px 30px rgba(40, 24, 16, 0.06); - }} - .item .meta {{ - padding: 8px 10px; - font-size: 12px; - color: rgba(67, 51, 44, 0.72); - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; + content-visibility: auto; + contain-intrinsic-size: 800px; }} .item img {{ width: 100%; @@ -174,12 +163,6 @@ def _build_wtf_html(*, base_url: str) -> str: opacity: 1; transform: translateY(0px); }} - .item.fail {{ - border-color: rgba(180, 35, 24, 0.26); - }} - .item.fail .meta {{ - color: rgba(180, 35, 24, 0.86); - }} .sentinel {{ text-align: center; padding: 18px 0 0; @@ -227,91 +210,103 @@ def _build_wtf_html(*, base_url: str) -> str: info.textContent = baseQuery ? `当前过滤: ?${{baseQuery}}` : "当前过滤: (无)"; let paused = false; - let counter = 0; + let seq = 0; let inflight = 0; - - function buildUrl() {{ - const p = new URLSearchParams(baseParams); - p.set("t", String(Date.now()) + "_" + String(counter++)); - return "/random?" + p.toString(); - }} - - function addItem() {{ - const item = document.createElement("div"); - item.className = "item"; - const img = document.createElement("img"); - img.loading = "lazy"; - img.decoding = "async"; - img.referrerPolicy = "no-referrer"; - - const meta = document.createElement("div"); - meta.className = "meta"; - meta.innerHTML = `#${{String(counter)}}加载中…`; - - let tries = 0; - const setSrc = () => {{ - meta.children[1].textContent = tries > 0 ? `重试 ${{tries}}…` : "加载中…"; - img.src = buildUrl(); - }}; - - img.onload = () => {{ - item.classList.add("loaded"); - meta.children[1].textContent = "OK"; - inflight = Math.max(0, inflight - 1); - updateSentinel(); - maybePrefill(); - }}; - img.onerror = () => {{ - tries += 1; - if (tries <= 3) {{ - setTimeout(setSrc, 200 * tries); - return; - }} - item.classList.add("fail"); - meta.children[1].textContent = "加载失败"; - inflight = Math.max(0, inflight - 1); - updateSentinel(); - maybePrefill(); - }}; - - inflight += 1; - updateSentinel(); - item.appendChild(img); - item.appendChild(meta); - feed.appendChild(item); - setSrc(); - }} - - function batch(n) {{ - const count = Math.max(1, Number(n) || 1); - for (let i = 0; i < count; i++) addItem(); - }} + let rendered = 0; + let target = 0; function isMobile() {{ return window.matchMedia && window.matchMedia("(max-width: 520px)").matches; }} - function updateSentinel() {{ - const items = feed.children.length; - const status = paused ? "已暂停" : "加载中"; - sentinel.textContent = `${{status}} · 已渲染 ${{items}} 张 · in-flight ${{inflight}}`; + function cfg() {{ + const mobile = isMobile(); + const conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection; + const effectiveType = conn && conn.effectiveType ? String(conn.effectiveType) : ""; + const slow = effectiveType.includes("2g") || effectiveType.includes("3g"); + + const base = mobile + ? { initial: 8, step: 6, maxInflight: 8 } + : { initial: 12, step: 10, maxInflight: 14 }; + + return {{ + initial: base.initial, + step: base.step, + maxInflight: slow ? Math.max(4, Math.floor(base.maxInflight / 2)) : base.maxInflight, + }}; }} - function maybePrefill() {{ + function buildUrl() {{ + const p = new URLSearchParams(baseParams); + p.set("t", String(Date.now()) + "_" + String(seq++)); + return "/random?" + p.toString(); + }} + + function updateSentinel() {{ + const status = paused ? "已暂停" : "加载中"; + sentinel.textContent = `${{status}} · 已显示 ${{rendered}} 张 · in-flight ${{inflight}}`; + }} + + function appendItem(imgEl) {{ + const item = document.createElement("div"); + item.className = "item"; + item.appendChild(imgEl); + feed.appendChild(item); + requestAnimationFrame(() => item.classList.add("loaded")); + }} + + function startOne() {{ if (paused) return; - const items = feed.children.length; - const target = isMobile() ? 10 : 18; - if (items < target) {{ - batch(target - items); + const c = cfg(); + if (inflight >= c.maxInflight) return; + if ((rendered + inflight) >= target) return; + + inflight += 1; + updateSentinel(); + + const img = new Image(); + img.decoding = "async"; + img.referrerPolicy = "no-referrer"; + img.alt = ""; + + let tries = 0; + const load = () => {{ + tries += 1; + img.src = buildUrl(); + }}; + + img.onload = () => {{ + inflight = Math.max(0, inflight - 1); + rendered += 1; + appendItem(img); + updateSentinel(); + ensure(); + }}; + img.onerror = () => {{ + if (tries < 3) {{ + setTimeout(load, 250 * tries); + return; + }} + inflight = Math.max(0, inflight - 1); + updateSentinel(); + ensure(); + }}; + + load(); + }} + + function ensure() {{ + if (paused) return; + const c = cfg(); + while (inflight < c.maxInflight && (rendered + inflight) < target) {{ + startOne(); }} }} - function loadMore() {{ - if (paused) return; - const maxInflight = isMobile() ? 10 : 18; - if (inflight >= maxInflight) return; - const n = isMobile() ? 6 : 10; - batch(n); + function bump() {{ + const c = cfg(); + target = Math.max(target, rendered + inflight + c.step); + ensure(); }} toggle.addEventListener("click", () => {{ @@ -319,22 +314,27 @@ def _build_wtf_html(*, base_url: str) -> str: toggle.textContent = paused ? "继续加载" : "暂停加载"; updateSentinel(); if (!paused) {{ - maybePrefill(); - loadMore(); + ensure(); }} }}); const io = new IntersectionObserver((entries) => {{ for (const e of entries) {{ - if (e.isIntersecting) loadMore(); + if (e.isIntersecting) bump(); }} - }}, {{ root: null, rootMargin: "900px 0px", threshold: 0 }}); + }}, {{ root: null, rootMargin: "2200px 0px", threshold: 0 }}); io.observe(sentinel); // Initial fill. - maybePrefill(); - loadMore(); + target = cfg().initial; + ensure(); updateSentinel(); + + window.addEventListener("resize", () => {{ + const c = cfg(); + target = Math.max(target, c.initial); + ensure(); + }}); @@ -349,4 +349,3 @@ async def wtf_page(request: Request) -> HTMLResponse: resp = HTMLResponse(content=html, status_code=200, headers={"Cache-Control": "no-store"}) set_request_id_header(resp, rid) return resp -
必须包含的标签:参数之间是 AND,单个参数内用 | 表示 OR。
例:included_tags=girl|boy&included_tags=white|black 表示 (girl OR boy) AND (white OR black)。
+ 提示:URL 里的 & 是“参数分隔符”,要写 AND 就重复参数;要写 OR 就在同一参数里用 |。
示例:{examples["tag_loli"]}
excluded_tags必须不包含的标签(OR)。可重复传参。示例:excluded_tags=AI生成 + 必须不包含的标签:任意命中即排除。可重复传参:excluded_tags=a&excluded_tags=b。
+ excluded_tags=a|b 等价于排除 a 或 b(效果同上)。 +
min_width
min_height
min_pixels