initial import: random background api
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.build-venv/
|
||||
.wrangler/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
54
README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# 随机背景API
|
||||
|
||||
一个可直接部署到 Cloudflare Pages 的静态站点 + Pages Functions API,用来按设备方向随机返回背景图。
|
||||
|
||||
## 目录约定
|
||||
|
||||
- `mobile-image/`:竖屏图片原图上传目录
|
||||
- `desketop-image/`:横屏图片原图上传目录
|
||||
|
||||
## 使用流程
|
||||
|
||||
1. 把图片上传到对应目录。
|
||||
2. 运行构建脚本:
|
||||
```bash
|
||||
python3 build.py
|
||||
```
|
||||
3. 把项目根目录作为 Cloudflare Pages 的发布目录部署。
|
||||
4. 打开:
|
||||
- 页面:`/`
|
||||
- API:`/api/random`
|
||||
|
||||
## API
|
||||
|
||||
- `GET /api/random`
|
||||
- 默认按 `mode=auto` 随机返回图片重定向
|
||||
- `GET /api/random?format=json`
|
||||
- 返回 JSON
|
||||
- `GET /api/list`
|
||||
- 返回图片清单
|
||||
|
||||
## 组件嵌入
|
||||
|
||||
```html
|
||||
<script src="https://random-background-api.pages.dev/widget.js"></script>
|
||||
<random-background-widget
|
||||
src="https://random-background-api.pages.dev"
|
||||
mode="auto"
|
||||
blur="12"
|
||||
radius="16px"
|
||||
height="260px">
|
||||
</random-background-widget>
|
||||
```
|
||||
|
||||
`widget.js` 只读取静态的 `manifest.json` 和图片文件,不会调用 `api/random`。
|
||||
|
||||
## 构建说明
|
||||
|
||||
`build.py` 会把目录里的原图转换成同目录下的编号 `webp` 文件,例如:
|
||||
|
||||
- `1.webp`
|
||||
- `2.webp`
|
||||
- `55.webp`
|
||||
|
||||
同时生成 `manifest.json`,供 Pages Functions 读取。
|
||||
13
_headers
Normal file
@@ -0,0 +1,13 @@
|
||||
/manifest.json
|
||||
Access-Control-Allow-Origin: *
|
||||
Cache-Control: public, max-age=300
|
||||
|
||||
/widget.js
|
||||
Access-Control-Allow-Origin: *
|
||||
Cache-Control: public, max-age=300
|
||||
|
||||
/mobile-image/*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/desketop-image/*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
161
build.py
Normal file
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import venv
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
MANIFEST_PATH = BASE_DIR / "manifest.json"
|
||||
|
||||
VARIANTS = {
|
||||
"mobile": [BASE_DIR / "mobile-image"],
|
||||
"desktop": [BASE_DIR / "desketop-image"],
|
||||
}
|
||||
|
||||
INPUT_EXTENSIONS = {
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".png",
|
||||
".bmp",
|
||||
".gif",
|
||||
".tif",
|
||||
".tiff",
|
||||
".webp",
|
||||
}
|
||||
|
||||
GENERATED_NAME_RE = re.compile(r"^\d+\.webp$", re.IGNORECASE)
|
||||
VENV_DIR = BASE_DIR / ".build-venv"
|
||||
|
||||
|
||||
def ensure_pillow() -> None:
|
||||
try:
|
||||
from PIL import Image # noqa: F401
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
venv_python = VENV_DIR / "bin" / "python"
|
||||
if not venv_python.exists():
|
||||
print("Pillow not found, creating a local build venv...", file=sys.stderr)
|
||||
builder = venv.EnvBuilder(with_pip=True)
|
||||
builder.create(VENV_DIR)
|
||||
|
||||
has_pillow = subprocess.run(
|
||||
[str(venv_python), "-c", "import PIL"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
).returncode == 0
|
||||
|
||||
if not has_pillow:
|
||||
print("Installing Pillow into the local build venv...", file=sys.stderr)
|
||||
subprocess.check_call([str(venv_python), "-m", "pip", "install", "--quiet", "Pillow"])
|
||||
os.execv(str(venv_python), [str(venv_python), str(Path(__file__).resolve()), *sys.argv[1:]])
|
||||
|
||||
|
||||
def load_pillow():
|
||||
ensure_pillow()
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
return Image, ImageOps
|
||||
|
||||
|
||||
def iter_source_files(directory: Path) -> list[Path]:
|
||||
if not directory.exists():
|
||||
return []
|
||||
files: list[Path] = []
|
||||
for path in sorted(directory.iterdir(), key=lambda item: item.name.lower()):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if GENERATED_NAME_RE.match(path.name):
|
||||
continue
|
||||
if path.suffix.lower() not in INPUT_EXTENSIONS:
|
||||
continue
|
||||
files.append(path)
|
||||
return files
|
||||
|
||||
|
||||
def select_source_directory(candidates: list[Path]) -> Path:
|
||||
for directory in candidates:
|
||||
if iter_source_files(directory):
|
||||
return directory
|
||||
for directory in candidates:
|
||||
if directory.exists():
|
||||
return directory
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def clear_generated_files(directory: Path) -> None:
|
||||
if not directory.exists():
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
return
|
||||
for path in directory.iterdir():
|
||||
if path.is_file() and GENERATED_NAME_RE.match(path.name):
|
||||
path.unlink()
|
||||
|
||||
|
||||
def convert_to_webp(source: Path, target: Path, image_mod, image_ops) -> None:
|
||||
with image_mod.open(source) as image:
|
||||
image = image_ops.exif_transpose(image)
|
||||
if image.mode not in {"RGB", "RGBA", "LA", "P"}:
|
||||
image = image.convert("RGBA")
|
||||
elif image.mode == "P":
|
||||
image = image.convert("RGBA")
|
||||
elif image.mode == "LA":
|
||||
image = image.convert("RGBA")
|
||||
elif image.mode == "RGB":
|
||||
pass
|
||||
image.save(target, format="WEBP", quality=92, method=6)
|
||||
|
||||
|
||||
def build_variant(name: str, directories: list[Path], image_mod, image_ops) -> dict:
|
||||
directory = select_source_directory(directories)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
clear_generated_files(directory)
|
||||
|
||||
sources = iter_source_files(directory)
|
||||
images: list[str] = []
|
||||
for index, source in enumerate(sources, start=1):
|
||||
target = directory / f"{index}.webp"
|
||||
try:
|
||||
convert_to_webp(source, target, image_mod, image_ops)
|
||||
images.append(target.name)
|
||||
print(f"[{name}] {source.name} -> {target.name}")
|
||||
except Exception as exc:
|
||||
print(f"[{name}] skip {source.name}: {exc}", file=sys.stderr)
|
||||
|
||||
return {
|
||||
"folder": directory.name,
|
||||
"count": len(images),
|
||||
"images": images,
|
||||
}
|
||||
|
||||
|
||||
def write_manifest(manifest: dict) -> None:
|
||||
MANIFEST_PATH.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
image_mod, image_ops = load_pillow()
|
||||
|
||||
manifest = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"variants": {},
|
||||
}
|
||||
|
||||
for name, directories in VARIANTS.items():
|
||||
manifest["variants"][name] = build_variant(name, directories, image_mod, image_ops)
|
||||
|
||||
write_manifest(manifest)
|
||||
print(f"Wrote manifest: {MANIFEST_PATH}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
1
desketop-image/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
BIN
desketop-image/1.webp
Normal file
|
After Width: | Height: | Size: 918 KiB |
BIN
desketop-image/10.webp
Normal file
|
After Width: | Height: | Size: 340 KiB |
BIN
desketop-image/11.webp
Normal file
|
After Width: | Height: | Size: 647 KiB |
BIN
desketop-image/12.webp
Normal file
|
After Width: | Height: | Size: 229 KiB |
BIN
desketop-image/13.webp
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
desketop-image/14.webp
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
desketop-image/15.webp
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
desketop-image/16.webp
Normal file
|
After Width: | Height: | Size: 258 KiB |
BIN
desketop-image/17.webp
Normal file
|
After Width: | Height: | Size: 707 KiB |
BIN
desketop-image/18.webp
Normal file
|
After Width: | Height: | Size: 609 KiB |
BIN
desketop-image/19.webp
Normal file
|
After Width: | Height: | Size: 324 KiB |
BIN
desketop-image/2.webp
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
desketop-image/3.webp
Normal file
|
After Width: | Height: | Size: 513 KiB |
BIN
desketop-image/3yfvfgrD.webp
Normal file
|
After Width: | Height: | Size: 939 KiB |
BIN
desketop-image/4.webp
Normal file
|
After Width: | Height: | Size: 918 KiB |
BIN
desketop-image/5.webp
Normal file
|
After Width: | Height: | Size: 545 KiB |
BIN
desketop-image/5CrdoShv.webp
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
desketop-image/5E2wsNlc.webp
Normal file
|
After Width: | Height: | Size: 551 KiB |
BIN
desketop-image/6.webp
Normal file
|
After Width: | Height: | Size: 207 KiB |
BIN
desketop-image/7.webp
Normal file
|
After Width: | Height: | Size: 608 KiB |
BIN
desketop-image/8.webp
Normal file
|
After Width: | Height: | Size: 595 KiB |
BIN
desketop-image/9.webp
Normal file
|
After Width: | Height: | Size: 338 KiB |
BIN
desketop-image/AGWXMclV.webp
Normal file
|
After Width: | Height: | Size: 938 KiB |
BIN
desketop-image/IIbmur0t.webp
Normal file
|
After Width: | Height: | Size: 351 KiB |
BIN
desketop-image/ItOJOHST.webp
Normal file
|
After Width: | Height: | Size: 684 KiB |
BIN
desketop-image/L0nQHehz.webp
Normal file
|
After Width: | Height: | Size: 239 KiB |
BIN
desketop-image/RK0sjvKj.webp
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
desketop-image/XI78fkzD.webp
Normal file
|
After Width: | Height: | Size: 728 KiB |
BIN
desketop-image/atm7rt6D.webp
Normal file
|
After Width: | Height: | Size: 572 KiB |
BIN
desketop-image/c2HxMuGK.webp
Normal file
|
After Width: | Height: | Size: 216 KiB |
BIN
desketop-image/cUDkKiOf.webp
Normal file
|
After Width: | Height: | Size: 610 KiB |
BIN
desketop-image/cuSpSkq4.webp
Normal file
|
After Width: | Height: | Size: 622 KiB |
BIN
desketop-image/hj64Cqxn.webp
Normal file
|
After Width: | Height: | Size: 348 KiB |
BIN
desketop-image/mrSQGVzt.webp
Normal file
|
After Width: | Height: | Size: 280 KiB |
BIN
desketop-image/mtQKTMpU.webp
Normal file
|
After Width: | Height: | Size: 2.4 MiB |
BIN
desketop-image/nhS9Wnnh.webp
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
desketop-image/xTsVkCli.webp
Normal file
|
After Width: | Height: | Size: 634 KiB |
BIN
desketop-image/zr6atT6m.webp
Normal file
|
After Width: | Height: | Size: 345 KiB |
22
functions/api/list.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { getVariantPayload, loadManifest } from '../lib/background.js';
|
||||
|
||||
export async function onRequestGet(context) {
|
||||
const { request } = context;
|
||||
const manifest = await loadManifest(request);
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
generated_at: manifest?.generated_at || null,
|
||||
variants: {
|
||||
mobile: summarizeVariant(manifest, 'mobile'),
|
||||
desktop: summarizeVariant(manifest, 'desktop'),
|
||||
},
|
||||
},
|
||||
{ headers: { 'Cache-Control': 'no-store, max-age=0', 'Access-Control-Allow-Origin': '*' } },
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeVariant(manifest, variant) {
|
||||
const { folder, images } = getVariantPayload(manifest, variant);
|
||||
return { folder, count: images.length, images };
|
||||
}
|
||||
36
functions/api/random.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import { buildAssetUrl, detectVariant, getVariantPayload, loadManifest, pickRandom } from '../lib/background.js';
|
||||
|
||||
export async function onRequestGet(context) {
|
||||
const { request } = context;
|
||||
const url = new URL(request.url);
|
||||
const format = (url.searchParams.get('format') || 'redirect').toLowerCase();
|
||||
const manifest = await loadManifest(request);
|
||||
const variant = detectVariant(request);
|
||||
const { folder, images } = getVariantPayload(manifest, variant);
|
||||
|
||||
if (!images.length) {
|
||||
return Response.json(
|
||||
{ error: 'no images available', variant, folder },
|
||||
{ status: 404, headers: { 'Cache-Control': 'no-store, max-age=0', 'Access-Control-Allow-Origin': '*' } },
|
||||
);
|
||||
}
|
||||
|
||||
const filename = pickRandom(images);
|
||||
const assetUrl = buildAssetUrl(request, folder, filename);
|
||||
|
||||
if (format === 'json') {
|
||||
return Response.json(
|
||||
{ variant, folder, filename, url: assetUrl, count: images.length },
|
||||
{ headers: { 'Cache-Control': 'no-store, max-age=0', 'Access-Control-Allow-Origin': '*' } },
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: assetUrl,
|
||||
'Cache-Control': 'no-store, max-age=0',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
}
|
||||
52
functions/lib/background.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const FALLBACK_MANIFEST = {
|
||||
variants: {
|
||||
mobile: { folder: 'mobile-image', images: [] },
|
||||
desktop: { folder: 'desketop-image', images: [] },
|
||||
},
|
||||
};
|
||||
|
||||
export async function loadManifest(request) {
|
||||
try {
|
||||
const response = await fetch(new URL('/manifest.json', request.url));
|
||||
if (!response.ok) return FALLBACK_MANIFEST;
|
||||
return await response.json();
|
||||
} catch {
|
||||
return FALLBACK_MANIFEST;
|
||||
}
|
||||
}
|
||||
|
||||
export function detectVariant(request) {
|
||||
const url = new URL(request.url);
|
||||
const params = url.searchParams;
|
||||
const mode = (params.get('mode') || 'auto').toLowerCase();
|
||||
const orientation = (params.get('orientation') || '').toLowerCase();
|
||||
const device = (params.get('device') || '').toLowerCase();
|
||||
const ua = (request.headers.get('user-agent') || '').toLowerCase();
|
||||
const chMobile = request.headers.get('sec-ch-ua-mobile');
|
||||
|
||||
if (mode === 'mobile' || mode === 'desktop') return mode;
|
||||
if (orientation === 'portrait' || orientation === 'vertical') return 'mobile';
|
||||
if (orientation === 'landscape' || orientation === 'horizontal') return 'desktop';
|
||||
if (device === 'mobile' || device === 'desktop') return device;
|
||||
if (chMobile === '?1') return 'mobile';
|
||||
if (chMobile === '?0') return 'desktop';
|
||||
if (['mobi', 'android', 'iphone', 'ipad', 'ipod', 'phone'].some((token) => ua.includes(token))) return 'mobile';
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
export function getVariantPayload(manifest, variant) {
|
||||
const data = manifest?.variants?.[variant] || {};
|
||||
const folder = data.folder || (variant === 'mobile' ? 'mobile-image' : 'desketop-image');
|
||||
const images = Array.isArray(data.images)
|
||||
? data.images.filter((name) => typeof name === 'string' && name.toLowerCase().endsWith('.webp'))
|
||||
: [];
|
||||
return { folder, images };
|
||||
}
|
||||
|
||||
export function buildAssetUrl(request, folder, filename) {
|
||||
return new URL(`/${folder}/${filename}`, request.url).toString();
|
||||
}
|
||||
|
||||
export function pickRandom(items) {
|
||||
return items[Math.floor(Math.random() * items.length)];
|
||||
}
|
||||
390
index.html
Normal file
@@ -0,0 +1,390 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>随机背景 API</title>
|
||||
<meta name="description" content="Cloudflare Pages 随机背景 API 文档与在线预览。">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #f6f7fb;
|
||||
--panel: #fff;
|
||||
--text: #172033;
|
||||
--muted: #5b657a;
|
||||
--line: #e3e7ef;
|
||||
--soft: #f3f5f9;
|
||||
--accent: #2563eb;
|
||||
--code: #0b1220;
|
||||
--shadow: 0 10px 28px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(180deg, #fff 0%, var(--bg) 100%);
|
||||
color: var(--text);
|
||||
font: 14px/1.55 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
.wrap {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.top {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: minmax(0, 1fr) 250px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.main {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--shadow);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pad { padding: 12px; }
|
||||
|
||||
h1, h2, p { margin: 0; }
|
||||
h1 {
|
||||
font-size: clamp(24px, 3vw, 34px);
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
h2 {
|
||||
font-size: 15px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin-top: 6px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--soft);
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.links {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.endpoint {
|
||||
padding: 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.endpoint strong {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.code {
|
||||
margin: 0;
|
||||
padding: 9px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--code);
|
||||
color: #e7eefc;
|
||||
overflow-x: auto;
|
||||
font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
select, button, input {
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr)) 100px;
|
||||
}
|
||||
|
||||
.preview-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.preview-box {
|
||||
aspect-ratio: 16 / 10;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
background: #eef2f7;
|
||||
}
|
||||
|
||||
.preview-box img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.small {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.top, .main, .grid-2, .preview-grid, .row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="wrap">
|
||||
<section class="top">
|
||||
<div class="card pad">
|
||||
<h1>随机背景 API</h1>
|
||||
<p class="lead">Cloudflare Pages 随机背景接口。上传图片后先用 `build.py` 生成序号 `.webp`,然后可通过 `/api/random` 和 `/api/list` 调用。</p>
|
||||
<div class="meta">
|
||||
<span class="tag">竖屏:mobile-image</span>
|
||||
<span class="tag">横屏:desketop-image</span>
|
||||
<span class="tag">返回:302 / JSON</span>
|
||||
<span class="tag">组件:widget.js</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card pad">
|
||||
<h2>快速链接</h2>
|
||||
<div class="links">
|
||||
<a href="/api/list" target="_blank" rel="noreferrer">/api/list</a>
|
||||
<a href="/manifest.json" target="_blank" rel="noreferrer">manifest.json</a>
|
||||
<a href="/mobile-image/1.webp" target="_blank" rel="noreferrer">mobile-image/1.webp</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="main">
|
||||
<div class="card pad section" id="api">
|
||||
<h2>API 调用</h2>
|
||||
<div class="grid-2">
|
||||
<div class="endpoint">
|
||||
<strong>随机图片</strong>
|
||||
<div class="small">默认 302 跳转到随机图片。</div>
|
||||
<pre class="code">GET /api/random</pre>
|
||||
</div>
|
||||
<div class="endpoint">
|
||||
<strong>返回 JSON</strong>
|
||||
<div class="small">适合前端自己处理图片地址。</div>
|
||||
<pre class="code">GET /api/random?format=json</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
<div class="endpoint">
|
||||
<strong>强制竖屏</strong>
|
||||
<pre class="code">GET /api/random?mode=mobile</pre>
|
||||
</div>
|
||||
<div class="endpoint">
|
||||
<strong>强制横屏</strong>
|
||||
<pre class="code">GET /api/random?mode=desktop</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="small">支持参数:`mode=auto|mobile|desktop`、`orientation=portrait|landscape`、`device=mobile|desktop`、`format=redirect|json`。</p>
|
||||
</div>
|
||||
|
||||
<div class="card pad section" id="preview">
|
||||
<h2>在线预览</h2>
|
||||
<div class="row">
|
||||
<div>
|
||||
<label for="modeSelect">mode</label>
|
||||
<select id="modeSelect">
|
||||
<option value="auto">auto</option>
|
||||
<option value="mobile">mobile</option>
|
||||
<option value="desktop">desktop</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="orientationSelect">orientation</label>
|
||||
<select id="orientationSelect">
|
||||
<option value="">auto</option>
|
||||
<option value="portrait">portrait</option>
|
||||
<option value="landscape">landscape</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="formatSelect">format</label>
|
||||
<select id="formatSelect">
|
||||
<option value="json">json</option>
|
||||
<option value="redirect">redirect</option>
|
||||
</select>
|
||||
</div>
|
||||
<button id="refreshBtn" type="button">刷新</button>
|
||||
</div>
|
||||
|
||||
<div class="preview-grid">
|
||||
<div>
|
||||
<label for="apiUrl">当前地址</label>
|
||||
<input id="apiUrl" readonly value="">
|
||||
<div class="preview-box" style="margin-top:8px">
|
||||
<img id="previewImg" alt="随机背景预览" src="">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label>返回结果</label>
|
||||
<pre class="code" id="jsonOutput">{}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card pad section" style="margin-top:10px">
|
||||
<h2>嵌入组件</h2>
|
||||
<div class="grid-2">
|
||||
<div class="endpoint">
|
||||
<strong>直接引用</strong>
|
||||
<pre class="code"><script src="https://random-background-api.pages.dev/widget.js"></script>
|
||||
<random-background-widget
|
||||
src="https://random-background-api.pages.dev"
|
||||
blur="12"
|
||||
radius="16px"
|
||||
height="260px">
|
||||
</random-background-widget></pre>
|
||||
</div>
|
||||
<div class="endpoint">
|
||||
<strong>说明</strong>
|
||||
<div class="small">组件只读静态 `manifest.json` 和图片,不消耗 Functions 额度;`blur` 控制高斯模糊像素值。</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="footer">
|
||||
构建命令:<code>python3 build.py</code>。这里保留 Pages Functions,所以可以直接提供 HTTP 随机 API。
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const modeSelect = document.getElementById('modeSelect');
|
||||
const orientationSelect = document.getElementById('orientationSelect');
|
||||
const formatSelect = document.getElementById('formatSelect');
|
||||
const refreshBtn = document.getElementById('refreshBtn');
|
||||
const apiUrl = document.getElementById('apiUrl');
|
||||
const previewImg = document.getElementById('previewImg');
|
||||
const jsonOutput = document.getElementById('jsonOutput');
|
||||
|
||||
function detectOrientation() {
|
||||
return window.matchMedia('(orientation: portrait)').matches ? 'portrait' : 'landscape';
|
||||
}
|
||||
|
||||
function detectDevice() {
|
||||
if (navigator.userAgentData && typeof navigator.userAgentData.mobile === 'boolean') {
|
||||
return navigator.userAgentData.mobile ? 'mobile' : 'desktop';
|
||||
}
|
||||
return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent) ? 'mobile' : 'desktop';
|
||||
}
|
||||
|
||||
function buildApiPath() {
|
||||
const params = new URLSearchParams();
|
||||
params.set('mode', modeSelect.value);
|
||||
params.set('device', detectDevice());
|
||||
params.set('format', formatSelect.value);
|
||||
const orientation = orientationSelect.value || detectOrientation();
|
||||
params.set('orientation', orientation);
|
||||
return `/api/random?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const url = buildApiPath();
|
||||
apiUrl.value = url;
|
||||
previewImg.style.opacity = '0.72';
|
||||
|
||||
if (formatSelect.value === 'json') {
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
jsonOutput.textContent = JSON.stringify(data, null, 2);
|
||||
previewImg.src = data.url || '';
|
||||
} else {
|
||||
jsonOutput.textContent = '{ "format": "redirect" }';
|
||||
previewImg.src = url;
|
||||
}
|
||||
}
|
||||
|
||||
previewImg.addEventListener('load', () => {
|
||||
previewImg.style.opacity = '1';
|
||||
});
|
||||
|
||||
refreshBtn.addEventListener('click', refresh);
|
||||
modeSelect.addEventListener('change', refresh);
|
||||
orientationSelect.addEventListener('change', refresh);
|
||||
formatSelect.addEventListener('change', refresh);
|
||||
refresh();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
42
manifest.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"generated_at": "2026-03-28T07:14:07.314011+00:00",
|
||||
"variants": {
|
||||
"mobile": {
|
||||
"folder": "mobile-image",
|
||||
"count": 6,
|
||||
"images": [
|
||||
"1.webp",
|
||||
"2.webp",
|
||||
"3.webp",
|
||||
"4.webp",
|
||||
"5.webp",
|
||||
"6.webp"
|
||||
]
|
||||
},
|
||||
"desktop": {
|
||||
"folder": "desketop-image",
|
||||
"count": 19,
|
||||
"images": [
|
||||
"1.webp",
|
||||
"2.webp",
|
||||
"3.webp",
|
||||
"4.webp",
|
||||
"5.webp",
|
||||
"6.webp",
|
||||
"7.webp",
|
||||
"8.webp",
|
||||
"9.webp",
|
||||
"10.webp",
|
||||
"11.webp",
|
||||
"12.webp",
|
||||
"13.webp",
|
||||
"14.webp",
|
||||
"15.webp",
|
||||
"16.webp",
|
||||
"17.webp",
|
||||
"18.webp",
|
||||
"19.webp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
1
mobile-image/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
BIN
mobile-image/1.webp
Normal file
|
After Width: | Height: | Size: 158 KiB |
BIN
mobile-image/1772108021881_44374ab6c1daa54e0204bca48ac382f2.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
mobile-image/1772108021977_8020902a0c8788538eee1cd06e784c6a.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
mobile-image/1772108022800_f945774e0a45f7a4afdc3da2b112025f.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
mobile-image/1772108024006_3f9030ba77e355869115bc90fe019d53.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
mobile-image/1772108030393_159bbf61f88b38475ee9144a2e8e4956.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
mobile-image/1772108123232_VJ86r.jpg
Normal file
|
After Width: | Height: | Size: 310 KiB |
BIN
mobile-image/2.webp
Normal file
|
After Width: | Height: | Size: 161 KiB |
BIN
mobile-image/3.webp
Normal file
|
After Width: | Height: | Size: 218 KiB |
BIN
mobile-image/4.webp
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
mobile-image/5.webp
Normal file
|
After Width: | Height: | Size: 177 KiB |
BIN
mobile-image/6.webp
Normal file
|
After Width: | Height: | Size: 157 KiB |
209
widget.js
Normal file
@@ -0,0 +1,209 @@
|
||||
(() => {
|
||||
const DEFAULT_BASE_URL = (() => {
|
||||
try {
|
||||
return new URL(document.currentScript?.src || window.location.href).origin;
|
||||
} catch {
|
||||
return window.location.origin;
|
||||
}
|
||||
})();
|
||||
|
||||
const STYLE = `
|
||||
:host {
|
||||
display: block;
|
||||
width: var(--rbw-width, 100%);
|
||||
height: var(--rbw-height, 260px);
|
||||
}
|
||||
.frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: var(--rbw-radius, 14px);
|
||||
background: #eef2f7;
|
||||
border: 1px solid rgba(15, 23, 42, 0.08);
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: var(--rbw-fit, cover);
|
||||
filter: blur(var(--rbw-blur, 0px));
|
||||
transform: scale(1.04);
|
||||
opacity: 0;
|
||||
transition: opacity 180ms ease;
|
||||
will-change: filter, opacity, transform;
|
||||
}
|
||||
.state {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: #5b657a;
|
||||
font: 13px/1.5 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,.45), rgba(255,255,255,.2));
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
:host([loaded]) .state,
|
||||
:host([error]) .state {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
:host([loaded]) img {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const DEFAULTS = {
|
||||
mode: 'auto',
|
||||
blur: '0',
|
||||
radius: '14px',
|
||||
fit: 'cover',
|
||||
height: '260px',
|
||||
width: '100%',
|
||||
};
|
||||
|
||||
class RandomBackgroundWidget extends HTMLElement {
|
||||
static observedAttributes = ['src', 'mode', 'blur', 'radius', 'fit', 'height', 'width'];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
this._manifest = null;
|
||||
this._requestSeq = 0;
|
||||
this._boundRefresh = this.refresh.bind(this);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.render();
|
||||
this.refresh();
|
||||
window.addEventListener('resize', this._boundRefresh, { passive: true });
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
window.removeEventListener('resize', this._boundRefresh);
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
if (this.isConnected) {
|
||||
this.render();
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
get baseUrl() {
|
||||
return (this.getAttribute('src') || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
get mode() {
|
||||
return (this.getAttribute('mode') || DEFAULTS.mode).toLowerCase();
|
||||
}
|
||||
|
||||
get blur() {
|
||||
const raw = Number.parseFloat(this.getAttribute('blur') || DEFAULTS.blur);
|
||||
return Number.isFinite(raw) && raw > 0 ? raw : 0;
|
||||
}
|
||||
|
||||
get radius() {
|
||||
return this.getAttribute('radius') || DEFAULTS.radius;
|
||||
}
|
||||
|
||||
get fit() {
|
||||
return this.getAttribute('fit') || DEFAULTS.fit;
|
||||
}
|
||||
|
||||
get height() {
|
||||
return this.getAttribute('height') || DEFAULTS.height;
|
||||
}
|
||||
|
||||
get width() {
|
||||
return this.getAttribute('width') || DEFAULTS.width;
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.shadowRoot) return;
|
||||
this.style.setProperty('--rbw-width', this.width);
|
||||
this.style.setProperty('--rbw-height', this.height);
|
||||
this.style.setProperty('--rbw-radius', this.radius);
|
||||
this.style.setProperty('--rbw-fit', this.fit);
|
||||
this.style.setProperty('--rbw-blur', `${this.blur}px`);
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>${STYLE}</style>
|
||||
<div class="frame">
|
||||
<img part="image" alt="random background">
|
||||
<div class="state">加载中</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
const seq = ++this._requestSeq;
|
||||
const img = this.shadowRoot.querySelector('img');
|
||||
const state = this.shadowRoot.querySelector('.state');
|
||||
|
||||
try {
|
||||
if (!this._manifest) {
|
||||
this._manifest = await this.fetchManifest();
|
||||
}
|
||||
if (seq !== this._requestSeq) return;
|
||||
|
||||
const variant = this.resolveVariant();
|
||||
const data = this._manifest?.variants?.[variant] || {};
|
||||
const folder = data.folder || (variant === 'mobile' ? 'mobile-image' : 'desketop-image');
|
||||
const images = Array.isArray(data.images) ? data.images.filter((item) => typeof item === 'string') : [];
|
||||
|
||||
if (!images.length) {
|
||||
throw new Error(`no images in ${folder}`);
|
||||
}
|
||||
|
||||
const filename = images[Math.floor(Math.random() * images.length)];
|
||||
img.src = `${this.baseUrl}/${folder}/${filename}`;
|
||||
img.alt = `random background ${variant}`;
|
||||
this.setAttribute('loaded', '');
|
||||
this.removeAttribute('error');
|
||||
state.textContent = '';
|
||||
} catch (error) {
|
||||
this.removeAttribute('loaded');
|
||||
this.setAttribute('error', '');
|
||||
state.textContent = error instanceof Error ? error.message : 'load failed';
|
||||
}
|
||||
}
|
||||
|
||||
resolveVariant() {
|
||||
if (this.mode === 'mobile' || this.mode === 'desktop') return this.mode;
|
||||
|
||||
const orientation = window.matchMedia('(orientation: portrait)').matches ? 'portrait' : 'landscape';
|
||||
const device = this.detectDevice();
|
||||
|
||||
if (orientation === 'portrait') return 'mobile';
|
||||
if (orientation === 'landscape') return 'desktop';
|
||||
return device;
|
||||
}
|
||||
|
||||
detectDevice() {
|
||||
if (navigator.userAgentData && typeof navigator.userAgentData.mobile === 'boolean') {
|
||||
return navigator.userAgentData.mobile ? 'mobile' : 'desktop';
|
||||
}
|
||||
return /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent) ? 'mobile' : 'desktop';
|
||||
}
|
||||
|
||||
async fetchManifest() {
|
||||
const url = new URL('manifest.json', `${this.baseUrl}/`);
|
||||
const response = await fetch(url.toString(), { mode: 'cors', cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`manifest ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get('random-background-widget')) {
|
||||
customElements.define('random-background-widget', RandomBackgroundWidget);
|
||||
}
|
||||
|
||||
window.RandomBackgroundWidget = RandomBackgroundWidget;
|
||||
})();
|
||||