[crawler]
This commit is contained in:
2
crawler_platform/app/web/__init__.py
Normal file
2
crawler_platform/app/web/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Admin web UI."""
|
||||
|
||||
405
crawler_platform/app/web/static/app.js
Normal file
405
crawler_platform/app/web/static/app.js
Normal file
@@ -0,0 +1,405 @@
|
||||
const state = {
|
||||
projects: [],
|
||||
selectedProject: null,
|
||||
projectDetail: null,
|
||||
ontology: null,
|
||||
};
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
function csv(value) {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...options,
|
||||
});
|
||||
if (!response.ok) {
|
||||
let detail = `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const body = await response.json();
|
||||
detail = body.detail || body.error || detail;
|
||||
} catch {
|
||||
// Keep the HTTP status text.
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function toast(message) {
|
||||
const node = $("toast");
|
||||
node.textContent = message;
|
||||
node.classList.add("show");
|
||||
window.setTimeout(() => node.classList.remove("show"), 2400);
|
||||
}
|
||||
|
||||
function renderProjects() {
|
||||
const list = $("projectList");
|
||||
list.innerHTML = "";
|
||||
state.projects.forEach((project) => {
|
||||
const button = document.createElement("button");
|
||||
button.className = `project-item ${state.selectedProject === project.name ? "active" : ""}`;
|
||||
button.innerHTML = `<strong>${project.name}</strong><span>${project.domain}</span>`;
|
||||
button.addEventListener("click", () => selectProject(project.name));
|
||||
list.appendChild(button);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
state.projects = await api("/projects");
|
||||
if (!state.selectedProject && state.projects.length) {
|
||||
state.selectedProject = state.projects[0].name;
|
||||
}
|
||||
renderProjects();
|
||||
if (state.selectedProject) {
|
||||
await selectProject(state.selectedProject);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectProject(projectName) {
|
||||
state.selectedProject = projectName;
|
||||
state.projectDetail = await api(`/projects/${encodeURIComponent(projectName)}`);
|
||||
state.ontology = await api(`/ontology/${encodeURIComponent(state.projectDetail.domain)}`);
|
||||
renderProjects();
|
||||
renderOverview();
|
||||
renderOntology();
|
||||
await Promise.all([loadEntities(), loadClaims(), loadTags()]);
|
||||
}
|
||||
|
||||
function renderOverview() {
|
||||
const detail = state.projectDetail;
|
||||
$("metricProject").textContent = detail?.name ?? "-";
|
||||
$("metricDomain").textContent = detail?.domain ?? "-";
|
||||
$("metricSources").textContent = detail?.sources?.length ?? 0;
|
||||
const sourceSelect = $("sourceSelect");
|
||||
sourceSelect.innerHTML = "";
|
||||
(detail?.sources ?? []).forEach((source) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = source.name;
|
||||
option.textContent = `${source.name} (${source.type})`;
|
||||
sourceSelect.appendChild(option);
|
||||
});
|
||||
$("sourceTable").innerHTML = table(
|
||||
["Name", "Type", "Trust", "Robots", "Rate"],
|
||||
(detail?.sources ?? []).map((source) => [
|
||||
source.name,
|
||||
source.type,
|
||||
source.trust_level,
|
||||
source.respect_robots_txt ? "on" : "off",
|
||||
`${source.rate_limit_per_minute}/min`,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
function renderOntology() {
|
||||
const entityTypes = state.ontology?.entity_types ?? [];
|
||||
const predicates = state.ontology?.predicates ?? [];
|
||||
$("ontologyEntities").innerHTML = entityTypes.map(chip).join("");
|
||||
$("ontologyPredicates").innerHTML = predicates.map(chip).join("");
|
||||
const filter = $("entityTypeFilter");
|
||||
filter.innerHTML = `<option value="">All types</option>${entityTypes
|
||||
.map((type) => `<option value="${escapeHtml(type)}">${escapeHtml(type)}</option>`)
|
||||
.join("")}`;
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
const configPath = $("configPath").value.trim();
|
||||
if (!configPath) return;
|
||||
const result = await api("/projects", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ config_path: configPath }),
|
||||
});
|
||||
toast(`프로젝트 생성: ${result.name}`);
|
||||
state.selectedProject = result.name;
|
||||
await loadProjects();
|
||||
}
|
||||
|
||||
async function crawl() {
|
||||
if (!state.selectedProject) return;
|
||||
const sourceName = $("sourceSelect").value;
|
||||
const url = $("crawlUrl").value.trim();
|
||||
const provider = $("extractorProvider").value;
|
||||
const model = $("extractorModel").value.trim();
|
||||
const baseUrl = $("extractorBaseUrl").value.trim();
|
||||
$("crawlResult").textContent = "수집 중...";
|
||||
try {
|
||||
const result = await api("/crawl", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
config_path: $("configPath").value.trim(),
|
||||
source_name: sourceName,
|
||||
url,
|
||||
extractor_provider: provider,
|
||||
extractor_model: model || null,
|
||||
extractor_base_url: baseUrl || null,
|
||||
}),
|
||||
});
|
||||
$("crawlResult").textContent = `analyzer ${provider}, page ${result.page_id}, claims ${result.claim_count}, entities ${result.entity_count}`;
|
||||
toast("수집 완료");
|
||||
await Promise.all([loadEntities(), loadClaims(), loadTags()]);
|
||||
} catch (error) {
|
||||
$("crawlResult").textContent = `수집 실패: ${error.message}`;
|
||||
toast("수집 실패");
|
||||
}
|
||||
}
|
||||
|
||||
function updateExtractorOptions() {
|
||||
const provider = $("extractorProvider").value;
|
||||
$("extractorOptions").classList.toggle("active", provider !== "rule_based");
|
||||
if (provider === "ollama" && !$("extractorBaseUrl").value.trim()) {
|
||||
$("extractorBaseUrl").placeholder = "http://localhost:11434/api/chat";
|
||||
} else if (provider === "lm_studio" && !$("extractorBaseUrl").value.trim()) {
|
||||
$("extractorBaseUrl").placeholder = "http://localhost:1234/v1";
|
||||
} else {
|
||||
$("extractorBaseUrl").placeholder = "optional provider endpoint";
|
||||
}
|
||||
}
|
||||
|
||||
async function testExtractor() {
|
||||
const provider = $("extractorProvider").value;
|
||||
const baseUrl = $("extractorBaseUrl").value.trim();
|
||||
$("crawlResult").textContent = "분석기 연결 확인 중...";
|
||||
const result = await api("/extractors/models", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
provider,
|
||||
base_url: baseUrl || null,
|
||||
}),
|
||||
});
|
||||
if (!result.ok) {
|
||||
$("crawlResult").textContent = `분석기 연결 실패: ${result.error}`;
|
||||
toast("분석기 연결 실패");
|
||||
return;
|
||||
}
|
||||
const models = result.models ?? [];
|
||||
if (models.length && !$("extractorModel").value.trim()) {
|
||||
$("extractorModel").value = models[0].id;
|
||||
}
|
||||
$("crawlResult").textContent = models.length
|
||||
? `연결됨. 모델 ${models.length}개: ${models.map((model) => model.id).join(", ")}`
|
||||
: "연결됨. 모델 목록은 비어 있습니다.";
|
||||
toast("분석기 연결 확인 완료");
|
||||
}
|
||||
|
||||
async function discover() {
|
||||
const sourceName = $("sourceSelect").value;
|
||||
const url = $("crawlUrl").value.trim();
|
||||
$("crawlResult").textContent = "주소 발견 중...";
|
||||
$("discoveredLinks").innerHTML = "";
|
||||
try {
|
||||
const result = await api("/discover", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
config_path: $("configPath").value.trim(),
|
||||
source_name: sourceName,
|
||||
url,
|
||||
limit: 30,
|
||||
}),
|
||||
});
|
||||
if (!result.ok) {
|
||||
$("crawlResult").textContent = result.error ?? "주소 발견 실패";
|
||||
return;
|
||||
}
|
||||
$("crawlResult").textContent = `발견된 주소 ${result.links.length}개`;
|
||||
$("discoveredLinks").innerHTML = result.links.map(renderDiscoveredLink).join("");
|
||||
document.querySelectorAll("[data-discovered-url]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
$("crawlUrl").value = button.dataset.discoveredUrl;
|
||||
toast("URL 입력칸에 넣었습니다.");
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
$("crawlResult").textContent = `주소 발견 실패: ${error.message}`;
|
||||
toast("주소 발견 실패");
|
||||
}
|
||||
}
|
||||
|
||||
function renderDiscoveredLink(link) {
|
||||
return `
|
||||
<button class="discovered-link" data-discovered-url="${escapeHtml(link.url)}">
|
||||
<strong>${escapeHtml(link.label)}</strong>
|
||||
<span>${escapeHtml(link.kind)} · ${escapeHtml(link.url)}</span>
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
async function loadEntities() {
|
||||
if (!state.selectedProject) return;
|
||||
const type = $("entityTypeFilter").value;
|
||||
const query = type ? `?entity_type=${encodeURIComponent(type)}&limit=100` : "?limit=100";
|
||||
const entities = await api(`/projects/${encodeURIComponent(state.selectedProject)}/entities${query}`);
|
||||
$("metricEntities").textContent = entities.length;
|
||||
$("entityTable").innerHTML = table(
|
||||
["ID", "Type", "Name", "Metadata"],
|
||||
entities.map((entity) => [
|
||||
entity.id,
|
||||
entity.type,
|
||||
entity.name,
|
||||
`<code>${escapeHtml(JSON.stringify(entity.metadata ?? {}))}</code>`,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
async function mergeEntities() {
|
||||
if (!state.selectedProject) return;
|
||||
const sourceId = Number($("mergeSourceId").value);
|
||||
const targetId = Number($("mergeTargetId").value);
|
||||
if (!sourceId || !targetId || sourceId === targetId) {
|
||||
toast("병합할 ID와 남길 ID를 확인하세요.");
|
||||
return;
|
||||
}
|
||||
const result = await api("/entities/merge", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
project_name: state.selectedProject,
|
||||
source_entity_id: sourceId,
|
||||
target_entity_id: targetId,
|
||||
}),
|
||||
});
|
||||
if (!result.ok) {
|
||||
toast(result.error ?? "병합 실패");
|
||||
return;
|
||||
}
|
||||
toast("Entity 병합 완료");
|
||||
$("mergeSourceId").value = "";
|
||||
$("mergeTargetId").value = "";
|
||||
await Promise.all([loadEntities(), loadClaims(), loadTags()]);
|
||||
}
|
||||
|
||||
async function loadClaims() {
|
||||
if (!state.selectedProject) return;
|
||||
const claims = await api(`/projects/${encodeURIComponent(state.selectedProject)}/claims?limit=100`);
|
||||
$("claimTable").innerHTML = claims.map(renderClaim).join("");
|
||||
document.querySelectorAll("[data-save-claim]").forEach((button) => {
|
||||
button.addEventListener("click", () => updateClaimConfidence(button.dataset.saveClaim));
|
||||
});
|
||||
}
|
||||
|
||||
function renderClaim(claim) {
|
||||
const object = claim.object ?? JSON.stringify(claim.object_value ?? "");
|
||||
return `
|
||||
<article class="claim-card">
|
||||
<div class="claim-main">
|
||||
<strong>${escapeHtml(claim.subject)}</strong>
|
||||
<span class="predicate">${escapeHtml(claim.predicate)}</span>
|
||||
<span>${escapeHtml(object)}</span>
|
||||
<span class="confidence">${Math.round(claim.confidence * 100)}%</span>
|
||||
</div>
|
||||
<div class="evidence">${escapeHtml(claim.evidence_text ?? "")}</div>
|
||||
<div class="evidence">${escapeHtml(claim.source)} · ${escapeHtml(claim.page_url ?? "")}</div>
|
||||
<div class="claim-actions">
|
||||
<input id="confidence-${claim.id}" type="number" min="0" max="1" step="0.01" value="${claim.confidence}" aria-label="confidence" />
|
||||
<input id="reason-${claim.id}" value="${escapeHtml(claim.confidence_reason ?? "")}" aria-label="reason" />
|
||||
<button data-save-claim="${claim.id}">저장</button>
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
async function updateClaimConfidence(claimId) {
|
||||
const confidence = Number($(`confidence-${claimId}`).value);
|
||||
const reason = $(`reason-${claimId}`).value.trim();
|
||||
await api(`/claims/${claimId}/confidence`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ confidence, reason }),
|
||||
});
|
||||
toast("신뢰도 수정 완료");
|
||||
await loadClaims();
|
||||
}
|
||||
|
||||
async function loadTags() {
|
||||
if (!state.selectedProject) return;
|
||||
const tags = await api(`/projects/${encodeURIComponent(state.selectedProject)}/recommendation-tags`);
|
||||
$("tagTable").innerHTML = table(
|
||||
["Predicate", "Type", "Name", "Support", "Confidence"],
|
||||
tags.map((tag) => [
|
||||
tag.predicate,
|
||||
tag.type,
|
||||
tag.name,
|
||||
tag.support_count,
|
||||
Math.round(tag.max_confidence * 100) + "%",
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
async function recommend() {
|
||||
if (!state.selectedProject) return;
|
||||
const result = await api("/recommend", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
project_name: state.selectedProject,
|
||||
target_entity_type: state.projectDetail?.config?.recommendation?.target_entity_type ?? "Perfume",
|
||||
preferences: {
|
||||
preferred_notes: csv($("preferredNotes").value),
|
||||
avoided_notes: csv($("avoidedNotes").value),
|
||||
preferred_moods: csv($("preferredMoods").value),
|
||||
season_context: $("seasonContext").value.trim() || null,
|
||||
occasion_context: $("occasionContext").value.trim() || null,
|
||||
},
|
||||
limit: 10,
|
||||
}),
|
||||
});
|
||||
$("recommendTable").innerHTML = table(
|
||||
["Name", "Type", "Score", "Reasons"],
|
||||
result.map((item) => [item.name, item.entity_type, item.score, item.reasons.join(", ")])
|
||||
);
|
||||
}
|
||||
|
||||
function chip(value) {
|
||||
return `<span class="chip">${escapeHtml(value)}</span>`;
|
||||
}
|
||||
|
||||
function table(headers, rows) {
|
||||
if (!rows.length) {
|
||||
return `<table><tbody><tr><td>데이터가 없습니다.</td></tr></tbody></table>`;
|
||||
}
|
||||
return `
|
||||
<table>
|
||||
<thead><tr>${headers.map((header) => `<th>${escapeHtml(header)}</th>`).join("")}</tr></thead>
|
||||
<tbody>
|
||||
${rows
|
||||
.map((row) => `<tr>${row.map((cell) => `<td>${String(cell)}</td>`).join("")}</tr>`)
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
document.querySelectorAll(".tab").forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
document.querySelectorAll(".tab").forEach((node) => node.classList.remove("active"));
|
||||
document.querySelectorAll(".tab-panel").forEach((node) => node.classList.remove("active"));
|
||||
tab.classList.add("active");
|
||||
$(tab.dataset.tab).classList.add("active");
|
||||
});
|
||||
});
|
||||
|
||||
$("refreshBtn").addEventListener("click", loadProjects);
|
||||
$("createProjectBtn").addEventListener("click", createProject);
|
||||
$("discoverBtn").addEventListener("click", discover);
|
||||
$("crawlBtn").addEventListener("click", crawl);
|
||||
$("extractorProvider").addEventListener("change", updateExtractorOptions);
|
||||
$("testExtractorBtn").addEventListener("click", testExtractor);
|
||||
$("loadEntitiesBtn").addEventListener("click", loadEntities);
|
||||
$("mergeEntitiesBtn").addEventListener("click", mergeEntities);
|
||||
$("loadClaimsBtn").addEventListener("click", loadClaims);
|
||||
$("loadTagsBtn").addEventListener("click", loadTags);
|
||||
$("recommendBtn").addEventListener("click", recommend);
|
||||
|
||||
updateExtractorOptions();
|
||||
loadProjects().catch((error) => toast(error.message));
|
||||
179
crawler_platform/app/web/static/index.html
Normal file
179
crawler_platform/app/web/static/index.html
Normal file
@@ -0,0 +1,179 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Ontology Crawler Platform</title>
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<h1>Ontology Crawler</h1>
|
||||
<p>프로젝트별 수집, Claim 검수, 온톨로지 매핑, 추천 태그 확인</p>
|
||||
</div>
|
||||
<button id="refreshBtn" class="icon-button" title="새로고침" aria-label="새로고침">↻</button>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<aside class="sidebar">
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Projects</h2>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<input id="configPath" value="configs/perfume_subscription.yaml" aria-label="Config path" />
|
||||
<button id="createProjectBtn" title="프로젝트 생성">+</button>
|
||||
</div>
|
||||
<div id="projectList" class="list"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Crawl</h2>
|
||||
</div>
|
||||
<label>
|
||||
Source
|
||||
<select id="sourceSelect"></select>
|
||||
</label>
|
||||
<label>
|
||||
URL
|
||||
<input id="crawlUrl" value="tests/fixtures/sample_perfume.html" />
|
||||
</label>
|
||||
<label>
|
||||
Analyzer
|
||||
<select id="extractorProvider">
|
||||
<option value="rule_based">Rule-based</option>
|
||||
<option value="openai">OpenAI API</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="lm_studio">LM Studio</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="extractor-options" id="extractorOptions">
|
||||
<label>
|
||||
Model
|
||||
<input id="extractorModel" placeholder="예: local model or API model" />
|
||||
</label>
|
||||
<label>
|
||||
Base URL
|
||||
<input id="extractorBaseUrl" placeholder="optional provider endpoint" />
|
||||
</label>
|
||||
<button id="testExtractorBtn">연결 테스트</button>
|
||||
</div>
|
||||
<div class="button-grid">
|
||||
<button id="discoverBtn">주소 발견</button>
|
||||
<button id="crawlBtn" class="primary">수집 실행</button>
|
||||
</div>
|
||||
<div id="crawlResult" class="mini-log"></div>
|
||||
<div id="discoveredLinks" class="discovered-links"></div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="workspace">
|
||||
<nav class="tabs" aria-label="Admin sections">
|
||||
<button class="tab active" data-tab="overview">Overview</button>
|
||||
<button class="tab" data-tab="ontology">Ontology</button>
|
||||
<button class="tab" data-tab="entities">Entities</button>
|
||||
<button class="tab" data-tab="claims">Claims</button>
|
||||
<button class="tab" data-tab="tags">Tags</button>
|
||||
<button class="tab" data-tab="recommend">Recommend</button>
|
||||
</nav>
|
||||
|
||||
<section id="overview" class="tab-panel active">
|
||||
<div class="summary-grid">
|
||||
<div class="metric">
|
||||
<span>Project</span>
|
||||
<strong id="metricProject">-</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span>Domain</span>
|
||||
<strong id="metricDomain">-</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span>Sources</span>
|
||||
<strong id="metricSources">0</strong>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<span>Entities</span>
|
||||
<strong id="metricEntities">0</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wide-panel">
|
||||
<h2>Sources</h2>
|
||||
<div id="sourceTable" class="table"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="ontology" class="tab-panel">
|
||||
<div class="split">
|
||||
<div class="wide-panel">
|
||||
<h2>Entity Types</h2>
|
||||
<div id="ontologyEntities" class="chips"></div>
|
||||
</div>
|
||||
<div class="wide-panel">
|
||||
<h2>Predicates</h2>
|
||||
<div id="ontologyPredicates" class="chips"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="entities" class="tab-panel">
|
||||
<div class="toolbar wrap">
|
||||
<select id="entityTypeFilter"></select>
|
||||
<button id="loadEntitiesBtn">조회</button>
|
||||
</div>
|
||||
<div class="merge-bar">
|
||||
<input id="mergeSourceId" placeholder="병합할 Entity ID" aria-label="source entity id" />
|
||||
<input id="mergeTargetId" placeholder="남길 Entity ID" aria-label="target entity id" />
|
||||
<button id="mergeEntitiesBtn">병합</button>
|
||||
</div>
|
||||
<div id="entityTable" class="table"></div>
|
||||
</section>
|
||||
|
||||
<section id="claims" class="tab-panel">
|
||||
<div class="toolbar">
|
||||
<button id="loadClaimsBtn">Claim 새로고침</button>
|
||||
</div>
|
||||
<div id="claimTable" class="claim-list"></div>
|
||||
</section>
|
||||
|
||||
<section id="tags" class="tab-panel">
|
||||
<div class="toolbar">
|
||||
<button id="loadTagsBtn">태그 조회</button>
|
||||
</div>
|
||||
<div id="tagTable" class="table"></div>
|
||||
</section>
|
||||
|
||||
<section id="recommend" class="tab-panel">
|
||||
<div class="recommend-grid">
|
||||
<label>
|
||||
Preferred notes
|
||||
<input id="preferredNotes" value="Bergamot, Musk" />
|
||||
</label>
|
||||
<label>
|
||||
Avoided notes
|
||||
<input id="avoidedNotes" value="" />
|
||||
</label>
|
||||
<label>
|
||||
Preferred moods
|
||||
<input id="preferredMoods" value="Fresh" />
|
||||
</label>
|
||||
<label>
|
||||
Season
|
||||
<input id="seasonContext" value="Summer" />
|
||||
</label>
|
||||
<label>
|
||||
Occasion
|
||||
<input id="occasionContext" value="Daily" />
|
||||
</label>
|
||||
</div>
|
||||
<button id="recommendBtn" class="primary">추천 테스트</button>
|
||||
<div id="recommendTable" class="table"></div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div id="toast" role="status" aria-live="polite"></div>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
452
crawler_platform/app/web/static/styles.css
Normal file
452
crawler_platform/app/web/static/styles.css
Normal file
@@ -0,0 +1,452 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f6f7f4;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #eef3ee;
|
||||
--text: #202420;
|
||||
--muted: #667063;
|
||||
--line: #d8ded6;
|
||||
--accent: #236b5b;
|
||||
--accent-2: #9c4f30;
|
||||
--danger: #a33434;
|
||||
--shadow: 0 16px 40px rgba(24, 35, 28, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: Inter, "Segoe UI", Arial, sans-serif;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
background: white;
|
||||
padding: 0 10px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 18px 24px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.topbar p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 40px;
|
||||
padding: 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 320px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
.workspace {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.wide-panel,
|
||||
.metric {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.panel,
|
||||
.wide-panel {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 42px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.project-item {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
text-align: left;
|
||||
height: auto;
|
||||
min-height: 52px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.project-item.active {
|
||||
border-color: var(--accent);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.project-item strong {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.project-item span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mini-log {
|
||||
min-height: 38px;
|
||||
margin-top: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.button-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.extractor-options {
|
||||
display: none;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.extractor-options.active {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.extractor-options button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.discovered-links {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
max-height: 280px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.discovered-link {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-height: 44px;
|
||||
padding: 7px 8px;
|
||||
text-align: left;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.discovered-link span {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: var(--text);
|
||||
color: white;
|
||||
border-color: var(--text);
|
||||
}
|
||||
|
||||
.tab-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-panel.active {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.metric span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.metric strong {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 20px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.split {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbar.wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar select {
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.merge-bar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 180px) minmax(120px, 180px) 80px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.table {
|
||||
overflow-x: auto;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--muted);
|
||||
background: #fafbf8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.claim-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.claim-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.claim-main {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.predicate {
|
||||
color: var(--accent);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.confidence {
|
||||
margin-left: auto;
|
||||
color: var(--accent-2);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.evidence {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.claim-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 120px minmax(0, 1fr) 90px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.recommend-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
#toast {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: 18px;
|
||||
max-width: min(420px, calc(100vw - 36px));
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
background: var(--text);
|
||||
color: white;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
transition: 180ms ease;
|
||||
pointer-events: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
#toast.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.summary-grid,
|
||||
.split,
|
||||
.recommend-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.topbar {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.summary-grid,
|
||||
.split,
|
||||
.recommend-grid,
|
||||
.claim-actions,
|
||||
.merge-bar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user