feat: 뉴스 전용 페이지 + 분류 격리 + 읽음 상태
- /news 전용 페이지: 신문사 필터, 읽지않음 필터, 시간순 리스트, 미리보기 - 뉴스 분류 격리: ai_domain='News', classify 제거, embed만 등록 - is_read: 클릭 시 자동 읽음, 전체 읽음 API - documents 목록에서 뉴스 제외 (source_channel != 'news') - nav에 뉴스 링크 추가 - GET /api/news/articles, POST /api/news/mark-all-read Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,7 @@ class DocumentResponse(BaseModel):
|
||||
derived_path: str | None
|
||||
original_format: str | None
|
||||
conversion_status: str | None
|
||||
is_read: bool | None
|
||||
review_status: str | None
|
||||
edit_url: str | None
|
||||
preview_status: str | None
|
||||
@@ -146,8 +147,8 @@ async def list_documents(
|
||||
source: str | None = None,
|
||||
format: str | None = None,
|
||||
):
|
||||
"""문서 목록 조회 (페이지네이션 + 필터)"""
|
||||
query = select(Document).where(Document.deleted_at == None)
|
||||
"""문서 목록 조회 (페이지네이션 + 필터, 뉴스 제외)"""
|
||||
query = select(Document).where(Document.deleted_at == None, Document.source_channel != "news")
|
||||
|
||||
if domain:
|
||||
# prefix 매칭: Industrial_Safety 클릭 시 하위 전부 포함
|
||||
|
||||
@@ -98,6 +98,62 @@ async def delete_source(
|
||||
return {"message": f"소스 {source_id} 삭제됨"}
|
||||
|
||||
|
||||
@router.get("/articles")
|
||||
async def list_articles(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
source: str | None = None,
|
||||
unread_only: bool = False,
|
||||
page: int = 1,
|
||||
page_size: int = 30,
|
||||
):
|
||||
"""뉴스 기사 목록"""
|
||||
from sqlalchemy import func
|
||||
from models.document import Document
|
||||
|
||||
query = select(Document).where(
|
||||
Document.source_channel == "news",
|
||||
Document.deleted_at == None,
|
||||
)
|
||||
if source:
|
||||
query = query.where(Document.ai_sub_group == source)
|
||||
if unread_only:
|
||||
query = query.where(Document.is_read == False)
|
||||
|
||||
count_q = select(func.count()).select_from(query.subquery())
|
||||
total = (await session.execute(count_q)).scalar()
|
||||
|
||||
query = query.order_by(Document.is_read.asc(), Document.created_at.desc())
|
||||
query = query.offset((page - 1) * page_size).limit(page_size)
|
||||
result = await session.execute(query)
|
||||
items = result.scalars().all()
|
||||
|
||||
from api.documents import DocumentResponse
|
||||
return {
|
||||
"items": [DocumentResponse.model_validate(doc) for doc in items],
|
||||
"total": total,
|
||||
"page": page,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/mark-all-read")
|
||||
async def mark_all_read(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
session: Annotated[AsyncSession, Depends(get_session)],
|
||||
):
|
||||
"""전체 읽음 처리"""
|
||||
from sqlalchemy import update
|
||||
from models.document import Document
|
||||
|
||||
result = await session.execute(
|
||||
update(Document)
|
||||
.where(Document.source_channel == "news", Document.is_read == False)
|
||||
.values(is_read=True)
|
||||
)
|
||||
await session.commit()
|
||||
return {"marked": result.rowcount}
|
||||
|
||||
|
||||
@router.post("/collect")
|
||||
async def trigger_collect(
|
||||
user: Annotated[User, Depends(get_current_user)],
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from sqlalchemy import BigInteger, DateTime, Enum, String, Text
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, Enum, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -55,6 +55,9 @@ class Document(Base):
|
||||
original_format: Mapped[str | None] = mapped_column(String(20))
|
||||
conversion_status: Mapped[str | None] = mapped_column(String(20), default="none")
|
||||
|
||||
# 읽음 상태 (뉴스용)
|
||||
is_read: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
||||
|
||||
# 승인/삭제
|
||||
review_status: Mapped[str | None] = mapped_column(String(20), default="pending")
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
@@ -139,6 +139,7 @@ async def _fetch_rss(session, source: NewsSource) -> int:
|
||||
continue
|
||||
|
||||
category = _normalize_category(source.category or "")
|
||||
source_short = source.name.split(" ")[0] # "경향신문 문화" → "경향신문"
|
||||
|
||||
doc = Document(
|
||||
file_path=f"news/{source.name}/{article_id}",
|
||||
@@ -154,14 +155,14 @@ async def _fetch_rss(session, source: NewsSource) -> int:
|
||||
data_origin="external",
|
||||
edit_url=link,
|
||||
review_status="approved",
|
||||
ai_domain="News",
|
||||
ai_sub_group=source_short,
|
||||
ai_tags=[f"News/{source_short}/{category}"],
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
|
||||
# classify + embed 큐 등록 (extract 불필요)
|
||||
session.add(ProcessingQueue(document_id=doc.id, stage="classify", status="pending"))
|
||||
|
||||
# 30일 이내만 embed
|
||||
# embed만 등록 (classify 불필요 — 소스/분야 이미 확정)
|
||||
days_old = (datetime.now(timezone.utc) - pub_dt).days
|
||||
if days_old <= 30:
|
||||
session.add(ProcessingQueue(document_id=doc.id, stage="embed", status="pending"))
|
||||
@@ -219,6 +220,7 @@ async def _fetch_api(session, source: NewsSource) -> int:
|
||||
continue
|
||||
|
||||
category = _normalize_category(article.get("section", source.category or ""))
|
||||
source_short = source.name.split(" ")[0]
|
||||
|
||||
doc = Document(
|
||||
file_path=f"news/{source.name}/{article_id}",
|
||||
@@ -234,12 +236,13 @@ async def _fetch_api(session, source: NewsSource) -> int:
|
||||
data_origin="external",
|
||||
edit_url=link,
|
||||
review_status="approved",
|
||||
ai_domain="News",
|
||||
ai_sub_group=source_short,
|
||||
ai_tags=[f"News/{source_short}/{category}"],
|
||||
)
|
||||
session.add(doc)
|
||||
await session.flush()
|
||||
|
||||
session.add(ProcessingQueue(document_id=doc.id, stage="classify", status="pending"))
|
||||
|
||||
days_old = (datetime.now(timezone.utc) - pub_dt).days
|
||||
if days_old <= 30:
|
||||
session.add(ProcessingQueue(document_id=doc.id, stage="embed", status="pending"))
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
<a href="/documents" class="text-xs hover:text-[var(--accent)]">문서</a>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/news" class="text-xs text-[var(--text-dim)] hover:text-[var(--text)]">뉴스</a>
|
||||
<a href="/inbox" class="text-xs text-[var(--text-dim)] hover:text-[var(--text)]">Inbox</a>
|
||||
<a href="/settings" class="text-xs text-[var(--text-dim)] hover:text-[var(--text)]">설정</a>
|
||||
<button
|
||||
|
||||
198
frontend/src/routes/news/+page.svelte
Normal file
198
frontend/src/routes/news/+page.svelte
Normal file
@@ -0,0 +1,198 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api';
|
||||
import { addToast } from '$lib/stores/ui';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
|
||||
function renderMd(text) {
|
||||
return DOMPurify.sanitize(marked(text), {
|
||||
USE_PROFILES: { html: true },
|
||||
FORBID_TAGS: ['style', 'script'],
|
||||
FORBID_ATTR: ['onerror', 'onclick'],
|
||||
ALLOW_UNKNOWN_PROTOCOLS: false,
|
||||
});
|
||||
}
|
||||
|
||||
let articles = $state([]);
|
||||
let total = $state(0);
|
||||
let loading = $state(true);
|
||||
let selectedArticle = $state(null);
|
||||
let filterSource = $state('');
|
||||
let showUnreadOnly = $state(false);
|
||||
let sources = $state([]);
|
||||
let currentPage = $state(1);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const srcData = await api('/news/sources');
|
||||
// 신문사별 유니크
|
||||
const names = new Set();
|
||||
srcData.forEach(s => names.add(s.name.split(' ')[0]));
|
||||
sources = [...names];
|
||||
} catch (e) {}
|
||||
loadArticles();
|
||||
});
|
||||
|
||||
async function loadArticles() {
|
||||
loading = true;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(currentPage));
|
||||
params.set('page_size', '30');
|
||||
if (filterSource) params.set('source', filterSource);
|
||||
if (showUnreadOnly) params.set('unread_only', 'true');
|
||||
|
||||
const data = await api(`/news/articles?${params}`);
|
||||
articles = data.items;
|
||||
total = data.total;
|
||||
} catch (err) {
|
||||
addToast('error', '뉴스 로딩 실패');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function selectArticle(article) {
|
||||
selectedArticle = article;
|
||||
if (!article.is_read) markRead(article);
|
||||
}
|
||||
|
||||
async function markRead(article) {
|
||||
try {
|
||||
await api(`/documents/${article.id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ is_read: true }),
|
||||
});
|
||||
article.is_read = true;
|
||||
articles = [...articles];
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
try {
|
||||
const result = await api('/news/mark-all-read', { method: 'POST' });
|
||||
addToast('success', `${result.marked}건 읽음 처리`);
|
||||
articles = articles.map(a => ({ ...a, is_read: true }));
|
||||
} catch (e) {
|
||||
addToast('error', '실패');
|
||||
}
|
||||
}
|
||||
|
||||
function timeAgo(dateStr) {
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 60) return `${mins}분 전`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `${hours}시간 전`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}일 전`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const _s = filterSource;
|
||||
const _u = showUnreadOnly;
|
||||
currentPage = 1;
|
||||
loadArticles();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full">
|
||||
<!-- 좌측 필터 -->
|
||||
<div class="w-48 shrink-0 border-r border-[var(--border)] bg-[var(--sidebar-bg)] p-3 overflow-y-auto">
|
||||
<h2 class="text-xs font-semibold text-[var(--text-dim)] uppercase mb-3">필터</h2>
|
||||
|
||||
<button
|
||||
onclick={() => { filterSource = ''; }}
|
||||
class="w-full text-left px-2 py-1.5 rounded text-sm mb-1 {filterSource === '' ? 'bg-[var(--accent)]/15 text-[var(--accent)]' : 'text-[var(--text-dim)] hover:bg-[var(--surface)]'}"
|
||||
>📰 전체</button>
|
||||
|
||||
{#each sources as src}
|
||||
<button
|
||||
onclick={() => { filterSource = src; }}
|
||||
class="w-full text-left px-2 py-1.5 rounded text-sm mb-0.5 {filterSource === src ? 'bg-[var(--accent)]/15 text-[var(--accent)]' : 'text-[var(--text-dim)] hover:bg-[var(--surface)]'}"
|
||||
>{src}</button>
|
||||
{/each}
|
||||
|
||||
<hr class="my-3 border-[var(--border)]">
|
||||
|
||||
<label class="flex items-center gap-2 px-2 text-xs text-[var(--text-dim)]">
|
||||
<input type="checkbox" bind:checked={showUnreadOnly} class="rounded">
|
||||
읽지 않음만
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 메인 -->
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
<!-- 상단 바 -->
|
||||
<div class="flex items-center justify-between px-4 py-2 border-b border-[var(--border)] shrink-0">
|
||||
<span class="text-xs text-[var(--text-dim)]">{total}건</span>
|
||||
<button
|
||||
onclick={markAllRead}
|
||||
class="text-xs text-[var(--text-dim)] hover:text-[var(--accent)] px-2 py-1 rounded border border-[var(--border)]"
|
||||
>전체 읽음</button>
|
||||
</div>
|
||||
|
||||
<!-- 상단: 기사 리스트 -->
|
||||
<div class="overflow-y-auto {selectedArticle ? 'h-[40%] shrink-0 border-b border-[var(--border)]' : 'flex-1'}">
|
||||
{#if loading}
|
||||
<div class="p-4 space-y-2">
|
||||
{#each Array(5) as _}
|
||||
<div class="h-12 bg-[var(--surface)] rounded animate-pulse"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if articles.length === 0}
|
||||
<div class="text-center py-16 text-[var(--text-dim)]">
|
||||
<p class="text-sm">뉴스가 없습니다</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each articles as article}
|
||||
<button
|
||||
onclick={() => selectArticle(article)}
|
||||
class="w-full text-left px-4 py-2.5 border-b border-[var(--border)]/30 hover:bg-[var(--surface)] transition-colors
|
||||
{selectedArticle?.id === article.id ? 'bg-[var(--accent)]/5 border-l-2 border-l-[var(--accent)]' : ''}"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="mt-1 text-[10px] {article.is_read ? 'text-[var(--text-dim)]' : 'text-[var(--accent)]'}">
|
||||
{article.is_read ? '○' : '●'}
|
||||
</span>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm truncate {article.is_read ? 'text-[var(--text-dim)]' : 'font-medium'}">{article.title}</p>
|
||||
<div class="flex items-center gap-2 mt-0.5 text-[10px] text-[var(--text-dim)]">
|
||||
<span>{article.ai_sub_group || ''}</span>
|
||||
{#if article.ai_tags?.length}
|
||||
<span>{article.ai_tags[0]?.split('/').pop()}</span>
|
||||
{/if}
|
||||
<span>{timeAgo(article.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 하단: 기사 미리보기 -->
|
||||
{#if selectedArticle}
|
||||
<div class="flex-1 overflow-y-auto p-5 bg-[var(--surface)]">
|
||||
<h2 class="text-lg font-bold mb-2">{selectedArticle.title}</h2>
|
||||
<div class="flex items-center gap-2 mb-4 text-xs text-[var(--text-dim)]">
|
||||
<span>{selectedArticle.ai_sub_group}</span>
|
||||
<span>·</span>
|
||||
<span>{timeAgo(selectedArticle.created_at)}</span>
|
||||
</div>
|
||||
<div class="markdown-body mb-4">
|
||||
{@html renderMd(selectedArticle.extracted_text || '')}
|
||||
</div>
|
||||
{#if selectedArticle.edit_url}
|
||||
<a
|
||||
href={selectedArticle.edit_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-[var(--accent)] text-white rounded-lg hover:bg-[var(--accent-hover)] text-sm"
|
||||
>원문 보기 →</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
9
migrations/013_news_features.sql
Normal file
9
migrations/013_news_features.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- 읽음 상태
|
||||
ALTER TABLE documents ADD COLUMN IF NOT EXISTS is_read BOOLEAN DEFAULT false;
|
||||
|
||||
-- 뉴스 인덱스
|
||||
CREATE INDEX IF NOT EXISTS idx_documents_is_read ON documents(is_read) WHERE source_channel = 'news';
|
||||
CREATE INDEX IF NOT EXISTS idx_news_feed ON documents(source_channel, created_at DESC);
|
||||
|
||||
-- 기존 뉴스 재분류 (taxonomy에서 분리)
|
||||
UPDATE documents SET ai_domain = 'News', ai_sub_group = '' WHERE source_channel = 'news';
|
||||
Reference in New Issue
Block a user