SAkizuki GitHub Actions commited on
Commit
6c9a052
ยท
0 Parent(s):

Super-squash branch 'main' using huggingface_hub

Browse files

Co-authored-by: GitHub Actions <GitHub Actions@users.noreply.huggingface.co>

.gitattributes ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.pb filter=lfs diff=lfs merge=lfs -text
19
+ *.pickle filter=lfs diff=lfs merge=lfs -text
20
+ *.pkl filter=lfs diff=lfs merge=lfs -text
21
+ *.pt filter=lfs diff=lfs merge=lfs -text
22
+ *.pth filter=lfs diff=lfs merge=lfs -text
23
+ *.rar filter=lfs diff=lfs merge=lfs -text
24
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
25
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
26
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar filter=lfs diff=lfs merge=lfs -text
28
+ *.tflite filter=lfs diff=lfs merge=lfs -text
29
+ *.tgz filter=lfs diff=lfs merge=lfs -text
30
+ *.wasm filter=lfs diff=lfs merge=lfs -text
31
+ *.xz filter=lfs diff=lfs merge=lfs -text
32
+ *.zip filter=lfs diff=lfs merge=lfs -text
33
+ *.zst filter=lfs diff=lfs merge=lfs -text
34
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
.github/workflows/sync_to_hf.yml ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Sync to HuggingFace and ModelScope
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ sync:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - name: Checkout GitHub repo
13
+ uses: actions/checkout@v4
14
+ with:
15
+ lfs: false
16
+
17
+ - name: Install Dependencies
18
+ run: pip install huggingface_hub
19
+
20
+ - name: Sync to HuggingFace Space
21
+ env:
22
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
23
+ run: |
24
+ python - << 'PYEOF'
25
+ import os
26
+ from huggingface_hub import HfApi
27
+
28
+ api = HfApi(token=os.environ["HF_TOKEN"])
29
+ repo_id = "SAkizuki/DanbooruSearch"
30
+
31
+ api.upload_folder(
32
+ folder_path=".",
33
+ repo_id=repo_id,
34
+ repo_type="space",
35
+ ignore_patterns=[
36
+ "tags_embedding/*",
37
+ "origin_database/*",
38
+ ".git/*",
39
+ "__pycache__/*",
40
+ "*.pyc",
41
+ "*.parquet",
42
+ "*.csv"
43
+ ],
44
+ commit_message="Auto-sync from GitHub Actions"
45
+ )
46
+ print("HF sync done")
47
+ PYEOF
48
+
49
+ - name: Sync to ModelScope Studio
50
+ env:
51
+ MS_TOKEN: ${{ secrets.MS_TOKEN }}
52
+ run: |
53
+ git config --global user.name "github-actions[bot]"
54
+ git config --global user.email "github-actions[bot]@users.noreply.github.com"
55
+
56
+ export GIT_LFS_SKIP_SMUDGE=1
57
+ git clone https://oauth2:${MS_TOKEN}@www.modelscope.cn/studios/SAkizuki/DanbooruSearchOnline.git ms_repo
58
+
59
+ # ๆ‹ท่ดๆ น็›ฎๅฝ•ไธ‹็š„ .py ๆ–‡ไปถใ€ไพ่ต–ๆ–‡ไปถๅŠ README.md
60
+ cp *.py ms_repo/ 2>/dev/null || true
61
+ cp requirements.txt ms_repo/ 2>/dev/null || true
62
+ cp README.md ms_repo/ 2>/dev/null || true
63
+
64
+ # ๆ‹ท่ด core ็›ฎๅฝ•ไธ‹็š„ .py ๆ–‡ไปถ
65
+ mkdir -p ms_repo/core
66
+ cp core/*.py ms_repo/core/ 2>/dev/null || true
67
+
68
+ cd ms_repo
69
+ git add .
70
+
71
+ if ! git diff --staged --quiet; then
72
+ git commit -m "Auto-sync from GitHub Actions"
73
+ git push origin master
74
+ echo "MS sync done"
75
+ else
76
+ echo "No code changes to sync to MS."
77
+ fi
.gitignore ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *cuda*
2
+ *.pyc
3
+ core/__pychahce__
4
+ __pychahce__
5
+ my_model*/
6
+ *.ignore
7
+ tags_embedding/
8
+ origin_database/
9
+ .idea/
10
+ *ignore*.py
11
+ *.sh
12
+ tempsave/
13
+ *.env
14
+ *.png
15
+ *.bat
16
+ eval/
17
+ legacy_history.json
18
+ CLAUDE.md
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12
2
+ WORKDIR /app
3
+ COPY requirements.txt .
4
+ RUN pip install --no-cache-dir -r requirements.txt
5
+ RUN useradd -m -u 1000 user
6
+ USER user
7
+ ENV HOME=/home/user \
8
+ PATH=/home/user/.local/bin:$PATH
9
+ ENV PYTHONUNBUFFERED=1
10
+ COPY --chown=user . /app
11
+ EXPOSE 7860
12
+ CMD ["python", "ui_nicegui.py"]
README.md ADDED
@@ -0,0 +1,472 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: DanbooruSearch
3
+ emoji: ๐Ÿ‘
4
+ colorFrom: red
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: mit
10
+ short_description: ๅŸบไบŽ่ฏญไน‰ๅŒน้…็š„ Danbooru ๆ ‡็ญพๆœ็ดขๅผ•ๆ“Ž๏ผŒๆ”ฏๆŒๅคš็ปดๅŒน้…ใ€ๆ™บ่ƒฝๅˆ†่ฏไธŽๅ…ฑ็Žฐๅ…ณ่”ๆŽจ่ใ€‚
11
+ tags:
12
+ - text-to-image
13
+ - prompt-engineering
14
+ - stable-diffusion
15
+ - danbooru
16
+ - nlp
17
+ thumbnail: >-
18
+ https://akizukipic.oss-cn-beijing.aliyuncs.com/img/202604022117025.png
19
+ ---
20
+
21
+ # Danbooru Tags Searcher
22
+
23
+ ![](https://akizukipic.oss-cn-beijing.aliyuncs.com/img/202604022117025.png)
24
+
25
+ ๆœฌ้กน็›ฎๆไพ›ไธ€็งๅŸบไบŽ่ฏญไน‰ๅŒน้…็š„ Danbooru ๆ ‡็ญพๆŸฅๆ‰พๆ–นๆกˆใ€‚็”จๆˆทๅฏไปฅ้€š่ฟ‡่พ“ๅ…ฅๆ—ฅๅธธ่ฏญ่จ€ๆˆ–ๆจก็ณŠๆ่ฟฐ๏ผŒๆŸฅๆ‰พๅนถๅŒน้… Danbooru ๆ•ฐๆฎ้›†ไธญๅฏนๅบ”็š„ๆ ‡ๅ‡†ๆ ‡็ญพ๏ผŒ้€‚็”จไบŽ่พ…ๅŠฉๆž„ๅปบ Stable Diffusionใ€NovelAI ็ญ‰ AI ็ป˜็”ปๅทฅๅ…ท็š„ๆ็คบ่ฏ๏ผˆPrompt๏ผ‰ใ€‚
26
+
27
+ ็›ฎๅ‰ๆ”ฏๆŒไฝฟ็”จๆฑ‰่ฏญๅ’Œ่‹ฑ่ฏญ่ฟ›่กŒๆŸฅๆ‰พ
28
+
29
+ **็ซ‹ๅณไฝฟ็”จ๏ผš** https://huggingface.co/spaces/SAkizuki/DanbooruSearch
30
+
31
+ **ComfyUI ๆ’ไปถ็‰ˆๆœฌ๏ผš** [ComfyUI-DanbooruSearcher](https://github.com/SuzumiyaAkizuki/ComfyUI-DanbooruSearcher)
32
+
33
+ ๆœฌ้กน็›ฎ็š„ๆ•ฐๆฎๅบ“็”ฑDanbooru APIๆŠ“ๅ–่€Œๆˆ๏ผŒไฝฟ็”จLLM่ฟ›่กŒ่ฏญไน‰ๆ‰ฉๅ……ๅ’Œไธญๆ–‡็ฟป่ฏ‘ใ€‚่ง’่‰ฒๅใ€ไฝœๅ“ๅไฝฟ็”จไบŒๆฌกๅ…ƒๅž‚็ฑปๆ•ฐๆฎๅบ“Bangumi API่ฟ›่กŒ็ฒพ็กฎๆŸฅ่ฏข๏ผŒๅฐฝๅฏ่ƒฝๅœฐ้ฟๅ…ไบ†ๅœจ่ง’่‰ฒๅใ€ไฝœๅ“ๅไธŠ็š„ๅนป่ง‰้—ฎ้ข˜ใ€‚
34
+
35
+ **ๆ•ฐๆฎๅบ“็”Ÿๆˆไปฃ็ ๏ผš** https://github.com/SuzumiyaAkizuki/danbooru-tag-pipeline
36
+
37
+ ---
38
+
39
+ ## ๆ ธๅฟƒ็‰นๆ€ง
40
+
41
+ ๆœฌ็‰ˆๆœฌๅœจๅŸบ็ก€่ฏญไน‰ๆฃ€็ดขไน‹ไธŠ๏ผŒๅผ•ๅ…ฅไบ†ๅฎŒๆ•ด็š„ๆ ‡็ญพๅทฅไฝœๆต่พ…ๅŠฉๅŠŸ่ƒฝ๏ผš
42
+
43
+ * **ๅคš็ปดๅบฆ่ฏญไน‰ๅŒน้…๏ผš** ๅบ•ๅฑ‚ๆ”ฏๆŒ 4 ไธช็ปดๅบฆ็š„ๅ‘้‡็ฉบ้—ดๆฃ€็ดข๏ผˆ่‹ฑๆ–‡ใ€ไธญๆ–‡ๆ‰ฉๅฑ•่ฏใ€็ปดๅŸบ้‡Šไน‰ใ€ไธญๆ–‡ๆ ธๅฟƒ่ฏ๏ผ‰๏ผŒๅคงๅน…ๆๅ‡ๆจก็ณŠๆ่ฟฐ็š„ๅ‘ฝไธญ็އใ€‚
44
+ * **ๆ™บ่ƒฝๅˆ†่ฏไธŽๆ˜พๅผๅˆ†้š”๏ผš** ๆ”ฏๆŒไธ€้”ฎๅผ€ๅฏ/ๅ…ณ้—ญ่‡ชๅŠจๅˆ†่ฏใ€‚ๅผ€ๅฏๅŽ่‡ชๅŠจๆๅ–้•ฟๅฅไธญๆฆ‚ๅฟตๅˆ†ๅˆซๆฃ€็ดข๏ผ›ๅŒๆ—ถๆ”ฏๆŒ็”จ็ฉบๆ ผใ€ๆข่กŒใ€ไธญๆ–‡้€—ๅท๏ผˆ๏ผŒ๏ผ‰ๅ’Œ้กฟๅท๏ผˆใ€๏ผ‰ๆ‰‹ๅŠจๅˆ†้š”ๆฆ‚ๅฟต๏ผŒ็ณป็ปŸไผšๅฐŠ้‡ไฝ ็š„ๅˆ†้š”ๆ„ๅ›พ๏ผŒๅฐ†ๆฏไธช็‰‡ๆฎตไฝœไธบๆ•ดไฝ“ๅŒน้…๏ผˆ่ถ…่ฟ‡ 7 ๅญ—็š„็‰‡ๆฎตไป็„ถ่‡ชๅŠจๅˆ†่ฏ๏ผ‰ใ€‚
45
+ * **ๆ ‡็ญพไบ’็›ธๅ…ณๆŽจ่๏ผš** ๅผ•ๆ“Žๅ†…็ฝฎๆ ‡็ญพๅ…ฑ็Žฐๆ•ฐๆฎใ€‚ๅฝ“ไฝ ้€‰ไธญๆŸไบ›ๆ ‡็ญพๅŽ๏ผŒ็ณป็ปŸไผš่‡ชๅŠจๆŽจ่ๅœจ Danbooru ๅ›พๅบ“ไธญ็ปๅธธไธŽๅฎƒไปฌไธ€ๅŒๅ‡บ็Žฐ็š„็›ธๅ…ณๆ ‡็ญพ๏ผŒ่พ…ๅŠฉ่กฅๅ…จ็”ป้ข็ป†่Š‚ใ€‚
46
+ * **็ฒพ็ป†ๅŒ–ๆŸฅ้˜…ไธŽ่ฟ‡ๆปค๏ผš** ๆ”ฏๆŒๆŒ‰ๆ ‡็ญพ็ฑปๅˆซ๏ผˆGeneral้€š็”จ / Character่ง’่‰ฒ / Copyrightไฝœๅ“๏ผ‰่ฟ›่กŒ็กฌ่ฟ‡ๆปคใ€‚
47
+ * ๆไพ› **NSFW ไฟๆŠคๆจกๅผ**๏ผˆๅผ€ๅฏๅŽ่‡ชๅŠจๆจก็ณŠๆ•ๆ„Ÿ่ฏๆฑ‡๏ผ‰ใ€‚
48
+ * ่กจๆ ผๅ†…็ฝฎ Danbooru ็ปดๅŸบ้‡Šไน‰ๆ‚ฌๆตฎๆ็คบ๏ผŒๅนถๆไพ›ๅ‰ๅพ€ๅŽŸ็ซ™็š„ๅฟซๆท้“พๆŽฅใ€‚
49
+
50
+
51
+ ## ๅŠŸ่ƒฝๆฆ‚่งˆ
52
+
53
+ ๆœฌๅทฅๅ…ทๆไพ›ไปฅไธ‹ไบ”้กนๆ ธๅฟƒๅŠŸ่ƒฝ๏ผš
54
+
55
+ - [็ฒพ็กฎๆŸฅ่ฏ](#1-็ฒพ็กฎๆŸฅ่ฏ)๏ผˆๅฎน้”™ๆ‹ผๅ†™้”™่ฏฏ๏ผ‰
56
+ - [ๆฆ‚ๅฟตๆจก็ณŠๅ‘ๆ•ฃ](#2-ๆฆ‚ๅฟตๆจก็ณŠๅ‘ๆ•ฃ)
57
+ - [่พ“ๅ…ฅๆ่ฟฐๆŸฅ่ฏ](#3-่พ“ๅ…ฅๆ่ฟฐๆŸฅ่ฏ)
58
+ - [ๅฎŒๆ•ด็”ป้ขๆŸฅๆ‰พ](#4-ๅฎŒๆ•ด็”ป้ขๆŸฅๆ‰พ)
59
+ - [ๅ…ณ่”ๆŽจ่](#5-ๅ…ณ่”ๆŽจ่ๅ…ฑ็ŽฐๅŒน้…)
60
+
61
+ ๅ‰ๅ››้กนๅŸบไบŽ**่ฏญไน‰ๅ‘้‡ๅŒน้…**๏ผŒๅŽไธ€้กนๅŸบไบŽ**ๆ ‡็ญพๅ…ฑ็Žฐๆ•ฐๆฎ**ใ€‚
62
+
63
+ ---
64
+
65
+ ## ๅ‚ๆ•ฐ่ฏดๆ˜Ž
66
+
67
+ ๅœจ่ฟ›ๅ…ฅๅŠŸ่ƒฝไป‹็ปไน‹ๅ‰๏ผŒๅ…ˆไบ†่งฃๅ‡ ไธชๅ…ณ้”ฎๅ‚ๆ•ฐ๏ผŒๅฎƒไปฌไผšๅฝฑๅ“ๆ‰€ๆœ‰ๆœ็ดขๆจกๅผ็š„็ป“ๆžœ่ดจ้‡ใ€‚
68
+
69
+ **Top K๏ผˆ่ฏญไน‰็›ธๅ…ณ๏ผ‰**
70
+ ้’ˆๅฏนๅ•ไธชๅˆ†่ฏ๏ผŒๆฃ€็ดข่ฏญไน‰ๆœ€็›ธๅ…ณ็š„ๅ€™้€‰ๆ ‡็ญพๆ•ฐ้‡ใ€‚่ฏฅๅ€ผไฝœ็”จไบŽๆฏไธชๅ‘้‡ๆฃ€็ดขๅฑ‚๏ผŒๆœ€็ปˆๅ€™้€‰ๆฑ ๅคงๅฐ็บฆไธบ Top K ร— ๅฑ‚ๆ•ฐ๏ผŒๅ†ๆˆชๆ–ญ่‡ณ็ป“ๆžœไธŠ้™ใ€‚ๅ€ผ่ถŠๅคง๏ผŒๅฌๅ›ž่ถŠๅนฟ๏ผŒไฝ†็ƒญๅบฆ้ซ˜็š„ๅˆ†่ฏไผšๅ ๆฎๆ›ดๅคšๅ้ข๏ผŒๅฏ่ƒฝๆŒคๅŽ‹ไฝŽ้ข‘ไฝ†็ฒพ็กฎ็š„ๅˆ†่ฏใ€‚็ฒพ็กฎๆŸฅ่ฏๅœบๆ™ฏๅปบ่ฎฎ่ฐƒไฝŽ๏ผˆ20๏ผ‰๏ผŒๅ‘ๆ•ฃๆŽข็ดขๅœบๆ™ฏๅฏไปฅ่ฐƒ้ซ˜๏ผˆ80~160๏ผ‰ใ€‚
71
+
72
+ **็ป“ๆžœไธŠ้™**
73
+ ๆœ€็ปˆๅฑ•็คบ็š„ๆ ‡็ญพๆ€ปๆกๆ•ฐใ€‚ๅปบ่ฎฎ่ฎพไธบ 70~80 ไปฅ้€‚้…ไธปๆต SDXL ๆจกๅž‹็š„ prompt ้•ฟๅบฆใ€‚็ฒพ็กฎๆŸฅ่ฏๆ—ถๅฏไปฅ่ฐƒไฝŽๅˆฐ 10๏ผŒ้ฟๅ…ๅนฒๆ‰ฐใ€‚
74
+
75
+ **็ƒญๅบฆๆƒ้‡๏ผˆ0~1๏ผ‰**
76
+ ๆŽงๅˆถๆ ‡็ญพๅœจ Danbooru ็ซ™ๅ†…ๅ‘ๅธ–้‡ๅฏนๆœ€็ปˆๆŽ’ๅบ็š„ๅฝฑๅ“ใ€‚ๆ•ฐๅ€ผ่ถŠ้ซ˜๏ผŒ่ถŠๅ€พๅ‘ไบŽๆŽจ่ๅธธ็”จ้ซ˜้ข‘ๆ ‡็ญพใ€‚้ป˜่ฎคๅ€ผ 0.15 ๅœจๅคงๅคšๆ•ฐๅœบๆ™ฏไธ‹่กจ็Žฐ่‰ฏๅฅฝใ€‚
77
+
78
+ **ๆ™บ่ƒฝๅˆ†่ฏ**
79
+ ๅผ€ๅฏๆ—ถ๏ผŒ็จ‹ๅบไผš่‡ชๅŠจๆ‹†ๅˆ†่พ“ๅ…ฅ็š„้•ฟๅฅ๏ผŒๆๅ–ๅ…ถไธญ็š„ๅ…ณ้”ฎๆฆ‚ๅฟตๅˆ†ๅˆซๆฃ€็ดข๏ผŒๅ†ๅˆๅนถ็ป“ๆžœใ€‚้€‚ๅˆๅฎŒๆ•ด็”ป้ขๆ่ฟฐ๏ผ›่‹ฅๅชๆƒณๆฃ€็ดขไธ€ไธชๅฎŒๆ•ด็Ÿญ่ฏญ็š„่ฏญไน‰๏ผŒๅปบ่ฎฎๅ…ณ้—ญใ€‚
80
+
81
+ **ๆ–ฐ็‰ˆๆ”ฏๆŒๆ˜พๅผๅˆ†้š”็ฌฆ๏ผš** ็”จ็ฉบๆ ผใ€ๆข่กŒใ€ๆˆ–ไธญๆ–‡้€—ๅท/้กฟๅท๏ผˆ๏ผŒใ€๏ผ‰ๆ‰‹ๅŠจๅˆ†้š”ๆฆ‚ๅฟตๆ—ถ๏ผŒ็ณป็ปŸไผšๅฐ†ๆฏไธชๅˆ†้š”็‰‡ๆฎต่ง†ไธบ็‹ฌ็ซ‹็š„ๅŽŸๅญๆฆ‚ๅฟต๏ผŒไธๅ†ๅš่ฟ›ไธ€ๆญฅๆ‹†ๅˆ†ใ€‚่ฟ™ๅœจไฝ ๆ˜Ž็กฎ็Ÿฅ้“่‡ชๅทฑๆƒณ่ฆๅ“ชไบ›ๆฆ‚ๅฟตๆ—ถๆฏ”่‡ชๅŠจๅˆ†่ฏๆ›ด็ฒพๅ‡†ใ€‚
82
+
83
+ | ่พ“ๅ…ฅ็คบไพ‹ | ๅˆ†่ฏ็ป“ๆžœ | ่ฏดๆ˜Ž |
84
+ |---|---|---|
85
+ | `่ฟๅŠจ็คพๅ›ข ๆ ก้˜Ÿ ๆฏ”่ต›` | `่ฟๅŠจ็คพๅ›ข` `ๆ ก้˜Ÿ` `ๆฏ”่ต›` | ็ฉบๆ ผๅˆ†้š”๏ผŒๆฏไธช็‰‡ๆฎตๅŽŸๆ ทไฟ็•™ |
86
+ | `่ฟๅŠจ็คพๅ›ขใ€ๆ ก้˜Ÿใ€ๆฏ”่ต›` | `่ฟๅŠจ็คพๅ›ข` `ๆ ก้˜Ÿ` `ๆฏ”่ต›` | ้€—ๅท/้กฟๅทๅŒ็† |
87
+ | `ไธ€ไธช็ฉฟ็€็™ฝ่‰ฒๆฐดๆ‰‹ๆœ็š„ๅฐ‘ๅฅณ` | `็ฉฟ็€` `็™ฝ่‰ฒ` `ๆฐดๆ‰‹ๆœ` `ๅฐ‘ๅฅณ` | ๆ— ๆ˜พๅผๅˆ†้š”๏ผŒ่‡ชๅŠจๅˆ†่ฏ๏ผˆๅœ็”จ่ฏๅทฒ่ฟ‡ๆปค๏ผ‰ |
88
+
89
+ > ๅฝ“ๅˆ†้š”ๅŽ็š„ๆŸไธช็‰‡ๆฎต่ถ…่ฟ‡ **7 ไธชๅญ—**ๆ—ถ๏ผŒ็ณป็ปŸไผš้€€ๅ›žๅˆฐ่‡ชๅŠจๅˆ†่ฏโ€”โ€”ๅ› ไธบ่ถ…่ฟ‡่ฟ™ไธช้•ฟๅบฆ็š„็‰‡ๆฎต้€šๅธธๆ˜ฏ็Ÿญๅฅ่€Œ้žๅ•ไธ€ๆฆ‚ๅฟตใ€‚่ฟ™ไธ€้˜ˆๅ€ผๅŸบไบŽๅฏนๆ‰€ๆœ‰ๆ ‡็ญพๆ ธๅฟƒไธญๆ–‡ๅ้•ฟๅบฆ็š„็ปŸ่ฎกๅˆ†ๆžๅพ—ๅ‡บใ€‚
90
+
91
+ ---
92
+
93
+ ## 1. ็ฒพ็กฎๆŸฅ่ฏ
94
+
95
+ **้€‚็”จๅœบๆ™ฏ๏ฟฝ๏ฟฝ๏ฟฝ** ไฝ ็Ÿฅ้“ๆŸไธชๆ ‡็ญพ็š„ๅคงๆฆ‚ๅ†™ๆณ•๏ผŒไฝ†ไธ็กฎๅฎšๆ‹ผๅ†™ๆ˜ฏๅฆๅ‡†็กฎ๏ผŒๆˆ–่€…ๅช่ฎฐๅพ—้ƒจๅˆ†ๅ†™ๆณ•ใ€‚
96
+
97
+ ๆœฌๅทฅๅ…ท้€š่ฟ‡่ฏญไน‰็›ธไผผๅบฆๅŒน้…่€Œ้žๅญ—็ฌฆไธฒ็ฒพ็กฎๅŒน้…๏ผŒๅ› ๆญคๅ…ทๅค‡ไธ€ๅฎš็š„ๆ‹ผๅ†™ๅฎน้”™่ƒฝๅŠ›ใ€‚
98
+
99
+ **ๅปบ่ฎฎๅ‚ๆ•ฐ๏ผš** Top K: 20 | ็ป“ๆžœไธŠ้™: 10 | ็ƒญๅบฆๆƒ้‡: 0.15 | **ๅ…ณ้—ญๆ™บ่ƒฝๅˆ†่ฏ**
100
+
101
+ > ๅ…ณ้—ญๆ™บ่ƒฝๅˆ†่ฏ๏ผŒ็กฎไฟๆ•ดไธช่พ“ๅ…ฅไฝœไธบไธ€ไธชๆ•ดไฝ“่ฟ›่กŒ่ฏญไน‰ๅŒน้…๏ผŒไธไผš่ขซๆ‹†ๆ•ฃใ€‚
102
+
103
+ ### ็คบไพ‹
104
+
105
+ | ไฝ ็š„่พ“ๅ…ฅ | ๅฎž้™…ๆ ‡ๅ‡†ๆ ‡็ญพ | ่ฏดๆ˜Ž |
106
+ |---|---|---|
107
+ | `selafuku` | `serafuku` | ็ปๅ…ธๆ‹ผๅ†™้”™่ฏฏ๏ผŒไปๅฏๅ‘ฝไธญ |
108
+ | `school unifor` | `school_uniform` | ๆผๅญ—ๆฏ๏ผŒไปๅฏๅ‘ฝไธญ |
109
+ | `twintail` | `twintails` | ๅ•ๅคๆ•ฐๆททๆท†๏ผŒไปๅฏๅ‘ฝไธญ |
110
+ | `zettai ryouiki` | `zettai_ryouiki` | ็ฝ—้ฉฌ้Ÿณ่พ“ๅ…ฅ๏ผŒๅฏๅ‘ฝไธญ |
111
+ | `thighhigh` | `thighhighs` | ่ฏๅฝขๅ˜ไฝ“๏ผŒไปๅฏๅ‘ฝไธญ |
112
+
113
+ ---
114
+
115
+ ## 2. ๆฆ‚ๅฟตๆจก็ณŠๅ‘ๆ•ฃ
116
+
117
+ **้€‚็”จๅœบๆ™ฏ๏ผš** ไฝ ่„‘ไธญๆœ‰ไธ€ไธชๆจก็ณŠ็š„ๆฆ‚ๅฟตๆˆ–้ฃŽๆ ผๆ„่ฑก๏ผŒไฝ†ไธ็Ÿฅ้“ๅœจ Danbooru ้‡Œๅฏนๅบ”ๅ“ชไบ›ๆ ‡็ญพ๏ผŒๅธŒๆœ›็ณป็ปŸๅธฎไฝ "ๅ‘ๆ•ฃ"ๅ‡บ็›ธๅ…ณ็š„ๅ€™้€‰ๆ ‡็ญพใ€‚
118
+
119
+ **ๅปบ่ฎฎๅ‚ๆ•ฐ๏ผš** Top K: 80~160 | ็ป“ๆžœไธŠ้™: 80 | ็ƒญๅบฆๆƒ้‡: 0.15 | **ๅผ€ๅฏๆ™บ่ƒฝๅˆ†่ฏ**
120
+
121
+ ### ็คบไพ‹
122
+
123
+ **็คบไพ‹ 1๏ผšใ€Œๅ…”่€ณๆœตใ€**
124
+
125
+ ่พ“ๅ…ฅไธ€ไธชๅฎฝๆณ›ๆฆ‚ๅฟต๏ผŒ็ณป็ปŸไผš่ฟ”ๅ›žๅ„็ฑป่€ณๅž‹็›ธๅ…ณๆ ‡็ญพ๏ผŒๅŒ…ๆ‹ฌ็œŸๅ…”่€ณใ€ๅคด้ฅฐ่€ณใ€ๅ…ฝๅจ˜่€ณ็ญ‰ๅ˜ไฝ“ใ€‚
126
+
127
+ ![image-20260402202537091](https://akizukipic.oss-cn-beijing.aliyuncs.com/img/202604022025661.png)
128
+
129
+ ---
130
+
131
+ **็คบไพ‹ 2๏ผšใ€Œไธญๅ›ฝ้ฃŽๅค่ฃ…ใ€**
132
+
133
+ ่ฟ”ๅ›žๆฑ‰ๆœใ€ๆ——่ขใ€ๅ„ๆœไปฃๆœๅˆถ็ญ‰็ป†ๅˆ†ๆ ‡็ญพ๏ผŒๅธฎๅŠฉไฝ ๅฟซ้€Ÿไบ†่งฃ่ฟ™ไธ€้ฃŽๆ ผไธ‹็š„ๆ ‡็ญพไฝ“็ณปใ€‚
134
+
135
+ ![image-20260402202652780](https://akizukipic.oss-cn-beijing.aliyuncs.com/img/202604022026925.png)
136
+
137
+ ---
138
+
139
+ **็คบไพ‹ 3๏ผšใ€Œๆœบๆขฐไน‰่‚ขใ€**
140
+
141
+ ่ฟ”ๅ›žๅŒ…ๅซไน‰่‚ขใ€ๆœบๆขฐ่‡‚ใ€่ต›ๅšๆœ‹ๅ…‹่‚ขไฝ“็ญ‰ๆ ‡็ญพ๏ผŒ้€‚ๅˆ่ฟ›ไธ€ๆญฅ็ป“ๅˆๅ…ณ่”ๆŽจ่ๅš็ป†ๅŒ–็ญ›้€‰ใ€‚
142
+
143
+ ![image-20260402202730735](https://akizukipic.oss-cn-beijing.aliyuncs.com/img/202604022027911.png)
144
+
145
+ ---
146
+
147
+ **็คบไพ‹ 4๏ผšใ€Œๆˆ˜ๆ–—ๅงฟๅŠฟใ€**
148
+
149
+ ่ฟ”ๅ›žๅ„็ฑปๆˆ˜ๆ–—ๅŠจไฝœใ€ๆŒๆญฆๅ™จๅŠจไฝœ็ญ‰ๆ ‡็ญพใ€‚
150
+
151
+ ![image-20260402202757757](https://akizukipic.oss-cn-beijing.aliyuncs.com/img/202604022028668.png)
152
+
153
+ ---
154
+
155
+ ## 3. ่พ“ๅ…ฅๆ่ฟฐๆŸฅ่ฏ
156
+
157
+ **้€‚็”จๅœบๆ™ฏ๏ผš** ไฝ ่ƒฝๆ่ฟฐๆŸไธชๅ…ทไฝ“็š„ไบ‹็‰ฉใ€่ง’่‰ฒใ€ๅœบๆ™ฏ๏ผŒไฝ†ไธ็Ÿฅ้“ Danbooru ็š„ๅฏนๅบ”ๆ ‡็ญพใ€‚ๅฏไปฅไฝฟ็”จไธญๆ–‡ๆˆ–่‹ฑๆ–‡่‡ช็„ถ่ฏญ่จ€ๆ่ฟฐ๏ผŒไนŸๅฏไปฅ้€š่ฟ‡ IP ๅ็งฐใ€่ง’่‰ฒๅค–ๅทๆฅๆŸฅๆ‰พ่ง’่‰ฒๆ ‡็ญพใ€‚
158
+
159
+ **ๅปบ่ฎฎๅ‚ๆ•ฐ๏ผš** Top K: 20 | ็ป“ๆžœไธŠ้™: 20 | ็ƒญๅบฆๆƒ้‡: 0.15 | **ๅ…ณ้—ญๆ™บ่ƒฝๅˆ†่ฏ**
160
+
161
+ > ๅ…ณ้—ญๆ™บ่ƒฝๅˆ†่ฏ๏ผŒไฝฟ็ณป็ปŸๅฏนไฝ ็š„ๆ•ดๆฎตๆ่ฟฐๅšๆ•ดไฝ“่ฏญไน‰ๅŒน้…๏ผŒ่€Œไธๆ˜ฏๆ‹†่ฏใ€‚
162
+
163
+ ### ็คบไพ‹
164
+
165
+ **็คบไพ‹ 1๏ผš็”จๆ่ฟฐๅฎšไฝ่ง’่‰ฒ**
166
+
167
+ | ไฝ ็š„่พ“ๅ…ฅ | ๆœŸๆœ›ๅ‘ฝไธญ็š„่ง’่‰ฒๆ ‡็ญพ | ่ฏดๆ˜Ž |
168
+ |---|---|---|
169
+ | `ๅŽŸ็ฅž้‡Œ้ข้กปๅผฅไธป็บฟ็š„NPC่ดตๆ—ๅฅณๅญฉ` | `dunyarzad_(genshin_impact)`๏ผˆ่ฟช็บณๆณฝ้ป›๏ผ‰ | ้€š่ฟ‡ๅ‰งๆƒ…่บซไปฝๆ่ฟฐๅฎšไฝ่ง’่‰ฒ |
170
+ | `ๅ‘ฝ่ฟ็Ÿณไน‹้—จ็š„ๅŠฉๆ‰‹` | `makise_kurisu`๏ผˆ็‰ง็€ฌ็บข่މๆ –๏ผ‰ | ้€š่ฟ‡ไบบ็‰ฉๅ…ณ็ณปๆ่ฟฐๅฎšไฝ่ง’่‰ฒ |
171
+ | `ๆŸ็ง‘ๅญฆ็š„่ถ…็”ต็ฃ็‚ฎไธญไผšไผ ้€็š„้‚ฃไธชไบบ` | `shirai_kuroko`๏ผˆ็™ฝไบ•้ป‘ๅญ๏ผ‰ | ้€š่ฟ‡่ƒฝๅŠ›ๅ’ŒๅŠจไฝœๆ่ฟฐๅฎšไฝ่ง’่‰ฒ |
172
+ | `EVA ้‡Œ้ฉพ้ฉถ้›ถๅทๆœบ็š„่“ๅ‘่ง’่‰ฒ` | `ayanami_rei`๏ผˆๅ‡Œๆณขไธฝ๏ผ‰ | ้€š่ฟ‡ๆœบไฝ“ๅ’Œๅค–่ฒŒๆ่ฟฐๅฎšไฝ่ง’่‰ฒ |
173
+ | `ๆ˜Žๆ—ฅๆ–น่ˆŸ้‡Œๆธธๆˆๅ•†ๅบ—็š„่€ๆฟ` | `closure_(arknights)`๏ผˆๅฏ้œฒๅธŒๅฐ”๏ผ‰ | ้€š่ฟ‡่Œไฝๆ่ฟฐๅฎšไฝ่ง’่‰ฒ |
174
+
175
+ ---
176
+
177
+ **็คบไพ‹ 2๏ผš็”จๆ่ฟฐๅฎšไฝๆœ้ฅฐ/็‰ฉๅ“**
178
+
179
+ | ไฝ ็š„่พ“ๅ…ฅ | ๆœŸๆœ›ๅ‘ฝไธญ็š„ๆ ‡็ญพ | ่ฏดๆ˜Ž |
180
+ |---|---|---|
181
+ | `็ดง่บซ่กฃๅ‹’ๅ…ฅ็šฎ่‚คไบง็”Ÿ็š„ๅ‡น้™ท` | `skindentation` | ้žๅธธๅ…ทไฝ“็š„่ง†่ง‰็ป†่Š‚ๆ่ฟฐ |
182
+ | `ๅŠ้€ๆ˜Ž่–„็บฑ่ฆ†็›–่ƒธๅ‰` | `see-through_bra` | ๆ่ดจไธŽ้ฎ็›–ๅ…ณ็ณปๆ่ฟฐ |
183
+ | `ไธคไพงๆœ‰ๅผ€็ผใ€ๆœ‰ๆ‹‰็ปณ็š„่ฟๅŠจ็Ÿญ่ฃค` | `dolphin_shorts` | ๆœ้ฅฐ็ป†่Š‚ๆ่ฟฐ |
184
+
185
+ ---
186
+
187
+ ## 4. ๅฎŒๆ•ด็”ป้ขๆŸฅๆ‰พ
188
+
189
+ **้€‚็”จๅœบๆ™ฏ๏ผš** ไฝ ่„‘ไธญๅทฒ็ปๆœ‰ไบ†ไธ€ๅน…ๅฎŒๆ•ด็š„็”ป้ข๏ผŒๅธŒๆœ›ไธ€ๆฌกๆ€งๆŠŠๆ•ดๆฎตๆ่ฟฐ่ฝฌๆขๆˆๅฐฝๅฏ่ƒฝๅคš็š„ Danbooru ๆ ‡็ญพ๏ผŒ็”จไบŽ AI ็ป˜็”ป็š„ๅฎŒๆ•ด Promptใ€‚
190
+
191
+ **ๅปบ่ฎฎๅ‚ๆ•ฐ๏ผš** Top K: 5 | ็ป“ๆžœไธŠ้™: 80 | ็ƒญๅบฆๆƒ้‡: 0.15 | **ๅผ€ๅฏๆ™บ่ƒฝๅˆ†่ฏ**
192
+
193
+ > ๅผ€ๅฏๆ™บ่ƒฝๅˆ†่ฏ๏ผš็ณป็ปŸไผš่‡ชๅŠจๆ‹†ๅˆ†ไฝ ็š„้•ฟๅฅ๏ผŒๆๅ–ๅ…ถไธญ็š„ๅ…ณ้”ฎๆฆ‚ๅฟตๅˆ†ๅˆซๆฃ€็ดข๏ผŒๅ†ๅˆๅนถๅŽป้‡๏ผŒ่ฆ†็›–ๅฐฝๅฏ่ƒฝๅคš็š„็”ป้ขๅ…ƒ็ด ใ€‚
194
+
195
+ ### ็คบไพ‹
196
+
197
+ **็คบไพ‹ 1๏ผš้›จไธญๅฅ”่ท‘็š„ๅฐ‘ๅฅณ**
198
+
199
+ ```
200
+ ไธ€ไธช็ฉฟ็€็™ฝ่‰ฒๆฐดๆ‰‹ๆœใ€่“่‰ฒ็Ÿญ่ฃ™็š„ๅฐ‘ๅฅณ๏ผŒๅœจไธ‹็€ๅคง้›จ็š„ๅŸŽๅธ‚่ก—้“ๅฅ”่ท‘๏ผŒ
201
+ ๅฅน็š„่กจๆƒ…ๆ˜ฏไธ็”˜ใ€ๆ„คๆ€’ใ€ๆตๆณช๏ผŒๅฅน็š„่กฃๆœๆนฟ้€ใ€‚
202
+ ```
203
+
204
+ > short_dress, city, streaming_tears, street, white_serafuku, furious, fume, rain, tears, running, skirt, crying, tearing_up, urban
205
+
206
+ **็คบไพ‹ 2๏ผš่ต›ๅšๆœ‹ๅ…‹ๅŸŽๅธ‚ๅคœๆ™ฏ**
207
+
208
+ ```
209
+ ่ต›ๅšๆœ‹ๅ…‹้ฃŽๆ ผ็š„ๅŸŽๅธ‚๏ผŒ้œ“่™น็ฏๆ‹›็‰Œ๏ผŒ้›จๅŽๆฝฎๆนฟ็š„่ก—้“ๅ€’ๆ˜ ็ฏๅ…‰๏ผŒ
210
+ ่ฟœๅค„้ซ˜ๆฅผๆž—็ซ‹๏ผŒไธ€ไธช็ฉฟ็€็šฎๅคนๅ…‹็š„ๅฅณๅญฉ็ซ™ๅœจ่ทฏ็ฏไธ‹ๆŠฝ็ƒŸใ€‚
211
+ ```
212
+
213
+ > street, reflection, lights, lamp, leather_jacket, fur_jacket, 1girl, standing, lamppost, smoking, holding_cigarette, cyberpunk, after_rain, city, urban, neon_lights, wet_floor, utility_pole
214
+
215
+ ---
216
+
217
+ **็คบไพ‹ 3๏ผšๅฅ‡ๅนปๆˆ˜ๅœบ**
218
+
219
+ ```
220
+ ไธ€ไธช้“ถๅ‘็š„็ฒพ็ตๅฅณๆˆ˜ๅฃซ๏ผŒ่บซ็ฉฟ่ฝป็”ฒ๏ผŒๆ‰‹ๆŒๅผ“็ฎญ๏ผŒ็ซ™ๅœจๅบŸๅขŸไธŠ๏ผŒ
221
+ ่ƒŒๆ™ฏๆ˜ฏๆญฃๅœจ็‡ƒ็ƒง็š„ๅŸŽๅธ‚ๅ’ŒๅธƒๆปกไนŒไบ‘็š„ๅคฉ็ฉบ๏ผŒ่กฃๆœไธŠๆœ‰่ก€่ฟนใ€‚
222
+ ```
223
+
224
+ > burning, city, dark_cloud, sky, clothes, blood_stain, blood_on_clothes, silver_hairband, standing, white_hair, cyberpunk, after_rain, holding, bow_(weapon), urban, ruins, elf, rubble, holding_bow_(weapon), arrow_(projectile), neon_lights, burning_building, wet_floor, utility_pole
225
+
226
+ ---
227
+
228
+ **ๆŠ€ๅทง๏ผšๅ…ณไบŽๆ่ฟฐ็š„ๅ†™ๆณ•**
229
+
230
+ - ๅˆ†ๆฎตๆ่ฟฐไธๅŒ่ฆ็ด ๏ผˆไบบ็‰ฉ / ่ƒŒๆ™ฏ / ่กจๆƒ… / ๆœ่ฃ…๏ผ‰ๆฏ”ๆททๅœจไธ€่ตทๆ•ˆๆžœๆ›ดๅฅฝ
231
+ - ็ป†่Š‚่ถŠๅ…ทไฝ“๏ผŒๅŒน้…่ถŠๅ‡†็กฎโ€”โ€”ใ€Œ่“่‰ฒ้•ฟๅ‘ใ€ไผ˜ไบŽใ€Œ็‰นๅˆซ็š„ๅ‘่‰ฒใ€
232
+ - ้‡ๅˆฐๆ„Ÿ่ง‰ๅบ”่ฏฅๆœ‰ไฝ†ๆฒกๅ‘ฝไธญ็š„ๆ ‡็ญพ๏ผŒๅฏไปฅๆŠŠ่ฏฅ้ƒจๅˆ†ๅ•็‹ฌๆˆชๅ–๏ผŒๅˆ‡ๆขๅˆฐใ€Œ่พ“ๅ…ฅๆ่ฟฐๆŸฅ่ฏใ€ๆจกๅผๅ•็‹ฌๆŸฅ
233
+
234
+ ---
235
+
236
+ ## 5. ๅ…ณ่”ๆŽจ่๏ผˆๅ…ฑ็ŽฐๅŒน้…๏ผ‰
237
+
238
+ ๅ…ณ่”ๆŽจ่ไธŽๅ‰ๅ››้กนๅŠŸ่ƒฝๆœบๅˆถไธๅŒ๏ผŒๅฎƒไธไพ่ต–่ฏญไน‰็›ธไผผๅบฆ๏ผŒ่€Œๆ˜ฏๅŸบไบŽ **Danbooru ๅ›พๅบ“ไธญๆ ‡็ญพ็š„ๅ…ฑ็Žฐ็ปŸ่ฎก**๏ผšๅฆ‚ๆžœไธคไธชๆ ‡็ญพ็ปๅธธๅ‡บ็ŽฐๅœจๅŒไธ€ๅผ ๅ›พไธŠ๏ผŒๅฐฑ่ฎคไธบๅฎƒไปฌ็›ธๅ…ณใ€‚
239
+
240
+ **ไฝฟ็”จๆ–นๅผ๏ผš** ๅœจๅทฆไพง็ป“ๆžœ่กจๆ ผไธญๅ‹พ้€‰ไฝ ๆ„Ÿๅ…ด่ถฃ็š„ๆ ‡็ญพ๏ผŒๅณไพงๅ…ณ่”ๆŽจ่ๆ ไผš่‡ชๅŠจๅˆทๆ–ฐ๏ผŒๆ˜พ็คบๅœจ Danbooru ๅ›พๅบ“ไธญไธŽไฝ ้€‰ไธญๆ ‡็ญพๆœ€ๅธธๅ…ฑ็Žฐ็š„ๅ…ถไป–ๆ ‡็ญพใ€‚ไนŸๅฏไปฅ็‚นๅ‡ปใ€Œๆ นๆฎๅทฒ้€‰ๅˆทๆ–ฐใ€ๆ‰‹ๅŠจ่งฆๅ‘ใ€‚
241
+
242
+ ๅ…ณ่”ๆŽจ่็š„ๅพ—ๅˆ†ๆ˜ฏๅŸบไบŽ NPMI๏ผˆไบ’ไฟกๆฏๅฝ’ไธ€ๅŒ–๏ผ‰่ฎก็ฎ—็š„๏ผŒ่ƒฝๆœ‰ๆ•ˆ่ฟ‡ๆปคๆމ้‚ฃไบ›ๅชๆ˜ฏๅ› ไธบ่‡ช่บซ็ƒญๅบฆ้ซ˜่€Œ้ข‘็นๅ‡บ็Žฐ็š„ๆ ‡็ญพใ€‚
243
+
244
+ ### ้€‚็”จๅœบๆ™ฏ
245
+
246
+ **ๅœบๆ™ฏ 1๏ผšๆŸฅ็œ‹ๆŸ็งๆœ้ฅฐ/้“ๅ…ท็š„ๅธธ่งๆญ้…**
247
+
248
+ ๅ‹พ้€‰ `fingerless_gloves`๏ผˆ้œฒๆŒ‡ๆ‰‹ๅฅ—๏ผ‰๏ผŒๅ…ณ่”ๆŽจ่ไผšๅˆ—ๅ‡บ็ฉฟ้œฒๆŒ‡ๆ‰‹ๅฅ—็š„่ง’่‰ฒไปฌใ€‚
249
+
250
+ ![image-20260402204128858](https://akizukipic.oss-cn-beijing.aliyuncs.com/img/202604022041742.png)
251
+
252
+ ---
253
+
254
+ **ๅœบๆ™ฏ 2๏ผšๆŸฅ็œ‹ๆŸไธช่ง’่‰ฒ็š„ๅ…ธๅž‹็‰นๅพ**
255
+
256
+ ๅ‹พ้€‰่ง’่‰ฒๆ ‡็ญพ๏ผˆๅฆ‚ `amiya_(arknights)`๏ผ‰๏ผŒๅ…ณ่”ๆŽจ่ไผšๅˆ—ๅ‡บ่ฏฅ่ง’่‰ฒๆœ€ๅธธๅ‡บ็Žฐ็š„ๆœ่ฃ…ใ€้…ไปถใ€่กจๆƒ…็ญ‰ๆ ‡็ญพ๏ผŒ็›ธๅฝ“ไบŽ่‡ชๅŠจ็”Ÿๆˆ่ง’่‰ฒ็š„"ๆ ‡็ญพ็”ปๅƒ"ใ€‚
257
+
258
+ ![image-20260402204239836](https://akizukipic.oss-cn-beijing.aliyuncs.com/img/202604022042492.png)
259
+
260
+ ---
261
+
262
+ **ๅœบๆ™ฏ 3๏ผšๆŽข็ดขๆŸ็ฑปไธป้ข˜็š„ๆ ‡็ญพไฝ“็ณป**
263
+
264
+ ๅ‹พ้€‰ `fighter_jet`๏ผˆๆˆ˜ๆ–—ๆœบ๏ผ‰๏ผŒๅ…ณ่”ๆŽจ่ไผšๅˆ—ๅ‡บๅ„็ฑปๆœบๅž‹ใ€้ฃž่กŒๅŠจไฝœใ€่ƒŒๆ™ฏ็ญ‰็›ธๅ…ณๆ ‡็ญพ๏ผŒๅธฎๅŠฉไฝ ๅฟซ้€Ÿๆ‘ธๆธ…ไธ€ไธชไธป้ข˜ไธ‹็š„ๆ ‡็ญพ็”Ÿๆ€ใ€‚
265
+
266
+ ![image-20260402204306860](https://akizukipic.oss-cn-beijing.aliyuncs.com/img/202604022043665.png)
267
+
268
+ ---
269
+
270
+ **ๅœบๆ™ฏ 4๏ผš็ป„ๅˆๅคšไธชๆ ‡็ญพ่Žทๅพ—ไบค้›†ๆŽจ่**
271
+
272
+ ๅŒๆ—ถๅ‹พ้€‰ๅคšไธชๆ ‡็ญพๆ—ถ๏ผŒๅ…ณ่”ๆŽจ่ไผš็ปผๅˆ่ฎก็ฎ—ๅคšๆ ‡็ญพ็š„ๅ…ฑ็Žฐๅ…ณ็ณป๏ผŒๆŽจ่ไธŽ่ฟ™็ป„ๆ ‡็ญพ**ๆ•ดไฝ“**ๆœ€ๅŒน้…็š„ๆ ‡็ญพ๏ผŒ่€Œไธๅชๆ˜ฏๅ•ไธชๆ ‡็ญพ็š„้‚ปๅฑ…ใ€‚
273
+
274
+ ๆฏ”ๅฆ‚ๅŒๆ—ถๅ‹พ้€‰ `maid` + `twintails`๏ผŒๆŽจ่็ป“ๆžœไผšๆ›ดๅ€พๅ‘ไบŽใ€Œๅฅณไป†ไธ”ๅŒ้ฉฌๅฐพใ€ๅœบๆ™ฏไธ‹็ปๅธธๅ‡บ็Žฐ็š„ๆ ‡็ญพ๏ผŒ่€Œไธๆ˜ฏๅ•็บฏใ€Œๅฅณไป†ใ€ๆˆ–ใ€ŒๅŒ้ฉฌๅฐพใ€ๅ„่‡ช็š„้ซ˜้ข‘ๆญ้…ใ€‚
275
+
276
+ ![image-20260402204415282](https://akizukipic.oss-cn-beijing.aliyuncs.com/img/202604022044935.png)
277
+
278
+ ---
279
+
280
+ ## ไฝฟ็”จๆŠ€ๅทง
281
+
282
+ **ๆŠ€ๅทง 1๏ผšๅ…ˆๅฎฝๅŽ็ช„**
283
+ ๅ…ˆ็”จๅฎŒๆ•ด็”ป้ขๆŸฅๆ‰พๆ‹ฟๅˆฐๅคง้‡ๅ€™้€‰ๆ ‡็ญพ๏ผŒๅ†็”จๅˆ†่ฏ็ญ›้€‰ chip ๆˆ–ๆ‰‹ๅŠจๅŽปๆމไธ็›ธๅ…ณ็š„๏ผŒ็„ถๅŽๅ‹พ้€‰ๅ‰ฉไฝ™ๆ ‡็ญพ่งฆๅ‘ๅ…ณ่”ๆŽจ่๏ผŒ่กฅๅ……้—ๆผ็ป†่Š‚ใ€‚
284
+
285
+ **ๆŠ€ๅทง 2๏ผšๆ‹ฟไธๅ‡†ๆ ‡็ญพๅซไน‰ๆ—ถๆ‚ฌๆตฎๆŸฅ็œ‹**
286
+ ้ผ ๆ ‡ๆ‚ฌๅœๅœจไปปๆ„ๆ ‡็ญพ่กŒๆˆ–ๅ…ณ่”ๆŽจ่่กŒไธŠ๏ผŒไผšๅผนๅ‡บ่ฏฅๆ ‡็ญพ็š„ Danbooru ็ปดๅŸบ้‡Šไน‰๏ผŒๆ–นไพฟๅฟซ้€Ÿๅˆคๆ–ญๆ˜ฏๅฆ็ฌฆๅˆ้œ€ๆฑ‚๏ผŒๆ— ้œ€่ทณ่ฝฌ็ซ™ๅค–ใ€‚
287
+
288
+ **ๆŠ€ๅทง 3๏ผš็‚นๅ‡ปๆ ‡็ญพๅ็›ดๆŽฅ่ทณ่ฝฌ**
289
+ ๆ ‡็ญพๅๆ˜ฏๅฏ็‚นๅ‡ป้“พๆŽฅ๏ผŒ็‚นๅ‡ปๅŽๅœจๆ–ฐๆ ‡็ญพ้กตๆ‰“ๅผ€ Danbooru ็š„ๅฏนๅบ”็ปดๅŸบ้กต๏ผŒๅฏไปฅๆŸฅ็œ‹่ฏฆ็ป†่ฏดๆ˜Žใ€็›ธๅ…ณๅ›พไพ‹ๅ’Œ่ฟ‘ไน‰ๆ ‡็ญพใ€‚
290
+
291
+ **ๆŠ€ๅทง 4๏ผšNSFW ไฟๆŠคๆจกๅผ**
292
+ ้ป˜่ฎคๅ…ณ้—ญ NSFW ๆ˜พ็คบใ€‚ๆ‰“ๅผ€ๅŽๆ ‡็ญพ็š„ๅฎŒๆ•ดๅ็งฐๅ’Œๅซไน‰ไผšๆ˜พ็คบๅ‡บๆฅ๏ผŒไฝ†ๅนณๅฐ้™ๅˆถไธ‹๏ผˆ้ญ”ๆญๅˆ›็ฉบ้—ด๏ผ‰ๆญค้€‰้กนไธๅฏ็”จใ€‚
293
+
294
+ **ๆŠ€ๅทง 5๏ผšๅคๅˆถๅ…จ้ƒจ vs ๅคๅˆถ้€‰ไธญ**
295
+ - **ๅคๅˆถๅ…จ้ƒจๆ ‡็ญพ**๏ผšๆŠŠๅฝ“ๅ‰ๆœ็ดข็ป“ๆžœไธญๆ‰€ๆœ‰ๆ ‡็ญพๅคๅˆถไธบ้€—ๅทๅˆ†้š”็š„ๅญ—็ฌฆไธฒ๏ผŒ้€‚ๅˆๅฟซ้€Ÿ็”ŸๆˆๅฎŒๆ•ด Prompt
296
+ - **ๅคๅˆถ้€‰ไธญๆ ‡็ญพ**๏ผšๅชๅคๅˆถไฝ ๅœจ่กจๆ ผไธญๆ‰‹ๅŠจๅ‹พ้€‰็š„ๆ ‡็ญพ๏ผŒ้€‚ๅˆ็ฒพ้€‰ๅŽไฝฟ็”จ
297
+
298
+ **ๆŠ€ๅทง 6๏ผš็”จๆ˜พๅผๅˆ†้š”็ฌฆ็ฒพ็ป†ๆŽงๅˆถๅˆ†่ฏ**
299
+ ๅฝ“ไฝ ็Ÿฅ้“่‡ชๅทฑๆƒณ่ฆๅ“ชไบ›็‹ฌ็ซ‹ๆฆ‚ๅฟตๆ—ถ๏ผŒ็”จ็ฉบๆ ผๆˆ–ไธญๆ–‡้€—ๅทๆ‰‹ๅŠจๅˆ†้š”ๅฎƒไปฌ๏ผš
300
+ - ใ€Œ`่ฟๅŠจ็คพๅ›ข ๆ ก้˜Ÿ ๆฏ”่ต› ่ฟๅŠจไผš ไฝ“่‚ฒ็ฅญ ็”ฐๅพ„้ƒจ`ใ€โ†’ ๆฏไธชๆฆ‚ๅฟตๅŽŸๆ ทไฟ็•™
301
+ - ใ€Œ`ๅไนŒๆ‰˜้‚ฆใ€่ต›ๅšๆœ‹ๅ…‹ใ€่’ธๆฑฝๆœ‹ๅ…‹ใ€ๅบŸๅœŸ`ใ€โ†’ ้€—ๅทๅˆ†้š”ๅŒ็†
302
+ - ๅˆ†้š”ๅŽ็š„็Ÿญ็‰‡ๆฎต๏ผˆโ‰ค7 ๅญ—๏ผ‰ไฝœไธบๆ•ดไฝ“ๅŒน้…๏ผŒไธไผš่ขซ้”™่ฏฏๆ‹†ๅผ€
303
+ - ๅฆ‚ๆžœ้œ€่ฆๆททๅˆ้•ฟ็ŸญๆŸฅ่ฏข๏ผŒๅฏไปฅไธ€้ƒจๅˆ†็”จ็ฉบๆ ผๅˆ†้š”ใ€ๅฆไธ€้ƒจๅˆ†ๅ†™ๆˆ่‡ช็„ถ่ฏญๅฅ
304
+
305
+ ---
306
+
307
+ ## API ๆŽฅๅฃ
308
+
309
+ ๆœฌๅทฅๅ…ทๅŒๆ—ถๆไพ› REST API๏ผŒๅฏ้›†ๆˆๅˆฐไฝ ่‡ชๅทฑ็š„ๅทฅไฝœๆตไธญใ€‚
310
+
311
+ ๅฏๅŠจๅŽ่ฎฟ้—ฎ `/api/docs` ๆŸฅ็œ‹ๅฎŒๆ•ด็š„ไบคไบ’ๅผ API ๆ–‡ๆกฃใ€‚
312
+
313
+ **ๆœ็ดขๆŽฅๅฃ**
314
+ ```
315
+ POST /api/search
316
+ {
317
+ "query": "็™ฝ่‰ฒๆฐดๆ‰‹ๆœ็š„ๅฅณๅญฉ",
318
+ "top_k": 20,
319
+ "limit": 20,
320
+ "show_nsfw": false
321
+ }
322
+ ```
323
+
324
+ **ๅ…ณ่”ๆŽจ่ๆŽฅๅฃ**
325
+ ```
326
+ POST /api/related
327
+ {
328
+ "tags": ["white_serafuku", "sailor_collar"],
329
+ "limit": 20,
330
+ "show_nsfw": false
331
+ }
332
+ ```
333
+
334
+ ่ฏฆ่ง๏ผš[API ๆ–‡ๆกฃ](https://sakizuki-danboorusearch.hf.space/api/docs)
335
+
336
+ ---
337
+
338
+ ## MCP ๆŽฅๅฃ
339
+
340
+ ๆœฌๅทฅๅ…ทๆไพ› MCP๏ผˆModel Context Protocol๏ผ‰ๆœๅŠก๏ผŒๆ”ฏๆŒๅฐ†ๆœ็ดขๅผ•ๆ“Ž็›ดๆŽฅๆŽฅๅ…ฅ Claude Desktopใ€Cursorใ€Cherry Studio ็ญ‰ๆ”ฏๆŒ MCP ๅ่ฎฎ็š„ๅคงๆจกๅž‹ๅฎขๆˆท็ซฏ๏ผŒ่ฎฉ AI ่ƒฝๅคŸๅœจๅฏน่ฏไธญ็›ดๆŽฅ่ฐƒ็”จๆ ‡็ญพๆœ็ดข่ƒฝๅŠ›ใ€‚
341
+
342
+ **MCP ๆœๅŠกๅœฐๅ€๏ผš**
343
+ ```
344
+ https://sakizuki-danboorusearch.hf.space/mcp/mcp
345
+ ```
346
+
347
+ ### ๆŽฅๅ…ฅๆ–นๆณ•
348
+
349
+ **ๆ–นๆณ•ไธ€๏ผšClaude Desktop**
350
+
351
+ Claude Desktop ไธๆ”ฏๆŒ็›ดๆŽฅ่ฟžๆŽฅ Streamable HTTP ็ซฏ็‚น๏ผŒ้œ€่ฆ้€š่ฟ‡ `mcp-remote` ไฝœไธบๆœฌๅœฐๆกฅๆŽฅใ€‚
352
+
353
+ ้ฆ–ๅ…ˆๅ…จๅฑ€ๅฎ‰่ฃ… `mcp-remote`๏ผˆ้œ€่ฆๆœฌๆœบๅทฒๅฎ‰่ฃ… Node.js๏ผ‰๏ผš
354
+
355
+ ```bash
356
+ npm install -g mcp-remote
357
+ ```
358
+
359
+ ็„ถๅŽๅœจ้…็ฝฎๆ–‡ไปถไธญๆทปๅŠ ไปฅไธ‹ๅ†…ๅฎน๏ผš
360
+
361
+ - Windows๏ผš`%APPDATA%\Claude\claude_desktop_config.json`
362
+ - macOS๏ผš`~/Library/Application Support/Claude/claude_desktop_config.json`
363
+
364
+ ```json
365
+ {
366
+ "mcpServers": {
367
+ "danbooru-searcher": {
368
+ "command": "mcp-remote",
369
+ "args": [
370
+ "https://sakizuki-danboorusearch.hf.space/mcp/mcp"
371
+ ]
372
+ }
373
+ }
374
+ }
375
+ ```
376
+
377
+ ไฟๅญ˜ๅŽ้‡ๅฏ Claude Desktop๏ผŒๅทฅๅ…ทๅˆ—่กจไธญๅ‡บ็Žฐ `search_tags` ๅ’Œ `get_related_tags` ๅณไธบๆˆๅŠŸใ€‚
378
+
379
+ > ๆณจๆ„๏ผšไธ่ฆไฝฟ็”จ `"url"` ๅญ—ๆฎต็›ดๆŽฅๅกซๅ†™ๅœฐๅ€๏ผŒClaude Desktop ไธๆ”ฏๆŒ่ฏฅๆ ผๅผ๏ผŒไผšๆ็คบ้…็ฝฎๆ— ๆ•ˆใ€‚ไนŸไธๆŽจ่้€š่ฟ‡ `npx mcp-remote` ่ฐƒ็”จ๏ผŒ้ฆ–ๆฌกๆ‰ง่กŒๆ—ถ `npx` ้œ€่ฆไธดๆ—ถไธ‹่ฝฝๅŒ…๏ผŒnpm ็ผ“ๅญ˜ๆŸๅๆ—ถไผšๅฏผ่‡ดๅฏๅŠจๅคฑ่ดฅใ€‚ๅ…จๅฑ€ๅฎ‰่ฃ…ๅฏ่ง„้ฟๆญค้—ฎ้ข˜ใ€‚
380
+
381
+ **ๆ–นๆณ•ไบŒ๏ผšๅ›พๅฝข็•Œ้ข๏ผˆCherry Studio ็ญ‰๏ผ‰**
382
+
383
+ ๅœจ MCP ๆœๅŠกๅ™จ็ฎก็†้กต้ข็‚นๅ‡ปใ€ŒๆทปๅŠ ใ€๏ผŒๅกซๅ†™ไปฅไธ‹ไฟกๆฏ๏ผš
384
+
385
+ - ๅ็งฐ๏ผš`danbooru-searcher`๏ผˆๅฏ่‡ชๅฎšไน‰๏ผ‰
386
+ - ็ฑปๅž‹๏ผš`Streamable HTTP`
387
+ - URL๏ผš`https://sakizuki-danboorusearch.hf.space/mcp/mcp`
388
+
389
+ > ๆณจๆ„๏ผš็ฑปๅž‹ๅŠกๅฟ…้€‰ๆ‹ฉ **Streamable HTTP**๏ผŒไธ่ฆ้€‰ SSE๏ผŒๅฆๅˆ™ๅทฅๅ…ทๅˆ—่กจๆ— ๆณ•ๆญฃๅธธๅŠ ่ฝฝใ€‚
390
+
391
+ ๆทปๅŠ ๅฎŒๆˆๅŽ้‡ๅฏๅฎขๆˆท็ซฏ๏ผŒๆˆ–ๅœจ MCP ็ฎก็†็•Œ้ขๆ‰‹ๅŠจๅˆทๆ–ฐ่ฟžๆŽฅใ€‚
392
+
393
+ ### ๅฏ็”จๅทฅๅ…ท
394
+
395
+ ๆŽฅๅ…ฅๅŽ AI ๅฏ่ฐƒ็”จไปฅไธ‹ไธคไธชๅทฅๅ…ท๏ผš
396
+
397
+ **`search_tags`** โ€” ่‡ช็„ถ่ฏญ่จ€ๆœ็ดขๆ ‡็ญพ
398
+
399
+ ๆŽฅๅ—ไธญๆ–‡ๆˆ–่‹ฑๆ–‡ๆ่ฟฐ๏ผŒ่ฟ”ๅ›žๅŒน้…็š„ Danbooru ๆ ‡็ญพๅˆ—่กจๅ’Œๅฏ็›ดๆŽฅ็”จไบŽ AI ็ป˜็”ป็š„ prompt ๅญ—็ฌฆไธฒใ€‚ๆ”ฏๆŒๅ‚ๆ•ฐ๏ผš
400
+
401
+ | ๅ‚ๆ•ฐ | ้ป˜่ฎคๅ€ผ | ่ฏดๆ˜Ž |
402
+ |---|---|---|
403
+ | `query` | โ€” | ่‡ช็„ถ่ฏญ่จ€ๆ่ฟฐ๏ผˆๅฟ…ๅกซ๏ผŒๆŽจ่ไธญๆ–‡๏ผ‰ใ€‚ๆ”ฏๆŒ็ฉบๆ ผ/้€—ๅท/้กฟๅทๆ˜พๅผๅˆ†้š”ๆฆ‚ๅฟต |
404
+ | `use_segmentation` | `true` | ๆ™บ่ƒฝๅˆ†่ฏๅผ€ๅ…ณ๏ผŒๅฎŒๆ•ด็”ป้ขๆ่ฟฐๆ—ถๅผ€ๅฏ๏ผŒ็ฒพ็กฎๆŸฅ่ฏๆ—ถๅ…ณ้—ญ |
405
+ | `top_k` | `5` | ๆฏไธชๅˆ†่ฏ็š„่ฏญไน‰ๅฌๅ›žๆ•ฐ้‡๏ผŒๅ‘ๆ•ฃๆŽข็ดขๆ—ถ่ฐƒ้ซ˜่‡ณ 80~160 |
406
+ | `limit` | `80` | ่ฟ”ๅ›žๆ ‡็ญพๆ€ปๆ•ฐไธŠ้™ |
407
+ | `popularity_weight` | `0.15` | ็ƒญๅบฆๆƒ้‡๏ผŒๆŽงๅˆถ้ซ˜้ข‘ๆ ‡็ญพ็š„ๆŽ’ๅๅฝฑๅ“ |
408
+ | `show_nsfw` | `true` | ๆ˜ฏๅฆๅŒ…ๅซ NSFW ๆ ‡็ญพ |
409
+ | `category` | `"all"` | ๆ ‡็ญพ็ฑปๅˆซ็ญ›้€‰๏ผš`"all"` ๅ…จ้ƒจใ€`"general"` ้€š็”จใ€`"character"` ่ง’่‰ฒใ€`"copyright"` ไฝœๅ“ |
410
+
411
+ **`get_related_tags`** โ€” ๅ…ฑ็Žฐๅ…ณ่”ๆŽจ่
412
+
413
+ ็ป™ๅฎšไธ€็ป„ๅทฒ้€‰ๆ ‡็ญพ๏ผŒ่ฟ”ๅ›žๅœจ Danbooru ๅ›พๅบ“ไธญไธŽไน‹ๆœ€ๅธธๅ…ฑ็Žฐ็š„็›ธๅ…ณๆ ‡็ญพ๏ผŒๅŸบไบŽ NPMI ่ฏ„ๅˆ†๏ผŒๅฏๆœ‰ๆ•ˆ่ฟ‡ๆปคๆމไป…ๅ› ่‡ช่บซ็ƒญๅบฆ้ซ˜่€Œ้ข‘็นๅ‡บ็Žฐ็š„ๆ ‡็ญพใ€‚
414
+
415
+ ### ่ฐƒ็”จ็คบไพ‹
416
+
417
+ ๆŽฅๅ…ฅๅŽ๏ผŒไฝ ๅฏไปฅ็›ดๆŽฅ็”จ่‡ช็„ถ่ฏญ่จ€ๅ‘Š่ฏ‰ AI ไฝ ็š„้œ€ๆฑ‚๏ผŒAI ไผš่‡ชๅŠจ้€‰ๆ‹ฉๅˆ้€‚็š„ๅ‚ๆ•ฐ่ฐƒ็”จๅทฅๅ…ท๏ผš
418
+
419
+ > ๅธฎๆˆ‘ๆŠŠใ€Œไธ€ไธช็ฉฟ็™ฝ่‰ฒๆฐดๆ‰‹ๆœ็š„ๅฐ‘ๅฅณๅœจๅคง้›จไธญๅฅ”่ท‘๏ผŒ่กจๆƒ…ๆ„คๆ€’ใ€ๆตๆณช๏ผŒ่กฃๆœๆนฟ้€ใ€่ฝฌๆขๆˆ Danbooru ๆ ‡็ญพ prompt
420
+
421
+ > ๆˆ‘ๆƒณๆ‰พใ€Œๆœบๆขฐไน‰่‚ขใ€็›ธๅ…ณ็š„ๆ ‡็ญพ๏ผŒๅธฎๆˆ‘ๅ‘ๆ•ฃไธ€ไธ‹
422
+
423
+ > ๅธฎๆˆ‘ๆŸฅไธ€ไธ‹ใ€Œๅ‘ฝ่ฟ็Ÿณไน‹้—จ็š„ๅŠฉๆ‰‹ใ€ๅฏนๅบ”็š„่ง’่‰ฒๆ ‡็ญพๆ˜ฏไป€ไนˆ
424
+
425
+ > ๆˆ‘ๅทฒ็ป้€‰ไบ† `white_serafuku` ๅ’Œ `rain` ่ฟ™ไธคไธชๆ ‡็ญพ๏ผŒๅธฎๆˆ‘ๆŽจ่ไธ€ไบ›ๅธธ่ง็š„ๆญ้…ๆ ‡็ญพ
426
+
427
+ > ๆŸฅไธ€ไธ‹ใ€Œ่ฟๅŠจ็คพๅ›ข ๆ ก้˜Ÿ ๆฏ”่ต› ่ฟๅŠจไผš ไฝ“่‚ฒ็ฅญ ็”ฐๅพ„้ƒจ ็ฝ‘็ƒ้ƒจ ็ฏฎ็ƒ้ƒจใ€่ฟ™ไบ›ๆฆ‚ๅฟตๅฏนๅบ”็š„ๆ ‡็ญพ
428
+
429
+ > ๆ‰พใ€ŒๅไนŒๆ‰˜้‚ฆใ€่ต›ๅšๆœ‹ๅ…‹ใ€่’ธๆฑฝๆœ‹ๅ…‹ใ€็š„ General ็ฑปๆ ‡็ญพ๏ผŒๅชๆŸฅ้€š็”จ็‰นๅพไธๆŸฅไฝœๅ“ๅ
430
+
431
+ ### ๆŸฅ่ฏขๅ†™ไฝœๆŠ€ๅทง๏ผˆๅ†™็ป™ LLM๏ผ‰
432
+
433
+ ไธบไบ†่ฎฉๆœ็ดขๆ›ด็ฒพๅ‡†๏ผŒๅœจๆž„้€  `query` ๆ—ถ่ฏทๆณจๆ„ไปฅไธ‹ๅ‡ ็‚น๏ผš
434
+
435
+ 1. **ๆฆ‚ๅฟตๅˆ—่กจ็”จๆ˜พๅผๅˆ†้š”็ฌฆ๏ผš** ้œ€่ฆๆ‰น้‡ๆŸฅ่ฏขๅคšไธช็‹ฌ็ซ‹ๆฆ‚ๅฟตๆ—ถ๏ผŒ็”จ็ฉบๆ ผใ€ไธญๆ–‡้€—ๅท๏ผˆ๏ผŒ๏ผ‰ๆˆ–้กฟๅท๏ผˆใ€๏ผ‰ๅˆ†้š”ใ€‚ๆฏไธช โ‰ค7 ๅญ—็š„็‰‡ๆฎตไผšไฝœไธบๅฎŒๆ•ดๆฆ‚ๅฟตๅŒน้…๏ผŒไธไผš่ขซ้”™่ฏฏๆ‹†ๅผ€ใ€‚ไพ‹ๅฆ‚ `่ฟๅŠจ็คพๅ›ข ๆ ก้˜Ÿ ๆฏ”่ต›` ่€Œ้ž `่ฟๅŠจ็คพๅ›ขๆ ก้˜Ÿๆฏ”่ต›`ใ€‚
436
+
437
+ 2. **็”ป้ขๆ่ฟฐๅ†™่‡ช็„ถ่ฏญๅฅ๏ผš** ๅฎŒๆ•ดๅœบๆ™ฏๆ่ฟฐไธ้œ€่ฆๆ˜พๅผๅˆ†้š”๏ผŒ็ณป็ปŸไผš่‡ชๅŠจๅˆ†่ฏใ€‚ไพ‹ๅฆ‚ `ไธ€ไธช็ฉฟ็€็™ฝ่‰ฒๆฐดๆ‰‹ๆœ่“่‰ฒ็Ÿญ่ฃ™็š„ๅฐ‘ๅฅณๅœจ้›จไธญๅฅ”่ท‘`ใ€‚
438
+
439
+ 3. **ๆŸฅ่ง’่‰ฒ/ไฝœๅ“ๆ—ถๅ…ณ้—ญๅˆ†่ฏ๏ผš** ็ฒพ็กฎๆŸฅๆ‰พๆŸไธช่ง’่‰ฒๅๆˆ–ไฝœๅ“ๅๆ—ถ๏ผŒ่ฎพ `use_segmentation=false` ๅ’Œ `category="character"`๏ผˆๆˆ– `"copyright"`๏ผ‰๏ผŒ้ฟๅ…ๅๅญ—ไธญ็š„่ฏๆฑ‡่ขซๅ•็‹ฌๅŒน้…ๅˆฐๆ— ๅ…ณๆ ‡็ญพใ€‚
440
+
441
+ 4. **้•ฟ็Ÿญ่ฏญไผš่‡ชๅŠจไฟๆŒๅฎŒๆ•ด่ฏญไน‰๏ผš** ๅณไฝฟ็”จๆ˜พๅผๅˆ†้š”็ฌฆๅˆ‡ๅ‡บ็š„็‰‡ๆฎต่ถ…่ฟ‡ 7 ๅญ—๏ผŒ็ณป็ปŸไนŸไผšๅฏนๅ…ถ่‡ชๅŠจๅˆ†่ฏๅนถๅŒๆ—ถไฟ็•™ๅŽŸๅง‹็‰‡ๆฎตไฝœไธบ้ขๅค–ๆŸฅ่ฏข๏ผˆไปŽๅฅ็บง่ฏญไน‰๏ผ‰๏ผŒไธไผšไธขๅคฑๆ•ดไฝ“่ฏญๅขƒใ€‚
442
+
443
+ ### ๆณจๆ„ไบ‹้กน
444
+
445
+ HF Space ๅœจๆ— ๆต้‡ๆ—ถไผš่ฟ›ๅ…ฅไผ‘็œ ็Šถๆ€๏ผŒ้ฆ–ๆฌก่ฏทๆฑ‚้œ€่ฆ็ญ‰ๅพ…ๅ†ทๅฏๅŠจ๏ผˆ็บฆ 30~60 ็ง’๏ผ‰ใ€‚่‹ฅ AI ่ฐƒ็”จ่ถ…ๆ—ถ๏ผŒ็จ็ญ‰็‰‡ๅˆปๅŽ้‡่ฏ•ๅณๅฏ๏ผŒๆˆ–ๅ…ˆ่ฎฟ้—ฎ [Space ้กต้ข](https://huggingface.co/spaces/SAkizuki/DanbooruSearch) ๅฐ†ๅ…ถๅ”ค้†’ใ€‚
446
+
447
+ ---
448
+
449
+ ## ๆ•ฐๆฎๅบ“่ฏดๆ˜Ž
450
+
451
+ - ๆ•ฐๆฎๆฅๆบ๏ผšDanbooru API ๆŠ“ๅ–
452
+ - ไธญๆ–‡็ฟป่ฏ‘ไธŽ่ฏญไน‰ๆ‰ฉๅ……๏ผš็”ฑ LLM ่พ…ๅŠฉๅฎŒๆˆ
453
+ - ่ง’่‰ฒๅใ€ไฝœๅ“ๅ๏ผš้€š่ฟ‡ไบŒๆฌกๅ…ƒๅž‚็ฑปๆ•ฐๆฎๅบ“ [Bangumi](https://bgm.tv) API ็ฒพ็กฎๆŸฅ่ฏข๏ผŒๅฐฝ้‡้ฟๅ…ๅนป่ง‰
454
+ - ไป…ๆ”ถๅฝ• Danbooru ้ข‘ๆ•ฐ **โ‰ฅ 100** ็š„ๆ ‡็ญพ๏ผŒไฟ่ฏๆ ‡็ญพๅฎž้™…ๅฏ็”จ
455
+ - ไป…่ฆ†็›– General๏ผˆ้€š็”จ็‰นๅพ๏ผ‰ใ€Character๏ผˆ่ง’่‰ฒ๏ผ‰ใ€Copyright๏ผˆไฝœๅ“๏ผ‰ไธ‰็ฑปๆ ‡็ญพ
456
+
457
+ **ๆ•ฐๆฎๅบ“็”Ÿๆˆไปฃ็ ๏ผš** https://github.com/SuzumiyaAkizuki/danbooru-tag-pipeline
458
+
459
+ ---
460
+
461
+ ## ๆณจๆ„ไบ‹้กน
462
+
463
+ - ๆœฌๅทฅๅ…ทไธบ AI ่พ…ๅŠฉๅทฅๅ…ท๏ผŒๆฃ€็ดข็ป“ๆžœๆœชๅฟ… 100% ๅ‡†็กฎ
464
+ - ๆŸฅๆ‰พ็ป“ๆžœๅฏ่ƒฝๅŒ…ๅซ NSFW ๅ†…ๅฎน๏ผˆ้ป˜่ฎค้š่—๏ผ‰
465
+ - ไป…ๆ”ฏๆŒไธญๆ–‡ / ่‹ฑๆ–‡ๅŒ่ฏญๆŸฅๆ‰พ
466
+ - ๅฆ‚ๆžœๆฃ€็ดข็ป“ๆžœไธ็†ๆƒณ๏ผŒๆฌข่ฟŽ็‚นๅ‡ปใ€Œๆฒกๆœๅˆฐ๏ผŸใ€ๆŒ‰้’ฎๆไบคๅ้ฆˆ๏ผŒๅธฎๅŠฉๆŒ็ปญไผ˜ๅŒ–ๅผ•ๆ“Ž
467
+
468
+
469
+
470
+ ## ๆœ็ดข็ปŸ่ฎก
471
+
472
+ ![](https://dsocounter.oss-cn-hongkong.aliyuncs.com/danbooru_counter/search_stats.png)
api_fastapi.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ api_fastapi.py
3
+ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
4
+ FastAPI ้€‚้…ๅฑ‚๏ผˆๅฏ้€‰๏ผ‰ใ€‚
5
+
6
+ ๆผ”็คบๅฆ‚ไฝ•ๅœจๅฎŒๅ…จไธไฟฎๆ”น core/ ็š„ๆƒ…ๅ†ตไธ‹ๅฐ†ๅผ•ๆ“Ž API ๅŒ–ใ€‚
7
+
8
+ ๅฏๅŠจๆ–นๅผ๏ผš
9
+ uvicorn api_fastapi:app --host 0.0.0.0 --port 8000
10
+
11
+ ่ฏทๆฑ‚็คบไพ‹๏ผš
12
+ POST /search
13
+ {
14
+ "query": "็™ฝ่‰ฒๆฐดๆ‰‹ๆœ็š„ๅฅณๅญฉ",
15
+ "top_k": 5,
16
+ "limit": 20
17
+ }
18
+
19
+ POST /related
20
+ {
21
+ "tags": ["white_serafuku", "sailor_collar"],
22
+ "limit": 20,
23
+ "show_nsfw": false
24
+ }
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ from fastapi import FastAPI
31
+ from pydantic import BaseModel, Field
32
+
33
+ from core.engine import DanbooruTagger
34
+ from core.models import SearchRequest, SearchResponse, TagResult, RelatedTag
35
+ import core.counter as counter
36
+
37
+
38
+ # โ”€โ”€ Pydantic I/O ๆจกๅž‹๏ผˆAPI ๅฑ‚ไธ“็”จ๏ผŒไธŽ core.models ่งฃ่€ฆ๏ผ‰โ”€โ”€
39
+
40
+ class SearchIn(BaseModel):
41
+ query: str
42
+ top_k: int = Field(5, ge=1, le=50)
43
+ limit: int = Field(80, ge=1, le=500)
44
+ popularity_weight: float = Field(0.15, ge=0.0, le=1.0)
45
+ show_nsfw: bool = True
46
+ use_segmentation: bool = True
47
+ target_layers: list[str] = ['่‹ฑๆ–‡', 'ไธญๆ–‡ๆ‰ฉๅฑ•่ฏ', '้‡Šไน‰', 'ไธญๆ–‡ๆ ธๅฟƒ่ฏ']
48
+ target_categories: list[str] = ['General', 'Character', 'Copyright']
49
+
50
+
51
+ class TagOut(BaseModel):
52
+ tag: str
53
+ cn_name: str
54
+ category: str
55
+ nsfw: str
56
+ final_score: float
57
+ semantic_score: float
58
+ count: int
59
+ source: str
60
+ layer: str
61
+ wiki: str = ""
62
+
63
+
64
+ class RelatedIn(BaseModel):
65
+ tags: list[str]
66
+ limit: int = Field(50, ge=1, le=200)
67
+ show_nsfw: bool = True
68
+
69
+
70
+ class RelatedTagOut(BaseModel):
71
+ tag: str
72
+ cn_name: str
73
+ category: str
74
+ nsfw: str
75
+ cooc_count: int
76
+ cooc_score: float
77
+ sources: list[str]
78
+
79
+
80
+ class SearchOut(BaseModel):
81
+ tags_all: str
82
+ tags_sfw: str
83
+ results: list[TagOut]
84
+ keywords: list[str]
85
+
86
+
87
+ # โ”€โ”€ FastAPI ๅญๅบ”็”จ๏ผˆๆŒ‚่ฝฝๅˆฐ NiceGUI ็š„ /api ่ทฏๅพ„ไธ‹๏ผ‰โ”€โ”€
88
+ # lifespan / ้ข„็ƒญ็”ฑ ui_nicegui.py ็š„ @app.on_startup ็ปŸไธ€็ฎก็†๏ผŒๆญคๅค„ไธ้‡ๅคใ€‚
89
+ app = FastAPI(
90
+ title="Danbooru Tag Searcher API",
91
+ description="้€š่ฟ‡ /api/docs ๆŸฅ็œ‹ๅฎŒๆ•ดๆŽฅๅฃๆ–‡ๆกฃใ€‚",
92
+ version="1.0.0",
93
+ )
94
+
95
+
96
+ # โ”€โ”€ ็ซฏ็‚น โ”€โ”€
97
+
98
+ @app.post("/search", response_model=SearchOut)
99
+ async def search(body: SearchIn) -> SearchOut:
100
+ tagger = await DanbooruTagger.get_instance()
101
+
102
+ # SearchIn โ†’ core.models.SearchRequest๏ผˆไธค่€…ๅญ—ๆฎตไธ€ไธ€ๅฏนๅบ”๏ผŒ็›ดๆŽฅ่งฃๅŒ…๏ผ‰
103
+ request = SearchRequest(**body.model_dump())
104
+
105
+ # ๅœจ็บฟ็จ‹ๆฑ ไธญ่ฟ่กŒ้˜ปๅกž็š„ search()
106
+ response: SearchResponse = await asyncio.to_thread(tagger.search, request)
107
+
108
+ # ่ฎกๆ•ฐ๏ผšๆฏๆฌก API ๆœ็ดข่ฐƒ็”จๅ‡่ฎกๅ…ฅๆœ็ดขใ€ๆˆๅŠŸใ€ๅคๅˆถ๏ผ›่ฎฟ้—ฎไธๅ˜
109
+ await counter.increment()
110
+ await counter.increment_success()
111
+ await counter.increment_copy()
112
+
113
+ return SearchOut(
114
+ tags_all=response.tags_all,
115
+ tags_sfw=response.tags_sfw,
116
+ results=[TagOut(**vars(r)) for r in response.results],
117
+ keywords=response.keywords,
118
+ )
119
+
120
+
121
+ @app.post("/related", response_model=list[RelatedTagOut])
122
+ async def related(body: RelatedIn) -> list[RelatedTagOut]:
123
+ """
124
+ ็ป™ๅฎšๅทฒ้€‰ๆ ‡็ญพๅˆ—่กจ๏ผŒ่ฟ”ๅ›žๅŸบไบŽๅ…ฑ็Žฐ่กจ็š„ๅ…ณ่”ๆŽจ่ใ€‚
125
+
126
+ - tags๏ผš็งๅญๆ ‡็ญพๅˆ—่กจ๏ผˆDanbooru ่‹ฑๆ–‡ๆ ‡็ญพๅ๏ผ‰
127
+ - limit๏ผšๆœ€ๅคš่ฟ”ๅ›žๆกๆ•ฐ๏ผŒ้ป˜่ฎค 50
128
+ - show_nsfw๏ผšๆ˜ฏๅฆๅŒ…ๅซ NSFW ๆ ‡็ญพ๏ผŒ้ป˜่ฎค True
129
+ """
130
+ tagger = await DanbooruTagger.get_instance()
131
+ results = await asyncio.to_thread(
132
+ tagger.get_related,
133
+ body.tags,
134
+ set(body.tags), # exclude ๅทฒ้€‰ๆ ‡็ญพ่‡ช่บซ
135
+ body.limit,
136
+ body.show_nsfw,
137
+ )
138
+ # ่ฎกๆ•ฐ๏ผšๆฏๆฌก API related ่ฐƒ็”จๅ‡่ฎกๅ…ฅๆœ็ดขใ€ๆˆๅŠŸใ€ๅคๅˆถ๏ผ›่ฎฟ้—ฎไธๅ˜
139
+ await counter.increment()
140
+ await counter.increment_success()
141
+ await counter.increment_copy()
142
+
143
+ return [
144
+ RelatedTagOut(
145
+ tag=r.tag,
146
+ cn_name=r.cn_name,
147
+ category=r.category,
148
+ nsfw=r.nsfw,
149
+ cooc_count=r.cooc_count,
150
+ cooc_score=r.cooc_score,
151
+ sources=r.sources,
152
+ )
153
+ for r in results
154
+ ]
155
+
156
+
157
+ @app.get("/health")
158
+ async def health():
159
+ tagger = await DanbooruTagger.get_instance()
160
+ return {"status": "ok", "loaded": tagger.is_loaded}
cli.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ cli.py
3
+ โ”€โ”€โ”€โ”€โ”€โ”€
4
+ ๅ‘ฝไปค่กŒ้€‚้…ๅฑ‚๏ผˆๅฏ้€‰๏ผ‰ใ€‚
5
+
6
+ ๅญๅ‘ฝไปค๏ผš
7
+ search ่ฏญไน‰ๆœ็ดขๆ ‡็ญพ
8
+ related ๅŸบไบŽๅ…ฑ็Žฐ่กจๆŸฅๅ…ณ่”ๆŽจ่
9
+
10
+ ็”จๆณ•๏ผš
11
+ python cli.py search "็™ฝ่‰ฒๆฐดๆ‰‹ๆœ็š„ๅฅณๅญฉ" --limit 10 --no-nsfw
12
+ python cli.py related "white_serafuku,sailor_collar" --limit 20
13
+ python cli.py related "white_serafuku,sailor_collar" --no-nsfw --show-sources
14
+ """
15
+
16
+ import argparse
17
+ import asyncio
18
+
19
+ from core.engine import DanbooruTagger
20
+ from core.models import SearchRequest
21
+
22
+
23
+ # โ”€โ”€ search โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
24
+
25
+ async def cmd_search(args):
26
+ tagger = await DanbooruTagger.get_instance()
27
+ request = SearchRequest(
28
+ query=args.query,
29
+ top_k=args.top_k,
30
+ limit=args.limit,
31
+ popularity_weight=args.weight,
32
+ show_nsfw=not args.no_nsfw,
33
+ use_segmentation=not args.no_seg,
34
+ target_layers=args.layers,
35
+ target_categories=args.categories,
36
+ )
37
+ resp = await asyncio.to_thread(tagger.search, request)
38
+
39
+ print(f"\n{'='*60}")
40
+ print(f"ๆŸฅ่ฏข๏ผš{args.query} | ๅ…ฑ {len(resp.results)} ๆก็ป“ๆžœ")
41
+ print(f"{'='*60}")
42
+ print(f"ๆŽจ่ Prompt๏ผš\n {resp.tags_sfw if args.no_nsfw else resp.tags_all}\n")
43
+
44
+ for r in resp.results:
45
+ nsfw_mark = "๐Ÿ”ด" if r.nsfw == '1' else "๐ŸŸข"
46
+ print(f" {nsfw_mark} [{r.final_score:.3f}] {r.tag:<30} {r.cn_name[:20]:<20} {r.category}")
47
+
48
+
49
+ # โ”€โ”€ related โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
50
+
51
+ async def cmd_related(args):
52
+ seed_tags = [t.strip() for t in args.tags.split(',') if t.strip()]
53
+ if not seed_tags:
54
+ print("[CLI] ้”™่ฏฏ๏ผš่ฏทๆไพ›่‡ณๅฐ‘ไธ€ไธช็งๅญๆ ‡็ญพ๏ผŒๅคšไธชๆ ‡็ญพไปฅ้€—ๅทๅˆ†้š”ใ€‚")
55
+ return
56
+
57
+ tagger = await DanbooruTagger.get_instance()
58
+ results = await asyncio.to_thread(
59
+ tagger.get_related,
60
+ seed_tags,
61
+ set(seed_tags), # exclude ็งๅญๆ ‡็ญพ่‡ช่บซ
62
+ args.limit,
63
+ not args.no_nsfw,
64
+ )
65
+
66
+ if not results:
67
+ print("[CLI] ๆœชๆ‰พๅˆฐๅ…ณ่”ๆŽจ่๏ผˆๅ…ฑ็Žฐ่กจๅฏ่ƒฝๆœชๅŠ ่ฝฝ๏ผŒๆˆ–็งๅญๆ ‡็ญพไธๅœจๅบ“ไธญ๏ผ‰ใ€‚")
68
+ return
69
+
70
+ print(f"\n{'='*60}")
71
+ print(f"็งๅญๆ ‡็ญพ๏ผš{', '.join(seed_tags)} | ๅ…ฑ {len(results)} ๆกๆŽจ่")
72
+ print(f"{'='*60}")
73
+
74
+ # ่พ“ๅ‡บ้€—ๅทๅˆ†้š”็š„ๆ ‡็ญพไธฒ๏ผˆๆ–นไพฟ็›ดๆŽฅๅคๅˆถไฝฟ็”จ๏ผ‰
75
+ tag_list = [r.tag for r in results if not (r.nsfw == '1' and args.no_nsfw)]
76
+ print(f"ๆŽจ่ๆ ‡็ญพ๏ผš\n {', '.join(tag_list)}\n")
77
+
78
+ # ๆ˜Ž็ป†่กจ
79
+ for r in results:
80
+ if r.nsfw == '1' and args.no_nsfw:
81
+ continue
82
+ nsfw_mark = "๐Ÿ”ด" if r.nsfw == '1' else "๐ŸŸข"
83
+ sources_str = f" โ† {', '.join(r.sources)}" if args.show_sources else ""
84
+ print(
85
+ f" {nsfw_mark} [{r.cooc_score:.3f}] {r.tag:<30}"
86
+ f" {r.cn_name[:20]:<20} {r.category}"
87
+ f" (ๅ…ฑ็Žฐ:{r.cooc_count:,}){sources_str}"
88
+ )
89
+
90
+
91
+ # โ”€โ”€ ๅ…ฅๅฃ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
92
+
93
+ async def main():
94
+ parser = argparse.ArgumentParser(
95
+ description="Danbooru Tag CLI Searcher",
96
+ formatter_class=argparse.RawDescriptionHelpFormatter,
97
+ epilog="""
98
+ ็คบไพ‹๏ผš
99
+ python cli.py search "็™ฝ่‰ฒๆฐดๆ‰‹ๆœ็š„ๅฅณๅญฉ" --limit 10
100
+ python cli.py related "white_serafuku,sailor_collar"
101
+ python cli.py related "white_serafuku" --limit 30 --no-nsfw --show-sources
102
+ """,
103
+ )
104
+ sub = parser.add_subparsers(dest='cmd', required=True)
105
+
106
+ _all_layers = ['่‹ฑๆ–‡', 'ไธญๆ–‡ๆ‰ฉๅฑ•่ฏ', '้‡Šไน‰', 'ไธญๆ–‡ๆ ธๅฟƒ่ฏ']
107
+ _all_cats = ['General', 'Character', 'Copyright']
108
+
109
+ # โ”€โ”€ search ๅญๅ‘ฝไปค โ”€โ”€
110
+ p_search = sub.add_parser('search', help='่ฏญไน‰ๆœ็ดขๆ ‡็ญพ')
111
+ p_search.add_argument('query', help='ๆœ็ดข่ฏ๏ผˆๆ”ฏๆŒไธญ่‹ฑๆ–‡่‡ช็„ถ่ฏญ่จ€๏ผ‰')
112
+ p_search.add_argument('--top-k', type=int, default=5, help='ๆฏๅฑ‚่ฟ”ๅ›žๆ•ฐ้‡๏ผˆ้ป˜่ฎค 5๏ผ‰')
113
+ p_search.add_argument('--limit', type=int, default=80, help='็ป“ๆžœไธŠ้™๏ผˆ้ป˜่ฎค 80๏ผ‰')
114
+ p_search.add_argument('--weight', type=float, default=0.15, help='็ƒญๅบฆๆƒ้‡๏ผˆ้ป˜่ฎค 0.15๏ผ‰')
115
+ p_search.add_argument('--no-nsfw', action='store_true', help='่ฟ‡ๆปค NSFW ๅ†…ๅฎน')
116
+ p_search.add_argument('--no-seg', action='store_true', help='็ฆ็”จๆ™บ่ƒฝๅˆ†่ฏ')
117
+ p_search.add_argument(
118
+ '--layers', nargs='+', default=_all_layers,
119
+ metavar='LAYER',
120
+ help=f'ๅŒน้…ๅฑ‚็ญ›้€‰๏ผŒๅฏๅคš้€‰๏ผŒ็”จ็ฉบๆ ผๅˆ†้š”๏ผˆ้ป˜่ฎคๅ…จ้ƒจ๏ผ‰ใ€‚ๅฏ้€‰ๅ€ผ๏ผš{_all_layers}',
121
+ )
122
+ p_search.add_argument(
123
+ '--categories', nargs='+', default=_all_cats,
124
+ metavar='CAT',
125
+ help=f'ๆ ‡็ญพ็ฑปๅž‹็ญ›้€‰๏ผŒๅฏๅคš้€‰๏ผŒ็”จ็ฉบๆ ผๅˆ†้š”๏ผˆ้ป˜่ฎคๅ…จ้ƒจ๏ผ‰ใ€‚ๅฏ้€‰ๅ€ผ๏ผš{_all_cats}',
126
+ )
127
+
128
+ # โ”€โ”€ related ๅญๅ‘ฝไปค โ”€โ”€
129
+ p_related = sub.add_parser('related', help='ๅŸบไบŽๅ…ฑ็Žฐ่กจๆŸฅๅ…ณ่”ๆŽจ่')
130
+ p_related.add_argument('tags', help='็งๅญๆ ‡็ญพ๏ผŒไปฅ่‹ฑๆ–‡้€—ๅทๅˆ†้š”๏ผˆๅฆ‚ white_serafuku,sailor_collar๏ผ‰')
131
+ p_related.add_argument('--limit', type=int, default=50, help='ๆŽจ่็ป“ๆžœไธŠ้™๏ผˆ้ป˜่ฎค 50๏ผ‰')
132
+ p_related.add_argument('--no-nsfw', action='store_true', help='่ฟ‡ๆปค NSFW ๅ†…ๅฎน')
133
+ p_related.add_argument('--show-sources', action='store_true', help='ๆ˜พ็คบๆฏๆกๆŽจ่็”ฑๅ“ชไธช็งๅญ่งฆๅ‘')
134
+
135
+ args = parser.parse_args()
136
+ if args.cmd == 'search':
137
+ await cmd_search(args)
138
+ elif args.cmd == 'related':
139
+ await cmd_related(args)
140
+
141
+
142
+ if __name__ == "__main__":
143
+ asyncio.run(main())
core/counter.py ADDED
@@ -0,0 +1,392 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ core/counter.py
3
+ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
4
+ ๆž็ฎ€ๆŒไน…ๅŒ–่ฎกๆ•ฐๅ™จ๏ผˆๆ ธๅฟƒไธšๅŠกๆŒ‡ๆ ‡๏ผšๆ€ปๆœ็ดขใ€ๆ€ป่ฎฟ้—ฎใ€ๅคๅˆถๆฌกๆ•ฐใ€ๆˆๅŠŸไบคไบ’ใ€่ฏ้ข‘่šๅˆใ€bad_case๏ผ‰ใ€‚
5
+
6
+ ๆ•ฐๆฎๆŒไน…ๅŒ–ๅˆฐ้˜ฟ้‡Œไบ‘ OSS๏ผŒ้€š่ฟ‡ platform_utils ๅฑ่”ฝ็ป†่Š‚ใ€‚
7
+ ๆ— ไปปไฝ•ๆœฌๅœฐ่ฝ็›˜ๆ“ไฝœใ€‚
8
+
9
+ count.json ็ป“ๆž„๏ผš
10
+ {
11
+ "total": int,
12
+ "visits": int,
13
+ "copies": int,
14
+ "successes": int,
15
+ "hot_keywords": {word: count, ...},
16
+ "bad_cases": [{q, t}, ...],
17
+ "history": [{"date": "YYYY-MM-DD", "total": int}, ...] # ๆฏๆ—ฅๅฟซ็…ง๏ผŒๆœ€่ฟ‘ 180 ๅคฉ
18
+ }
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import json
25
+ import time
26
+ from collections import Counter
27
+ from datetime import datetime, timezone, timedelta, date
28
+ from typing import Optional
29
+
30
+ from platform_utils import get_counter_cfg, read_bytes, upload_bytes, CounterConfig
31
+
32
+ # โ”€โ”€ ๆ ธๅฟƒ็Šถๆ€ๅ˜้‡ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
33
+ _memory_count: int = 0
34
+ _dirty_count: int = 0
35
+
36
+ _memory_visits: int = 0
37
+ _dirty_visits: int = 0
38
+ BASE_VISITS: int = 0
39
+
40
+ _memory_copies: int = 0
41
+ _dirty_copies: int = 0
42
+ BASE_COPIES: int = 0
43
+
44
+ _memory_mcp: int = 0
45
+ _dirty_mcp: int = 0
46
+
47
+ _memory_successes: int = 0
48
+ _dirty_successes: int = 0
49
+
50
+ _memory_keywords: Counter = Counter()
51
+ _dirty_keywords: Counter = Counter()
52
+ MAX_KEYWORDS_LIMIT = 200
53
+
54
+ _memory_bad_cases: list[dict] = []
55
+ _dirty_bad_cases: list[dict] = []
56
+ MAX_BAD_CASES = 50
57
+
58
+ _memory_history: list[dict] = [] # [{"date": "YYYY-MM-DD", "total": int}, ...]
59
+ MAX_HISTORY_DAYS = 180
60
+
61
+ _last_sync: float = 0.0
62
+ _sync_lock: Optional[asyncio.Lock] = None
63
+
64
+ SYNC_INTERVAL = 1800 # ๆฏ 30 ๅˆ†้’ŸๅŒๆญฅไธ€ๆฌก
65
+ SYNC_THRESHOLD = 500 # ๆˆ–ๅ„้กนๅขž้‡ไน‹ๅ’Œ่พพๅˆฐ 200 ๆฌกๆ—ถๅŒๆญฅ
66
+
67
+
68
+ def _get_sync_lock() -> asyncio.Lock:
69
+ global _sync_lock
70
+ if _sync_lock is None:
71
+ _sync_lock = asyncio.Lock()
72
+ return _sync_lock
73
+
74
+
75
+ # โ”€โ”€ ๅކๅฒๅฟซ็…งๅทฅๅ…ท โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
76
+
77
+ def _today_str() -> str:
78
+ return datetime.now(timezone(timedelta(hours=8))).date().isoformat()
79
+
80
+
81
+ def _merge_history(
82
+ remote: list[dict],
83
+ current_total: int,
84
+ memory: list[dict] | None = None,
85
+ ) -> list[dict]:
86
+ """
87
+ ๅฐ†ๅฝ“ๅคฉ็š„ total ๅ†™ๅ…ฅ๏ผˆๆˆ–ๆ›ดๆ–ฐ๏ผ‰ๅކๅฒๅˆ—่กจ๏ผŒไฟ็•™ๆœ€่ฟ‘ MAX_HISTORY_DAYS ๅคฉใ€‚
88
+ ๅŒไธ€ๅคฉๅชไฟ็•™ๆœ€ๆ–ฐไธ€ๆก๏ผˆtotal ๅ–่พƒๅคงๅ€ผ๏ผ‰ใ€‚
89
+
90
+ remote : ไปŽ OSS ่ฏปๅ›ž็š„ๅކๅฒ๏ผˆๅฏ่ƒฝๅ› ็ผ“ๅญ˜ๅฏผ่‡ดไธๅฎŒๆ•ด๏ผ‰
91
+ memory : ๅ†…ๅญ˜ไธญๅทฒๅŠ ่ฝฝ็š„ๅކๅฒ๏ผˆinit() ๆ—ถไปŽ OSS ๅฎŒๆ•ด่ฏปๅ–๏ผŒ็”จไบŽๅ…œๅบ•๏ผ‰
92
+ """
93
+ today = _today_str()
94
+ by_date: dict[str, int] = {}
95
+ # ๅ…ˆ็”จๅ†…ๅญ˜ๆ•ฐๆฎๆ‰“ๅบ•๏ผŒๅ†็”จ่ฟœ็ซฏๆ•ฐๆฎ่ฆ†็›–๏ผˆ่ฟœ็ซฏๆ˜ฏๆœ€็ปˆๅ†™ๅ…ฅๆ€๏ผŒไผ˜ๅ…ˆ็บงๆ›ด้ซ˜๏ผ‰
96
+ for r in (memory or []):
97
+ by_date[r['date']] = r['total']
98
+ for r in remote:
99
+ by_date[r['date']] = max(by_date.get(r['date'], 0), r['total'])
100
+ # ๅฝ“ๅคฉ่ฎฐๅฝ•ๅ–่พƒๅคงๅ€ผ๏ผŒ้ฟๅ…ๅŒๆญฅๆ—ถๅบ้—ฎ้ข˜ๅฏผ่‡ดๆ•ฐๆฎๅ›ž้€€
101
+ by_date[today] = max(by_date.get(today, 0), current_total)
102
+
103
+ sorted_records = sorted(by_date.items(), reverse=True)[:MAX_HISTORY_DAYS]
104
+ # ่ฟ”ๅ›žๆ—ถๆŒ‰ๆ—ถ้—ดๆญฃๅบ๏ผŒๆ–นไพฟ็ป˜ๅ›พ
105
+ return [{'date': d, 'total': t} for d, t in sorted(sorted_records)]
106
+
107
+
108
+ # โ”€โ”€ ่ฟœ็ซฏ IO โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
109
+
110
+ _COUNTER_FILE = 'count.json'
111
+
112
+
113
+ def _parse_remote_data(raw: bytes | None) -> tuple[int, int, int, int, int, dict, list, list]:
114
+ """่งฃๆž่ฟœ็ซฏ JSON bytes๏ผŒ่ฟ”ๅ›ž (total, visits, copies, mcp, successes, keywords, bad_cases, history)ใ€‚"""
115
+ if raw is None:
116
+ return 0, BASE_VISITS, BASE_COPIES, 0, 0, {}, [], []
117
+ try:
118
+ data = json.loads(raw.decode('utf-8'))
119
+ r_total = int(data.get('total', 0))
120
+ r_visits = int(data.get('visits', BASE_VISITS))
121
+ r_copies = int(data.get('copies', BASE_COPIES))
122
+ r_mcp = int(data.get('mcp', 0))
123
+ r_successes = int(data.get('successes', int(r_total * 0.75)))
124
+ r_keywords = data.get('hot_keywords', {})
125
+ r_bad_cases = data.get('bad_cases', [])
126
+ r_history = data.get('history', [])
127
+ return r_total, r_visits, r_copies, r_mcp, r_successes, r_keywords, r_bad_cases, r_history
128
+ except Exception:
129
+ return 0, BASE_VISITS, BASE_COPIES, 0, 0, {}, [], []
130
+
131
+
132
+ def _read_remote() -> tuple[int, int, int, int, int, dict, list, list]:
133
+ cfg = get_counter_cfg()
134
+ if not cfg.available:
135
+ return 0, BASE_VISITS, BASE_COPIES, 0, 0, {}, [], []
136
+
137
+ try:
138
+ raw = read_bytes(_COUNTER_FILE, cfg)
139
+ except Exception as e:
140
+ print(f'[Counter] ๅฏๅŠจ่ฏปๅ–่ฟœ็ซฏๆ•ฐๆฎๅผ‚ๅธธ: {e}')
141
+ return 0, BASE_VISITS, BASE_COPIES, 0, 0, {}, [], []
142
+
143
+ if raw is None:
144
+ return 0, BASE_VISITS, BASE_COPIES, 0, 0, {}, [], []
145
+ return _parse_remote_data(raw)
146
+
147
+
148
+ def _sync_remote_task(
149
+ adds_count: int,
150
+ adds_visits: int,
151
+ adds_copies: int,
152
+ adds_mcp: int,
153
+ adds_successes: int,
154
+ adds_keywords: dict,
155
+ adds_bad_cases: list,
156
+ memory_history_snapshot: list,
157
+ ) -> tuple[bool, int, int, int, int, int, dict, list, list]:
158
+ cfg: CounterConfig = get_counter_cfg()
159
+ if not cfg.available:
160
+ return False, 0, 0, 0, 0, 0, {}, [], []
161
+
162
+ try:
163
+ raw = read_bytes(_COUNTER_FILE, cfg)
164
+ except Exception as e:
165
+ print(f'[Counter] ่ฟœ็ซฏ่ฏปๅ–ๅผ‚ๅธธ๏ผŒไธญๆญขๆœฌๆฌกๅŒๆญฅไปฅไฟๆŠคๆ•ฐๆฎ: {e}')
166
+ return False, 0, 0, 0, 0, 0, {}, [], []
167
+
168
+ r_total, r_visits, r_copies, r_mcp, r_successes, r_keywords, r_bad_cases, r_history = (
169
+ _parse_remote_data(raw)
170
+ )
171
+
172
+ n_total = r_total + adds_count
173
+ n_visits = r_visits + adds_visits
174
+ n_copies = r_copies + adds_copies
175
+ n_mcp = r_mcp + adds_mcp
176
+ n_successes = r_successes + adds_successes
177
+
178
+ merged_keywords = Counter(r_keywords)
179
+ for word, count in adds_keywords.items():
180
+ merged_keywords[word] += count
181
+ top_keywords = dict(merged_keywords.most_common(MAX_KEYWORDS_LIMIT))
182
+
183
+ merged_bad_cases = (adds_bad_cases + r_bad_cases)[:MAX_BAD_CASES]
184
+
185
+ n_history = _merge_history(r_history, n_total, memory=memory_history_snapshot)
186
+
187
+ content = json.dumps({
188
+ 'total': n_total,
189
+ 'visits': n_visits,
190
+ 'copies': n_copies,
191
+ 'mcp': n_mcp,
192
+ 'successes': n_successes,
193
+ 'hot_keywords': top_keywords,
194
+ 'bad_cases': merged_bad_cases,
195
+ 'history': n_history,
196
+ }, ensure_ascii=False, indent=2).encode('utf-8')
197
+
198
+ commit_msg = (
199
+ f'Sync: ๆœ็ดข:{n_total} | MCP:{n_mcp} | ๆˆๅŠŸ:{n_successes} | '
200
+ f'ๅคๅˆถ:{n_copies} | ่ฎฟ้—ฎ:{n_visits} | bad_cases:{len(merged_bad_cases)}'
201
+ )
202
+
203
+ ok = upload_bytes(content, _COUNTER_FILE, cfg, commit_msg, retries=3, retry_delay=1.0)
204
+ if ok:
205
+ print(
206
+ f'[Counter] ๅŒๆญฅๆˆๅŠŸ๏ผๆœ็ดข:{n_total}, MCP:{n_mcp}, ๆˆๅŠŸไบคไบ’:{n_successes}, '
207
+ f'ๅคๅˆถ:{n_copies}, bad_cases:{len(merged_bad_cases)}'
208
+ )
209
+ return True, n_total, n_visits, n_copies, n_mcp, n_successes, top_keywords, merged_bad_cases, n_history
210
+
211
+ return False, 0, 0, 0, 0, 0, {}, [], []
212
+
213
+
214
+ # โ”€โ”€ ๅŽๅฐๅŒๆญฅๅ็จ‹ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
215
+
216
+ async def _perform_sync():
217
+ global _dirty_count, _dirty_visits, _dirty_copies, _dirty_mcp, _dirty_successes
218
+ global _dirty_keywords, _dirty_bad_cases, _last_sync
219
+ global _memory_count, _memory_visits, _memory_copies, _memory_mcp, _memory_successes
220
+ global _memory_keywords, _memory_bad_cases, _memory_history
221
+
222
+ lock = _get_sync_lock()
223
+ if lock.locked():
224
+ return
225
+
226
+ async with lock:
227
+ has_dirty = (
228
+ _dirty_count + _dirty_visits + _dirty_copies + _dirty_mcp + _dirty_successes
229
+ + len(_dirty_keywords) + len(_dirty_bad_cases)
230
+ ) > 0
231
+ if not has_dirty:
232
+ return
233
+
234
+ c_adds, v_adds, cp_adds, m_adds, s_adds = (
235
+ _dirty_count, _dirty_visits, _dirty_copies, _dirty_mcp, _dirty_successes
236
+ )
237
+ k_adds = dict(_dirty_keywords)
238
+ bc_adds = list(_dirty_bad_cases)
239
+ history_snapshot = list(_memory_history)
240
+
241
+ _dirty_count = 0
242
+ _dirty_visits = 0
243
+ _dirty_copies = 0
244
+ _dirty_mcp = 0
245
+ _dirty_successes = 0
246
+ _dirty_keywords.clear()
247
+ _dirty_bad_cases.clear()
248
+
249
+ loop = asyncio.get_running_loop()
250
+ result = await loop.run_in_executor(
251
+ None, _sync_remote_task,
252
+ c_adds, v_adds, cp_adds, m_adds, s_adds, k_adds, bc_adds, history_snapshot,
253
+ )
254
+ success, l_total, l_visits, l_copies, l_mcp, l_successes, l_keywords, l_bad_cases, l_history = result
255
+
256
+ if success:
257
+ _last_sync = time.time()
258
+ _memory_count = max(_memory_count, l_total)
259
+ _memory_visits = max(_memory_visits, l_visits)
260
+ _memory_copies = max(_memory_copies, l_copies)
261
+ _memory_mcp = max(_memory_mcp, l_mcp)
262
+ _memory_successes = max(_memory_successes, l_successes)
263
+ _memory_keywords.clear()
264
+ _memory_keywords.update(l_keywords)
265
+ _memory_bad_cases.clear()
266
+ _memory_bad_cases.extend(l_bad_cases)
267
+ _memory_history.clear()
268
+ _memory_history.extend(l_history)
269
+ else:
270
+ _dirty_count += c_adds
271
+ _dirty_visits += v_adds
272
+ _dirty_copies += cp_adds
273
+ _dirty_mcp += m_adds
274
+ _dirty_successes += s_adds
275
+ _dirty_keywords.update(k_adds)
276
+ _dirty_bad_cases.extend(bc_adds)
277
+
278
+
279
+ # โ”€โ”€ ๅ…ฌๅ…ฑ API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
280
+
281
+ async def init():
282
+ """ๅฏๅŠจๆ—ถไปŽ OSS ๆ‹‰ๅ–ๆœ€ๆ–ฐ่ฎกๆ•ฐ๏ผŒๅˆๅง‹ๅŒ–ๅ†…ๅญ˜็Šถๆ€ใ€‚"""
283
+ global _memory_count, _memory_visits, _memory_copies, _memory_mcp, _memory_successes
284
+ global _memory_keywords, _memory_bad_cases, _memory_history, _last_sync
285
+
286
+ cfg = get_counter_cfg()
287
+ if not cfg.available:
288
+ print(f'[Counter] ่ฎกๆ•ฐๅ™จๆœช้…็ฝฎ๏ผˆplatform={cfg.platform}๏ผ‰๏ผŒไป…ไฝฟ็”จๅ†…ๅญ˜่ฎกๆ•ฐใ€‚')
289
+ return
290
+
291
+ loop = asyncio.get_running_loop()
292
+ (
293
+ _memory_count, _memory_visits, _memory_copies, _memory_mcp, _memory_successes,
294
+ r_keys, r_bad_cases, r_history,
295
+ ) = await loop.run_in_executor(None, _read_remote)
296
+
297
+ _memory_keywords.update(r_keys)
298
+ _memory_bad_cases.clear()
299
+ _memory_bad_cases.extend(r_bad_cases)
300
+ _memory_history.clear()
301
+ _memory_history.extend(r_history)
302
+ _last_sync = time.time()
303
+ print(
304
+ f'[Counter] ๅˆๅง‹ๅŒ–ๅฎŒๆˆ๏ผˆ{cfg.platform}๏ผ‰๏ผšๆœ็ดข={_memory_count}, MCP={_memory_mcp}, '
305
+ f'่ฎฟ้—ฎ={_memory_visits}, ๅคๅˆถ={_memory_copies}, ๅކๅฒ={len(_memory_history)}ๅคฉ'
306
+ )
307
+
308
+
309
+ def _check_sync():
310
+ cfg = get_counter_cfg()
311
+ if not cfg.available:
312
+ return
313
+ if (time.time() - _last_sync > SYNC_INTERVAL) or (
314
+ _dirty_count + _dirty_visits + _dirty_copies + _dirty_mcp + _dirty_successes
315
+ + len(_dirty_keywords) + len(_dirty_bad_cases)
316
+ ) >= SYNC_THRESHOLD:
317
+ asyncio.create_task(_perform_sync())
318
+
319
+
320
+ async def increment() -> int:
321
+ global _memory_count, _dirty_count
322
+ _memory_count += 1; _dirty_count += 1
323
+ _check_sync()
324
+ return _memory_count
325
+
326
+ async def increment_visit() -> int:
327
+ global _memory_visits, _dirty_visits
328
+ _memory_visits += 1; _dirty_visits += 1
329
+ _check_sync()
330
+ return _memory_visits
331
+
332
+ async def increment_copy() -> int:
333
+ global _memory_copies, _dirty_copies
334
+ _memory_copies += 1; _dirty_copies += 1
335
+ _check_sync()
336
+ return _memory_copies
337
+
338
+ async def increment_mcp() -> int:
339
+ global _memory_mcp, _dirty_mcp
340
+ _memory_mcp += 1; _dirty_mcp += 1
341
+ _check_sync()
342
+ return _memory_mcp
343
+
344
+ async def increment_success() -> int:
345
+ global _memory_successes, _dirty_successes
346
+ _memory_successes += 1; _dirty_successes += 1
347
+ _check_sync()
348
+ return _memory_successes
349
+
350
+ async def add_keywords(words: list[str]):
351
+ global _dirty_keywords, _memory_keywords
352
+ valid_words = [w for w in words if len(w) > 1 and w not in {'ไธ€ไธช', '็š„', 'ๅœจ', 'ไบ†', 'ๆ˜ฏ', 'ๆœ‰'}]
353
+ for word in valid_words:
354
+ _memory_keywords[word] += 1
355
+ _dirty_keywords[word] += 1
356
+ _check_sync()
357
+
358
+ async def add_bad_case(query: str, platform: str = '', settings: dict | None = None) -> None:
359
+ global _dirty_bad_cases, _memory_bad_cases
360
+ entry = {
361
+ 'q': query,
362
+ 't': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
363
+ }
364
+ if platform:
365
+ entry['platform'] = platform
366
+ if settings:
367
+ entry['settings'] = settings
368
+ _memory_bad_cases.insert(0, entry)
369
+ if len(_memory_bad_cases) > MAX_BAD_CASES:
370
+ _memory_bad_cases[:] = _memory_bad_cases[:MAX_BAD_CASES]
371
+ _dirty_bad_cases.insert(0, entry)
372
+ _check_sync()
373
+
374
+
375
+ def get() -> int: return _memory_count
376
+ def get_visits() -> int: return _memory_visits
377
+ def get_copies() -> int: return _memory_copies
378
+ def get_mcp() -> int: return _memory_mcp
379
+ def get_successes() -> int: return _memory_successes
380
+
381
+ def get_hot_keywords(top_n: int = 10) -> dict:
382
+ return dict(_memory_keywords.most_common(top_n))
383
+
384
+ def get_bad_cases() -> list[dict]:
385
+ return list(_memory_bad_cases)
386
+
387
+ def get_history() -> list[dict]:
388
+ """่ฟ”ๅ›žๆฏๆ—ฅๆœ็ดข้‡ๅฟซ็…ง๏ผŒๆ ผๅผ [{"date": "YYYY-MM-DD", "total": int}, ...]๏ผŒๆŒ‰ๆ—ฅๆœŸๆญฃๅบใ€‚"""
389
+ return list(_memory_history)
390
+
391
+ async def force_sync():
392
+ await _perform_sync()
core/engine.py ADDED
@@ -0,0 +1,897 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ core/engine.py
3
+ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
4
+ DanbooruTagger ๆ ธๅฟƒๅผ•ๆ“Ž
5
+
6
+ ็ผ“ๅญ˜ๆ ผๅผ๏ผˆๅญ˜ไบŽ cache_dir/ ็›ฎๅฝ•๏ผ‰๏ผš
7
+ embeddings.safetensors โ€” ๅ››่ทฏๅ‘้‡็Ÿฉ้˜ต๏ผˆFP16๏ผ‰๏ผŒ่กŒ้กบๅบไธŽ metadata.parquet ๅฎŒๅ…จๅฏน้ฝ
8
+ metadata.parquet โ€” DataFrame๏ผˆname/cn_name/cn_core/wiki/nsfw/category/post_count๏ผ‰
9
+ meta.json โ€” ๆ ‡้‡ๅ…ƒๆ•ฐๆฎ๏ผˆmax_log_countใ€schema_version๏ผ‰
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import json
16
+ import os
17
+ import re
18
+ import time
19
+ from collections import OrderedDict
20
+ from pathlib import Path
21
+ from datetime import datetime
22
+ from typing import Any, Optional
23
+
24
+ import jieba
25
+ import numpy as np
26
+ import pandas as pd
27
+ import torch
28
+ from safetensors.torch import load_file as st_load, save_file as st_save
29
+ from sentence_transformers import SentenceTransformer
30
+
31
+ from .models import SearchRequest, SearchResponse, TagResult
32
+ from platform_utils import (
33
+ PLATFORM,
34
+ is_cloud,
35
+ download_file,
36
+ resolve_model_path,
37
+ )
38
+
39
+
40
+ # LRU ็ผ“ๅญ˜
41
+ class LRUCache:
42
+ def __init__(self, maxsize: int):
43
+ self._cache: OrderedDict[Any, Any] = OrderedDict()
44
+ self._maxsize = maxsize
45
+
46
+ def get(self, key: Any) -> Any:
47
+ if key not in self._cache:
48
+ return None
49
+ self._cache.move_to_end(key)
50
+ return self._cache[key]
51
+
52
+ def put(self, key: Any, value: Any) -> None:
53
+ if key in self._cache:
54
+ self._cache.move_to_end(key)
55
+ else:
56
+ if len(self._cache) >= self._maxsize:
57
+ self._cache.popitem(last=False)
58
+ self._cache[key] = value
59
+
60
+ def __len__(self) -> int:
61
+ return len(self._cache)
62
+
63
+
64
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
65
+ # ๅธธ้‡
66
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
67
+
68
+ STOP_WORDS: frozenset[str] = frozenset({
69
+ ',', '.', ':', ';', '?', '!', '"', "'", '`',
70
+ '(', ')', '[', ']', '{', '}', '<', '>',
71
+ '-', '_', '=', '+', '/', '\\', '|', '@', '#', '$', '%', '^', '&', '*', '~',
72
+ '๏ผŒ', 'ใ€‚', '๏ผš', '๏ผ›', '๏ผŸ', '๏ผ', '\u201c', '\u201d', '\u2018', '\u2019',
73
+ '๏ผˆ', '๏ผ‰', 'ใ€', 'ใ€‘', 'ใ€Š', 'ใ€‹', 'ใ€', 'โ€ฆ', 'โ€”', 'ยท',
74
+ ' ', '\t', '\n', '\r',
75
+ '็š„', 'ๅœฐ', 'ๅพ—', 'ไบ†', '็€', '่ฟ‡',
76
+ 'ๆ˜ฏ', 'ไธบ', '่ขซ', '็ป™', 'ๆŠŠ', '่ฎฉ', '็”ฑ',
77
+ 'ๅœจ', 'ไปŽ', '่‡ช', 'ๅ‘', 'ๅพ€', 'ๅฏน', 'ไบŽ',
78
+ 'ๅ’Œ', 'ไธŽ', 'ๅŠ', 'ๆˆ–', 'ไธ”', '่€Œ', 'ไฝ†', 'ๅนถ', 'ๅณ', 'ๅˆ', 'ไนŸ',
79
+ 'ๅ•Š', 'ๅ—', 'ๅง', 'ๅ‘ข', 'ๅ™ข', 'ๅ“ฆ', 'ๅ“ˆ', 'ๅ‘€', 'ๅ“‡',
80
+ 'ๆˆ‘', 'ไฝ ', 'ไป–', 'ๅฅน', 'ๅฎƒ', 'ๆˆ‘ไปฌ', 'ไฝ ไปฌ', 'ไป–ไปฌ',
81
+ '่ฟ™', '้‚ฃ', 'ๆญค', 'ๅ…ถ', '่ฐ', 'ๅ•ฅ', 'ๆŸ', 'ๆฏ',
82
+ '่ฟ™ไธช', '้‚ฃไธช', '่ฟ™ไบ›', '้‚ฃไบ›', '่ฟ™้‡Œ', '้‚ฃ้‡Œ',
83
+ 'ไธช', 'ไฝ', 'ๅช', 'ๆก', 'ๅผ ', 'ๅน…', 'ไปถ', 'ๅฅ—', 'ๅŒ', 'ๅฏน', 'ๅ‰ฏ',
84
+ '็ง', '็ฑป', '็พค', 'ไบ›', '็‚น', 'ไปฝ', '้ƒจ', 'ๅ',
85
+ 'ๅพˆ', 'ๅคช', 'ๆ›ด', 'ๆœ€', 'ๆŒบ', '็‰น', 'ๅฅฝ', '็œŸ',
86
+ 'ไธ€', 'ไธ€ไธช', 'ไธ€็ง', 'ไธ€ไธ‹', 'ไธ€็‚น', 'ไธ€ไบ›',
87
+ 'ๆœ‰', 'ๆ— ', '้ž', 'ๆฒก', 'ไธ',
88
+ 'ๆญฃๅœจ', 'ๅทฒ็ป', 'ๆญฃ', 'ๅˆš', 'ๅผ€ๅง‹', '็ปง็ปญ', 'ไธ€็›ด', 'ไธๆ–ญ',
89
+ '็ฉฟ็€', 'ๆˆด็€', '็ฉฟ', 'ๆˆด',
90
+ 'ๅธฆๆœ‰', 'ๅ…ทๆœ‰', 'ๆ‹ฅๆœ‰',
91
+ '็œ‹่ตทๆฅ', '็œ‹ไธŠๅŽป', 'ๆ˜พๅพ—', 'ไปฟไฝ›', 'ไผผไนŽ',
92
+ 'ๅๅˆ†', '้žๅธธ', '็‰นๅˆซ', 'ๆฏ”่พƒ',
93
+ 'ๅ›พ็‰‡', '็”ป้ข', 'ๅ›พๅƒ',
94
+ 'ไฝไบŽ', 'ๅค„ไบŽ',
95
+ '่ฎธๅคš', 'ๅคง้‡', 'ๅ„็ง', 'ๆ‰€ๆœ‰', 'ๅ…ถไป–', 'ๅ…ถๅฎƒ'
96
+ })
97
+
98
+ CAT_MAP: dict[str, str] = {
99
+ '0': 'General', '1': 'Artist', '3': 'Copyright', '4': 'Character', '5': 'Meta',
100
+ }
101
+
102
+ SCHEMA_VERSION = 3 # ๅ‡็บงๆญคๅ€ผๅฐ†่‡ชๅŠจ่งฆๅ‘ๅ…จ้‡้‡ๅปบ๏ผŒ็”จไบŽ็ ดๅๆ€งๆ ผๅผๅ˜ๆ›ด
103
+
104
+ # ็”จๆˆทๆ˜พๅผๅˆ†้š”ๅŽ๏ผŒ็บฏ CJK ็‰‡ๆฎต่ถ…่ฟ‡ๆญค้•ฟๅบฆไป็”จ jieba ๅˆ‡ๅˆ†๏ผˆ้ฟๅ…้•ฟๅฅ่ขซๅฝ“ไฝœๅŽŸๅญๆฆ‚ๅฟต๏ผ‰
105
+ _ATOMIC_CJK_MAX_LEN = 7
106
+
107
+ # ๅ››่ทฏ embedding ๅฑ‚้…็ฝฎ: (ๅฑ‚ๅ, tensor ๅฑžๆ€งๅ, DataFrame ๅˆ—ๅ)
108
+ _LAYER_SPEC: list[tuple[str, str, str]] = [
109
+ ('่‹ฑๆ–‡', 'emb_en', 'name'),
110
+ ('ไธญๆ–‡ๆ‰ฉๅฑ•่ฏ', 'emb_cn', 'cn_name'),
111
+ ('้‡Šไน‰', 'emb_wiki', 'wiki'),
112
+ ('ไธญๆ–‡ๆ ธๅฟƒ่ฏ', 'emb_cn_core', 'cn_core'),
113
+ ]
114
+ _ALL_LAYER_NAMES = [ln for ln, _, _ in _LAYER_SPEC]
115
+
116
+
117
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
118
+ # ็ผ“ๅญ˜่ทฏๅพ„ๅŠฉๆ‰‹
119
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
120
+
121
+ class _CachePaths:
122
+ def __init__(self, cache_dir: str | Path):
123
+ self.dir = Path(cache_dir)
124
+ self.embeddings = self.dir / 'danbooru_multiview_embeddings.safetensors'
125
+ self.metadata = self.dir / 'tags_metadata.parquet'
126
+ self.meta_json = self.dir / 'version_data.json'
127
+
128
+ def exists(self) -> bool:
129
+ return (
130
+ self.embeddings.is_file()
131
+ and self.metadata.is_file()
132
+ and self.meta_json.is_file()
133
+ )
134
+
135
+ def ensure_dir(self):
136
+ self.dir.mkdir(parents=True, exist_ok=True)
137
+
138
+
139
+ class DanbooruTagger:
140
+ """ๆ ธๅฟƒๆœ็ดขๅผ•ๆ“Ž๏ผˆๅ•ไพ‹๏ผ‰"""
141
+
142
+ _instance: Optional['DanbooruTagger'] = None
143
+ _lock: Optional[asyncio.Lock] = None
144
+
145
+ @classmethod
146
+ def is_ready(cls) -> bool:
147
+ return cls._instance is not None and cls._instance.is_loaded
148
+
149
+ @classmethod
150
+ async def get_instance(cls, **kwargs) -> 'DanbooruTagger':
151
+ if cls._lock is None:
152
+ cls._lock = asyncio.Lock()
153
+ async with cls._lock:
154
+ if cls._instance is None:
155
+ inst = cls(**kwargs)
156
+ await asyncio.to_thread(inst.load)
157
+ cls._instance = inst
158
+ return cls._instance
159
+
160
+ def __init__(
161
+ self,
162
+ model_path: Optional[str] = None,
163
+ csv_file: str = 'origin_database/tags_enhanced.csv',
164
+ cache_dir: str = 'tags_embedding',
165
+ cooc_file: str = 'origin_database/cooccurrence_clean.csv',
166
+ ):
167
+ # ๆจกๅž‹่ทฏๅพ„๏ผšไผ˜ๅ…ˆไฝฟ็”จๆ˜พๅผไผ ๅ…ฅ๏ผŒๅฆๅˆ™ไบค็”ฑ platform_utils ่งฃๆž
168
+ self.model_path = model_path or resolve_model_path()
169
+
170
+ self.csv_path = csv_file
171
+ self.device = 'cuda' if torch.cuda.is_available() else 'cpu'
172
+ self.paths = _CachePaths(cache_dir)
173
+ self.cooc_file = cooc_file
174
+
175
+ self.model: Optional[SentenceTransformer] = None
176
+ self.df: Optional[pd.DataFrame] = None
177
+ self.emb_en: Optional[torch.Tensor] = None
178
+ self.emb_cn: Optional[torch.Tensor] = None
179
+ self.emb_wiki: Optional[torch.Tensor] = None
180
+ self.emb_cn_core: Optional[torch.Tensor] = None
181
+ self.max_log_count: float = 15.0
182
+ self.cooc: dict[str, list[tuple[str, int]]] = {}
183
+ self._name_to_idx: dict[str, int] = {}
184
+ self.is_loaded: bool = False
185
+
186
+ # ้ข„ๆๅ–็š„ๅˆ—ๆ•ฐ็ป„๏ผŒ้ฟๅ…็ƒญ็‚น่ทฏๅพ„ไธŠๅๅคๆ‰ง่กŒ df.iloc[idx]
187
+ self._arr_name: Optional[np.ndarray] = None
188
+ self._arr_cn_name: Optional[np.ndarray] = None
189
+ self._arr_category: Optional[np.ndarray] = None
190
+ self._arr_nsfw: Optional[np.ndarray] = None
191
+ self._arr_wiki: Optional[np.ndarray] = None
192
+ self._arr_post_count: Optional[np.ndarray] = None
193
+ self._arr_pop_score: Optional[np.ndarray] = None
194
+
195
+ # ไธ‰ๅฑ‚ LRU ็ผ“ๅญ˜๏ผˆ็บฏๅ†…ๅญ˜๏ผŒ้‡ๅฏๅŽ่‡ชๅŠจ้‡็ƒญ๏ผ‰
196
+ # embedding ็ผ“ๅญ˜๏ผškey=ๆ–‡ๆœฌ, value=ๅฝ’ไธ€ๅŒ–ๅŽ็š„ 1-D Tensor (D,)๏ผŒ็บฆ 40 MB
197
+ self._emb_cache: LRUCache = LRUCache(maxsize=10_000)
198
+ # ๆœ็ดข็ป“ๆžœ็ผ“ๅญ˜๏ผškey=่ฏทๆฑ‚ๅ‚ๆ•ฐ tuple, value=SearchResponse๏ผŒ็บฆ 100 MB
199
+ self._search_cache: LRUCache = LRUCache(maxsize=5_000)
200
+ # ๅ…ณ่”ๆŽจ่็ผ“ๅญ˜๏ผškey=(seed_tuple, limit, show_nsfw), value=list[RelatedTag]๏ผŒ็บฆ 20 MB
201
+ self._related_cache: LRUCache = LRUCache(maxsize=2_000)
202
+
203
+ # โ”€โ”€ ๅˆๅง‹ๅŒ– โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
204
+
205
+ def load(self) -> None:
206
+ """ๅŒๆญฅๅŠ ่ฝฝ๏ผŒๅœจ็บฟ็จ‹ๆฑ ไธญ่ฐƒ็”จใ€‚"""
207
+ if self.is_loaded:
208
+ return
209
+ t0 = time.time()
210
+
211
+ # โ”€โ”€ ไบ‘็ซฏ็Žฏๅขƒ๏ผšไปŽๅฏนๅบ”ๅนณๅฐ Hub ๆ‹‰ๅ–ๆ•ฐๆฎๆ–‡ไปถ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
212
+ if is_cloud():
213
+ self._pull_cloud_files()
214
+
215
+ # โ”€โ”€ ็ผ“ๅญ˜ๆ ก้ชŒไธŽๆž„ๅปบ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
216
+ if not self.paths.exists():
217
+ print('\n' + '=' * 50)
218
+ print('[Engine] ๆœชๆ‰พๅˆฐ็ผ“ๅญ˜๏ผŒๅผ€ๅง‹้ฆ–ๆฌกๆž„ๅปบ๏ผˆ็บฆ 1~3 ๅˆ†้’Ÿ๏ผ‰...')
219
+ print('=' * 50 + '\n')
220
+ self._load_model()
221
+ self._build_full()
222
+ else:
223
+ print(f'[Engine] ๅŠ ่ฝฝ็ผ“ๅญ˜ ({self.paths.dir}) ...')
224
+ self._load_from_cache()
225
+ if self._cached_schema_version() != SCHEMA_VERSION:
226
+ print('[Engine] ็ผ“ๅญ˜ๆ ผๅผ็‰ˆๆœฌไธ็ฌฆ๏ผŒ่งฆๅ‘ๅ…จ้‡้‡ๅปบ...')
227
+ self._load_model()
228
+ self._build_full()
229
+ elif os.path.exists(self.csv_path):
230
+ self._load_model()
231
+ self._smart_update()
232
+
233
+ if self.model is None:
234
+ self._load_model()
235
+
236
+ self._setup_jieba_from_memory()
237
+ self._load_cooc()
238
+ self._name_to_idx = {n: i for i, n in enumerate(self.df['name'])}
239
+ self._rebuild_arrays_from_df()
240
+ self._normalize_embeddings()
241
+ self.is_loaded = True
242
+ print(f'[Engine] ๅˆๅง‹ๅŒ–ๅฎŒๆˆ๏ผŒ่€—ๆ—ถ {time.time() - t0:.2f}s')
243
+
244
+ def _normalize_embeddings(self) -> None:
245
+ """
246
+ ๅฏนๅ››่ทฏ embedding ็Ÿฉ้˜ตๅš L2 ๅฝ’ไธ€ๅŒ–๏ผˆin-place ๆ›ฟๆข self.emb_*๏ผ‰ใ€‚
247
+ ๅฝ’ไธ€ๅŒ–ๅŽ search ้˜ถๆฎตๅฏ็›ดๆŽฅ็”จ็Ÿฉ้˜ตไน˜ๆณ•ๅพ—ๅˆฐ cosine similarity๏ผŒ
248
+ ๆ— ้œ€ๆฏๆฌก่ฐƒ็”จ util.semantic_search ๅ†…้ƒจๅ†ๅšไธ€ๆฌกๅฝ’ไธ€ๅŒ–ใ€‚
249
+ """
250
+ for _, attr, _ in _LAYER_SPEC:
251
+ t = getattr(self, attr)
252
+ if t is None:
253
+ continue
254
+ setattr(self, attr, torch.nn.functional.normalize(t, p=2, dim=1))
255
+
256
+ def _rebuild_arrays_from_df(self) -> None:
257
+ """
258
+ ๅฐ† DataFrame ไธญๆœ็ดข็ƒญ็‚น่ทฏๅพ„้œ€่ฆ็š„ๅˆ—้ข„ๆๅ–ไธบ numpy ๆ•ฐ็ป„ใ€‚
259
+ ไปปไฝ•ไฟฎๆ”น self.df ่กŒๅ†…ๅฎนๆˆ–่กŒๆ•ฐ็š„ๆ“ไฝœไน‹ๅŽ้ƒฝๅฟ…้กป่ฐƒ็”จๆญคๆ–นๆณ•ๅˆทๆ–ฐใ€‚
260
+ """
261
+ if self.df is None:
262
+ return
263
+ self._arr_name = self.df['name'].to_numpy()
264
+ self._arr_cn_name = self.df['cn_name'].to_numpy()
265
+ self._arr_category = self.df['category'].astype(str).to_numpy()
266
+ self._arr_nsfw = self.df['nsfw'].astype(str).to_numpy()
267
+ self._arr_wiki = self.df['wiki'].astype(str).to_numpy()
268
+ self._arr_post_count = self.df['post_count'].to_numpy()
269
+ # ้ข„็ฎ—็ƒญๅบฆๅฝ’ไธ€ๅŒ–ๅˆ†๏ผŒ้ฟๅ… search ไธญๆฏๆฌก np.log1p
270
+ max_log = self.max_log_count if self.max_log_count > 0 else 1.0
271
+ self._arr_pop_score = np.log1p(self._arr_post_count) / max_log
272
+
273
+ def _pull_cloud_files(self) -> None:
274
+ """
275
+ ไปŽๅฝ“ๅ‰ไบ‘ๅนณๅฐๆ‹‰ๅ–ๆ‰€ๆœ‰ๆ•ฐๆฎๆ–‡ไปถ๏ผŒๅนถๅฐ†่ทฏๅพ„ๅ†™ๅ›žๅฎžไพ‹ๅฑžๆ€งใ€‚
276
+ HF / MS ็š„ๅทฎๅผ‚ๅฎŒๅ…จ็”ฑ platform_utils.download_file() ๅฑ่”ฝใ€‚
277
+ """
278
+ print(f'[Engine] ไบ‘็ซฏ็Žฏๅขƒ ({PLATFORM})๏ผŒๅผ€ๅง‹ๆ‹‰ๅ–ๆ•ฐๆฎๆ–‡ไปถ...')
279
+
280
+ # โ”€โ”€ HF ๅนณๅฐ้œ€่ฆ้ขๅค–ๆŒ‡ๅฎš repo_id๏ผˆSPACE_ID๏ผ‰ๅ’Œ repo_type โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
281
+ extra_hf_kwargs = {}
282
+ if PLATFORM == 'hf':
283
+ extra_hf_kwargs = {
284
+ 'hf_repo_id': os.environ.get('SPACE_ID'),
285
+ 'hf_repo_type': 'space',
286
+ }
287
+
288
+ def pull(filename: str) -> str:
289
+ try:
290
+ return download_file(filename, **extra_hf_kwargs)
291
+ except Exception as e:
292
+ print(f'[Engine] ๆ‹‰ๅ– {filename} ๅคฑ่ดฅ๏ผˆ้ž่‡ดๅ‘ฝ๏ผ‰: {e}')
293
+ return filename # ๅ›ž้€€ๅˆฐๅŽŸๅง‹่ทฏๅพ„๏ผŒ่ฎฉๅŽ็ปญ้€ป่พ‘ๅ†ณๅฎšๆ˜ฏๅฆ้‡ๅปบ
294
+
295
+ self.csv_path = pull('origin_database/tags_enhanced.csv')
296
+ self.cooc_file = pull('origin_database/cooccurrence_clean.parquet')
297
+
298
+ meta_path = pull('tags_embedding/tags_metadata.parquet')
299
+ emb_path = pull('tags_embedding/danbooru_multiview_embeddings.safetensors')
300
+ json_path = pull('tags_embedding/version_data.json')
301
+
302
+ # ๅชๆœ‰ไธ‰ไธช็ผ“ๅญ˜ๆ–‡ไปถ้ƒฝๆˆๅŠŸๆ‹‰ๅ–ๆ‰่ฆ†็›–่ทฏๅพ„๏ผŒ้˜ฒๆญข้ƒจๅˆ†ๅคฑ่ดฅๅฏผ่‡ด exists() ่ฏฏๅˆค
303
+ if all(
304
+ Path(p).is_file()
305
+ for p in (meta_path, emb_path, json_path)
306
+ ):
307
+ self.paths.metadata = Path(meta_path)
308
+ self.paths.embeddings = Path(emb_path)
309
+ self.paths.meta_json = Path(json_path)
310
+ print('[Engine] ไบ‘็ซฏๆ•ฐๆฎๆ–‡ไปถๆ‹‰ๅ–ๅฎŒๆฏ•ใ€‚')
311
+ else:
312
+ print('[Engine] ้ƒจๅˆ†็ผ“ๅญ˜ๆ–‡ไปถๆ‹‰ๅ–ๅคฑ่ดฅ๏ผŒๅฐ†่งฆๅ‘ๆœฌๅœฐ้‡ๅปบใ€‚')
313
+
314
+ # โ”€โ”€ ๆœ็ดข โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
315
+
316
+ def _encode_queries(self, queries: list[str]) -> torch.Tensor:
317
+ """ๆ‰น้‡็ผ–็ ๆŸฅ่ฏข่ฏ๏ผŒๅ‘ฝไธญ embedding ็ผ“ๅญ˜็š„่ทณ่ฟ‡ model.encodeใ€‚"""
318
+ cached_vecs: list[Optional[torch.Tensor]] = [self._emb_cache.get(q) for q in queries]
319
+ uncached_idx = [i for i, v in enumerate(cached_vecs) if v is None]
320
+
321
+ if uncached_idx:
322
+ uncached_texts = [queries[i] for i in uncached_idx]
323
+ new_embs = self.model.encode(
324
+ uncached_texts, convert_to_tensor=True, show_progress_bar=False,
325
+ ).float()
326
+ new_embs = torch.nn.functional.normalize(new_embs, p=2, dim=1)
327
+ for j, i in enumerate(uncached_idx):
328
+ emb = new_embs[j]
329
+ self._emb_cache.put(queries[i], emb)
330
+ cached_vecs[i] = emb
331
+
332
+ return torch.stack(cached_vecs) # type: ignore[arg-type]
333
+
334
+ def search(self, request: SearchRequest) -> SearchResponse:
335
+ if not self.is_loaded:
336
+ self.load()
337
+
338
+ cache_key = (
339
+ request.query,
340
+ request.top_k,
341
+ request.limit,
342
+ request.popularity_weight,
343
+ request.use_segmentation,
344
+ tuple(sorted(request.target_layers)),
345
+ tuple(sorted(request.target_categories)),
346
+ )
347
+ cached = self._search_cache.get(cache_key)
348
+ if cached is not None:
349
+ return cached
350
+
351
+ if request.use_segmentation:
352
+ raw_kw, raw_segments = self._smart_split(request.query)
353
+ keywords = [w.strip() for w in raw_kw if w.strip() and w.strip() not in STOP_WORDS]
354
+ # raw_segments: ๅˆ†้š”็ฌฆๅˆ‡ๅˆ†ๅŽ็š„ๅŽŸๅง‹็‰‡ๆฎต๏ผˆๆœช็ป jieba๏ผ‰๏ผŒไฝœไธบไปŽๅฅ็บงๆŸฅ่ฏขๆ’ๅ…ฅใ€‚
355
+ # ๆŽ’้™คไธŽๅฎŒๆ•ด query ็›ธๅŒใ€ไปฅๅŠๅทฒๅ‡บ็Žฐๅœจ keywords ไธญ็š„็‰‡ๆฎต๏ผŒ้ฟๅ…้‡ๅค็ผ–็ ๅ’Œๆƒ้‡่†จ่ƒ€ใ€‚
356
+ keywords_set = set(keywords)
357
+ extra_segments = [s for s in raw_segments if s != request.query and s not in keywords_set]
358
+ queries = [request.query] + extra_segments + keywords
359
+ else:
360
+ keywords = []
361
+ extra_segments = []
362
+ queries = [request.query]
363
+
364
+ q_emb = self._encode_queries(queries)
365
+
366
+ tl = request.target_layers
367
+ k = request.top_k
368
+
369
+ # ๆฏไธชๆŸฅ่ฏข่ฏๅ•็‹ฌๅšๆ„ๅ›พ่ฏ†ๅˆซ๏ผŒ้ฟๅ…้•ฟๅฅๆ„ๅ›พๆฑกๆŸ“็Ÿญๅˆ†่ฏ
370
+ query_weights = [self._detect_intent(q) for q in queries]
371
+ active_layers = [ln for ln in _ALL_LAYER_NAMES if ln in tl]
372
+
373
+ # ้ข„็ฎ—ๆฏไธช query ร— ๆฏไธช layer ็š„ top_k ้…้ข
374
+ # cur_pvk_per_q[i][ln] = ็ฌฌ i ไธช query ๅœจ layer ln ็š„้…้ข
375
+ cur_pvk_per_q: list[dict[str, int]] = []
376
+ for cur_weights in query_weights:
377
+ if active_layers:
378
+ aw = {l: cur_weights.get(l, 1.0) for l in active_layers}
379
+ total_aw = sum(aw.values())
380
+ cur_pvk = {l: max(1, round(k * aw[l] / total_aw)) for l in active_layers}
381
+ else:
382
+ cur_pvk = {}
383
+ cur_pvk_per_q.append(cur_pvk)
384
+
385
+ target_cats = request.target_categories
386
+ w_pop = request.popularity_weight
387
+
388
+ final: dict[str, TagResult] = {}
389
+
390
+ # ๆŒ‰ layer ๆ‰น้‡ๅš็Ÿฉ้˜ตไน˜ + topk๏ผŒๅˆๅนถ Qร—L=20 ๆฌกๅฐ่ฐƒ็”จไธบ L=4 ๆฌกๅคง่ฐƒ็”จ
391
+ for ln, attr, _ in _LAYER_SPEC:
392
+ if ln not in tl:
393
+ continue
394
+ emb_matrix = getattr(self, attr) # (N, D)๏ผŒๅทฒๅฝ’ไธ€ๅŒ–
395
+ if emb_matrix is None:
396
+ continue
397
+
398
+ # ่ฏฅ layer ๅœจๆ‰€ๆœ‰ query ไธญ็š„ๆœ€ๅคง้…้ข๏ผˆๅฐ‘ๆ•ฐ query ไผš็ฎ—ๅˆฐๅคšไฝ™็š„ hit๏ผŒๆœ€ๅŽๆŒ‰ๅ„่‡ช้…้ขๆˆชๆ–ญ๏ผ‰
399
+ k_max = max((cur_pvk_per_q[i].get(ln, 1) for i in range(len(queries))), default=1)
400
+ k_max = min(k_max, emb_matrix.shape[0])
401
+
402
+ scores = q_emb @ emb_matrix.T # (Q, N)
403
+ top_v, top_i = scores.topk(k_max, dim=1) # (Q, k_max)
404
+ top_v_list = top_v.tolist()
405
+ top_i_list = top_i.tolist()
406
+
407
+ for i, source_word in enumerate(queries):
408
+ cur_weights = query_weights[i]
409
+ kq = cur_pvk_per_q[i].get(ln, 1)
410
+ layer_w = cur_weights.get(ln, 1.0)
411
+ row_v = top_v_list[i]
412
+ row_i = top_i_list[i]
413
+ # ไป…ๅ–่ฏฅ query ่‡ชๅทฑ็š„้…้ขๆกๆ•ฐ
414
+ for j in range(min(kq, len(row_v))):
415
+ score = row_v[j]
416
+ if score < 0.35:
417
+ # topk ๅทฒๆŒ‰ๅˆ†ๆ•ฐ้™ๅบ๏ผŒๅŽ็ปญ้ƒฝไฝŽไบŽ้˜ˆๅ€ผ๏ผŒๅฏๆๅ‰็ป“ๆŸ
418
+ break
419
+ idx = row_i[j]
420
+ cat_text = CAT_MAP.get(self._arr_category[idx], 'Other')
421
+ if cat_text not in target_cats:
422
+ continue
423
+ tag_name = self._arr_name[idx]
424
+ count = self._arr_post_count[idx]
425
+ pop_score = self._arr_pop_score[idx]
426
+ final_score = score * layer_w * (1 - w_pop) + pop_score * w_pop
427
+ if tag_name not in final or final_score > final[tag_name].final_score:
428
+ final[tag_name] = TagResult(
429
+ tag=tag_name, cn_name=self._arr_cn_name[idx], category=cat_text,
430
+ nsfw=self._arr_nsfw[idx],
431
+ final_score=round(float(final_score), 4),
432
+ semantic_score=round(float(score), 4),
433
+ count=int(count), source=source_word, layer=ln,
434
+ wiki=self._arr_wiki[idx],
435
+ )
436
+
437
+ # โ”€โ”€ ๅ…จๅฅ่ฏญไน‰ไธ€่‡ดๆ€ง่ฝฏ้‡ๆŽ’ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
438
+ # ๅฏนๆฏไธชๅ€™้€‰ๆ ‡็ญพ๏ผŒ่ฎก็ฎ—ๅ…ถไธŽๅฎŒๆ•ดๅŽŸๅง‹ๆŸฅ่ฏข๏ผˆ่€Œ้žๅˆ†่ฏ็‰‡ๆฎต๏ผ‰็š„่ฏญไน‰็›ธไผผๅบฆ๏ผŒ
439
+ # ๅฐ†็›ธไผผๅบฆไฝœไธบ่ฝฏๅ› ๅญไน˜ๅ…ฅ final_score๏ผŒไฝฟไป…็”ฑๅˆ†่ฏ็ขŽ็‰‡ๅŒน้…ๅˆฐ็š„ๅ™ชๅฃฐ
440
+ # ๆ ‡็ญพ่‡ช็„ถไธ‹ๆฒ‰๏ผŒๅŒๆ—ถไธ็กฌ่ฟ‡ๆปคไปปไฝ•็ป“ๆžœใ€‚
441
+ full_q = q_emb[0] # queries[0] ๅง‹็ปˆไธบๅฎŒๆ•ดๅŽŸๅง‹ๆŸฅ่ฏข
442
+ alpha = 0.3 if SearchRequest.use_segmentation else 0 # ไธ€่‡ดๆ€ง่ฐƒ่Š‚ๅผบๅบฆ๏ผˆ0=ไธ่ฐƒ่Š‚, 1=ๅฎŒๅ…จๆŒ‰ไธ€่‡ดๆ€ง้‡ๆŽ’๏ผ‰๏ผŒไป…ๅœจๅฏ็”จๅˆ†่ฏๆ—ถๆœ‰ๆ„ไน‰
443
+ for r in final.values():
444
+ idx = self._name_to_idx[r.tag]
445
+ max_co = 0.0
446
+ for ln, attr, _ in _LAYER_SPEC:
447
+ if ln not in tl:
448
+ continue
449
+ max_co = max(max_co, float(torch.dot(full_q, getattr(self, attr)[idx])))
450
+ # coherence=0 ๆ—ถๆœ€ๅคšๆ‰ฃ alpha=15%๏ผ›coherence=1 ๆ—ถไธๆ‰ฃๅˆ†
451
+ r.final_score = round(r.final_score * (1.0 - alpha + alpha * max_co), 4)
452
+
453
+ # ๆ”ถ้›†ๆฏไธชๆŸฅ่ฏขๆบ็š„ top-1 ็ป“ๆžœ๏ผˆ้ซ˜ไบŽ้˜ˆๅ€ผ๏ผ‰
454
+ guaranteed_tags: set[str] = set()
455
+ for source_word in queries:
456
+ best: TagResult | None = None
457
+ for r in final.values():
458
+ if r.source == source_word and r.final_score > 0.45:
459
+ if best is None or r.final_score > best.final_score:
460
+ best = r
461
+ if best is not None:
462
+ guaranteed_tags.add(best.tag)
463
+
464
+ # ๅฏนๆ‰€ๆœ‰ๅ€™้€‰่ฟ›่กŒๆŽ’ๅบ๏ผŒ็„ถๅŽๅœจไฟ็•™ไฟ่ฏ็ป“ๆžœ็š„ๅŒๆ—ถๆˆชๆ–ญ่‡ณ้™ๅˆถๆ•ฐ้‡
465
+ sorted_results = sorted(final.values(), key=lambda r: r.final_score, reverse=True)
466
+ valid: list[TagResult] = []
467
+ for r in sorted_results:
468
+ if r.final_score <= 0.45:
469
+ continue
470
+ if len(valid) < request.limit or r.tag in guaranteed_tags:
471
+ valid.append(r)
472
+
473
+ tags_all = ', '.join(r.tag for r in valid)
474
+ tags_sfw = ', '.join(r.tag for r in valid if r.nsfw != '1')
475
+ response = SearchResponse(
476
+ tags_all=tags_all, tags_sfw=tags_sfw,
477
+ results=valid, keywords=keywords, segments=extra_segments,
478
+ )
479
+ self._search_cache.put(cache_key, response)
480
+ return response
481
+
482
+ # โ”€โ”€ ๅ…จ้‡ๆž„ๅปบ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
483
+
484
+ def _build_full(self) -> None:
485
+ print(f'[Engine] ๅ…จ้‡่ฏปๅ– {self.csv_path} ...')
486
+ raw_df = self._read_csv_robust(self.csv_path)
487
+ self.df = self._preprocess_raw_df(raw_df)
488
+ self.max_log_count = float(np.log1p(self.df['post_count'].max()))
489
+ self._encode_all_and_save()
490
+
491
+ def _encode_all_and_save(self) -> None:
492
+ print('[Engine] ๅ…จ้‡็ผ–็ ...')
493
+ for _, attr, col in _LAYER_SPEC:
494
+ setattr(self, attr, self._encode_texts(self.df[col].tolist()))
495
+ self._save_cache()
496
+
497
+ # โ”€โ”€ ๅขž้‡ๆ›ดๆ–ฐ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
498
+
499
+ def _smart_update(self) -> None:
500
+ print('[Engine] ๆฃ€ๆŸฅๅขž้‡ๅ˜ๆ›ด...')
501
+ t0 = time.time()
502
+
503
+ raw_df = self._read_csv_robust(self.csv_path)
504
+ new_df = self._preprocess_raw_df(raw_df)
505
+
506
+ _SIG_COLS = ['cn_name', 'wiki', 'cn_core']
507
+
508
+ def _sig(df: pd.DataFrame, iloc_idx: int) -> tuple:
509
+ row = df.iloc[iloc_idx]
510
+ return tuple(str(row.get(c, '')) for c in _SIG_COLS)
511
+
512
+ cached_idx: dict[str, int] = {n: i for i, n in enumerate(self.df['name'])}
513
+ new_idx: dict[str, int] = {n: i for i, n in enumerate(new_df['name'])}
514
+
515
+ added_names = [n for n in new_idx if n not in cached_idx]
516
+ deleted_names = [n for n in cached_idx if n not in new_idx]
517
+ changed_names = [
518
+ n for n in new_idx
519
+ if n in cached_idx and _sig(new_df, new_idx[n]) != _sig(self.df, cached_idx[n])
520
+ ]
521
+
522
+ if not added_names and not deleted_names and not changed_names:
523
+ print('[Engine] ๆ•ฐๆฎๅทฒๆ˜ฏๆœ€ๆ–ฐ๏ผŒๆ— ้œ€ๆ›ดๆ–ฐใ€‚')
524
+ return
525
+
526
+ print(f'[Engine] ๅ˜ๆ›ด โ†’ ๆ–ฐๅขž: {len(added_names)} ไฟฎๆ”น: {len(changed_names)} ๅˆ ้™ค: {len(deleted_names)}')
527
+
528
+ if deleted_names:
529
+ keep_mask = ~self.df['name'].isin(set(deleted_names))
530
+ keep_pos = [i for i, v in enumerate(keep_mask) if v]
531
+ self.df = self.df[keep_mask].reset_index(drop=True)
532
+ for _, attr, _ in _LAYER_SPEC:
533
+ setattr(self, attr, getattr(self, attr)[keep_pos])
534
+ cached_idx = {n: i for i, n in enumerate(self.df['name'])}
535
+
536
+ if changed_names:
537
+ changed_rows = new_df[new_df['name'].isin(set(changed_names))].reset_index(drop=True)
538
+ _vecs = {attr: self._encode_texts(changed_rows[col].tolist()) for _, attr, col in _LAYER_SPEC}
539
+ for j, name in enumerate(changed_rows['name']):
540
+ ci = cached_idx[name]
541
+ for _, attr, _ in _LAYER_SPEC:
542
+ getattr(self, attr)[ci] = _vecs[attr][j]
543
+ for col in changed_rows.columns:
544
+ self.df.at[ci, col] = changed_rows.at[j, col]
545
+
546
+ if added_names:
547
+ added_rows = new_df[new_df['name'].isin(set(added_names))].reset_index(drop=True)
548
+ for _, attr, col in _LAYER_SPEC:
549
+ vecs = self._encode_texts(added_rows[col].tolist())
550
+ setattr(self, attr, torch.cat([getattr(self, attr), vecs], dim=0))
551
+ self.df = pd.concat([self.df, added_rows], ignore_index=True)
552
+
553
+ self.max_log_count = float(np.log1p(self.df['post_count'].max()))
554
+ self._name_to_idx = {n: i for i, n in enumerate(self.df['name'])}
555
+ self._rebuild_arrays_from_df()
556
+ self._normalize_embeddings()
557
+ self._save_cache()
558
+ print(f'[Engine] ๅขž้‡ๆ›ดๆ–ฐๅฎŒๆˆ๏ผŒ่€—ๆ—ถ {time.time() - t0:.2f}s๏ผˆๅ…ฑ {len(self.df)} ๆก๏ผ‰')
559
+
560
+ # โ”€โ”€ ็ผ“ๅญ˜ I/O โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
561
+
562
+ def _save_cache(self) -> None:
563
+ self.paths.ensure_dir()
564
+ st_save(
565
+ {attr: getattr(self, attr).half() for _, attr, _ in _LAYER_SPEC},
566
+ str(self.paths.embeddings),
567
+ )
568
+ save_cols = ['name', 'cn_name', 'cn_core', 'wiki', 'nsfw', 'category', 'post_count']
569
+ self.df[save_cols].to_parquet(str(self.paths.metadata), index=False)
570
+
571
+ current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
572
+ with open(self.paths.meta_json, 'w', encoding='utf-8') as f:
573
+ json.dump({
574
+ 'schema_version': SCHEMA_VERSION,
575
+ 'updated_at': current_time,
576
+ }, f, ensure_ascii=False, indent=4)
577
+
578
+ print(f'[Engine] ็ผ“ๅญ˜ไฟๅญ˜ๅฎŒๆˆ๏ผˆ{len(self.df)} ๆก่ฎฐๅฝ•๏ผ‰๏ผŒ็”Ÿๆˆๆ—ถ้—ด: {current_time}')
579
+
580
+ def _load_from_cache(self) -> None:
581
+ tensors = st_load(str(self.paths.embeddings), device=self.device)
582
+ for _, attr, _ in _LAYER_SPEC:
583
+ setattr(self, attr, tensors[attr].float())
584
+ self.df = pd.read_parquet(str(self.paths.metadata))
585
+ self.max_log_count = float(np.log1p(self.df['post_count'].max()))
586
+
587
+ def _cached_schema_version(self) -> int:
588
+ try:
589
+ with open(self.paths.meta_json, 'r', encoding='utf-8') as f:
590
+ return int(json.load(f).get('schema_version', 1))
591
+ except Exception:
592
+ return 0
593
+
594
+ # โ”€โ”€ ็ผ–็  & ้ข„ๅค„็† โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
595
+
596
+ def _encode_texts(self, texts: list[str]) -> torch.Tensor:
597
+ return self.model.encode(
598
+ texts, batch_size=64, show_progress_bar=False, convert_to_tensor=True,
599
+ ).float()
600
+
601
+ def _load_model(self) -> None:
602
+ if self.model is not None:
603
+ return
604
+ print(f'[Engine] ๅŠ ่ฝฝๆจกๅž‹ (path={self.model_path}, device={self.device})...')
605
+ try:
606
+ self.model = SentenceTransformer(self.model_path, device=self.device)
607
+ except Exception as e:
608
+ print(f'[Engine] ๆŒ‡ๅฎš่ทฏๅพ„ๅŠ ่ฝฝๅคฑ่ดฅ๏ผŒๅฐ่ฏ•้‡ๆ–ฐ่งฃๆž: {e}')
609
+ fallback = resolve_model_path()
610
+ self.model = SentenceTransformer(fallback, device=self.device)
611
+
612
+ def _read_csv_robust(self, path: str) -> pd.DataFrame:
613
+ for enc in ['utf-8', 'gbk', 'gb18030']:
614
+ try:
615
+ return pd.read_csv(path, dtype=str, encoding=enc).fillna('')
616
+ except UnicodeDecodeError:
617
+ continue
618
+ raise ValueError('CSV ่ฏปๅ–ๅคฑ่ดฅ๏ผŒ่ฏทๆฃ€ๆŸฅ็ผ–็ ')
619
+
620
+ def _preprocess_raw_df(self, df: pd.DataFrame) -> pd.DataFrame:
621
+ df = df.copy()
622
+ df.dropna(subset=['name'], inplace=True)
623
+ df = df[df['name'].str.strip() != '']
624
+ for col in ['cn_name', 'category', 'wiki', 'nsfw']:
625
+ if col not in df.columns:
626
+ df[col] = ''
627
+ df['category'] = df['category'].fillna('0')
628
+ df['nsfw'] = df['nsfw'].fillna('0')
629
+ for char in ['๏ผŒ', '|', 'ใ€']:
630
+ df['cn_name'] = df['cn_name'].str.replace(char, ',', regex=False)
631
+ if 'post_count' not in df.columns:
632
+ df['post_count'] = 0
633
+ df['post_count'] = pd.to_numeric(df['post_count'], errors='coerce').fillna(0)
634
+ df['cn_name'] = df['cn_name'].fillna('')
635
+ df['wiki'] = df['wiki'].fillna('')
636
+ df['cn_core'] = df['cn_name'].str.split(',', n=1).str[0].str.strip().fillna('')
637
+ df.drop_duplicates(subset=['name'], inplace=True)
638
+ df.reset_index(drop=True, inplace=True)
639
+ return df
640
+
641
+ def _setup_jieba_from_memory(self) -> None:
642
+ if self.df is None:
643
+ return
644
+ unique_words: set[str] = set()
645
+ for text in self.df['cn_name'].dropna().astype(str):
646
+ for part in text.replace(',', ' ').split():
647
+ part = part.strip()
648
+ if len(part) > 1:
649
+ unique_words.add(part)
650
+ for word in unique_words:
651
+ jieba.add_word(word, 2000)
652
+
653
+ def _detect_intent(self, query: str) -> dict[str, float]:
654
+ """
655
+ ๆ นๆฎๆŸฅ่ฏข่ฏ็‰นๅพ่ฟ”ๅ›žๅ„่ง†ๅ›พ็š„่ฏญไน‰ๅˆ†็ณปๆ•ฐใ€‚
656
+ ็ณปๆ•ฐ > 1 ่กจ็คบๅŠ ๆƒ๏ผŒ< 1 ่กจ็คบ้™ๆƒใ€‚
657
+ """
658
+ stripped = query.replace(' ', '')
659
+ cn_chars = sum(1 for c in stripped if '\u4e00' <= c <= '\u9fff')
660
+ en_chars = sum(1 for c in stripped if c.isascii() and c.isalpha())
661
+ total = max(len(stripped), 1)
662
+ is_long = len(query) > 8
663
+ is_cn = cn_chars / total > 0.5
664
+ is_en = en_chars / total > 0.5
665
+
666
+ if is_long and is_cn:
667
+ return {'่‹ฑๆ–‡': 0.8, 'ไธญๆ–‡ๆ ธๅฟƒ่ฏ': 0.9, 'ไธญๆ–‡ๆ‰ฉๅฑ•่ฏ': 1.1, '้‡Šไน‰': 1.4}
668
+ if is_long and is_en:
669
+ return {'่‹ฑๆ–‡': 1.0, 'ไธญๆ–‡ๆ ธๅฟƒ่ฏ': 0.8, 'ไธญๆ–‡ๆ‰ฉๅฑ•่ฏ': 0.9, '้‡Šไน‰': 1.3}
670
+ if is_cn:
671
+ return {'่‹ฑๆ–‡': 0.8, 'ไธญๆ–‡ๆ ธๅฟƒ่ฏ': 1.3, 'ไธญๆ–‡ๆ‰ฉๅฑ•่ฏ': 1.1, '้‡Šไน‰': 0.6}
672
+ if is_en:
673
+ return {'่‹ฑๆ–‡': 1.3, 'ไธญๆ–‡ๆ ธๅฟƒ่ฏ': 1.0, 'ไธญๆ–‡ๆ‰ฉๅฑ•่ฏ': 0.8, '้‡Šไน‰': 0.6}
674
+ return {'่‹ฑๆ–‡': 1.0, 'ไธญๆ–‡ๆ ธๅฟƒ่ฏ': 1.0, 'ไธญๆ–‡ๆ‰ฉๅฑ•่ฏ': 1.0, '้‡Šไน‰': 1.0}
675
+
676
+ def _smart_split(self, text: str) -> tuple[list[str], list[str]]:
677
+ """ๅฐ†ๆŸฅ่ฏขๆ–‡ๆœฌๆ‹†ๅˆ†ไธบๅ…ณ้”ฎ่ฏๅˆ—่กจ๏ผŒๅŒๆ—ถ่ฟ”ๅ›žๅˆ†้š”ๅŽ็š„ๅŽŸๅง‹็‰‡ๆฎตใ€‚
678
+
679
+ ไธคๅฑ‚ๅˆ‡ๅˆ†๏ผš
680
+ 1. ้ฆ–ๅ…ˆๆŒ‰ๅˆ†้š”็ฌฆๅˆ‡ๅˆ†โ€”โ€”็ฉบๆ ผใ€ๆข่กŒใ€ไธญๆ–‡้€—ๅท/้กฟๅท/ๅˆ†ๅท/ๅฅๅทๅ‡่ง†ไธบ
681
+ ็”จๆˆทๆ˜พๅผๆŒ‡ๅฎš็š„ๆฆ‚ๅฟต่พน็•Œ๏ผˆไธญๆ–‡่‡ช็„ถ่ฏญๅฅไธไฝฟ็”จ่ฟ™ไบ›็ฌฆๅทๆฅๅˆ†้š”ๆฆ‚ๅฟต๏ผ‰ใ€‚
682
+ 2. ่‹ฅๅชๆœ‰ไธ€ไธช็‰‡ๆฎต๏ผˆๆ— ๆ˜พๅผๅˆ†้š”๏ผ‰๏ผŒๅฎŒๅ…จ่ตฐๅŽŸๆœ‰ jieba ้€ป่พ‘ใ€‚
683
+ ่‹ฅๆœ‰ๅคš็‰‡ๆฎต๏ผŒๆฏไธช็บฏ CJK ็‰‡ๆฎตๆŒ‰้•ฟๅบฆๅ†ณๅฎš๏ผš
684
+ - โ‰ค _ATOMIC_CJK_MAX_LEN ๅญ— โ†’ ๅŽŸๅญๆฆ‚ๅฟต๏ผŒ็›ดๆŽฅไฟ็•™
685
+ - > _ATOMIC_CJK_MAX_LEN ๅญ— โ†’ ่ตฐ jieba ๅˆ‡ๅˆ†๏ผˆ้•ฟ็Ÿญ่ฏญ/็Ÿญๅฅ๏ผ‰
686
+ ๆททๅˆๆ–‡ๆœฌ๏ผˆๅซๆ ‡็‚น/่‹ฑๆ–‡๏ผ‰็‰‡ๆฎตๅง‹็ปˆ่ตฐๅŽŸๆœ‰้€ป่พ‘ใ€‚
687
+
688
+ Returns:
689
+ (tokens, raw_segments):
690
+ - tokens: ๅค„็†ๅŽ็š„ๅ…ณ้”ฎ่ฏๅˆ—่กจ๏ผˆๅŽŸๅญๆฆ‚ๅฟตๆˆ– jieba ๅˆ‡ๅˆ†็ป“ๆžœ๏ผ‰
691
+ - raw_segments: ๅˆ†้š”็ฌฆๅˆ‡ๅˆ†ๅŽ็š„ๅŽŸๅง‹็‰‡ๆฎต๏ผˆๆœช็ป jieba๏ผ‰๏ผŒ็”จไบŽๅคš็ฒ’ๅบฆๆŸฅ่ฏข
692
+ """
693
+ segments = [s.strip() for s in re.split(r'[\s\n\r๏ผŒใ€๏ผ›ใ€‚]+', text) if s.strip()]
694
+ if not segments:
695
+ return [], []
696
+
697
+ tokens: list[str] = []
698
+ # ๅ•ไธ€็‰‡ๆฎต โ†’ ๆ— ๆ˜พๅผๅˆ†้š”๏ผŒๅฎŒๅ…จ่ตฐๅŽŸๆœ‰ jieba ้€ป่พ‘
699
+ if len(segments) == 1:
700
+ segment = segments[0]
701
+ for chunk in re.split(r'([ไธ€-้พฅ]+)', segment):
702
+ if not chunk.strip():
703
+ continue
704
+ if re.match(r'[ไธ€-้พฅ]+', chunk):
705
+ tokens.extend(jieba.cut(chunk))
706
+ else:
707
+ cleaned = re.sub(r'[,()\[\]{}:]', ' ', chunk)
708
+ for part in cleaned.split():
709
+ try:
710
+ float(part)
711
+ except ValueError:
712
+ tokens.append(part)
713
+ return tokens, segments
714
+
715
+ # ๅคš็‰‡ๆฎต โ†’ ๆฏไธชๅˆ†้š”็š„็‰‡ๆฎตๆŒ‰้•ฟๅบฆๅ†ณๅฎšๆ˜ฏๅฆๅŽŸๅญไฟ็•™
716
+ for segment in segments:
717
+ # ็บฏ CJK ็‰‡ๆฎต
718
+ if re.match(r'^[ไธ€-้พฅ]+$', segment):
719
+ if len(segment) <= _ATOMIC_CJK_MAX_LEN:
720
+ tokens.append(segment) # ็Ÿญ โ†’ ๅŽŸๅญๆฆ‚ๅฟต
721
+ else:
722
+ tokens.extend(jieba.cut(segment)) # ้•ฟ โ†’ jieba ๅˆ‡ๅˆ†
723
+ continue
724
+ # ๆททๅˆๆ–‡ๆœฌ โ†’ ๅŽŸๆœ‰ jieba ๅˆ‡ๅˆ†้€ป่พ‘
725
+ for chunk in re.split(r'([ไธ€-้พฅ]+)', segment):
726
+ if not chunk.strip():
727
+ continue
728
+ if re.match(r'[ไธ€-้พฅ]+', chunk):
729
+ tokens.extend(jieba.cut(chunk))
730
+ else:
731
+ cleaned = re.sub(r'[,()\[\]{}:]', ' ', chunk)
732
+ for part in cleaned.split():
733
+ try:
734
+ float(part)
735
+ except ValueError:
736
+ tokens.append(part)
737
+ return tokens, segments
738
+
739
+ # โ”€โ”€ ๅ…ณ่”ๆŽจ่ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
740
+
741
+ def get_related(
742
+ self,
743
+ seed_tags: list[str],
744
+ exclude: set[str] | None = None,
745
+ limit: int = 20,
746
+ show_nsfw: bool = True,
747
+ ) -> list:
748
+ from .models import RelatedTag
749
+ import math
750
+
751
+ if not self.cooc or not seed_tags:
752
+ return []
753
+ exclude = exclude or set()
754
+
755
+ related_key = (tuple(sorted(seed_tags)), tuple(sorted(exclude)), limit, show_nsfw)
756
+ cached = self._related_cache.get(related_key)
757
+ if cached is not None:
758
+ return cached
759
+
760
+ # ไผฐ็ฎ—่ฏญๆ–™ๅบ“ๆ€ปๅคงๅฐ N๏ผŒๅ–ๆ•ฐๆฎ้›†ไธญๅ‘ๅธ–้‡็š„ๆœ€ๅคงๅ€ผ๏ผŒๅนถ่ฎพ็ฝฎๅˆ็†ไธ‹้™
761
+ total_posts = float(max(self.df['post_count'].max(), 7000000.0))
762
+
763
+ npmi_scores: dict[str, float] = {}
764
+ total_cooc: dict[str, int] = {}
765
+ tag_sources: dict[str, list[str]] = {}
766
+ name_to_idx = self._name_to_idx
767
+ arr_post_count = self._arr_post_count
768
+
769
+ for seed in seed_tags:
770
+ if seed not in name_to_idx:
771
+ continue
772
+
773
+ seed_count = float(arr_post_count[name_to_idx[seed]] or 1)
774
+
775
+ for neighbor, cnt in self.cooc.get(seed, []):
776
+ if neighbor in exclude or neighbor == seed:
777
+ continue
778
+ if neighbor not in name_to_idx:
779
+ continue
780
+
781
+ neighbor_count = float(arr_post_count[name_to_idx[neighbor]] or 1)
782
+
783
+ cooc = min(float(cnt), seed_count, neighbor_count)
784
+ if cooc <= 0:
785
+ continue
786
+
787
+ # ่ฎก็ฎ—ๅˆ†ๅญ๏ผš(Cooc * N) / (Count(A) * Count(B))
788
+ numerator = (cooc * total_posts) / (seed_count * neighbor_count)
789
+
790
+ # ๅฟฝ็•ฅ่ดŸ็›ธๅ…ณๆˆ–ๅฎŒๅ…จไธ็›ธๅ…ณ็š„่ฏๆก
791
+ if numerator <= 1.0:
792
+ continue
793
+
794
+ pmi = math.log(numerator)
795
+
796
+ # ่ฎก็ฎ—ๅˆ†ๆฏ๏ผš-log(P(A, B))
797
+ p_a_b = cooc / total_posts
798
+ if p_a_b >= 1.0:
799
+ npmi = 1.0
800
+ else:
801
+ npmi = pmi / -math.log(p_a_b)
802
+
803
+ # ๅคš่ฏๆกๆœ็ดขๆ—ถ็ดฏๅŠ  NPMI
804
+ npmi_scores[neighbor] = npmi_scores.get(neighbor, 0.0) + npmi
805
+ total_cooc[neighbor] = total_cooc.get(neighbor, 0) + cnt
806
+ tag_sources.setdefault(neighbor, []).append(seed)
807
+
808
+ if not npmi_scores:
809
+ return []
810
+
811
+ # ๅฝ’ไธ€ๅŒ–็”จไบŽๅ‰็ซฏๅฑ•็คบ
812
+ max_score = max(npmi_scores.values())
813
+
814
+ sorted_candidates = sorted(npmi_scores.items(), key=lambda x: x[1], reverse=True)
815
+ results = []
816
+
817
+ for tag_name, raw_score in sorted_candidates:
818
+ if len(results) >= limit:
819
+ break
820
+
821
+ idx = name_to_idx[tag_name]
822
+ nsfw = self._arr_nsfw[idx]
823
+ if nsfw == '1' and not show_nsfw:
824
+ continue
825
+
826
+ cat = CAT_MAP.get(self._arr_category[idx], 'Other')
827
+
828
+ results.append(RelatedTag(
829
+ tag=tag_name,
830
+ cn_name=str(self._arr_cn_name[idx]),
831
+ category=cat,
832
+ nsfw=nsfw,
833
+ cooc_count=total_cooc[tag_name],
834
+ cooc_score=round(raw_score / max_score, 4),
835
+ sources=tag_sources.get(tag_name, []),
836
+ post_count=int(self._arr_post_count[idx]),
837
+ wiki=str(self._arr_wiki[idx]) if self._arr_wiki is not None else '',
838
+ ))
839
+
840
+ self._related_cache.put(related_key, results)
841
+ return results
842
+
843
+ def _load_cooc(self) -> None:
844
+ csv_path = Path(self.cooc_file)
845
+ parquet_path = csv_path.with_suffix('.parquet')
846
+
847
+ if parquet_path.is_file() and (
848
+ not csv_path.is_file()
849
+ or parquet_path.stat().st_mtime >= csv_path.stat().st_mtime
850
+ ):
851
+ read_path = parquet_path
852
+ is_parquet = True
853
+ elif csv_path.is_file():
854
+ read_path = csv_path
855
+ is_parquet = False
856
+ else:
857
+ print(f'[Engine] ๆœชๆ‰พๅˆฐๅ…ฑ็Žฐ่กจ ({self.cooc_file})๏ผŒๅ…ณ่”ๆŽจ่ๅŠŸ่ƒฝไธๅฏ็”จใ€‚')
858
+ return
859
+
860
+ print(f'[Engine] ๅŠ ่ฝฝๅ…ฑ็Žฐ่กจ ({read_path.name})...')
861
+ t0 = time.time()
862
+ try:
863
+ if is_parquet:
864
+ df = pd.read_parquet(str(read_path))
865
+ else:
866
+ df = self._read_csv_robust(str(read_path))
867
+ df['count'] = pd.to_numeric(df['count'], errors='coerce').fillna(0).astype(int)
868
+ df.to_parquet(str(parquet_path), index=False)
869
+ print(f'[Engine] ๅทฒๅฐ†ๅ…ฑ็Žฐ่กจ็ผ“ๅญ˜ไธบ {parquet_path.name}๏ผŒไธ‹ๆฌกๅฏๅŠจๅฐ†็›ดๆŽฅๅŠ ่ฝฝใ€‚')
870
+
871
+ tag_a = df['tag_a'].astype(str).to_numpy()
872
+ tag_b = df['tag_b'].astype(str).to_numpy()
873
+ counts = df['count'].astype(int).to_numpy()
874
+
875
+ src = np.concatenate([tag_a, tag_b])
876
+ dst = np.concatenate([tag_b, tag_a])
877
+ cnt = np.concatenate([counts, counts])
878
+
879
+ sort_idx = np.lexsort((-cnt, src))
880
+ src = src[sort_idx]
881
+ dst = dst[sort_idx]
882
+ cnt = cnt[sort_idx]
883
+
884
+ unique_srcs, first_pos = np.unique(src, return_index=True)
885
+ end_pos = np.append(first_pos[1:], len(src))
886
+
887
+ cooc: dict[str, list[tuple[str, int]]] = {}
888
+ for s, start, end in zip(unique_srcs, first_pos, end_pos):
889
+ cooc[s] = list(zip(dst[start:end].tolist(), cnt[start:end].tolist()))
890
+
891
+ self.cooc = cooc
892
+ print(
893
+ f'[Engine] ๅ…ฑ็Žฐ่กจๅŠ ่ฝฝๅฎŒๆˆ๏ผŒ{len(cooc):,} ไธช tag๏ผŒ'
894
+ f'่€—ๆ—ถ {time.time() - t0:.2f}s'
895
+ )
896
+ except Exception as e:
897
+ print(f'[Engine] ๅ…ฑ็Žฐ่กจๅŠ ่ฝฝๅคฑ่ดฅ: {e}')
core/models.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ core/models.py
3
+ ๆ•ฐๆฎ็ป“ๆž„
4
+ """
5
+ from __future__ import annotations
6
+ from dataclasses import dataclass, field
7
+
8
+ @dataclass
9
+ class TagResult:
10
+ """ๅ•ๆกๆ ‡็ญพๆœ็ดข็ป“ๆžœใ€‚"""
11
+ tag: str
12
+ cn_name: str
13
+ category: str
14
+ nsfw: str
15
+ final_score: float
16
+ semantic_score: float
17
+ count: int
18
+ source: str
19
+ layer: str
20
+ wiki: str = ""
21
+
22
+
23
+ @dataclass
24
+ class RelatedTag:
25
+ """ๅ•ๆกๅ…ณ่”ๆŽจ่็ป“ๆžœ"""
26
+ tag: str
27
+ cn_name: str
28
+ category: str
29
+ nsfw: str
30
+ cooc_count: int # ็ดฏ่ฎกๅ…ฑ็Žฐๆฌกๆ•ฐ
31
+ cooc_score: float # ๅฝ’ไธ€ๅŒ–ๅˆ†ๆ•ฐ
32
+ sources: list[str] = field(default_factory=list) # ่งฆๅ‘่ฏฅๆŽจ่็š„็งๅญ tag ๅˆ—่กจ
33
+ post_count: int = 0 # ่ฏฅๆ ‡็ญพ็š„ๅ‘ๅธ–ๆ€ปๆ•ฐ๏ผˆไธŽ search_tags ็š„ count ๅฏน้ฝ๏ผ‰
34
+ wiki: str = "" # ๆ ‡็ญพ wiki ๆ่ฟฐ
35
+
36
+
37
+ @dataclass
38
+ class SearchRequest:
39
+ """ๆœ็ดขๅ‚ๆ•ฐ"""
40
+ query: str
41
+ top_k: int = 5
42
+ limit: int = 80
43
+ popularity_weight: float = 0.15
44
+ show_nsfw: bool = True
45
+ use_segmentation: bool = True
46
+ target_layers: list[str] = field(
47
+ default_factory=lambda: ['่‹ฑๆ–‡', 'ไธญๆ–‡ๆ‰ฉๅฑ•่ฏ', '้‡Šไน‰', 'ไธญๆ–‡ๆ ธๅฟƒ่ฏ']
48
+ )
49
+ target_categories: list[str] = field(
50
+ default_factory=lambda: ['General', 'Character', 'Copyright']
51
+ )
52
+
53
+
54
+ @dataclass
55
+ class SearchResponse:
56
+ """ๆœ็ดข่ฟ”ๅ›žๅ€ผใ€‚"""
57
+ tags_all: str
58
+ tags_sfw: str
59
+ results: list[TagResult]
60
+ keywords: list[str]
61
+ segments: list[str] = field(default_factory=list) # ๅˆ†้š”็ฌฆๅˆ‡ๅˆ†ๅŽ็š„ๅŽŸๅง‹ไปŽๅฅ็บง็‰‡ๆฎต
mcp_server.py ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ mcp_server.py
3
+ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
4
+ MCP ๆœๅŠกๅฑ‚
5
+
6
+ ๆŒ‚่ฝฝๆ–นๅผ๏ผˆๅœจ ui_nicegui.py ไธญ๏ผ‰๏ผš
7
+ from mcp_server import mcp
8
+ app.mount('/mcp', mcp.streamable_http_app())
9
+
10
+ ๆŽฅๅ…ฅๅœฐๅ€๏ผš
11
+ https://sakizuki-danboorusearch.hf.space/mcp/mcp
12
+
13
+ ๆ”ฏๆŒ็š„ๅทฅๅ…ท๏ผš
14
+ search_tags ่‡ช็„ถ่ฏญ่จ€ๆœ็ดขๆ ‡็ญพ
15
+ get_related_tags ๅŸบไบŽๅ…ฑ็Žฐ่กจๆŸฅๅ…ณ่”ๆŽจ่
16
+ """
17
+
18
+ import json
19
+ import asyncio
20
+ from mcp.server.fastmcp import FastMCP
21
+ from mcp.server.transport_security import TransportSecuritySettings
22
+ from core.engine import DanbooruTagger
23
+ from core.models import SearchRequest
24
+ import core.counter as counter
25
+ import re
26
+
27
+
28
+ mcp = FastMCP(
29
+ name="danbooru-searcher",
30
+ transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
31
+ )
32
+
33
+
34
+ @mcp.tool()
35
+ async def search_tags(
36
+ query: str,
37
+ use_segmentation: bool = True,
38
+ top_k: int = 5,
39
+ limit: int = 80,
40
+ popularity_weight: float = 0.15,
41
+ show_nsfw: bool = True,
42
+ include_wiki: bool = False,
43
+ category: str = "all",
44
+ ) -> str:
45
+ """
46
+ Search Danbooru tags using natural language and return a ready-to-use prompt.
47
+
48
+ ## Args
49
+ - query: Natural language description (Chinese recommended).
50
+ - use_segmentation: Split multi-concept input into segments for separate retrieval. True for scene descriptions, False for single-concept queries.
51
+ - top_k: Candidates recalled per segment. Semantics change with use_segmentation โ€” see guide below.
52
+ - limit: Max tags returned.
53
+ - popularity_weight: Influence of tag post count on ranking (0.0โ€“1.0). Default 0.15.
54
+ - show_nsfw: Include NSFW tags. Default True.
55
+ - include_wiki: Append wiki description to each result. Default False.
56
+ - category: Filter results to a specific tag category. Default "all".
57
+ "all" โ€” All categories (้€š็”จ + ็‰ˆๆƒ + ไบบ็‰ฉ )
58
+ "general" โ€” General: visual attributes, clothing, pose, background, etc.
59
+ "copyright" โ€” Copyright: specific anime/game/franchise titles
60
+ "character" โ€” Character: named characters from any series
61
+ Use this when you know what kind of tag you need โ€” e.g. looking for a
62
+ character name vs. describing a scene visually.
63
+
64
+ ## Query writing guide
65
+
66
+ The `query` parameter supports explicit delimiter control for precise segmentation.
67
+
68
+ ### Explicit delimiters
69
+
70
+ Use **spaces, newlines, Chinese commas (๏ผŒ), or Chinese dunhao (ใ€)** to manually separate concepts.
71
+ Each delimiter-bounded segment โ‰ค7 characters stays atomic โ€” the engine respects your intent.
72
+
73
+ | Query style | Example | When to use |
74
+ |---|---|---|
75
+ | Concept list (spaces) | `่ฟๅŠจ็คพๅ›ข ๆ ก้˜Ÿ ๆฏ”่ต› ่ฟๅŠจไผš` | You know the exact concepts to search |
76
+ | Concept list (dun hao) | `ๅไนŒๆ‰˜้‚ฆใ€่ต›ๅšๆœ‹ๅ…‹ใ€่’ธๆฑฝๆœ‹ๅ…‹` | Same, with Chinese list punctuation |
77
+ | Natural sentence | `ไธ€ไธช็ฉฟ็€็™ฝ่‰ฒๆฐดๆ‰‹ๆœ็š„ๅฐ‘ๅฅณๅœจ้›จไธญๅฅ”่ท‘` | Scene description, let the engine auto-split |
78
+ | Mixed | `่ฟๅŠจ็คพๅ›ข ไธ€ไธช็ฉฟๆฐดๆ‰‹ๆœ็š„ๅฐ‘ๅฅณ` | Mix concepts with descriptive phrases |
79
+
80
+ Segments >7 characters are still auto-split by jieba, but the raw segment is kept as an additional query
81
+ to preserve clause-level semantics (multi-granularity retrieval).
82
+
83
+ ### Recommendations
84
+
85
+ 1. **Concept lists โ†’ use explicit delimiters:** Group independent concepts with spaces or dunhao.
86
+ `่ฟๅŠจ็คพๅ›ข ๆ ก้˜Ÿ ๆฏ”่ต› ไฝ“่‚ฒ็ฅญ ็”ฐๅพ„้ƒจ` is better than `่ฟๅŠจ็คพๅ›ขๆ ก้˜Ÿๆฏ”่ต›ไฝ“่‚ฒ็ฅญ็”ฐๅพ„้ƒจ`.
87
+
88
+ 2. **Scene descriptions โ†’ write naturally:** Natural Chinese with Chinese commas works well for full scenes.
89
+ `ไธ€ไธช็ฉฟ็€็™ฝ่‰ฒๆฐดๆ‰‹ๆœ๏ผŒ่“่‰ฒ็Ÿญ่ฃ™็š„ๅฐ‘ๅฅณๅœจ้›จไธญๅฅ”่ท‘` โ€” commas here are grammatical, not delimiters.
90
+
91
+ 3. **Precise lookup โ†’ turn off segmentation:** For finding a specific character or copyright title,
92
+ set `use_segmentation=False` and combine with `category` filter.
93
+ e.g. `query="EVAไธญ่“ๅ‘็š„้›ถๅทๆœบ้ฉพ้ฉถๅ‘˜"` with `category="character"` and `use_segmentation=False`.
94
+
95
+ 4. **Category filtering:** Use `category` to narrow results. Looking for a character?
96
+ `category="character"`. Building a scene prompt? `category="general"`.
97
+
98
+ ## Parameter guide
99
+
100
+ ### Step 1 โ€” Decide use_segmentation + top_k together
101
+
102
+ top_k means "candidates per segment"; its effect depends on whether segmentation is on.
103
+
104
+ Multi-concept input (scene description) โ†’ use_segmentation=True
105
+
106
+ | Sub-scenario | top_k | Reason |
107
+ |-----------------------|-------|----------------------------------------------------------|
108
+ | Full scene โ†’ prompt | 5 | Many segments; low top_k distributes result slots fairly |
109
+ | Vague concept explore | 80 | Few segments; high top_k needed for broad recall |
110
+
111
+ Single-concept input โ†’ use_segmentation=False
112
+
113
+ top_k acts as total candidate pool size. Use 20 for all single-concept cases.
114
+
115
+ | Sub-scenario | top_k |
116
+ |-------------------------------|-------|
117
+ | Describe subject / find tag | 20 |
118
+ | Precise lookup / spell fix | 20 |
119
+
120
+ ### Step 2 โ€” Decide limit independently
121
+
122
+ | Goal | limit |
123
+ |------------------------------|-------|
124
+ | Full prompt for image gen | 80 |
125
+ | Concept exploration | 20โ€“80 |
126
+ | Precise lookup / role search | 10โ€“20 |
127
+
128
+ ### Auxiliary params
129
+
130
+ popularity_weight (default 0.15, rarely needs changing):
131
+ - Higher (0.3+): favor common, well-established tags
132
+ - Lower (0.0): surface niche/rare tags
133
+
134
+ include_wiki (default False):
135
+ - True: tag meaning matters โ€” disambiguation, explaining tags to user, exploring unfamiliar domains
136
+ - False: prompt generation (wiki irrelevant to downstream), tags already known
137
+
138
+ ### Quick reference
139
+
140
+ | Scenario | use_segmentation | top_k | limit |
141
+ |-------------------------------|------------------|-------|-------|
142
+ | Full scene โ†’ prompt (default) | True | 5 | 80 |
143
+ | Vague concept exploration | True | 80 | 80 |
144
+ | Describe subject / find tag | False | 20 | 20 |
145
+ | Precise lookup / spell fix | False | 20 | 10 |
146
+
147
+ ### Workflow
148
+
149
+ After search_tags, pass selected tags to get_related_tags to discover complementary tags via co-occurrence (accessories, character features, scene atmosphere).
150
+
151
+ ## Examples
152
+
153
+ Precise lookup / spell fix โ€” e.g. "selafuku", "thighhigh", "twintail"
154
+ โ†’ use_segmentation=False, top_k=20, limit=10
155
+
156
+ Vague concept exploration โ€” e.g. "ๅ…”่€ณๆœต", "่ต›ๅšๆœ‹ๅ…‹ๆœ่ฃ…", "ๅ‡่‚ข"
157
+ โ†’ use_segmentation=True, top_k=80, limit=80
158
+
159
+ Describe subject / find tag โ€” e.g. "EVAไธญ่“ๅ‘็š„้›ถๅทๆœบ้ฉพ้ฉถๅ‘˜", "ๅ‘ฝ่ฟ็Ÿณไน‹้—จไธญ็š„ๅŠฉๆ‰‹"
160
+ โ†’ use_segmentation=False, top_k=20, limit=20
161
+
162
+ Full scene โ†’ prompt โ€” e.g. "ไธ€ไธช็ฉฟ็€็™ฝ่‰ฒๆฐดๆ‰‹ๆœ๏ผŒ่“่‰ฒ็Ÿญ่ฃ™็š„ๅฐ‘ๅฅณๅœจ้›จไธญ็š„ๅŸŽๅธ‚้‡Œๅฅ”่ท‘"
163
+ โ†’ use_segmentation=True, top_k=5, limit=80
164
+
165
+ ## Returns
166
+ JSON with: prompt (comma-separated tags), keywords, results.
167
+ Each result: tag, cn_name, category, final_score, count[, wiki if include_wiki=True].
168
+ """
169
+ _CATEGORY_MAP: dict[str, list[str]] = {
170
+ "all": ["General", "Character", "Copyright", "Artist", "Meta"],
171
+ "general": ["General"],
172
+ "character": ["Character"],
173
+ "copyright": ["Copyright"],
174
+ }
175
+ target_categories = _CATEGORY_MAP.get(
176
+ category,
177
+ _CATEGORY_MAP["all"], # unrecognized value โ†’ fall back to all
178
+ )
179
+
180
+ tagger = await DanbooruTagger.get_instance()
181
+ request = SearchRequest(
182
+ query=query,
183
+ top_k=top_k,
184
+ limit=limit,
185
+ popularity_weight=popularity_weight,
186
+ show_nsfw=show_nsfw,
187
+ use_segmentation=use_segmentation,
188
+ target_categories=target_categories,
189
+ )
190
+ response = await asyncio.to_thread(tagger.search, request)
191
+ # ่ฎกๆ•ฐ๏ผšๆฏๆฌก MCP ๆœ็ดข่ฐƒ็”จๅ‡่ฎกๅ…ฅๆœ็ดขใ€ๆˆๅŠŸใ€ๅคๅˆถ๏ผ›่ฎฟ้—ฎไธๅ˜
192
+ await counter.increment()
193
+ await counter.increment_success()
194
+ await counter.increment_copy()
195
+ await counter.increment_mcp()
196
+
197
+ results = []
198
+ for r in response.results:
199
+ if r.nsfw == '1' and not show_nsfw:
200
+ continue
201
+ item = {
202
+ "tag": r.tag,
203
+ "cn_name": r.cn_name,
204
+ "category": r.category,
205
+ "final_score": r.final_score,
206
+ "count": r.count,
207
+ }
208
+ if include_wiki:
209
+ item["wiki"] = r.wiki
210
+ results.append(item)
211
+
212
+ payload = {
213
+ "prompt": response.tags_sfw if not show_nsfw else response.tags_all,
214
+ "keywords": response.keywords,
215
+ "results": results,
216
+ }
217
+ han_chars = re.findall(r'[\u4e00-\u9fff]', query)
218
+ if len(query) > 0 and len(han_chars) / len(query) < 0.5:
219
+ payload["hint"] = (
220
+ "ๆฃ€ๆต‹ๅˆฐ่‹ฑๆ–‡ๆŸฅ่ฏข๏ผŒ่ฏฅๆœ็ดขๅผ•ๆ“Žๅฏนไธญๆ–‡ๆŸฅ่ฏขไผ˜ๅŒ–ๆ›ดๅฅฝ๏ผŒๅฆ‚ๆžœๆœ็ดข็ป“ๆžœไธๅˆ้ข„ๆœŸ๏ผŒๆŽจ่็”จไธญๆ–‡้‡่ฏ•"
221
+ )
222
+ return json.dumps(payload, ensure_ascii=False, indent=2)
223
+
224
+
225
+
226
+ @mcp.tool()
227
+ async def get_related_tags(
228
+ tags: list[str],
229
+ limit: int = 20,
230
+ show_nsfw: bool = True,
231
+ include_wiki: bool = False,
232
+ ) -> str:
233
+ """
234
+ Return co-occurrence-based tag recommendations for a given tag list (NPMI scoring).
235
+ Typical workflow: call search_tags first, then pass selected tags here to discover complementary ones.
236
+
237
+ Works well for: clothing accessories, character visual features, theme exploration, multi-tag intersections.
238
+ e.g. tags=["fingerless_gloves"] โ†’ returns characters often wearing them
239
+ e.g. tags=["amiya_(arknights)"] โ†’ returns outfit, expression, accessory tags
240
+ that define this character's appearance
241
+ e.g. tags=["fighter_jet"] โ†’ returns aircraft types, action, background tags
242
+ e.g. tags=["maid", "twintails"] โ†’ returns tags specific to the
243
+ "maid + twintails" combination, not just each tag individually
244
+
245
+ Args:
246
+ tags: List of Danbooru tag names, e.g. ["white_serafuku", "sailor_collar"].
247
+ limit: Maximum number of recommendations returned.
248
+ show_nsfw: Whether to include NSFW tags. Defaults to True.
249
+ include_wiki: Append wiki description to each result. Default False.
250
+
251
+ Returns:
252
+ A JSON string containing recommended tags sorted by NPMI co-occurrence score.
253
+ Each result: tag, cn_name, category, count, cooc_score, sources[, wiki if include_wiki=True].
254
+ """
255
+ tagger = await DanbooruTagger.get_instance()
256
+ results = await asyncio.to_thread(
257
+ tagger.get_related,
258
+ tags,
259
+ set(tags),
260
+ limit,
261
+ show_nsfw,
262
+ )
263
+ # ่ฎกๆ•ฐ๏ผšๆฏๆฌก MCP related ่ฐƒ็”จๅ‡่ฎกๅ…ฅๆœ็ดขใ€ๆˆๅŠŸใ€ๅคๅˆถ๏ผ›่ฎฟ้—ฎไธๅ˜
264
+ await counter.increment()
265
+ await counter.increment_success()
266
+ await counter.increment_copy()
267
+ await counter.increment_mcp()
268
+
269
+ output = []
270
+ for r in results:
271
+ item = {
272
+ "tag": r.tag,
273
+ "cn_name": r.cn_name,
274
+ "category": r.category,
275
+ "count": r.post_count,
276
+ "cooc_score": r.cooc_score,
277
+ "sources": r.sources,
278
+ }
279
+ if include_wiki:
280
+ item["wiki"] = r.wiki
281
+ output.append(item)
282
+
283
+ return json.dumps(output, ensure_ascii=False, indent=2)
origin_database/tags_enhanced.csv ADDED
The diff for this file is too large to render. See raw diff
 
platform_utils.py ADDED
@@ -0,0 +1,298 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ platform_utils.py
3
+
4
+ ็ปŸไธ€็š„ๅนณๅฐๆฃ€ๆต‹ไธŽ Hub ๆ“ไฝœๅฐ่ฃ…ใ€‚
5
+
6
+ ๆ”ฏๆŒๅนณๅฐ๏ผš
7
+ - HuggingFace Space
8
+ - ModelScope ๅˆ›็ฉบ้—ด๏ผˆ้ญ”ๆญ๏ผ‰
9
+ - ๆœฌๅœฐๅผ€ๅ‘็Žฏๅขƒ
10
+
11
+ ๅฏนๅค–ๆšด้œฒ๏ผš
12
+ PLATFORM : Literal['hf', 'ms', 'local']
13
+ is_cloud() : bool
14
+ get_host_port() : tuple[str, int]
15
+ download_file() : ไธ‹่ฝฝๅ•ไธชๆ–‡ไปถ๏ผŒ่ฟ”ๅ›žๆœฌๅœฐ่ทฏๅพ„
16
+ upload_bytes() : ไธŠไผ  bytes ๅˆฐ OSS๏ผˆ็”จไบŽ่ฎกๆ•ฐๅ™จๆŒไน…ๅŒ–๏ผ‰
17
+ read_bytes() : ไปŽ OSS ่ฏปๅ–ๆ–‡ไปถๅ†…ๅฎน๏ผŒ่ฟ”ๅ›ž bytes | None
18
+ get_counter_cfg() : ่ฟ”ๅ›ž CounterConfig๏ผˆplatform / available๏ผ‰
19
+
20
+ ็Žฏๅขƒๅ˜้‡็บฆๅฎš๏ผš
21
+
22
+ HuggingFace Space๏ผˆ็”ฑ HF ่‡ชๅŠจๆณจๅ…ฅ๏ผ‰
23
+ SPACE_ID Space ๅ”ฏไธ€ๆ ‡่ฏ†๏ผŒๅญ˜ๅœจๅณไปฃ่กจๅœจ HF ็Žฏๅขƒ
24
+ SPACE_AUTHOR_NAME ไฝœ่€…ๅ
25
+
26
+ ็”จๆˆทๆ‰‹ๅŠจ้…็ฝฎ๏ผˆHF Secrets๏ผ‰๏ผš
27
+ HF_TOKEN HF ่ฎฟ้—ฎไปค็‰Œ๏ผˆไป…็”จไบŽ download_file๏ผŒ้ž่ฎกๆ•ฐๅ™จ๏ผ‰
28
+
29
+ ModelScope ๅˆ›็ฉบ้—ด๏ผˆ็”ฑ้ญ”ๆญ่‡ชๅŠจๆณจๅ…ฅ๏ผ‰
30
+ MODELSCOPE_ENVIRONMENT ๅญ˜ๅœจๅณไปฃ่กจๅœจ้ญ”ๆญ็Žฏๅขƒ๏ผˆๅ€ผ้€šๅธธไธบ "studio"๏ผ‰
31
+ STUDIO_ID ๅˆ›็ฉบ้—ด ID๏ผˆๅค‡็”จๆฃ€ๆต‹๏ผ‰
32
+
33
+ ้ญ”ๆญๅนณๅฐๆ•ฐๆฎๆ–‡ไปถ่ฏดๆ˜Ž๏ผš
34
+ ๆ•ฐๆฎๆ–‡ไปถ๏ผˆCSV / parquet / safetensors๏ผ‰็›ดๆŽฅๆ”พๅœจๅˆ›็ฉบ้—ด studio repo
35
+ ไธญ๏ผŒๅฎนๅ™จๅฏๅŠจๆ—ถไผš่‡ชๅŠจๅŒๆญฅๅˆฐๅทฅไฝœ็›ฎๅฝ•๏ผŒdownload_file() ๅœจ MS ๅนณๅฐ
36
+ ็›ดๆŽฅ่ฟ”ๅ›žๆœฌๅœฐ่ทฏๅพ„๏ผŒๆ— ้œ€้ขๅค–้…็ฝฎ Model repoใ€‚
37
+
38
+ ้˜ฟ้‡Œไบ‘ OSS๏ผˆ่ฎกๆ•ฐๅ™จๅ”ฏไธ€ๅŽ็ซฏ๏ผŒHF ไธŽ MS ๅ…ฑไบซๅŒไธ€ๆ•ฐๆฎ๏ผ‰
39
+
40
+ OSS_ACCESS_KEY_ID RAM ๅญ่ดฆๅท AccessKey ID
41
+ OSS_ACCESS_KEY_SECRET RAM ๅญ่ดฆๅท AccessKey Secret
42
+ OSS_ENDPOINT Bucket ๆ‰€ๅœจๅœฐๅŸŸ่Š‚็‚น
43
+ ไพ‹: oss-cn-hangzhou.aliyuncs.com
44
+ ๏ผˆๆ— ้œ€ๅŠ  https://๏ผŒไปฃ็ ่‡ชๅŠจๆ‹ผๆŽฅ๏ผ‰
45
+ OSS_BUCKET_NAME Bucket ๅ็งฐ
46
+ OSS_COUNTER_DIR ่ฎกๆ•ฐๆ–‡ไปถๅœจ Bucket ไธญ็š„ๅ‰็ผ€็›ฎๅฝ•๏ผˆๅฏ้€‰๏ผ‰
47
+ ้ป˜่ฎค "danbooru_counter"
48
+ ๆœ€็ปˆ่ทฏๅพ„: {OSS_COUNTER_DIR}/count.json
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ import os
54
+ import time
55
+ from dataclasses import dataclass
56
+ from pathlib import Path
57
+ import oss2
58
+ from typing import Literal, Optional
59
+
60
+ # ๅนณๅฐๆฃ€ๆต‹
61
+
62
+ def _detect_platform() -> Literal['hf', 'ms', 'local']:
63
+ if os.environ.get('SPACE_ID'):
64
+ return 'hf'
65
+ if os.environ.get('MODELSCOPE_ENVIRONMENT') or os.environ.get('STUDIO_ID'):
66
+ return 'ms'
67
+ return 'local'
68
+
69
+
70
+ PLATFORM: Literal['hf', 'ms', 'local'] = _detect_platform()
71
+
72
+
73
+ def is_cloud() -> bool:
74
+ """ๆ˜ฏๅฆ่ฟ่กŒๅœจไปปๆ„ไบ‘็ซฏๅนณๅฐใ€‚"""
75
+ return PLATFORM in ('hf', 'ms')
76
+
77
+
78
+ def get_host_port() -> tuple[str, int]:
79
+ """
80
+ ่ฟ”ๅ›ž NiceGUI ๅบ”ไฝฟ็”จ็š„ (host, port)ใ€‚
81
+ HF ๅ’Œ้ญ”ๆญๅˆ›็ฉบ้—ด้ƒฝไฝฟ็”จ 0.0.0.0:7860๏ผ›ๆœฌๅœฐไฝฟ็”จ 127.0.0.1:1111ใ€‚
82
+ """
83
+ if is_cloud():
84
+ return '0.0.0.0', 7860
85
+ return '127.0.0.1', 11111
86
+
87
+
88
+ def nsfw_allowed() -> bool:
89
+ """
90
+ ่ฟ”ๅ›žๅฝ“ๅ‰ๅนณๅฐๆ˜ฏๅฆๅ…่ฎธ็”จๆˆทๅผ€ๅฏ NSFW ๆ˜พ็คบใ€‚
91
+ ้ญ”ๆญ๏ผˆMS๏ผ‰ๅนณๅฐ็ฆ็”จ NSFW๏ผŒๅ…ถไฝ™ๅนณๅฐ้ป˜่ฎคๅ…่ฎธใ€‚
92
+ ๅฆ‚้œ€ๅœจไปปๆ„ๅนณๅฐๅผบๅˆถ็ฆ็”จ๏ผŒๅฏ่ฎพ็ฝฎ็Žฏๅขƒๅ˜้‡ DISABLE_NSFW=1ใ€‚
93
+ """
94
+ if os.environ.get('DISABLE_NSFW', '0') == '1':
95
+ return False
96
+ return PLATFORM != 'ms'
97
+
98
+
99
+ # ้˜ฟ้‡Œไบ‘ OSS
100
+
101
+ def _get_oss_bucket():
102
+ """
103
+ ไปŽ็Žฏๅขƒๅ˜้‡่ฏปๅ– OSS ้…็ฝฎ๏ผŒ่ฟ”ๅ›ž oss2.Bucket ๅฏน่ฑกใ€‚
104
+ ่‹ฅ็Žฏๅขƒๅ˜้‡ไธๅฎŒๆ•ดๆˆ– oss2 ๆœชๅฎ‰่ฃ…ๅˆ™่ฟ”ๅ›ž Noneใ€‚
105
+ """
106
+ ak = os.environ.get('OSS_ACCESS_KEY_ID')
107
+ sk = os.environ.get('OSS_ACCESS_KEY_SECRET')
108
+ ep = os.environ.get('OSS_ENDPOINT')
109
+ bkt = os.environ.get('OSS_BUCKET_NAME')
110
+ if not all([ak, sk, ep, bkt]):
111
+ return None
112
+ try:
113
+ import oss2
114
+ auth = oss2.Auth(ak, sk)
115
+ endpoint = ep if ep.startswith('http') else f'https://{ep}'
116
+ return oss2.Bucket(auth, endpoint, bkt)
117
+ except ImportError:
118
+ print('[PlatformUtils] oss2 ๆœชๅฎ‰่ฃ…๏ผŒOSS ่ฎกๆ•ฐๅ™จไธๅฏ็”จใ€‚่ฏท pip install oss2ใ€‚')
119
+ return None
120
+
121
+
122
+ def _oss_key(filename: str) -> str:
123
+ """ๅฐ† filename ๆ‹ผไธŠๅฏ้€‰็š„ๅ‰็ผ€็›ฎๅฝ•๏ผŒๅพ—ๅˆฐ OSS Object Keyใ€‚"""
124
+ prefix = os.environ.get('OSS_COUNTER_DIR', 'danbooru_counter').rstrip('/')
125
+ return f'{prefix}/{filename}'
126
+
127
+
128
+ def _oss_available() -> bool:
129
+ """ๆฃ€ๆต‹ OSS ๅ››้กน็Žฏๅขƒๅ˜้‡ๆ˜ฏๅฆๅ‡ๅทฒ่ฎพ็ฝฎไธ” oss2 ๅฏๅฏผๅ…ฅใ€‚"""
130
+ return _get_oss_bucket() is not None
131
+
132
+
133
+ # ่ฎกๆ•ฐๅ™จ้…็ฝฎ
134
+
135
+ @dataclass
136
+ class CounterConfig:
137
+ platform: Literal['oss', 'local']
138
+
139
+ @property
140
+ def available(self) -> bool:
141
+ if self.platform == 'oss':
142
+ return _oss_available()
143
+ return False
144
+
145
+
146
+ def get_counter_cfg() -> CounterConfig:
147
+ """
148
+ ่ฏปๅ–่ฎกๆ•ฐๅ™จ้…็ฝฎใ€‚
149
+ ้…็ฝฎไบ† OSS ็Žฏๅขƒๅ˜้‡ๅˆ™ไฝฟ็”จ OSS๏ผŒๅฆๅˆ™้€€ๅŒ–ไธบๆœฌๅœฐๆจกๅผ๏ผˆๆ— ๆŒไน…ๅŒ–๏ผ‰ใ€‚
150
+ """
151
+ if _oss_available():
152
+ return CounterConfig(platform='oss')
153
+ return CounterConfig(platform='local')
154
+
155
+
156
+ # ่ฎกๆ•ฐๅ™จ่ฏปๅ†™๏ผˆOSS๏ผ‰
157
+
158
+ def read_bytes(filename: str, cfg: CounterConfig) -> Optional[bytes]:
159
+ """
160
+ ไปŽ OSS ่ฏปๅ–ๆ–‡ไปถๅ†…ๅฎน๏ผŒ่ฟ”ๅ›ž bytesใ€‚
161
+ ๆ–‡ไปถไธๅญ˜ๅœจ่ฟ”ๅ›ž None๏ผ›็ฝ‘็ปœๆˆ–ๆƒ้™ๅผ‚ๅธธๅ‘ไธŠๆŠ›ๅ‡บใ€‚
162
+ """
163
+ if not cfg.available:
164
+ return None
165
+
166
+ bucket = _get_oss_bucket()
167
+ key = _oss_key(filename)
168
+ try:
169
+ import oss2
170
+ result = bucket.get_object(key)
171
+ return result.read()
172
+ except oss2.exceptions.NoSuchKey:
173
+ return None
174
+ except Exception as e:
175
+ print(f'[PlatformUtils] OSS ่ฏปๅ–ๅคฑ่ดฅ ({key}): {e}')
176
+ raise
177
+
178
+
179
+ def upload_bytes(
180
+ content: bytes,
181
+ filename: str,
182
+ cfg: CounterConfig,
183
+ commit_message: str = 'Update',
184
+ *,
185
+ retries: int = 3,
186
+ retry_delay: float = 1.0,
187
+ ) -> bool:
188
+ """
189
+ ๅฐ† bytes ๅ†™ๅ…ฅ OSS ็š„ filename ่ทฏๅพ„ใ€‚
190
+ ่ฟ”ๅ›ž True ่กจ็คบๆˆๅŠŸ๏ผŒFalse ่กจ็คบๅ…จ้ƒจ้‡่ฏ•ๅ‡ๅคฑ่ดฅใ€‚
191
+ commit_message ๅ‚ๆ•ฐไฟ็•™ไปฅๅ…ผๅฎน counter.py ็š„่ฐƒ็”จ็ญพๅ๏ผŒOSS ไธไฝฟ็”จใ€‚
192
+ """
193
+ if not cfg.available:
194
+ return False
195
+
196
+ bucket = _get_oss_bucket()
197
+ key = _oss_key(filename)
198
+
199
+ for attempt in range(retries):
200
+ try:
201
+ bucket.put_object(key, content)
202
+ return True
203
+ except Exception as e:
204
+ print(f'[PlatformUtils] OSS ไธŠไผ ๅคฑ่ดฅ๏ผˆ็ฌฌ {attempt + 1} ๆฌก๏ผ‰({key}): {e}')
205
+ if attempt < retries - 1:
206
+ time.sleep(retry_delay)
207
+
208
+ return False
209
+
210
+
211
+ # ๆ–‡ไปถไธ‹่ฝฝ๏ผˆๅผ•ๆ“Žๆ•ฐๆฎๆ–‡ไปถ๏ผŒไธŽ่ฎกๆ•ฐๅ™จๆ— ๅ…ณ๏ผ‰
212
+
213
+ # ้ญ”ๆญๅˆ›็ฉบ้—ดๅทฅไฝœ็›ฎๅฝ•๏ผŒstudio repo ็š„ๆ–‡ไปถไผš่ขซๅŒๆญฅๅˆฐๆญคๅค„
214
+ _MS_WORKDIR = Path('/home/user/app')
215
+
216
+
217
+ def download_file(
218
+ filename: str,
219
+ *,
220
+ # HF ไธ“็”จๅ‚ๆ•ฐ
221
+ hf_repo_id: Optional[str] = None,
222
+ hf_repo_type: str = 'space',
223
+ hf_token: Optional[str] = None,
224
+ # MS ไธ“็”จๅ‚ๆ•ฐ๏ผˆไฟ็•™็ญพๅๅ…ผๅฎนๆ€ง๏ผŒ้ญ”ๆญๅนณๅฐๅทฒไธๅ†ไฝฟ็”จ๏ผ‰
225
+ ms_repo_id: Optional[str] = None,
226
+ ms_token: Optional[str] = None,
227
+ ms_cache_dir: str = '/tmp/ms_cache',
228
+ ) -> str:
229
+ """
230
+ ไธ‹่ฝฝๅ•ไธชๅผ•ๆ“Žๆ•ฐๆฎๆ–‡ไปถ๏ผŒ่ฟ”ๅ›žๆœฌๅœฐ็ปๅฏน่ทฏๅพ„ๅญ—็ฌฆไธฒใ€‚
231
+
232
+ HF ๅนณๅฐ๏ผš
233
+ ไปŽ Space repo ไธ‹่ฝฝ๏ผŒhf_repo_id ้ป˜่ฎค่ฏปๅ–็Žฏๅขƒๅ˜้‡ SPACE_IDใ€‚
234
+
235
+ MS ๅนณๅฐ๏ผš
236
+ ๆ–‡ไปถๅทฒ้š studio repo ้ƒจ็ฝฒๅˆฐๅฎนๅ™จๆœฌๅœฐ๏ผŒ็›ดๆŽฅ่ฟ”ๅ›žๅทฅไฝœ็›ฎๅฝ•ไธ‹็š„่ทฏๅพ„๏ผŒ
237
+ ๆ— ้œ€้…็ฝฎไปปไฝ•้ขๅค– repoใ€‚่‹ฅๆ–‡ไปถไธๅญ˜ๅœจๅˆ™ๆŠ›ๅ‡บ FileNotFoundErrorใ€‚
238
+
239
+ ๆœฌๅœฐ๏ผš
240
+ ็›ดๆŽฅ่ฟ”ๅ›žๅŽŸๅง‹่ทฏๅพ„ใ€‚
241
+ """
242
+ if PLATFORM == 'hf':
243
+ from huggingface_hub import hf_hub_download
244
+ repo_id = hf_repo_id or os.environ.get('SPACE_ID')
245
+ if not repo_id:
246
+ raise RuntimeError('[PlatformUtils] HF ๅนณๅฐๆœชๆ‰พๅˆฐ SPACE_ID๏ผŒๆ— ๆณ•ไธ‹่ฝฝๆ–‡ไปถใ€‚')
247
+ return hf_hub_download(
248
+ repo_id=repo_id,
249
+ repo_type=hf_repo_type,
250
+ filename=filename,
251
+ token=hf_token or os.environ.get('HF_TOKEN'),
252
+ )
253
+
254
+ if PLATFORM == 'ms':
255
+ local_path = _MS_WORKDIR / filename
256
+ if not local_path.is_file():
257
+ raise FileNotFoundError(
258
+ f'[PlatformUtils] ้ญ”ๆญๅนณๅฐๆœฌๅœฐๆ–‡ไปถไธๅญ˜ๅœจ: {local_path}\n'
259
+ f'่ฏท็กฎ่ฎคๅทฒๅฐ† {filename} ๆไบคๅˆฐๅˆ›็ฉบ้—ด studio repo ไธญใ€‚'
260
+ )
261
+ print(f'[PlatformUtils] MS ๆœฌๅœฐๆ–‡ไปถ: {local_path}')
262
+ return str(local_path)
263
+
264
+ # ๆœฌๅœฐ็Žฏๅขƒ๏ผš็›ดๆŽฅ่ฟ”ๅ›žๅŽŸๅง‹่ทฏๅพ„๏ผˆ็”ฑ่ฐƒ็”จๆ–นไฟ่ฏๆ–‡ไปถๅญ˜ๅœจ๏ผ‰
265
+ return filename
266
+
267
+
268
+ # ๆจกๅž‹่ทฏๅพ„่งฃๆž
269
+
270
+ LOCAL_MODEL_PATH = 'my_model_bge_m3'
271
+ HF_MODEL_ID = 'BAAI/bge-m3'
272
+ MS_MODEL_ID = 'BAAI/bge-m3' # ้ญ”ๆญไธŠๅŒๅ๏ผŒ่ตฐๅ›ฝๅ†…่Š‚็‚น
273
+
274
+
275
+ def resolve_model_path(prefer_local: Optional[str] = None) -> str:
276
+ """
277
+ ๆŒ‰ไผ˜ๅ…ˆ็บง่งฃๆžๆจกๅž‹่ทฏๅพ„๏ผš
278
+ 1. ๆœฌๅœฐ็›ฎๅฝ•๏ผˆprefer_local ๆˆ– LOCAL_MODEL_PATH๏ผ‰
279
+ 2. ๅฝ“ๅ‰ๅนณๅฐ็š„ Hub Model ID๏ผˆ้ฆ–ๆฌกไผš่‡ชๅŠจไธ‹่ฝฝ็ผ“ๅญ˜๏ผ‰
280
+ ่ฟ”ๅ›žๅฏ็›ดๆŽฅไผ ็ป™ SentenceTransformer ็š„่ทฏๅพ„ๆˆ– model_id ๅญ—็ฌฆไธฒใ€‚
281
+ """
282
+ local = prefer_local or LOCAL_MODEL_PATH
283
+ if os.path.exists(local):
284
+ print(f'[PlatformUtils] ไฝฟ็”จๆœฌๅœฐๆจกๅž‹: {local}')
285
+ return local
286
+
287
+ if PLATFORM == 'ms':
288
+ print(f'[PlatformUtils] ้ญ”ๆญ็Žฏๅขƒ๏ผŒไฝฟ็”จ ModelScope Hub ๆจกๅž‹: {MS_MODEL_ID}')
289
+ try:
290
+ from modelscope import snapshot_download
291
+ cached = snapshot_download(MS_MODEL_ID, cache_dir='/tmp/ms_model')
292
+ print(f'[PlatformUtils] ๆจกๅž‹ๅทฒ็ผ“ๅญ˜่‡ณ: {cached}')
293
+ return cached
294
+ except Exception as e:
295
+ print(f'[PlatformUtils] ModelScope snapshot_download ๅคฑ่ดฅ๏ผŒๅ›ž้€€ๅˆฐ HF ID: {e}')
296
+
297
+ print(f'[PlatformUtils] ไฝฟ็”จ HuggingFace Hub ๆจกๅž‹: {HF_MODEL_ID}')
298
+ return HF_MODEL_ID
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ nicegui
2
+ pandas
3
+ sentence-transformers
4
+ torch
5
+ jieba
6
+ pyarrow
7
+ safetensors
8
+ huggingface_hub
9
+ fastapi
10
+ uvicorn
11
+ modelscope
12
+ oss2
13
+ mcp==1.27.0
tags_embedding/version_data.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "schema_version": 3,
3
+ "updated_at": "2026-05-02 20:19:08"
4
+ }
ui_nicegui.py ADDED
@@ -0,0 +1,1381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ui_nicegui.py
3
+ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
4
+ NiceGUI ๅ‰็ซฏๅฑ‚๏ผˆ้‡ๆž„็‰ˆ๏ผ‰ใ€‚
5
+
6
+ โ–ธ ๅช่ดŸ่ดฃๆธฒๆŸ“ / ไบคไบ’ใ€‚
7
+ โ–ธ ่ฐƒ็”จ core.engine.DanbooruTagger๏ผŒ้€š่ฟ‡ core.models ็š„ๆ•ฐๆฎ็ป“ๆž„้€šไฟกใ€‚
8
+ โ–ธ ไธๅŒ…ๅซไปปไฝ•็ฎ—ๆณ•้€ป่พ‘ใ€‚
9
+ โ–ธ ๅนณๅฐ็›ธๅ…ณ้…็ฝฎ๏ผˆhost/port/ไบ‘็ซฏๅˆคๆ–ญ๏ผ‰็ปŸไธ€็”ฑ platform_utils ๆไพ›ใ€‚
10
+ """
11
+ import sys
12
+ sys.stdout.reconfigure(line_buffering=True)
13
+ print("[UI] ่„šๆœฌๅผ€ๅง‹ๆ‰ง่กŒ", flush=True)
14
+ import asyncio
15
+ import os
16
+ import json as _json
17
+ import subprocess
18
+ import traceback
19
+ from dataclasses import asdict
20
+ from fastapi.responses import PlainTextResponse
21
+
22
+ def _excepthook(exc_type, exc_value, exc_tb):
23
+ print("[UI] FATAL ERROR ON STARTUP:", flush=True)
24
+ traceback.print_exception(exc_type, exc_value, exc_tb)
25
+ sys.__excepthook__(exc_type, exc_value, exc_tb)
26
+
27
+ sys.excepthook = _excepthook
28
+
29
+ from nicegui import ui, app, run
30
+ from core import counter
31
+ from api_fastapi import app as api_app
32
+ from core.engine import DanbooruTagger
33
+ from core.models import RelatedTag, SearchRequest
34
+ from platform_utils import is_cloud, get_host_port, nsfw_allowed
35
+ from mcp_server import mcp
36
+
37
+ import logging
38
+ logging.getLogger("httpx").setLevel(logging.WARNING)
39
+ logging.getLogger("huggingface_hub").setLevel(logging.WARNING)
40
+ logging.getLogger("mcp").setLevel(logging.WARNING)
41
+ logging.getLogger("mcp.server").setLevel(logging.WARNING)
42
+ logging.getLogger("fastmcp").setLevel(logging.WARNING)
43
+ # suppress MCP streamable-HTTP transport noise ("No response returned" from Starlette middleware)
44
+ class _SuppressMCPNoise(logging.Filter):
45
+ _MARKER = "No response returned"
46
+
47
+ def filter(self, record: logging.LogRecord) -> bool:
48
+ if self._MARKER in record.getMessage():
49
+ return False
50
+ if record.exc_info:
51
+ import traceback
52
+ tb_text = "".join(traceback.format_exception(*record.exc_info))
53
+ if self._MARKER in tb_text:
54
+ return False
55
+ return True
56
+
57
+ logging.getLogger("uvicorn.error").addFilter(_SuppressMCPNoise())
58
+
59
+ # โ”€โ”€ ่กจๆ ผๅˆ—ๅฎšไน‰ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
60
+
61
+ TABLE_COLUMNS = [
62
+ {'name': 'tag', 'label': 'ๅŒน้…ๆ ‡็ญพ', 'field': 'tag', 'align': 'left', 'sortable': True},
63
+ {'name': 'cn_name', 'label': 'ๅซไน‰', 'field': 'cn_name', 'align': 'left'},
64
+ {'name': 'nsfw', 'label': 'ๅˆ†็บง', 'field': 'nsfw', 'align': 'center', 'sortable': True},
65
+ {'name': 'final_score', 'label': '็ปผๅˆๅˆ†', 'field': 'final_score', 'sortable': True},
66
+ {'name': 'count', 'label': '็ƒญๅบฆ', 'field': 'count', 'sortable': True},
67
+ ]
68
+
69
+ OPTIONAL_COLS = {
70
+ 'semantic': {'name': 'semantic_score', 'label': '่ฏญไน‰ๅˆ†', 'field': 'semantic_score', 'sortable': True},
71
+ 'layer': {'name': 'layer', 'label': 'ๅŒน้…ๅฑ‚', 'field': 'layer'},
72
+ 'source': {'name': 'source', 'label': 'ๅŒน้…ๆฅๆบ', 'field': 'source'},
73
+ }
74
+
75
+ # localStorage key ไธŽ้…็ฝฎ็‰ˆๆœฌ๏ผŒ็‰ˆๆœฌๅ˜ๆ›ดๆ—ถ่‡ชๅŠจไธขๅผƒๆ—ง้…็ฝฎ
76
+ _CONFIG_LS_KEY = 'danbooru_search_config'
77
+ _CONFIG_VERSION = 4
78
+
79
+
80
+
81
+ # โ”€โ”€ ่พ…ๅŠฉๅ‡ฝๆ•ฐ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
82
+
83
+ def _get_git_commit() -> str:
84
+ try:
85
+ return subprocess.check_output(
86
+ ['git', 'rev-parse', '--short', 'HEAD'],
87
+ stderr=subprocess.DEVNULL,
88
+ text=True,
89
+ ).strip()
90
+ except Exception:
91
+ return os.environ.get('COMMIT_SHA', 'unknown')[:7]
92
+
93
+
94
+ def result_to_row(r, nsfw_visible: bool) -> dict:
95
+ d = asdict(r)
96
+ d['_nsfw_blocked'] = (r.nsfw == '1') and not nsfw_visible
97
+ return d
98
+
99
+
100
+ def apply_nsfw_filter(rows: list[dict], show_nsfw: bool) -> list[dict]:
101
+ result = []
102
+ for row in rows:
103
+ r = dict(row)
104
+ r['_nsfw_blocked'] = (r.get('nsfw') == '1') and not show_nsfw
105
+ result.append(r)
106
+ return result
107
+
108
+
109
+ def _format_tag_with_weight(tag: str, weight: float, fmt: str = 'sdxl') -> str:
110
+ """ๆ ผๅผๅŒ–ๅ•ไธชๆ ‡็ญพใ€‚
111
+ sdxl: (tag:1.2) ๆƒ้‡ 1.0 ๆ—ถ่พ“ๅ‡บ tag
112
+ nai: 1.2::tag:: ๆƒ้‡ 1.0 ๆ—ถ่พ“ๅ‡บ tag
113
+ """
114
+ if weight == 1.0:
115
+ return tag
116
+ if fmt == 'nai':
117
+ return f'{weight:.1f}::{tag}::'
118
+ return f'({tag}:{weight:.1f})'
119
+
120
+
121
+ # โ”€โ”€ UI ็ฑป โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
122
+
123
+ class DanbooruSearchUI:
124
+ def __init__(self):
125
+ self.search_count_label = None
126
+ self.current_search_interacted = True
127
+
128
+ self.full_table_data: list[dict] = []
129
+ self.current_segments: list[str] = [] # ไปŽๅฅ็บงๅŽŸๅง‹็‰‡ๆฎต๏ผŒ็”จไบŽๅŒบๅˆ† chip ้ขœ่‰ฒ
130
+ self.current_query_str: str = ""
131
+ self.full_tags_str: str = ""
132
+ self.full_tags_str_sfw: str = ""
133
+
134
+ self.result_table = None # ๅทฆๆ ่กจๆ ผ
135
+ self.related_list_container = None # ๅณๆ ๅ…ณ่”ๆŽจ่ๅˆ—่กจ
136
+ self.results_section = None # ๆ•ดไธช็ป“ๆžœๅŒบๅŸŸ๏ผˆๆœ็ดขๅ‰้š่—๏ผ‰
137
+ self.selection_count_label = None
138
+ self.selected_display = None # ๅทฒๅบŸๅผƒ textarea๏ผŒไฟ็•™ๅ…ผๅฎน
139
+ self.selected_chips_container = None # ๅทฒ้€‰ๆ ‡็ญพ chip ๅฎนๅ™จ
140
+ self.current_related: list = []
141
+ self.chip_extra_selected: set = set()
142
+
143
+ # tag -> prompt ๆƒ้‡๏ผŒ่Œƒๅ›ด [0.1, 1.9]๏ผŒ้ป˜่ฎค 1.0
144
+ self.tag_weights: dict[str, float] = {}
145
+ # ๅคๅˆถๆ ผๅผ๏ผš'sdxl' ๆˆ– 'nai'
146
+ self.prompt_format: str = 'sdxl'
147
+ self.format_toggle_btn = None
148
+
149
+ self.init_banner = None
150
+ self.input_top_k = None
151
+ self.input_limit = None
152
+ self.input_weight = None
153
+ self.input_nsfw = None
154
+ self.input_segment = None
155
+ self.search_input = None
156
+ self.keywords_container = None
157
+ self.spinner = None
158
+ self.search_btn = None
159
+
160
+ self.selected_layers = {'่‹ฑๆ–‡': True, 'ไธญๆ–‡ๆ‰ฉๅฑ•่ฏ': True, '้‡Šไน‰': True, 'ไธญๆ–‡ๆ ธๅฟƒ่ฏ': True}
161
+ self.selected_cats = {'General': True, 'Copyright': True, 'Character': True}
162
+
163
+ self.bad_case_btn = None
164
+
165
+ self.mcp_notice = None
166
+ self.notice_expansion = None
167
+
168
+ # ่กจๆ ผๆ˜พ็คบ้€‰้กนๅผ€ๅ…ณ
169
+ self.sw_semantic = None
170
+ self.sw_layer = None
171
+ self.sw_source = None
172
+
173
+ # ๅ…ณ่”ๆŽจ่็š„ checkbox ๅผ•็”จ
174
+ self._related_checkboxes: dict[str, ui.checkbox] = {}
175
+
176
+ # ้ซ˜็บง้€‰้กนไธญๅ„ๅฑ‚/็ฑปๅž‹็š„ checkbox ๅผ•็”จ๏ผŒ็”จไบŽ restore ๆ—ถๅŒๆญฅๆŽงไปถ็Šถๆ€
177
+ self._layer_checkboxes: dict[str, ui.checkbox] = {}
178
+ self._cat_checkboxes: dict[str, ui.checkbox] = {}
179
+
180
+ def _update_footer_text(self):
181
+ if self.search_count_label is not None:
182
+ try:
183
+ total = counter.get()
184
+ visits = counter.get_visits()
185
+ commit = _get_git_commit()
186
+ self.search_count_label.content = (
187
+ f'็ดฏ่ฎกๆœ็ดข {total:,} ๆฌก | ็ดฏ่ฎก่ฎฟ้—ฎ {visits:,} ๆฌก | '
188
+ f'<span class="font-mono text-gray-300">็‰ˆๆœฌๅท: {commit}</span>'
189
+ f'<br>'
190
+ f'<a href="/api/docs" '
191
+ f'target="_blank" rel="noopener noreferrer" '
192
+ f'class="text-blue-400 hover:text-blue-600 hover:underline">ไฝฟ็”จ API ๆœๅŠก</a>'
193
+ f' | <a href="https://github.com/SuzumiyaAkizuki/DanbooruSearchOnline#mcp-ๆŽฅๅฃ" '
194
+ f'target="_blank" rel="noopener noreferrer" '
195
+ f'class="text-blue-400 hover:text-blue-600 hover:underline">ไฝฟ็”จ MCP ๆœๅŠก</a>'
196
+ )
197
+ except AttributeError:
198
+ pass
199
+
200
+ def _mark_interaction(self, e=None):
201
+ if not self.current_search_interacted:
202
+ self.current_search_interacted = True
203
+
204
+ async def silent_success_update():
205
+ try:
206
+ await counter.increment_success()
207
+ except Exception:
208
+ pass
209
+ asyncio.create_task(silent_success_update())
210
+
211
+ # โ”€โ”€ ๅˆ†้กต่พ…ๅŠฉ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
212
+
213
+ def _get_rows_per_page(self) -> int:
214
+ if self.result_table is None:
215
+ return 0
216
+ p = self.result_table.pagination
217
+ # pagination ๅฏ่ƒฝๆ˜ฏ int ๆˆ– dict
218
+ if isinstance(p, dict):
219
+ return int(p.get('rowsPerPage', 0))
220
+ return int(p) if p else 0
221
+
222
+ def _set_rows_per_page(self, value: int):
223
+ if self.result_table is None:
224
+ return
225
+ allowed = {5, 7, 10, 15, 20, 25, 50, 0} # 0 = All
226
+ value = value if value in allowed else 0
227
+ p = self.result_table.pagination
228
+ if isinstance(p, dict):
229
+ p['rowsPerPage'] = value
230
+ self.result_table.pagination = p
231
+ else:
232
+ self.result_table.pagination = value
233
+
234
+ # โ”€โ”€ ้…็ฝฎๆŒไน…ๅŒ– โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
235
+
236
+ def _save_config(self):
237
+ """ๅฐ†ๅฝ“ๅ‰ๆŽงไปถ็Šถๆ€ๅบๅˆ—ๅŒ–ๅนถๅ†™ๅ…ฅ localStorageใ€‚"""
238
+ cfg = {
239
+ 'version': _CONFIG_VERSION,
240
+ 'top_k': int(self.input_top_k.value) if self.input_top_k else 10,
241
+ 'limit': int(self.input_limit.value) if self.input_limit else 80,
242
+ 'popularity_weight': float(self.input_weight.value) if self.input_weight else 0.15,
243
+ 'show_nsfw': bool(self.input_nsfw.value) if self.input_nsfw else False,
244
+ 'use_segmentation': bool(self.input_segment.value) if self.input_segment else True,
245
+ 'selected_layers': dict(self.selected_layers),
246
+ 'selected_cats': dict(self.selected_cats),
247
+ 'sw_semantic': bool(self.sw_semantic.value) if self.sw_semantic else False,
248
+ 'sw_layer': bool(self.sw_layer.value) if self.sw_layer else False,
249
+ 'sw_source': bool(self.sw_source.value) if self.sw_source else False,
250
+ 'prompt_format': self.prompt_format,
251
+ 'rows_per_page': self._get_rows_per_page(),
252
+ 'search_query': self.search_input.value if self.search_input else '',
253
+ 'notice_expanded': bool(self.notice_expansion.value) if self.notice_expansion else True,
254
+ 'mcp_notice_dismissed': not bool(self.mcp_notice.visible) if self.mcp_notice else False,
255
+ }
256
+ js = _json.dumps(cfg, ensure_ascii=False)
257
+ ui.run_javascript(f"localStorage.setItem('{_CONFIG_LS_KEY}', {_json.dumps(js)});")
258
+
259
+ async def _restore_config(self):
260
+ """ไปŽ localStorage ่ฏปๅ–้…็ฝฎๅนถๆขๅคๆŽงไปถ็Šถๆ€ใ€‚"""
261
+ try:
262
+ raw = await ui.run_javascript(
263
+ f"localStorage.getItem('{_CONFIG_LS_KEY}');",
264
+ timeout=5.0,
265
+ )
266
+ except Exception:
267
+ return
268
+
269
+ if not raw:
270
+ return
271
+
272
+ try:
273
+ cfg = _json.loads(raw)
274
+ except Exception:
275
+ return
276
+
277
+ if cfg.get('version') != _CONFIG_VERSION:
278
+ # ็‰ˆๆœฌไธ็ฌฆ๏ผŒไธขๅผƒๆ—ง้…็ฝฎ
279
+ ui.run_javascript(f"localStorage.removeItem('{_CONFIG_LS_KEY}');")
280
+ return
281
+
282
+ if self.input_top_k and 'top_k' in cfg:
283
+ self.input_top_k.set_value(cfg['top_k'])
284
+ if self.input_limit and 'limit' in cfg:
285
+ self.input_limit.set_value(cfg['limit'])
286
+ if self.input_weight and 'popularity_weight' in cfg:
287
+ self.input_weight.set_value(cfg['popularity_weight'])
288
+ if self.input_segment and 'use_segmentation' in cfg:
289
+ self.input_segment.set_value(cfg['use_segmentation'])
290
+
291
+ # NSFW๏ผšไป…ๅœจๅนณๅฐๅ…่ฎธๆ—ถๆขๅค
292
+ if nsfw_allowed() and self.input_nsfw and 'show_nsfw' in cfg:
293
+ self.input_nsfw.set_value(cfg['show_nsfw'])
294
+
295
+ if 'selected_layers' in cfg:
296
+ for layer, val in cfg['selected_layers'].items():
297
+ if layer in self.selected_layers:
298
+ self.selected_layers[layer] = bool(val)
299
+ if layer in self._layer_checkboxes:
300
+ self._layer_checkboxes[layer].set_value(bool(val))
301
+
302
+ if 'selected_cats' in cfg:
303
+ for cat, val in cfg['selected_cats'].items():
304
+ if cat in self.selected_cats:
305
+ self.selected_cats[cat] = bool(val)
306
+ if cat in self._cat_checkboxes:
307
+ self._cat_checkboxes[cat].set_value(bool(val))
308
+
309
+ if self.sw_semantic and 'sw_semantic' in cfg:
310
+ self.sw_semantic.set_value(cfg['sw_semantic'])
311
+ if self.sw_layer and 'sw_layer' in cfg:
312
+ self.sw_layer.set_value(cfg['sw_layer'])
313
+ if self.sw_source and 'sw_source' in cfg:
314
+ self.sw_source.set_value(cfg['sw_source'])
315
+
316
+ if 'prompt_format' in cfg and cfg['prompt_format'] in ('sdxl', 'nai'):
317
+ self.prompt_format = cfg['prompt_format']
318
+ if self.format_toggle_btn:
319
+ if self.prompt_format == 'nai':
320
+ self.format_toggle_btn.text = 'NAI'
321
+ self.format_toggle_btn.props('color=purple-7')
322
+ else:
323
+ self.format_toggle_btn.text = 'SDXL'
324
+ self.format_toggle_btn.props('color=grey-7')
325
+
326
+ if 'rows_per_page' in cfg:
327
+ self._set_rows_per_page(cfg['rows_per_page'])
328
+
329
+ if self.search_input and cfg.get('search_query'):
330
+ self.search_input.set_value(cfg['search_query'])
331
+
332
+ if self.notice_expansion and 'notice_expanded' in cfg:
333
+ self.notice_expansion.set_value(cfg['notice_expanded'])
334
+
335
+ if self.mcp_notice and cfg.get('mcp_notice_dismissed'):
336
+ self.mcp_notice.set_visibility(False)
337
+
338
+ # ่‹ฅ้ซ˜็บง้€‰้กนๅˆ—ๆœ‰ๅ˜ๆ›ด๏ผŒๅŒๆญฅๆ›ดๆ–ฐ่กจๆ ผๅˆ—
339
+ self._update_table_columns()
340
+
341
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
342
+ # ้กต้ขๆž„ๅปบ
343
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
344
+
345
+ def build_page(self):
346
+ ui.colors(primary='#4A90E2', secondary='#5E6C84', accent='#FF6B6B')
347
+ ui.add_head_html('''
348
+ <meta name="description" content="ๅŸบไบŽ่ฏญไน‰ๅŒน้…็š„ Danbooru ๆ ‡็ญพๆœ็ดขๅผ•ๆ“Ž๏ผŒๆ”ฏๆŒไธญ่‹ฑๅŒ่ฏญๆ่ฟฐใ€ๅคš็ปดๅŒน้…ใ€ๆ™บ่ƒฝๅˆ†่ฏไธŽๅ…ฑ็Žฐๅ…ณ่”ๆŽจ่ใ€‚">
349
+ <meta name="keywords" content="Danbooru, AI็ป˜็”ป, Stable Diffusion, ๆ็คบ่ฏ, ๆ ‡็ญพๆœ็ดข, RAG, Prompt, NovelAI">
350
+ <meta name="google-site-verification" content="cx4sl9Mb172GUFL556JFwKCP-pT3naQcmlMriy5B8ls" />
351
+
352
+ <style>
353
+ .nsfw-blur-cell { filter: blur(8px); opacity: 0.5; transition: all 0.3s ease;
354
+ pointer-events: none !important; user-select: none !important; }
355
+ .nsfw-checkbox-disabled { pointer-events: none !important; opacity: 0.3 !important; }
356
+ .nsfw-row-blocked { cursor: not-allowed !important; }
357
+ .related-item { transition: background-color 0.15s ease; }
358
+ .related-item:hover { background-color: rgba(74, 144, 226, 0.04); }
359
+ .tag-link { text-decoration: none; font-family: 'Consolas', 'Monaco', 'Courier New', monospace; }
360
+ .tag-link:hover { text-decoration: underline; }
361
+ .weight-chip { display: inline-flex; align-items: center; gap: 2px;
362
+ border-radius: 16px; padding: 2px 6px 2px 4px;
363
+ background: #e3edf7; border: 1px solid #b3cde8;
364
+ font-size: 12px; margin: 3px; white-space: nowrap; }
365
+ .weight-chip.boosted { background: #fff3e0; border-color: #ffb74d; }
366
+ .weight-chip.reduced { background: #f3e5f5; border-color: #ce93d8; }
367
+ .weight-btn { cursor: pointer; width: 18px; height: 18px; border-radius: 50%;
368
+ display: inline-flex; align-items: center; justify-content: center;
369
+ font-size: 13px; font-weight: bold; line-height: 1;
370
+ border: none; background: rgba(0,0,0,0.08);
371
+ color: #555; transition: background 0.15s; padding: 0; }
372
+ .weight-btn:hover { background: rgba(0,0,0,0.18); }
373
+ .weight-label { font-family: Consolas, Monaco, monospace; font-size: 11px;
374
+ color: #888; min-width: 28px; text-align: center; }
375
+
376
+ /* ๅผบๅˆถๅŒๆ ๅนถๆŽ’ */
377
+ .two-col-layout {
378
+ display: flex !important;
379
+ flex-wrap: nowrap !important;
380
+ align-items: flex-start !important;
381
+ gap: 16px !important;
382
+ }
383
+ .two-col-layout > .col-left {
384
+ flex: 0 0 62% !important;
385
+ min-width: 0 !important;
386
+ max-width: 62% !important;
387
+ overflow: hidden;
388
+ }
389
+ .two-col-layout > .col-right {
390
+ flex: 0 0 36% !important;
391
+ min-width: 0 !important;
392
+ max-width: 36% !important;
393
+ overflow: hidden;
394
+ }
395
+
396
+ /* ็ช„ๅฑๅ›ž้€€ไธบไธŠไธ‹ๆŽ’ๅˆ— */
397
+ @media (max-width: 900px) {
398
+ .two-col-layout {
399
+ flex-wrap: wrap !important;
400
+ }
401
+ .two-col-layout > .col-left,
402
+ .two-col-layout > .col-right {
403
+ flex: 1 1 100% !important;
404
+ max-width: 100% !important;
405
+ }
406
+ }
407
+ </style>
408
+ <script async src="https://www.googletagmanager.com/gtag/js?id=G-QPB7EEPR5G"></script>
409
+ <script>
410
+ window.dataLayer = window.dataLayer || [];
411
+ function gtag(){dataLayer.push(arguments);}
412
+ gtag('js', new Date());
413
+ gtag('config', 'G-QPB7EEPR5G');
414
+ </script>
415
+ <script>
416
+ document.addEventListener('DOMContentLoaded', function() {
417
+ function openExternal(root) {
418
+ root.querySelectorAll('a[href^="http"]').forEach(function(a) {
419
+ a.setAttribute('target', '_blank');
420
+ a.setAttribute('rel', 'noopener noreferrer');
421
+ });
422
+ }
423
+ openExternal(document);
424
+ new MutationObserver(function(mutations) {
425
+ mutations.forEach(function(m) {
426
+ m.addedNodes.forEach(function(node) {
427
+ if (node.querySelectorAll) openExternal(node);
428
+ });
429
+ });
430
+ }).observe(document.body, { childList: true, subtree: true });
431
+ });
432
+ </script>
433
+ ''')
434
+
435
+ with ui.column().classes('w-full max-w-7xl mx-auto p-4 gap-4'):
436
+
437
+ # โ”€โ”€ ๅˆๅง‹ๅŒ–ๆ็คบ โ”€โ”€
438
+ self.init_banner = ui.card().classes(
439
+ 'w-full bg-blue-50 border-l-4 border-blue-400'
440
+ )
441
+ with self.init_banner:
442
+ with ui.row().classes('items-center gap-3 p-2'):
443
+ ui.spinner(size='sm')
444
+ ui.label('ๅผ•ๆ“Žๅˆๅง‹ๅŒ–ไธญ๏ผŒ่ฏท็จๅ€™โ€ฆ้ฆ–ๆฌกๅŠ ่ฝฝ็บฆ้œ€ 15 ็ง’').classes('text-sm text-blue-700')
445
+ self.init_banner.set_visibility(not DanbooruTagger.is_ready())
446
+ if not DanbooruTagger.is_ready():
447
+ asyncio.ensure_future(self._hide_banner_when_ready())
448
+
449
+ # โ”€โ”€ 1. MCP ไธŠ็บฟ้€š็Ÿฅ โ”€โ”€
450
+ self._build_mcp_notice()
451
+
452
+ # โ”€โ”€ 2. ๆณจๆ„ไบ‹้กน โ”€โ”€
453
+ self._build_notice()
454
+
455
+ # โ”€โ”€ 2. ๆœ็ดขๅก็‰‡ โ”€โ”€
456
+ self._build_search_card()
457
+
458
+ # โ”€โ”€ 3~5. ็ป“ๆžœๅŒบๅŸŸ๏ผˆๆœ็ดขๅ‰้š่—๏ผ‰โ”€โ”€
459
+ self.results_section = ui.column().classes('w-full gap-4')
460
+ self.results_section.set_visibility(False)
461
+
462
+ with self.results_section:
463
+ # โ”€โ”€ 3. ๅทฒ้€‰ๆ ‡็ญพๆ  โ”€โ”€
464
+ self._build_selection_bar()
465
+
466
+ # โ”€โ”€ 4. ๅˆ†่ฏ็ญ›้€‰ chips โ”€โ”€
467
+ self.keywords_container = ui.row().classes('gap-2 items-center flex-wrap')
468
+
469
+ # โ”€โ”€ 5. ไธคๆ ็ป“ๆžœ โ”€โ”€
470
+ self._build_results_columns()
471
+
472
+ # โ”€โ”€ 6. ๅบ•้ƒจ โ”€โ”€
473
+ with ui.element('div').classes('w-full text-center py-4 mt-2'):
474
+ self.search_count_label = ui.html('ๆญฃๅœจๅŠ ่ฝฝๆ•ฐๆฎ...').classes('text-xs text-gray-400')
475
+ self._update_footer_text()
476
+
477
+ # โ”€โ”€ MCP ไธŠ็บฟ้€š็Ÿฅ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
478
+
479
+ def _build_mcp_notice(self):
480
+ self.mcp_notice = ui.card().classes(
481
+ 'w-full bg-green-50 border-l-4 border-green-500 p-0 overflow-hidden'
482
+ )
483
+ with self.mcp_notice:
484
+ with ui.column().classes('px-4 py-3 w-full gap-1'):
485
+ with ui.row().classes('items-center justify-between w-full'):
486
+ ui.label('๐ŸŽ‰ MCP ๆœๅŠกๆญฃๅผไธŠ็บฟ๏ผ').classes('text-base font-bold text-green-800')
487
+ ui.button(icon='close').props('flat dense round color=grey-6') \
488
+ .on_click(self._dismiss_mcp_notice)
489
+ ui.html(
490
+ 'ๆœฌ็ซ™็Žฐๅทฒๆ”ฏๆŒ้€š่ฟ‡ MCP ๅ่ฎฎๆŽฅๅ…ฅ AI Agent๏ผˆๅฆ‚ Claude Desktop๏ผ‰ใ€‚'
491
+ 'ๅฆ‚้œ€ไฝ“้ชŒ<b>ๆ‰˜็ฎก็‰ˆ</b>๏ผˆๅ…้…็ฝฎใ€ๅผ€็ฎฑๅณ็”จ๏ผ‰๏ผŒ่ฏท่ฎฟ้—ฎ '
492
+ '<a href="https://huggingface.co/spaces/SAkizuki/WenQiuYue" '
493
+ 'target="_blank" rel="noopener noreferrer" '
494
+ 'class="text-green-700 font-bold underline">้—ฎ็ง‹ๆœˆ Space</a>๏ผŒ'
495
+ '่ฟ›ๅ…ฅๅŽ็‚นๅ‡ปใ€Œๆœๆ ‡็ญพใ€ๅณๅฏ่ฐƒ็”จใ€‚'
496
+ '<span class="text-gray-500 ml-1">'
497
+ 'API ้ขๅบฆๆœ‰้™๏ผˆ็บฆ 30 ๅ…ƒ๏ผ‰๏ผŒ็”จๅฎŒๅณๆญข๏ผŒไป…ไพ›ไฝ“้ชŒใ€‚'
498
+ '</span>'
499
+ '&nbsp;&nbsp;'
500
+ '<a href="https://github.com/SuzumiyaAkizuki/DanbooruSearchOnline#mcp-ๆŽฅๅฃ" '
501
+ 'target="_blank" rel="noopener noreferrer" '
502
+ 'class="text-green-700 underline">ๆŸฅ็œ‹ๆŽฅๅ…ฅๆ–‡ๆกฃ โ†’</a>'
503
+ ).classes('text-sm text-green-900')
504
+
505
+ def _dismiss_mcp_notice(self):
506
+ if self.mcp_notice:
507
+ self.mcp_notice.set_visibility(False)
508
+ self._save_config()
509
+
510
+ # โ”€โ”€ ๆณจๆ„ไบ‹้กน โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
511
+
512
+ def _build_notice(self):
513
+ with ui.card().classes('w-full bg-orange-50 border-l-4 border-orange-500 p-0 overflow-hidden'):
514
+ with ui.expansion(value=True).classes('w-full') as notice_expansion:
515
+ self.notice_expansion = notice_expansion
516
+ notice_expansion.on('update:model-value', lambda _: self._save_config())
517
+ notice_expansion.add_slot('header', '''
518
+ <div class="flex items-center gap-2 px-4 py-2 w-full flex-wrap">
519
+ <span class="text-base font-bold text-orange-800">โš ๏ธ ๆณจๆ„ไบ‹้กน / Note</span>
520
+ <span v-if="!props.expanded" class="text-sm text-gray-600 ml-1">
521
+ ๅฆ‚ๆžœ่ง‰ๅพ—ๅฅฝ็”จ๏ผŒ่ฏท็‚นๅ‡ป้กถ้ƒจ็ป™ๆœฌ Space ็‚นไธช
522
+ <strong>Like โค๏ธ</strong>๏ผŒๆˆ–ๅ‰ๅพ€ GitHub ็‚นไธช <strong>Star โญ</strong>๏ผ
523
+ </span>
524
+ </div>
525
+ ''')
526
+ ui.markdown("""
527
+ - **AI ่พ…ๅŠฉ**๏ผšๅŸบไบŽ่ฏญไน‰ๅŒน้…๏ผŒ็ป“ๆžœๆœชๅฟ…็ปๅฏนๅ‡†็กฎ(Results may contain errors)
528
+ - **ๅ†…ๅฎน่ญฆๅ‘Š**๏ผšๆŸฅๆ‰พ็ป“ๆžœๅฏ่ƒฝๅŒ…ๅซ NSFW ๅ†…ๅฎน (May include NSFW content)
529
+ - **ๆฃ€็ดข้™ๅˆถ**๏ผšไป…ๆ”ฏๆŒไธญ/่‹ฑๅŒ่ฏญๆŸฅๆ‰พ ๏ผŒๆ›ดๆŽจ่ไธญๆ–‡(CN/EN only,CN is preferred)
530
+ - **ๆ ‡็ญพ่Œƒๅ›ด**๏ผšไป…ๆ˜พ็คบ็‰นๅพใ€่ง’่‰ฒไธŽไฝœๅ“ๆ ‡็ญพ๏ผŒไธ”้ข‘ๆ•ฐ้กป โ‰ฅ 100 (General, Character & Copyright only, Freq โ‰ฅ 100)
531
+ - **้›†ๆˆไธŽๆŽฅๅฃ**๏ฟฝ๏ฟฝ๏ฟฝ[ComfyUI ๆ’ไปถ](https://github.com/SuzumiyaAkizuki/ComfyUI-DanbooruSearcher) ยท [API ๆ–‡ๆกฃ](/api/docs) ยท [MCP ๆŽฅๅ…ฅ](https://github.com/SuzumiyaAkizuki/DanbooruSearchOnline#mcp-ๆŽฅๅฃ)
532
+ - **ๆ”ฏๆŒไฝœ่€…**๏ผšๅฆ‚ๆžœ่ง‰ๅพ—ๅฅฝ็”จ๏ผŒๆฌข่ฟŽ็‚นๅ‡ป้กถ้ƒจ็ป™ๆœฌ Space ็‚นไธช **Like โค๏ธ**๏ผŒๆˆ–ๅ‰ๅพ€ [GitHub](https://github.com/SuzumiyaAkizuki/DanbooruSearchOnline) ็‚นไธช **Star โญ**๏ผ
533
+ - **๐Ÿš€ ้ฆ–ๆฌกไฝฟ็”จ๏ผŸ[็‚นๅ‡ปๆŸฅ็œ‹ไฝฟ็”จๆŒ‡ๅ—](https://github.com/SuzumiyaAkizuki/DanbooruSearchOnline)**๏ผŒไบ†่งฃไบ”็งๆœ็ดขๆจกๅผไธŽ่ฟ›้˜ถๆŠ€ๅทง
534
+ """).classes('text-sm text-gray-800 px-4 pb-3')
535
+
536
+ # โ”€โ”€ ๆœ็ดขๅก็‰‡ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
537
+
538
+ def _build_search_card(self):
539
+ with ui.card().classes('w-full'):
540
+ with ui.row().classes('items-center gap-2 mb-2'):
541
+ ui.icon('search', size='2em', color='primary')
542
+ ui.label('Danbooru ๆ ‡็ญพๆจก็ณŠๆœ็ดข').classes('text-2xl font-bold text-gray-800')
543
+ ui.label('ๅŸบไบŽ่ฏญไน‰ๅŒน้…็š„ๆ ‡็ญพๆœ็ดขๅผ•ๆ“Ž๏ผŒๆ”ฏๆŒๅคš็ปดๅŒน้…ไธŽๅ…ฑ็Žฐๅ…ณ่”ๆŽจ่ใ€‚').classes(
544
+ 'text-sm text-gray-500 -mt-1 mb-3'
545
+ )
546
+
547
+ with ui.row().classes('w-full gap-3 items-stretch'):
548
+ self.search_input = ui.textarea(
549
+ placeholder='่พ“ๅ…ฅ่‡ช็„ถ่ฏญ่จ€ๆ่ฟฐๆˆ–ๆจก็ณŠๆฆ‚ๅฟต๏ผŒไพ‹ๅฆ‚๏ผšไธ€ไธช็ฉฟ็€็™ฝ่‰ฒๆฐดๆ‰‹ๆœ็š„ๅฐ‘ๅฅณๅœจ้›จไธญๅฅ”่ท‘...'
550
+ ).classes('flex-grow text-base').props('outlined rows=2')
551
+ self.search_input.on('keydown.ctrl.enter', self.perform_search)
552
+
553
+ with ui.column().classes('justify-center'):
554
+ self.search_btn = ui.button(
555
+ '', on_click=self.perform_search, icon='search'
556
+ ).classes('px-6 h-full min-h-16').props('unelevated color=dark')
557
+ with self.search_btn:
558
+ ui.label('ๆœ็ดข').classes('text-sm mt-1')
559
+ self.spinner = ui.spinner(size='2em').classes('hidden')
560
+
561
+ with ui.row().classes('w-full gap-6 items-center mt-3 flex-wrap'):
562
+ with ui.row().classes('items-center gap-2'):
563
+ ui.label('Top K (่ฏญไน‰็›ธๅ…ณ)').classes('text-sm text-gray-600')
564
+ self.input_top_k = ui.number(value=10, min=1, max=200).classes('w-20') \
565
+ .props('outlined dense')
566
+
567
+ with ui.row().classes('items-center gap-2'):
568
+ ui.label('็ป“ๆžœไธŠ้™').classes('text-sm text-gray-600')
569
+ self.input_limit = ui.number(value=80, min=10, max=500).classes('w-20') \
570
+ .props('outlined dense')
571
+
572
+ with ui.row().classes('items-center gap-2'):
573
+ ui.label('็ƒญๅบฆๆƒ้‡').classes('text-sm text-gray-600')
574
+ self.input_weight = ui.slider(min=0.0, max=1.0, value=0.15, step=0.05).classes('w-32')
575
+ ui.label().bind_text_from(self.input_weight, 'value', lambda v: f"{v:.2f}") \
576
+ .classes('text-sm font-mono text-gray-700 w-8')
577
+
578
+ with ui.switch('ๆ˜พ็คบ NSFW(ๆˆไบบ) ๅ†…ๅฎน', value=False).props('color=red') as _nsfw_sw:
579
+ if not nsfw_allowed():
580
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
581
+ ui.label('NSFW ๅ†…ๅฎนๅœจๅฝ“ๅ‰ๅนณๅฐไธๅฏ็”จ').style('font-size:14px;')
582
+ self.input_nsfw = _nsfw_sw
583
+ if not nsfw_allowed():
584
+ self.input_nsfw.disable()
585
+ else:
586
+ self.input_nsfw.on('update:model-value', self.on_nsfw_toggle)
587
+
588
+ with ui.switch('ๆ™บ่ƒฝๅˆ†่ฏ', value=True).props('color=primary') as _seg_sw:
589
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
590
+ ui.label('ๅ…ณ้—ญๅŽ็ณป็ปŸๅฐ†ๅชๅŒน้…ๅฎŒๆ•ดๅฅๅญ๏ผŒ้€‚็”จไบŽ็ฒพๅ‡†ๆœ็ดขๆ•ดๅฅใ€‚').style('font-size:14px;')
591
+ self.input_segment = _seg_sw
592
+
593
+ with ui.expansion('้ซ˜็บง้€‰้กน', icon='tune').classes('w-full mt-2'):
594
+ with ui.column().classes('w-full p-3 gap-4'):
595
+ with ui.row().classes('w-full gap-8 flex-wrap'):
596
+ with ui.column().classes('gap-2'):
597
+ ui.label('ๅŒน้…ๅฑ‚็ญ›้€‰').classes('font-bold text-sm text-gray-700')
598
+ display_map = {
599
+ '่‹ฑๆ–‡': '่‹ฑๆ–‡ๆ ‡็ญพ', 'ไธญๆ–‡ๆ‰ฉๅฑ•่ฏ': 'ไธญๆ–‡ๆ‰ฉๅฑ•่ฏ',
600
+ '้‡Šไน‰': '็ปดๅŸบ้‡Šไน‰', 'ไธญๆ–‡ๆ ธๅฟƒ่ฏ': 'ไธญๆ–‡ๆ ธๅฟƒ่ฏ',
601
+ }
602
+ for layer in ['่‹ฑๆ–‡', 'ไธญๆ–‡ๆ‰ฉๅฑ•่ฏ', '้‡Šไน‰', 'ไธญๆ–‡ๆ ธๅฟƒ่ฏ']:
603
+ cb = ui.checkbox(
604
+ display_map.get(layer, layer), value=True,
605
+ on_change=lambda e, l=layer: self.selected_layers.__setitem__(l, e.value)
606
+ ).props('color=primary dense')
607
+ self._layer_checkboxes[layer] = cb
608
+
609
+ with ui.column().classes('gap-2'):
610
+ ui.label('็ฑปๅž‹็ญ›้€‰').classes('font-bold text-sm text-gray-700')
611
+ color_map = {'General': 'blue', 'Copyright': 'pink', 'Character': 'green'}
612
+ label_map = {
613
+ 'General': '้€š็”จ (General)',
614
+ 'Copyright': 'ไฝœๅ“ (Copyright)',
615
+ 'Character': '่ง’่‰ฒ (Character)',
616
+ }
617
+ for cat in ['General', 'Copyright', 'Character']:
618
+ cb = ui.checkbox(
619
+ label_map[cat], value=True,
620
+ on_change=lambda e, c=cat: self.selected_cats.__setitem__(c, e.value)
621
+ ).props(f'color={color_map[cat]} dense')
622
+ self._cat_checkboxes[cat] = cb
623
+
624
+ with ui.column().classes('gap-2'):
625
+ ui.label('่กจๆ ผๆ˜พ็คบๅˆ—').classes('font-bold text-sm text-gray-700')
626
+ self.sw_semantic = ui.switch('ๆ˜พ็คบ่ฏญไน‰ๅˆ†', value=False)
627
+ self.sw_layer = ui.switch('ๆ˜พ็คบๅŒน้…ๅฑ‚', value=False)
628
+ self.sw_source = ui.switch('ๆ˜พ็คบๅŒน้…ๆฅๆบ', value=False)
629
+ self.sw_semantic.on('update:model-value', self._update_table_columns)
630
+ self.sw_layer.on('update:model-value', self._update_table_columns)
631
+ self.sw_source.on('update:model-value', self._update_table_columns)
632
+
633
+ # โ”€โ”€ ๅทฒ้€‰ๆ ‡็ญพๆ  โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
634
+
635
+ def _build_selection_bar(self):
636
+ with ui.card().classes('w-full bg-blue-50 border border-blue-200'):
637
+ with ui.row().classes('w-full items-center justify-between'):
638
+ with ui.row().classes('items-center gap-2'):
639
+ ui.icon('check_circle', color='primary')
640
+ ui.label('ๅทฒ้€‰ๆ ‡็ญพ').classes('font-bold text-primary')
641
+ self.selection_count_label = ui.label('0').classes(
642
+ 'bg-primary text-white px-2 rounded-full text-sm')
643
+ with ui.icon('info_outline', size='sm', color='grey').classes('cursor-help'):
644
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
645
+ ui.html(
646
+ '็‚นๅ‡ป <b>โˆ’</b> / <b>+</b> ๅฏ่ฐƒๆ•ดๆ ‡็ญพๆƒ้‡๏ผˆๆญฅ้•ฟ 0.1๏ผŒ่Œƒๅ›ด 0.1~1.9๏ผ‰ใ€‚<br>'
647
+ 'ๆƒ้‡ 1.0 ๆ—ถ่พ“ๅ‡บๅŽŸๅง‹ๆ ‡็ญพ๏ผ›ๅ…ถไฝ™่พ“ๅ‡บ <code>(tag:1.2)</code> ๆ ผๅผใ€‚'
648
+ ).style('font-size:14px;line-height:1.6;')
649
+
650
+ with ui.row().classes('items-center gap-2'):
651
+ with ui.button('ๆฒกๆœๅˆฐ๏ผŸ', icon='help_outline').props('dense flat color=grey-6').classes('text-sm') as _bad_btn:
652
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
653
+ ui.html('็‚นๅ‡ปๆญคๅค„ไปฅๅ้ฆˆๅคฑ่ดฅๆกˆไพ‹ใ€‚<br>ๆ‚จ็š„ๆœ็ดข่ฏๅฐ†่ขซๅŒฟๅๆ”ถ้›†็”จไบŽไผ˜ๅŒ–ๅผ•ๆ“Ž๏ผˆไธๅŒ…ๅซไธชไบบ้š็ง๏ผ‰ใ€‚').style('font-size:14px;line-height:1.5;')
654
+ self.bad_case_btn = _bad_btn
655
+ self.bad_case_btn.disable()
656
+ self.bad_case_btn.on_click(self.report_bad_case)
657
+ self.format_toggle_btn = ui.button(
658
+ 'SDXL', icon='swap_horiz'
659
+ ).props('dense flat color=grey-7').classes('text-xs font-mono')
660
+ with self.format_toggle_btn:
661
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
662
+ ui.html(
663
+ 'ๅˆ‡ๆขๅคๅˆถๆ ผๅผ๏ผš<br>'
664
+ '<b>SDXL</b>๏ผš<code>(tag:1.2)</code><br>'
665
+ '<b>NAI</b>๏ผš<code>1.2::tag::</code>'
666
+ ).style('font-size:13px;line-height:1.7;')
667
+ self.format_toggle_btn.on_click(self._toggle_prompt_format)
668
+ copy_btn = ui.button('ๅคๅˆถ้€‰ไธญ', icon='content_copy').props('dense unelevated color=primary')
669
+ copy_btn.on_click(self.copy_selection)
670
+
671
+ # chip ๅฎนๅ™จ๏ผšๆฏไธชๅทฒ้€‰ๆ ‡็ญพๆธฒๆŸ“ไธบไธ€ไธชๅธฆๅŠ ๅ‡ๆŒ‰้’ฎ็š„ chip
672
+ self.selected_chips_container = ui.element('div').classes(
673
+ 'w-full mt-2 min-h-10 p-1 rounded bg-white border border-blue-100 flex flex-wrap'
674
+ )
675
+
676
+ def _render_selected_chips(self):
677
+ """้‡ๆ–ฐๆธฒๆŸ“ๅทฒ้€‰ๆ ‡็ญพ็š„ chip ๅˆ—่กจใ€‚"""
678
+ if self.selected_chips_container is None:
679
+ return
680
+ self.selected_chips_container.clear()
681
+ tags = self._get_selected_tags()
682
+ if not tags:
683
+ with self.selected_chips_container:
684
+ ui.label('ๆš‚ๆ— ๅทฒ้€‰ๆ ‡็ญพ').classes('text-xs text-gray-400 italic p-2 self-center')
685
+ return
686
+ with self.selected_chips_container:
687
+ for tag in tags:
688
+ w = self.tag_weights.get(tag, 1.0)
689
+ extra_cls = 'boosted' if w > 1.0 else ('reduced' if w < 1.0 else '')
690
+ w_str = f'{w:.1f}'
691
+ with ui.element('div').classes(f'weight-chip {extra_cls}'):
692
+ # ๅˆ ้™คๆŒ‰้’ฎ๏ผˆร—๏ผ‰
693
+ with ui.element('button').classes('weight-btn').props(f'title="็งป้™ค {tag}"').on(
694
+ 'click', lambda t=tag: self._remove_selected_tag(t)
695
+ ):
696
+ ui.html('&times;')
697
+ # ๅ‡ๅท
698
+ with ui.element('button').classes('weight-btn').on(
699
+ 'click', lambda t=tag: self._adjust_weight(t, -0.1)
700
+ ):
701
+ ui.html('&minus;')
702
+ # ๆ ‡็ญพๅ
703
+ ui.label(tag).style(
704
+ 'font-family:Consolas,Monaco,monospace;font-size:12px;'
705
+ 'color:#2c5282;max-width:150px;overflow:hidden;'
706
+ 'text-overflow:ellipsis;white-space:nowrap;'
707
+ )
708
+ # ๆƒ้‡ๅ€ผ๏ผˆไป…้ž 1.0 ๆ—ถๆ˜พ็คบ๏ผ‰
709
+ if w != 1.0:
710
+ ui.label(w_str).classes('weight-label').style('color:#e65100;font-weight:bold;')
711
+ # ๅŠ ๅท
712
+ with ui.element('button').classes('weight-btn').on(
713
+ 'click', lambda t=tag: self._adjust_weight(t, +0.1)
714
+ ):
715
+ ui.html('&plus;')
716
+
717
+ def _adjust_weight(self, tag: str, delta: float):
718
+ """่ฐƒๆ•ดๅ•ไธชๆ ‡็ญพๆƒ้‡๏ผŒ้’ณไฝๅˆฐ [0.1, 1.9]ใ€‚"""
719
+ current = self.tag_weights.get(tag, 1.0)
720
+ new_w = round(current + delta, 1)
721
+ if new_w < 0.1:
722
+ ui.notify('ๆƒ้‡่Œƒๅ›ดไธบ 0.1 ~ 1.9๏ผŒๅทฒๅˆฐ่พพๆœ€ๅฐๅ€ผ', type='warning', timeout=2000)
723
+ return
724
+ if new_w > 1.9:
725
+ ui.notify('ๆƒ้‡่Œƒๅ›ดไธบ 0.1 ~ 1.9๏ผŒๅทฒๅˆฐ่พพๆœ€ๅคงๅ€ผ', type='warning', timeout=2000)
726
+ return
727
+ self.tag_weights[tag] = new_w
728
+ self._render_selected_chips()
729
+
730
+ def _remove_selected_tag(self, tag: str):
731
+ """ไปŽๅทฒ้€‰ไธญ็งป้™คๆ ‡็ญพ๏ผˆๅŒๆญฅ่กจๆ ผ้€‰ไธญ็Šถๆ€๏ผ‰ใ€‚"""
732
+ self._mark_interaction()
733
+ current = self._get_selected_tags()
734
+ if tag in current:
735
+ current.remove(tag)
736
+ self.tag_weights.pop(tag, None)
737
+ self._set_selected_tags(current)
738
+
739
+ # โ”€โ”€ ไธคๆ ็ป“ๆžœ๏ผˆCSS ๅผบๅˆถๅนถๆŽ’๏ผ‰โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
740
+
741
+ def _build_results_columns(self):
742
+ with ui.element('div').classes('w-full two-col-layout'):
743
+ # โ”€โ”€ ๅทฆๆ ๏ผš่ฏญไน‰ๅŒน้…็ป“ๆžœ๏ผˆ่กจๆ ผ๏ผ‰โ”€โ”€
744
+ with ui.card().classes('col-left'):
745
+ with ui.row().classes('items-center justify-between mb-2 w-full'):
746
+ ui.label('ๅŒน้…ๆ ‡็ญพ็ป“ๆžœ').classes('font-bold text-lg text-gray-800')
747
+ ui.button('ๅคๅˆถๅ…จ้ƒจๆ ‡็ญพ', icon='content_copy', on_click=self._copy_all_tags) \
748
+ .props('dense flat color=primary').classes('text-sm')
749
+
750
+ self.result_table = ui.table(
751
+ columns=TABLE_COLUMNS,
752
+ rows=[],
753
+ pagination=0,
754
+ selection='multiple',
755
+ row_key='tag',
756
+ ).classes('w-full')
757
+ self.result_table.on('selection', self._update_selection_display)
758
+ self.result_table.on('link_click', self._mark_interaction)
759
+ self.result_table.on('pagination', lambda _: self._save_config())
760
+
761
+ # ่‡ชๅฎšไน‰่กŒๆจกๆฟ๏ผš่กŒ่ƒŒๆ™ฏ่‰ฒๆŒ‰ๅˆ†็ฑป๏ผŒๆ•ด่กŒๆ‚ฌๆตฎๆ˜พ็คบ wiki๏ผˆNSFWๆจก็ณŠ่กŒ้™คๅค–๏ผ‰
762
+ self.result_table.add_slot('body', r'''
763
+ <q-tr :props="props"
764
+ :class="props.row._nsfw_blocked ? 'nsfw-row-blocked' : ''"
765
+ :style="{
766
+ 'background-color':
767
+ props.row.category === 'General' ? 'rgba(59,130,246,0.06)' :
768
+ props.row.category === 'Character' ? 'rgba(34,197,94,0.06)' :
769
+ props.row.category === 'Copyright' ? 'rgba(236,72,153,0.06)' : ''
770
+ }">
771
+ <q-td auto-width>
772
+ <q-checkbox v-model="props.selected"
773
+ :class="props.row._nsfw_blocked ? 'nsfw-checkbox-disabled' : ''"/>
774
+ </q-td>
775
+ <q-td v-for="col in props.cols" :key="col.name" :props="props">
776
+ <template v-if="col.name === 'tag' || col.name === 'cn_name'">
777
+ <div :class="props.row._nsfw_blocked ? 'nsfw-blur-cell' : ''">
778
+ <template v-if="col.name === 'cn_name' && col.value">
779
+ <span style="font-size:14px">
780
+ {{ col.value.split(',')[0] }}
781
+ </span>
782
+ </template>
783
+ <template v-else-if="col.name === 'tag'">
784
+ <a :href="'https://danbooru.donmai.us/wiki_pages/'+col.value"
785
+ target="_blank"
786
+ class="text-primary hover:underline font-bold inline-flex items-center"
787
+ style="text-decoration:none; font-family: Consolas, Monaco, Courier New, monospace;"
788
+ @click.stop="$emit('link_click', col.value)">
789
+ {{ col.value }}
790
+ <q-icon name="open_in_new" size="xs" class="q-ml-xs opacity-50"/>
791
+ </a>
792
+ </template>
793
+ <template v-else>{{ col.value }}</template>
794
+ </div>
795
+ </template>
796
+ <template v-else-if="col.name === 'nsfw'">
797
+ <div v-if="col.value === '1'" class="text-red-500">๐Ÿ”ด</div>
798
+ <div v-else class="text-green-500">๐ŸŸข</div>
799
+ </template>
800
+ <template v-else-if="col.name === 'final_score'">
801
+ <q-badge :color="col.value > 0.6 ? 'green' : (col.value > 0.5 ? 'teal' : 'orange')">
802
+ {{ col.value }}
803
+ </q-badge>
804
+ </template>
805
+ <template v-else>{{ col.value }}</template>
806
+ </q-td>
807
+ <q-tooltip v-if="props.row.wiki && !props.row._nsfw_blocked"
808
+ content-class="bg-black text-white shadow-4"
809
+ max-width="500px" :offset="[10,10]">
810
+ <div style="font-size:14px;line-height:1.5;">
811
+ <span style="opacity:0.7;margin-right:4px;">{{
812
+ props.row.category === 'General' ? '[้€š็”จ]' :
813
+ props.row.category === 'Character' ? '[่ง’่‰ฒ]' :
814
+ props.row.category === 'Copyright' ? '[ไฝœๅ“]' : ''
815
+ }}</span>{{ props.row.wiki }}
816
+ </div>
817
+ </q-tooltip>
818
+ </q-tr>
819
+ ''')
820
+
821
+ # โ”€โ”€ ๅณๆ ๏ผšๅ…ณ่”ๆŽจ่ โ”€โ”€
822
+ with ui.card().classes('col-right'):
823
+ with ui.row().classes('items-center justify-between w-full mb-2'):
824
+ with ui.row().classes('items-center gap-2'):
825
+ ui.label('ๅ…ณ่”ๆŽจ่').classes('font-bold text-lg text-gray-800')
826
+ with ui.icon('info_outline', size='sm', color='grey').classes('cursor-help'):
827
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4"'):
828
+ ui.html(
829
+ 'ๅŸบไบŽๆ ‡็ญพๅ…ฑ็Žฐๆ•ฐๆฎ๏ผŒๅ‘ๆŽ˜่ฏญไน‰ไน‹ๅค–็š„็›ธๅ…ณๆ€ง๏ผŒไธบๆ‚จๆŽจ่ๆ›ดๅคšๅฏ่ƒฝ็š„ๆ ‡็ญพใ€‚<br>ๅ‹พ้€‰ๅฏๅŠ ๅ…ฅๆˆ–็งปๅ‡บๅทฒ้€‰ใ€‚ๅฆ‚้œ€ๆ นๆฎๆœ€ๆ–ฐ้€‰้กนๆ›ดๆ–ฐๆŽจ่๏ผŒ่ฏท็‚นๅ‡ปๅˆทๆ–ฐๆŒ‰้’ฎใ€‚').style(
830
+ 'font-size:14px;line-height:1.5;')
831
+
832
+ # ๆ–ฐๅขžๆ‰‹ๅŠจๅˆทๆ–ฐๆŒ‰้’ฎ
833
+ ui.button('ๆ นๆฎๅทฒ้€‰ๅˆทๆ–ฐ', icon='refresh', on_click=self._manual_refresh_related) \
834
+ .props('dense flat color=primary').classes('text-sm')
835
+
836
+ self.related_list_container = ui.column().classes('w-full gap-0')
837
+ with self.related_list_container:
838
+ ui.label('่ฏทๅ…ˆๆœ็ดขๅนถๅ‹พ้€‰ๆ ‡็ญพโ€ฆ').classes('text-sm text-gray-400 italic p-4')
839
+
840
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
841
+ # ๆธฒๆŸ“ๅ…ณ่”ๆŽจ่ๅˆ—่กจ
842
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•๏ฟฝ๏ฟฝ๏ฟฝโ•โ•โ•โ•โ•โ•โ•โ•
843
+
844
+ def _render_related_list(self, related: list, show_nsfw: bool):
845
+ self.related_list_container.clear()
846
+ self._related_checkboxes.clear()
847
+
848
+ filtered = [r for r in related if not (r.nsfw == '1' and not show_nsfw)]
849
+ if not filtered:
850
+ with self.related_list_container:
851
+ ui.label('ๆš‚ๆ— ๆŽจ่').classes('text-sm text-gray-400 italic p-4')
852
+ return
853
+
854
+ selected_now = set(self._get_selected_tags())
855
+
856
+ with self.related_list_container:
857
+ for r in filtered:
858
+ tag = r.tag
859
+ cn_first = r.cn_name.split(',')[0].strip() if r.cn_name else ''
860
+ is_selected = tag in selected_now
861
+ score_pct = f'+{r.cooc_score * 100:.0f}%'
862
+
863
+ # ่Žทๅ– wiki
864
+ wiki_text = ''
865
+ try:
866
+ tagger = DanbooruTagger._instance
867
+ if tagger and tagger.df is not None and tag in tagger._name_to_idx:
868
+ idx = tagger._name_to_idx[tag]
869
+ wiki_text = str(tagger.df.iloc[idx].get('wiki', ''))
870
+ except Exception:
871
+ pass
872
+
873
+ sources_str = 'ใ€'.join(r.sources) if r.sources else 'โ€”'
874
+ CAT_LABEL = {'General': '้€š็”จ', 'Character': '่ง’่‰ฒ', 'Copyright': 'ไฝœๅ“'}
875
+ cat_label = CAT_LABEL.get(r.category, '')
876
+ tooltip_html = ''
877
+ if wiki_text:
878
+ prefix = f'<span style="opacity:0.7;margin-right:4px;">[{cat_label}] </span>' if cat_label else ''
879
+ tooltip_html += f'<div style="margin-bottom:6px;">{prefix}{wiki_text}</div>'
880
+ tooltip_html += (
881
+ f'<div style="opacity:0.85;">'
882
+ f'{r.cn_name}<br>'
883
+ f'ๅ…ฑ็Žฐ: {r.cooc_count:,} ็›ธๅ…ณๅบฆ: {r.cooc_score:.2f}<br>'
884
+ f'ๆฅ่‡ช้€‰ไธญ: {sources_str}'
885
+ f'</div>'
886
+ )
887
+
888
+ # ่กŒ่ƒŒๆ™ฏ่‰ฒๆŒ‰ๅˆ†็ฑปๅŒบๅˆ†
889
+ CAT_BG = {
890
+ 'General': 'background-color: rgba(59,130,246,0.06);', # ๆทก่“
891
+ 'Character': 'background-color: rgba(34,197,94,0.06);', # ๆทก็ปฟ
892
+ 'Copyright': 'background-color: rgba(236,72,153,0.06);', # ๆทก็บข/็ฒ‰
893
+ }
894
+ row_bg = CAT_BG.get(r.category, '')
895
+
896
+ # ๆ•ด่กŒๅฎนๅ™จ๏ผŒtooltip ๆŒ‚ๅœจ่กŒไธŠ
897
+ with ui.row().classes(
898
+ 'w-full items-center gap-2 px-3 py-2 related-item border-b border-gray-100'
899
+ ).style(row_bg):
900
+ # ๆ•ด่กŒ wiki tooltip
901
+ if tooltip_html:
902
+ with ui.tooltip().props('content-class="bg-black text-white shadow-4" max-width="500px"'):
903
+ ui.html(tooltip_html).style('font-size:14px;line-height:1.5;max-width:480px;')
904
+
905
+ # Checkbox
906
+ cb = ui.checkbox(
907
+ '', value=is_selected,
908
+ on_change=lambda e, t=tag: self._on_related_checkbox_change(t, e.value)
909
+ ).props('dense')
910
+ self._related_checkboxes[tag] = cb
911
+
912
+ # ๆ ‡็ญพๅ๏ผˆๅฏ็‚นๅ‡ป่ทณ่ฝฌ๏ผ‰+ ไธญๆ–‡ๅ
913
+ with ui.column().classes('flex-grow gap-0 min-w-0'):
914
+ link = ui.link(
915
+ tag,
916
+ f'https://danbooru.donmai.us/wiki_pages/{tag}',
917
+ new_tab=True
918
+ ).classes('tag-link text-primary font-bold text-xs')
919
+ link.on('click', self._mark_interaction)
920
+
921
+ if cn_first:
922
+ ui.label(cn_first).classes('text-xs text-gray-500 truncate')
923
+
924
+ # ๅ…ณ่”ๅˆ†ๆ•ฐ
925
+ score_color = 'green' if r.cooc_score > 0.6 else ('teal' if r.cooc_score > 0.3 else 'grey')
926
+ ui.label(score_pct).classes(f'text-sm font-bold text-{score_color}-600 whitespace-nowrap')
927
+
928
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
929
+ # ไบคไบ’้€ป่พ‘
930
+ # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•
931
+
932
+ async def _hide_banner_when_ready(self):
933
+ while not DanbooruTagger.is_ready():
934
+ await asyncio.sleep(1)
935
+ if self.init_banner:
936
+ self.init_banner.set_visibility(False)
937
+
938
+ def _client_alive(self) -> bool:
939
+ try:
940
+ _ = self.search_btn.client
941
+ return True
942
+ except RuntimeError:
943
+ return False
944
+
945
+ # โ”€โ”€ ๅˆ†่ฏ็ญ›้€‰ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€๏ฟฝ๏ฟฝ๏ฟฝโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
946
+
947
+ def _filter_by_source(self, keyword: str):
948
+ show_nsfw_val = self.input_nsfw.value
949
+ if not keyword or keyword == 'ALL':
950
+ filtered = self.full_table_data
951
+ else:
952
+ filtered = [r for r in self.full_table_data if r['source'] == keyword]
953
+
954
+ self.result_table.rows = apply_nsfw_filter(filtered, show_nsfw_val)
955
+
956
+ for child in self.keywords_container.default_slot.children:
957
+ if isinstance(child, ui.chip):
958
+ selected = (
959
+ (keyword == 'ALL' and child.text == 'ๅ…จ้ƒจ')
960
+ or (keyword == self.current_query_str and child.text == 'ๆ•ดๅฅ')
961
+ or (child.text == keyword)
962
+ )
963
+ is_segment = child.text in self.current_segments
964
+ if selected:
965
+ chip_color, text_color = 'primary', 'white'
966
+ elif is_segment:
967
+ chip_color, text_color = 'blue-1', 'blue-8'
968
+ else:
969
+ chip_color, text_color = 'grey-4', 'black'
970
+ child.props(f'color={chip_color} text-color={text_color}')
971
+
972
+ # โ”€โ”€ ๆœ็ดข โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
973
+
974
+ async def perform_search(self):
975
+ query = self.search_input.value.strip()
976
+ if not query:
977
+ return
978
+
979
+ # ๆœ็ดขๅ‰ไฟๅญ˜้…็ฝฎ
980
+ self._save_config()
981
+
982
+ self.current_query_str = query
983
+ self.search_btn.disable()
984
+ self.spinner.classes(remove='hidden')
985
+ ui.notify('ๆญฃๅœจๆœ็ดข...', type='info')
986
+
987
+ if self.bad_case_btn is not None:
988
+ self.bad_case_btn.disable()
989
+
990
+ target_layers_list = [k for k, v in self.selected_layers.items() if v]
991
+ target_cats_list = [k for k, v in self.selected_cats.items() if v]
992
+
993
+ if not target_layers_list:
994
+ ui.notify('่ฏท่‡ณๅฐ‘้€‰ๆ‹ฉไธ€ไธชๅŒน้…ๅฑ‚๏ผ', type='warning')
995
+ self.search_btn.enable()
996
+ self.spinner.classes(add='hidden')
997
+ return
998
+
999
+ try:
1000
+ tagger = await DanbooruTagger.get_instance()
1001
+
1002
+ # NSFW ไฟๆŠคๆจกๅผ๏ผšๅผ€ = ไธๆ˜พ็คบ NSFW
1003
+ show_nsfw_val = self.input_nsfw.value
1004
+
1005
+ request = SearchRequest(
1006
+ query=query,
1007
+ top_k=int(self.input_top_k.value),
1008
+ limit=int(self.input_limit.value),
1009
+ popularity_weight=float(self.input_weight.value),
1010
+ show_nsfw=show_nsfw_val,
1011
+ use_segmentation=self.input_segment.value if self.input_segment else True,
1012
+ target_layers=target_layers_list,
1013
+ target_categories=target_cats_list,
1014
+ )
1015
+ response = await run.io_bound(tagger.search, request)
1016
+
1017
+ # ๅŽๅฐ่ฎกๆ•ฐ
1018
+ async def silent_counter_update():
1019
+ try:
1020
+ await counter.increment()
1021
+ if response.keywords:
1022
+ await counter.add_keywords(response.keywords)
1023
+ self._update_footer_text()
1024
+ except Exception as e:
1025
+ print(f"[UI] ๅŽๅฐ้™้ป˜ๆ›ดๆ–ฐ่ฎกๆ•ฐๅคฑ่ดฅ: {e}", flush=True)
1026
+ asyncio.create_task(silent_counter_update())
1027
+
1028
+ if not self._client_alive():
1029
+ return
1030
+
1031
+ table_data = [result_to_row(r, show_nsfw_val) for r in response.results]
1032
+ self.full_table_data = table_data
1033
+ self.full_tags_str = response.tags_all
1034
+ self.full_tags_str_sfw = response.tags_sfw
1035
+ self.current_segments = list(response.segments) if response.segments else []
1036
+
1037
+ # ๆ˜พ็คบ็ป“ๆžœๅŒบๅŸŸ
1038
+ self.results_section.set_visibility(True)
1039
+
1040
+ _saved_rpp = self._get_rows_per_page()
1041
+ self.result_table.rows = apply_nsfw_filter(table_data, show_nsfw_val)
1042
+ self._set_rows_per_page(_saved_rpp)
1043
+ self.result_table.selected = []
1044
+ self.tag_weights.clear()
1045
+ self.chip_extra_selected.clear()
1046
+ self._render_selected_chips()
1047
+ self._update_selection_display(None)
1048
+
1049
+ # ๆธ…็ฉบๅ…ณ่”ๆŽจ่
1050
+ self._refresh_related([], show_nsfw_val)
1051
+
1052
+ # ๅˆ†่ฏ็ญ›้€‰ chips
1053
+ self.keywords_container.clear()
1054
+ with self.keywords_container:
1055
+ ui.label('ๅˆ†่ฏ็ญ›้€‰:').classes('text-sm text-gray-500 font-bold mr-2')
1056
+ ui.chip('ๅ…จ้ƒจ', on_click=lambda: self._filter_by_source('ALL')) \
1057
+ .props('color=primary text-color=white clickable')
1058
+ use_seg = self.input_segment.value if self.input_segment else True
1059
+ if use_seg:
1060
+ ui.chip('ๆ•ดๅฅ',
1061
+ on_click=lambda: self._filter_by_source(self.current_query_str)) \
1062
+ .props('color=grey-4 text-color=black clickable')
1063
+ # ไปŽๅฅ็บงๅŽŸๅง‹็‰‡ๆฎต๏ผˆๅˆ†้š”็ฌฆๅˆ‡ๅˆ†ๅŽๆœช jieba ็š„้•ฟ็‰‡ๆฎต๏ผŒๅŒบๅˆซไบŽๅ…ณ้”ฎ่ฏ๏ผ‰
1064
+ for seg in response.segments:
1065
+ ui.chip(seg,
1066
+ on_click=lambda s=seg: self._filter_by_source(s)) \
1067
+ .props('color=blue-1 text-color=blue-8 clickable')
1068
+ for kw in response.keywords:
1069
+ ui.chip(kw,
1070
+ on_click=lambda k=kw: self._filter_by_source(k)) \
1071
+ .props('color=grey-4 text-color=black clickable')
1072
+ else:
1073
+ ui.label('(ๅˆ†่ฏๅทฒๅ…ณ้—ญ)').classes('text-xs text-gray-400')
1074
+
1075
+ ui.notify(f'ๆ‰พๅˆฐ {len(table_data)} ไธชๆ ‡็ญพ', type='positive')
1076
+ self.current_search_interacted = False
1077
+
1078
+ if self.bad_case_btn is not None:
1079
+ self.bad_case_btn.enable()
1080
+
1081
+ except RuntimeError as e:
1082
+ if 'deleted' in str(e).lower() or 'client' in str(e).lower():
1083
+ return
1084
+ try:
1085
+ ui.notify(f'้”™่ฏฏ: {str(e)}', type='negative')
1086
+ except RuntimeError:
1087
+ pass
1088
+ except Exception as e:
1089
+ try:
1090
+ ui.notify(f'้”™่ฏฏ: {str(e)}', type='negative')
1091
+ except RuntimeError:
1092
+ pass
1093
+ finally:
1094
+ try:
1095
+ self.search_btn.enable()
1096
+ self.spinner.classes(add='hidden')
1097
+ except RuntimeError:
1098
+ pass
1099
+
1100
+ # โ”€โ”€ ้€‰ๆ‹ฉ็ฎก็† โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1101
+
1102
+ def _get_selected_tags(self) -> list[str]:
1103
+ table_tags = [row['tag'] for row in self.result_table.selected] if self.result_table else []
1104
+ seen = set(table_tags)
1105
+ extra = [t for t in self.chip_extra_selected if t not in seen]
1106
+ return table_tags + extra
1107
+
1108
+ def _set_selected_tags(self, tags: list[str]):
1109
+ tag_set = set(tags)
1110
+ table_tag_set = {row['tag'] for row in self.result_table.rows} if self.result_table else set()
1111
+ self.chip_extra_selected.clear()
1112
+ self.chip_extra_selected.update(t for t in tag_set if t not in table_tag_set)
1113
+ # clean up weights for deselected tags
1114
+ for t in list(self.tag_weights):
1115
+ if t not in tag_set:
1116
+ del self.tag_weights[t]
1117
+
1118
+ if self.result_table is not None:
1119
+ self.result_table.selected = [row for row in self.result_table.rows if row.get('tag') in tag_set]
1120
+
1121
+ all_tags = self._get_selected_tags()
1122
+ if self.selection_count_label is not None:
1123
+ self.selection_count_label.text = str(len(all_tags))
1124
+ self._render_selected_chips()
1125
+
1126
+ def _update_selection_display(self, _e):
1127
+ if self.result_table is None:
1128
+ return
1129
+ self._mark_interaction()
1130
+
1131
+ all_tags = self._get_selected_tags()
1132
+ # clean up weights for deselected tags
1133
+ tag_set = set(all_tags)
1134
+ for t in list(self.tag_weights):
1135
+ if t not in tag_set:
1136
+ del self.tag_weights[t]
1137
+ # init weight for newly selected tags
1138
+ for t in all_tags:
1139
+ self.tag_weights.setdefault(t, 1.0)
1140
+
1141
+ if self.selection_count_label is not None:
1142
+ self.selection_count_label.text = str(len(all_tags))
1143
+ self._render_selected_chips()
1144
+
1145
+ show_nsfw_val = self.input_nsfw.value
1146
+ if all_tags:
1147
+ self._refresh_related_from_selection(all_tags, show_nsfw_val)
1148
+ else:
1149
+ self.chip_extra_selected.clear()
1150
+ self._refresh_related([], show_nsfw_val)
1151
+
1152
+ def _on_related_checkbox_change(self, tag: str, checked: bool):
1153
+ self._mark_interaction()
1154
+ current = self._get_selected_tags()
1155
+ if checked:
1156
+ if tag not in current:
1157
+ current.append(tag)
1158
+ self.tag_weights.setdefault(tag, 1.0)
1159
+ self._set_selected_tags(current)
1160
+ ui.notify(f'ๅทฒๆทปๅŠ  {tag}', type='positive', timeout=1500)
1161
+ else:
1162
+ if tag in current:
1163
+ current.remove(tag)
1164
+ self.tag_weights.pop(tag, None)
1165
+ self._set_selected_tags(current)
1166
+ ui.notify(f'ๅทฒ็งป้™ค {tag}', type='warning', timeout=1500)
1167
+
1168
+ def _manual_refresh_related(self):
1169
+ """ๆ‰‹ๅŠจ่งฆๅ‘ๅ…ณ่”ๆŽจ่ๅˆ—่กจ็š„ๅˆทๆ–ฐ"""
1170
+ self._mark_interaction()
1171
+ show_nsfw_val = self.input_nsfw.value
1172
+ all_tags = self._get_selected_tags()
1173
+
1174
+ if all_tags:
1175
+ self._refresh_related_from_selection(all_tags, show_nsfw_val)
1176
+ ui.notify('ๅทฒ่งฆๅ‘ๅ…ณ่”ๆŽจ่ๆ›ดๆ–ฐ', type='info', timeout=1500)
1177
+ else:
1178
+ self.chip_extra_selected.clear()
1179
+ self._refresh_related([], show_nsfw_val)
1180
+ ui.notify('ๅทฒๆธ…็ฉบๅ…ณ่”ๆŽจ่', type='info', timeout=1500)
1181
+
1182
+ # โ”€โ”€ ๅ…ณ่”ๆŽจ่ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1183
+
1184
+ def _refresh_related(self, related: list, show_nsfw: bool):
1185
+ selected_now = set(self._get_selected_tags())
1186
+ old_related = self.current_related
1187
+ new_tags = {r.tag for r in related}
1188
+ preserved = [r for r in old_related if r.tag in selected_now and r.tag not in new_tags]
1189
+ merged = list(related) + preserved
1190
+
1191
+ self.current_related = merged
1192
+ if self.related_list_container is not None:
1193
+ self._render_related_list(merged, show_nsfw)
1194
+
1195
+ def _refresh_related_from_selection(self, selected_tags: list[str], show_nsfw: bool):
1196
+ async def _do():
1197
+ tagger = await DanbooruTagger.get_instance()
1198
+ related = await run.io_bound(
1199
+ tagger.get_related,
1200
+ selected_tags,
1201
+ set(selected_tags),
1202
+ 50,
1203
+ show_nsfw,
1204
+ )
1205
+ self._refresh_related(related, show_nsfw)
1206
+ asyncio.ensure_future(_do())
1207
+
1208
+ # โ”€โ”€ ่กจๆ ผๅˆ—ๅŠจๆ€ๆ›ดๆ–ฐ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1209
+
1210
+ def _update_table_columns(self, e=None):
1211
+ cols = list(TABLE_COLUMNS)
1212
+ if self.sw_semantic and self.sw_semantic.value:
1213
+ cols.append(OPTIONAL_COLS['semantic'])
1214
+ if self.sw_layer and self.sw_layer.value:
1215
+ cols.append(OPTIONAL_COLS['layer'])
1216
+ if self.sw_source and self.sw_source.value:
1217
+ cols.append(OPTIONAL_COLS['source'])
1218
+ self.result_table.columns = cols
1219
+
1220
+ # โ”€โ”€ NSFW ๅˆ‡ๆข โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1221
+
1222
+ def on_nsfw_toggle(self, e):
1223
+ show_nsfw_val = self.input_nsfw.value
1224
+
1225
+ self.result_table.rows = apply_nsfw_filter(self.full_table_data, show_nsfw_val)
1226
+ if not show_nsfw_val:
1227
+ self.result_table.selected = [r for r in self.result_table.selected if r.get('nsfw') != '1']
1228
+ self._update_selection_display(None)
1229
+
1230
+ # โ”€โ”€ ๅคๅˆถ / ๅ้ฆˆ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1231
+
1232
+ def _toggle_prompt_format(self):
1233
+ if self.prompt_format == 'sdxl':
1234
+ self.prompt_format = 'nai'
1235
+ if self.format_toggle_btn:
1236
+ self.format_toggle_btn.text = 'NAI'
1237
+ self.format_toggle_btn.props('color=purple-7')
1238
+ else:
1239
+ self.prompt_format = 'sdxl'
1240
+ if self.format_toggle_btn:
1241
+ self.format_toggle_btn.text = 'SDXL'
1242
+ self.format_toggle_btn.props('color=grey-7')
1243
+
1244
+ def copy_selection(self):
1245
+ self._mark_interaction()
1246
+ tags = self._get_selected_tags()
1247
+ prompt = ', '.join(
1248
+ _format_tag_with_weight(t, self.tag_weights.get(t, 1.0), self.prompt_format)
1249
+ for t in tags
1250
+ )
1251
+ ui.clipboard.write(prompt)
1252
+ fmt_label = 'NAI' if self.prompt_format == 'nai' else 'SDXL'
1253
+ ui.notify(f'ๅทฒๅคๅˆถ้€‰ไธญๆ ‡็ญพ๏ผˆ{fmt_label} ๆ ผๅผ๏ผ‰!', type='positive')
1254
+
1255
+ async def silent_copy_update():
1256
+ try:
1257
+ await counter.increment_copy()
1258
+ except Exception:
1259
+ pass
1260
+ asyncio.create_task(silent_copy_update())
1261
+
1262
+ def _copy_all_tags(self):
1263
+ self._mark_interaction()
1264
+ show_nsfw_val = self.input_nsfw.value
1265
+ tags_str = self.full_tags_str if show_nsfw_val else self.full_tags_str_sfw
1266
+ if tags_str:
1267
+ ui.clipboard.write(tags_str)
1268
+ ui.notify('ๅทฒๅคๅˆถๅ…จ้ƒจๆ ‡็ญพ!', type='positive')
1269
+ else:
1270
+ ui.notify('ๆš‚ๆ— ๆ ‡็ญพๅฏๅคๅˆถ', type='warning')
1271
+
1272
+ async def report_bad_case(self):
1273
+ from platform_utils import PLATFORM
1274
+ query = self.current_query_str.strip()
1275
+ if len(query) <= 1:
1276
+ ui.notify('ๆœ็ดข่ฏๅคช็Ÿญ๏ผŒๆ— ๆณ•ๆไบคๅ้ฆˆใ€‚', type='warning', timeout=2000)
1277
+ return
1278
+ if self.bad_case_btn is not None:
1279
+ self.bad_case_btn.disable()
1280
+ try:
1281
+ settings = {
1282
+ 'top_k': int(self.input_top_k.value) if self.input_top_k else None,
1283
+ 'segmentation': self.input_segment.value if self.input_segment else None,
1284
+ 'nsfw': self.input_nsfw.value if self.input_nsfw else None,
1285
+ }
1286
+ await counter.add_bad_case(query, platform=PLATFORM, settings=settings)
1287
+ ui.notify('ๆ„Ÿ่ฐขๅ้ฆˆ๏ผๆˆ‘ไปฌไผšๆŒ็ปญไผ˜ๅŒ–ใ€‚', type='positive', timeout=3000)
1288
+ except Exception as e:
1289
+ print(f'[UI] bad_case ่ฎฐๅฝ•ๅผ‚ๅธธ: {e}')
1290
+ ui.notify('่ฎฐๅฝ•ๅคฑ่ดฅ๏ผŒ่ฏท็จๅŽๅ†่ฏ•ใ€‚', type='warning', timeout=3000)
1291
+ if self.bad_case_btn is not None:
1292
+ self.bad_case_btn.enable()
1293
+
1294
+ # โ”€โ”€ ้กต้ข่ทฏ็”ฑ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1295
+
1296
+ @ui.page('/')
1297
+ async def main_page():
1298
+ app_ui = DanbooruSearchUI()
1299
+ app_ui.build_page()
1300
+
1301
+ async def silent_visit_update():
1302
+ try:
1303
+ await counter.increment_visit()
1304
+ app_ui._update_footer_text()
1305
+ except Exception:
1306
+ pass
1307
+ asyncio.create_task(silent_visit_update())
1308
+
1309
+ # ๆขๅค็”จๆˆท้…็ฝฎ๏ผˆๅœจ้กต้ขๆธฒๆŸ“ๅฎŒๆˆๅŽๆ‰ง่กŒ๏ผ‰
1310
+ await app_ui._restore_config()
1311
+
1312
+
1313
+ # โ”€โ”€ ๅ…ฅๅฃ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1314
+
1315
+ if __name__ in {'__main__', '__mp_main__'}:
1316
+ host, port = get_host_port()
1317
+
1318
+ @app.on_startup
1319
+ def _warmup():
1320
+ async def background_init_tasks():
1321
+ await asyncio.sleep(5)
1322
+ print("[UI] ๅผ€ๅง‹้ข„็ƒญ่ฎกๆ•ฐๅ™จไธŽๅผ•ๆ“Ž", flush=True)
1323
+ await counter.init()
1324
+ await DanbooruTagger.get_instance()
1325
+ print("[UI] ๅŽๅฐ้ข„็ƒญๅ…จ้ƒจๅฎŒๆˆ๏ผ", flush=True)
1326
+ asyncio.create_task(background_init_tasks())
1327
+
1328
+ @app.on_shutdown
1329
+ def _shutdown():
1330
+ try:
1331
+ loop = asyncio.get_event_loop()
1332
+ if loop.is_running():
1333
+ loop.create_task(counter.force_sync())
1334
+ else:
1335
+ asyncio.run(counter.force_sync())
1336
+ except Exception as e:
1337
+ print(f"[UI] ๅ…ณๆœบๅŒๆญฅๅคฑ่ดฅ: {e}")
1338
+
1339
+ app.mount('/api', api_app)
1340
+
1341
+ mcp_app = mcp.streamable_http_app()
1342
+ app.mount('/mcp', mcp_app)
1343
+ _mcp_lifespan_ctx = None
1344
+ @app.on_startup
1345
+ async def _start_mcp():
1346
+ global _mcp_lifespan_ctx
1347
+ _mcp_lifespan_ctx = mcp_app.router.lifespan_context(mcp_app)
1348
+ await _mcp_lifespan_ctx.__aenter__()
1349
+ @app.on_shutdown
1350
+ async def _stop_mcp():
1351
+ global _mcp_lifespan_ctx
1352
+ if _mcp_lifespan_ctx is not None:
1353
+ await _mcp_lifespan_ctx.__aexit__(None, None, None)
1354
+
1355
+ @app.get('/googlebd34b54f8562aa06.html')
1356
+ def google_verification():
1357
+ return PlainTextResponse('google-site-verification: googlebd34b54f8562aa06.html')
1358
+
1359
+ @app.get('/robots.txt')
1360
+ def robots_txt():
1361
+ content = (
1362
+ "User-agent: *\n"
1363
+ "Allow: /$\n"
1364
+ "Disallow: /api/\n"
1365
+ "Disallow: /_nicegui/\n"
1366
+ "Disallow: /socket.io/\n"
1367
+ )
1368
+ return PlainTextResponse(content)
1369
+
1370
+ @app.head('/')
1371
+ async def head_root():
1372
+ return PlainTextResponse("")
1373
+
1374
+ ui.run(
1375
+ host=host,
1376
+ port=port,
1377
+ title='Danbooru Tags Searcher',
1378
+ reload=not is_cloud(),
1379
+ show=not is_cloud(),
1380
+ reconnect_timeout=120,
1381
+ )