This commit is contained in:
tokuzou0829
2026-06-11 03:43:59 +09:00
commit 17be865447
22 changed files with 124248 additions and 0 deletions
+152
View File
@@ -0,0 +1,152 @@
import {
MAX_SEARCH_RESULTS,
apiPath,
fetchJson,
matchesFilters,
renderWorkList,
selectedAges,
splitFilters,
updateDatasetStatus,
} from "./shared.js";
const state = {
naturalResults: null,
requestId: 0,
};
const elements = {
datasetStatus: document.querySelector("#datasetStatus"),
searchInput: document.querySelector("#searchInput"),
naturalSearchButton: document.querySelector("#naturalSearchButton"),
clearNaturalSearchButton: document.querySelector("#clearNaturalSearchButton"),
searchMode: document.querySelector("#searchMode"),
tagInput: document.querySelector("#tagInput"),
ageFilters: document.querySelectorAll(".ageFilter"),
workList: document.querySelector("#workList"),
searchCount: document.querySelector("#searchCount"),
};
function currentFilters() {
return {
ages: selectedAges(elements.ageFilters),
tags: splitFilters(elements.tagInput.value),
query: elements.searchInput.value,
};
}
async function normalResults(requestId) {
const filters = currentFilters();
const payload = await fetchJson(apiPath("/api/works", {
q: filters.query,
limit: MAX_SEARCH_RESULTS,
age: [...filters.ages],
tag: filters.tags,
}));
if (requestId !== state.requestId) return null;
return payload.results || [];
}
function naturalResults() {
if (!state.naturalResults) return null;
const filters = currentFilters();
return state.naturalResults
.map((result) => ({ work: result.work, score: result.score }))
.filter((entry) => entry.work && matchesFilters(entry.work, { ...filters, query: "" }));
}
async function renderSearch() {
const semanticEntries = naturalResults();
if (semanticEntries) {
renderWorkList(elements.workList, semanticEntries, { emptyText: "自然文検索に該当する作品がありません。" });
elements.searchCount.textContent = `${semanticEntries.length}件`;
return;
}
const requestId = state.requestId + 1;
state.requestId = requestId;
elements.searchCount.textContent = "読み込み中";
renderWorkList(elements.workList, [], { emptyText: "最近追加されたASMRを読み込み中..." });
try {
const results = await normalResults(requestId);
if (!results) return;
renderWorkList(elements.workList, results, { emptyText: "該当する作品がありません。" });
elements.searchCount.textContent = `${results.length}件`;
elements.searchMode.textContent = elements.searchInput.value.trim()
? "作品名・タグ・声優名で絞り込み中です。"
: "最近追加されたASMRを表示しています。";
} catch (error) {
if (requestId !== state.requestId) return;
elements.searchCount.textContent = "エラー";
renderWorkList(elements.workList, [], { emptyText: `作品一覧を読み込めません: ${error.message}` });
}
}
function clearNaturalSearch() {
state.naturalResults = null;
elements.clearNaturalSearchButton.hidden = true;
elements.searchMode.textContent = "入力すると作品名・タグ・声優名で絞り込みます。";
renderSearch();
}
async function requestNaturalSearch() {
const query = elements.searchInput.value.trim();
if (!query) {
elements.searchMode.textContent = "聴きたい雰囲気を入力してください。";
return;
}
elements.naturalSearchButton.disabled = true;
elements.searchMode.textContent = "自然文検索中...";
try {
const payload = await fetchJson("/api/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query,
limit: MAX_SEARCH_RESULTS,
ages: [...selectedAges(elements.ageFilters)],
requiredTags: splitFilters(elements.tagInput.value),
}),
});
state.naturalResults = payload.results || [];
elements.clearNaturalSearchButton.hidden = false;
elements.searchMode.textContent = `自然文検索: ${query}`;
renderSearch();
} catch (error) {
state.naturalResults = null;
elements.clearNaturalSearchButton.hidden = true;
elements.searchMode.textContent = `自然文検索を使えません: ${error.message}`;
renderSearch();
} finally {
elements.naturalSearchButton.disabled = false;
}
}
function bindEvents() {
elements.searchInput.addEventListener("input", () => {
if (state.naturalResults) clearNaturalSearch();
else renderSearch();
});
elements.searchInput.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
event.preventDefault();
requestNaturalSearch();
});
elements.naturalSearchButton.addEventListener("click", requestNaturalSearch);
elements.clearNaturalSearchButton.addEventListener("click", clearNaturalSearch);
elements.tagInput.addEventListener("input", renderSearch);
for (const input of elements.ageFilters) input.addEventListener("change", renderSearch);
}
async function init() {
const status = await fetchJson("/api/status");
updateDatasetStatus(elements.datasetStatus, status);
bindEvents();
renderSearch();
}
init().catch((error) => {
elements.datasetStatus.textContent = `読み込み失敗: ${error.message}`;
});
+191
View File
@@ -0,0 +1,191 @@
export const MAX_SEARCH_RESULTS = 100;
export async function fetchJson(path, options = {}) {
const response = await fetch(path, options);
const text = await response.text();
let payload = {};
try {
payload = JSON.parse(text);
} catch {
// Astro dev server and static-only servers return HTML for missing APIs.
}
if (!response.ok) {
throw new Error(payload.error || `API の読み込みに失敗しました (${response.status})`);
}
return payload;
}
export function apiPath(path, params = {}) {
const url = new URL(path, location.origin);
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null || value === "") continue;
if (Array.isArray(value)) {
for (const item of value) {
if (item !== undefined && item !== null && item !== "") url.searchParams.append(key, item);
}
continue;
}
url.searchParams.set(key, value);
}
return `${url.pathname}${url.search}`;
}
export function normalize(value) {
return String(value ?? "").toLowerCase().trim();
}
export function selectedAges(inputs) {
return new Set([...inputs].filter((input) => input.checked).map((input) => input.value));
}
export function splitFilters(value) {
return normalize(value)
.split(/[\s,、]+/)
.map((item) => item.trim())
.filter(Boolean);
}
export function matchesFilters(work, { ages, tags, query }) {
if (ages?.size && !ages.has(work.ageCategory || "unknown")) return false;
if (tags?.length) {
const joinedTags = (work.tags || []).map(normalize).join(" ");
if (!tags.every((tag) => joinedTags.includes(tag))) return false;
}
if (query && !searchableText(work).includes(normalize(query))) return false;
return true;
}
export function searchableText(work) {
return [
work.title,
work.sourceId,
work.circle,
...(work.vas || []),
...(work.tags || []),
].map(normalize).join(" ");
}
export function durationLabel(seconds) {
if (!Number.isFinite(seconds) || seconds <= 0) return "時間不明";
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}分`;
const hours = Math.floor(minutes / 60);
const rest = minutes % 60;
return rest ? `${hours}時間${rest}分` : `${hours}時間`;
}
export function ratingLabel(work) {
if (!Number.isFinite(work.rateAverage)) return "評価なし";
return `★${work.rateAverage.toFixed(2)} (${Number(work.rateCount || 0).toLocaleString()})`;
}
export function voiceActorLabel(work, prefix = "CV") {
const names = Array.isArray(work.vas)
? work.vas.map((name) => String(name || "").trim()).filter(Boolean)
: [];
return names.length ? `${prefix} ${names.join(" / ")}` : "";
}
export function workUrl(work) {
const id = work.sourceId || work.id || work.embeddingIndex;
return `/work/?id=${encodeURIComponent(id)}`;
}
export function sourceUrl(work) {
return `https://asmr.one/work/${work.sourceId}`;
}
export function findWork(works, id) {
const normalizedId = normalize(id);
if (!normalizedId) return null;
return works.find((work) => (
normalize(work.sourceId) === normalizedId
|| normalize(work.id) === normalizedId
|| normalize(work.embeddingIndex) === normalizedId
)) || null;
}
export function renderWorkList(container, entries, options = {}) {
container.replaceChildren();
if (!entries.length) {
const empty = document.createElement("p");
empty.className = "helper-text";
empty.textContent = options.emptyText || "該当する作品がありません。";
container.append(empty);
return;
}
const fragment = document.createDocumentFragment();
for (const entry of entries) fragment.append(renderWorkRow(entry));
container.append(fragment);
}
export function renderWorkRow(entry) {
const work = entry.work || entry;
const score = Number.isFinite(entry.score) ? entry.score : null;
const row = document.createElement("a");
row.className = "work-row";
row.href = workUrl(work);
const image = document.createElement("img");
image.className = "work-thumb";
image.src = work.thumbnailCoverUrl || work.mainCoverUrl || "";
image.alt = work.title || "";
image.loading = "lazy";
const body = document.createElement("div");
body.className = "work-body";
const title = document.createElement("h3");
title.textContent = work.title || "タイトル不明";
const meta = document.createElement("p");
meta.className = "work-meta";
meta.textContent = [
work.circle || "サークル不明",
durationLabel(work.duration),
ratingLabel(work),
].join(" / ");
const voiceActors = voiceActorLabel(work);
const voice = document.createElement("p");
voice.className = "voice-line work-voice";
voice.textContent = voiceActors;
const tagLine = document.createElement("div");
tagLine.className = "tag-line";
for (const tag of (work.tags || []).slice(0, 6)) {
const span = document.createElement("span");
span.textContent = tag;
tagLine.append(span);
}
body.append(title, meta);
if (voiceActors) body.append(voice);
if (tagLine.childElementCount) body.append(tagLine);
const side = document.createElement("div");
side.className = "work-side";
const id = document.createElement("span");
id.className = "source-id";
id.textContent = work.sourceId || "";
side.append(id);
if (score !== null) {
const dna = document.createElement("span");
dna.className = "dna-score";
dna.textContent = `DNA ${Math.round(score * 100)}%`;
side.append(dna);
}
row.append(image, body, side);
return row;
}
export function updateDatasetStatus(element, manifest, readyText = "検索準備完了") {
element.textContent = `${readyText} / ${Number(manifest.count || 0).toLocaleString()}件 / ${manifest.model || "model unknown"}`;
}
+146
View File
@@ -0,0 +1,146 @@
import {
apiPath,
durationLabel,
fetchJson,
ratingLabel,
renderWorkList,
sourceUrl,
updateDatasetStatus,
voiceActorLabel,
} from "./shared.js";
const state = {
work: null,
};
const elements = {
datasetStatus: document.querySelector("#datasetStatus"),
workDetail: document.querySelector("#workDetail"),
similarList: document.querySelector("#similarList"),
similarCount: document.querySelector("#similarCount"),
resultLimit: document.querySelector("#resultLimit"),
excludeSameGroup: document.querySelector("#excludeSameGroup"),
};
function renderDetail(work) {
document.title = `${work.title || "作品"} - MyDNA`;
elements.workDetail.replaceChildren();
const image = document.createElement("img");
image.className = "detail-cover";
image.src = work.mainCoverUrl || work.thumbnailCoverUrl || "";
image.alt = work.title || "";
const body = document.createElement("div");
body.className = "detail-body";
const title = document.createElement("h1");
title.textContent = work.title || "タイトル不明";
const meta = document.createElement("p");
meta.className = "detail-meta";
meta.textContent = [
work.circle || "サークル不明",
work.sourceId,
durationLabel(work.duration),
`DL ${Number(work.dlCount || 0).toLocaleString()}`,
ratingLabel(work),
].filter(Boolean).join(" / ");
const voiceActors = voiceActorLabel(work, "声優:");
const voice = document.createElement("p");
voice.className = "voice-line detail-voice";
voice.textContent = voiceActors;
const tags = document.createElement("div");
tags.className = "tag-line detail-tags";
for (const tag of work.tags || []) {
const span = document.createElement("span");
span.textContent = tag;
tags.append(span);
}
body.append(title, meta);
if (voiceActors) body.append(voice);
if (tags.childElementCount) body.append(tags);
const href = sourceUrl(work);
if (href) {
const link = document.createElement("a");
link.className = "primary-link";
link.href = href;
link.target = "_blank";
link.rel = "noreferrer";
link.textContent = "作品ページを開く";
body.append(link);
}
elements.workDetail.append(image, body);
}
async function requestSimilar() {
if (!state.work) return;
elements.similarCount.textContent = "計算中";
renderWorkList(elements.similarList, [], { emptyText: "MyDNAを計算中..." });
try {
const payload = await fetchJson("/api/similar", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
index: state.work.embeddingIndex,
limit: Number(elements.resultLimit.value),
filters: {
ages: ["adult", "r15", "general"],
excludeSameGroup: elements.excludeSameGroup.checked,
},
}),
});
renderSimilar(payload.results || []);
} catch (error) {
elements.similarCount.textContent = "エラー";
renderWorkList(elements.similarList, [], { emptyText: `MyDNAを使えません: ${error.message}` });
}
}
function renderSimilar(results) {
const entries = results.map((result) => ({
work: result.work,
score: result.score,
})).filter((entry) => entry.work);
renderWorkList(elements.similarList, entries, { emptyText: "近い作品が見つかりませんでした。" });
elements.similarCount.textContent = `${entries.length}件`;
}
function bindEvents() {
elements.resultLimit.addEventListener("change", requestSimilar);
elements.excludeSameGroup.addEventListener("change", requestSimilar);
}
async function init() {
const id = new URLSearchParams(location.search).get("id");
const [status, payload] = await Promise.all([
fetchJson("/api/status"),
fetchJson(apiPath("/api/work", { id })),
]);
updateDatasetStatus(elements.datasetStatus, status, "データ読み込み完了");
const work = payload.work;
if (!work) {
elements.workDetail.innerHTML = '<p class="helper-text">作品が見つかりませんでした。</p>';
elements.similarCount.textContent = "0件";
renderWorkList(elements.similarList, []);
return;
}
state.work = work;
renderDetail(work);
bindEvents();
requestSimilar();
}
init().catch((error) => {
elements.datasetStatus.textContent = `読み込み失敗: ${error.message}`;
elements.workDetail.innerHTML = `<p class="helper-text">${error.message}</p>`;
});