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/作品类型等组合查询。
+
+ 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:
必须包含的标签:参数之间是 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_widthmin_heightmin_pixels
@@ -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
总作品数
{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
R18 / 非 R18
AI / 非 AI
- 萝莉 / 非萝莉
@@ -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();
+ }});