From d5174d583547290cb8c8a5afeb59959dad3a8885 Mon Sep 17 00:00:00 2001 From: dsyoon Date: Sat, 7 Feb 2026 16:28:39 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=89=B4=EC=8A=A4=20=EB=A7=81=ED=81=AC?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5/=EC=A1=B0=ED=9A=8C=20=EC=9B=B9=EC=95=B1?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Flask 기반 UI 및 /links API 구현 - 30개 단위 페이지네이션 + 무한 스크롤 적용 - 메타데이터(제목/요약/이미지) 추출 및 캐시 적용 Co-authored-by: Cursor --- .gitignore | 8 ++ app.py | 263 +++++++++++++++++++++++++++++++++++++++++ requirements.txt | 5 + static/app.js | 217 ++++++++++++++++++++++++++++++++++ static/placeholder.svg | 7 ++ static/styles.css | 21 ++++ templates/index.html | 127 ++++++++++++++++++++ 7 files changed, 648 insertions(+) create mode 100644 .gitignore create mode 100644 app.py create mode 100644 requirements.txt create mode 100644 static/app.js create mode 100644 static/placeholder.svg create mode 100644 static/styles.css create mode 100644 templates/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ecfa87 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.env +__pycache__/ +*.pyc +.DS_Store +.venv/ +venv/ +env/ +.pytest_cache/ diff --git a/app.py b/app.py new file mode 100644 index 0000000..7a20d65 --- /dev/null +++ b/app.py @@ -0,0 +1,263 @@ +import os +import time +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from urllib.parse import urlparse + +import psycopg2 +import requests +from bs4 import BeautifulSoup +from dotenv import load_dotenv +from flask import Flask, jsonify, render_template, request + +load_dotenv() + +app = Flask(__name__, static_folder="static", template_folder="templates") + + +DEFAULT_DESCRIPTION = "설명 없음" +DEFAULT_IMAGE = "/static/placeholder.svg" +CACHE_TTL_SECONDS = int(os.getenv("CACHE_TTL_SECONDS", "3600")) +FAILED_TTL_SECONDS = int(os.getenv("FAILED_TTL_SECONDS", "300")) +METADATA_CACHE = {} +PLACEHOLDER_DATA_URI = ( + "data:image/svg+xml;utf8," + "" + "" + "" + "" + "" + "No%20Image" + "" +) +DEFAULT_PAGE_SIZE = int(os.getenv("DEFAULT_PAGE_SIZE", "30")) +MAX_PAGE_SIZE = int(os.getenv("MAX_PAGE_SIZE", "60")) + + +def get_db_connection(): + return psycopg2.connect( + host=os.getenv("DB_HOST"), + port=os.getenv("DB_PORT"), + dbname=os.getenv("DB_NAME"), + user=os.getenv("DB_USER"), + password=os.getenv("DB_PASSWORD"), + ) + + +def normalize_url(raw_url: str) -> str: + if not raw_url: + return raw_url + parsed = urlparse(raw_url) + if parsed.scheme: + return raw_url + return f"https://{raw_url}" + + +def extract_meta(soup: BeautifulSoup, property_name: str, name: str): + tag = soup.find("meta", property=property_name) + if tag and tag.get("content"): + return tag.get("content").strip() + tag = soup.find("meta", attrs={"name": name}) + if tag and tag.get("content"): + return tag.get("content").strip() + return "" + + +def extract_fallback_description(soup: BeautifulSoup) -> str: + for paragraph in soup.find_all("p"): + text = paragraph.get_text(" ", strip=True) + if len(text) >= 40: + return text[:180] + return "" + + +def fetch_metadata(url: str): + fallback = { + "title": url, + "description": DEFAULT_DESCRIPTION, + "image": DEFAULT_IMAGE, + } + cached = METADATA_CACHE.get(url) + now = time.time() + if cached and cached["expires_at"] > now: + return cached["data"] + try: + response = requests.get( + url, + headers={ + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/121.0.0.0 Safari/537.36" + ) + }, + timeout=6, + ) + response.raise_for_status() + soup = BeautifulSoup(response.text, "html.parser") + resolved_url = response.url or url + + title = ( + extract_meta(soup, "og:title", "twitter:title") + or extract_meta(soup, "twitter:title", "title") + or (soup.title.string.strip() if soup.title and soup.title.string else "") + or resolved_url + ) + description = ( + extract_meta(soup, "og:description", "description") + or extract_meta(soup, "twitter:description", "description") + ) + if not description: + description = extract_fallback_description(soup) or DEFAULT_DESCRIPTION + image = ( + extract_meta(soup, "og:image", "twitter:image") + or extract_meta(soup, "twitter:image", "image") + or DEFAULT_IMAGE + ) + data = {"title": title, "description": description, "image": image} + METADATA_CACHE[url] = { + "data": data, + "expires_at": now + CACHE_TTL_SECONDS, + "ok": True, + } + return data + except Exception: + METADATA_CACHE[url] = { + "data": fallback, + "expires_at": now + FAILED_TTL_SECONDS, + "ok": False, + } + return fallback + + +def _clamp_int(value, default: int, minimum: int, maximum: int) -> int: + try: + parsed = int(value) + except Exception: + return default + return max(minimum, min(parsed, maximum)) + + +def fetch_links_page_from_db(limit: int, offset: int): + table = os.getenv("TABLE", "news_link") + with get_db_connection() as conn: + with conn.cursor() as cur: + cur.execute( + f"SELECT id, url, created_at FROM {table} ORDER BY created_at DESC OFFSET %s LIMIT %s", + (offset, limit), + ) + return cur.fetchall() + + +@app.get("/") +def index(): + links = [] + error_message = "" + try: + rows = fetch_links_page_from_db(DEFAULT_PAGE_SIZE, 0) + for link_id, url, created_at in rows: + links.append( + { + "id": link_id, + "url": url, + "created_at": created_at.isoformat() + if isinstance(created_at, datetime) + else str(created_at), + "title": "", + "description": "", + "image": "", + } + ) + except Exception as exc: + error_message = f"DB 조회 실패: {exc}" + return render_template( + "index.html", + links=links, + error_message=error_message, + placeholder_data_uri=PLACEHOLDER_DATA_URI, + default_image=DEFAULT_IMAGE, + ) + + +@app.get("/links") +def get_links(): + limit = _clamp_int( + request.args.get("limit"), DEFAULT_PAGE_SIZE, minimum=1, maximum=MAX_PAGE_SIZE + ) + offset = _clamp_int(request.args.get("offset"), 0, minimum=0, maximum=10_000_000) + + try: + rows_plus_one = fetch_links_page_from_db(limit + 1, offset) + except Exception as exc: + return jsonify({"error": "DB 조회 실패", "detail": str(exc)}), 500 + + has_more = len(rows_plus_one) > limit + rows = rows_plus_one[:limit] + + urls = [url for _, url, _ in rows] + metas = [] + if urls: + with ThreadPoolExecutor(max_workers=min(8, len(urls))) as executor: + metas = list(executor.map(fetch_metadata, urls)) + + results = [] + for (link_id, url, created_at), meta in zip(rows, metas): + results.append( + { + "id": link_id, + "url": url, + "created_at": created_at.isoformat() + if isinstance(created_at, datetime) + else str(created_at), + **meta, + } + ) + return jsonify( + { + "items": results, + "limit": limit, + "offset": offset, + "next_offset": offset + len(results), + "has_more": has_more, + } + ) + + +@app.post("/links") +def add_link(): + data = request.get_json(silent=True) or {} + raw_url = (data.get("url") or "").strip() + if not raw_url: + return jsonify({"error": "URL을 입력해주세요."}), 400 + + url = normalize_url(raw_url) + table = os.getenv("TABLE", "news_link") + try: + with get_db_connection() as conn: + with conn.cursor() as cur: + cur.execute( + f"INSERT INTO {table} (url) VALUES (%s) RETURNING id, created_at", + (url,), + ) + link_id, created_at = cur.fetchone() + conn.commit() + except Exception as exc: + return jsonify({"error": "DB 저장 실패", "detail": str(exc)}), 500 + + meta = fetch_metadata(url) + return jsonify( + { + "id": link_id, + "url": url, + "created_at": created_at.isoformat() + if isinstance(created_at, datetime) + else str(created_at), + **meta, + } + ) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8021, debug=True) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4a05d18 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +Flask +python-dotenv +psycopg2-binary +requests +beautifulsoup4 diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..865eb9d --- /dev/null +++ b/static/app.js @@ -0,0 +1,217 @@ +const linkList = document.getElementById("linkList"); +const emptyState = document.getElementById("emptyState"); +const linkForm = document.getElementById("linkForm"); +const urlInput = document.getElementById("urlInput"); +const formError = document.getElementById("formError"); +const loadingEl = document.getElementById("loading"); +const scrollSentinel = document.getElementById("scrollSentinel"); +const PLACEHOLDER_DATA_URI = + "data:image/svg+xml;utf8," + + "" + + "" + + "" + + "" + + "" + + "No%20Image" + + ""; + +function setLoading(isLoading) { + if (!loadingEl) return; + loadingEl.classList.toggle("d-none", !isLoading); +} + +function extractFirstUrl(text) { + if (!text) return ""; + const trimmed = String(text).trim(); + + // 1) http/https URL + const httpMatch = trimmed.match(/https?:\/\/[^\s<>"')\]]+/i); + if (httpMatch && httpMatch[0]) return httpMatch[0]; + + // 2) www. URL (no scheme) + const wwwMatch = trimmed.match(/www\.[^\s<>"')\]]+/i); + if (wwwMatch && wwwMatch[0]) return `https://${wwwMatch[0]}`; + + return ""; +} + +function setEmptyStateVisible(visible, message) { + if (!emptyState) return; + if (message) emptyState.textContent = message; + emptyState.classList.toggle("d-none", !visible); +} + +function appendLinks(links) { + if (!links.length) return; + setEmptyStateVisible(false); + + links.forEach((link) => { + const col = document.createElement("div"); + col.className = "col-12 col-md-6 col-lg-4"; + + const card = document.createElement("div"); + card.className = "card h-100 shadow-sm"; + + const image = document.createElement("img"); + image.className = "card-img-top"; + image.alt = "미리보기 이미지"; + const needsFallback = + !link.image || link.image === "/static/placeholder.svg"; + image.src = needsFallback ? PLACEHOLDER_DATA_URI : link.image; + if (!needsFallback) { + image.addEventListener("error", () => { + if (image.dataset.fallbackApplied) { + return; + } + image.dataset.fallbackApplied = "1"; + image.onerror = null; + image.src = PLACEHOLDER_DATA_URI; + }); + } + + const body = document.createElement("div"); + body.className = "card-body d-flex flex-column"; + + const title = document.createElement("h5"); + title.className = "card-title mb-2"; + title.textContent = link.title || link.url; + + const description = document.createElement("p"); + description.className = "card-text text-secondary flex-grow-1"; + description.textContent = link.description || "설명 없음"; + + const linkBtn = document.createElement("a"); + linkBtn.className = "btn btn-outline-primary btn-sm"; + linkBtn.href = link.url; + linkBtn.target = "_blank"; + linkBtn.rel = "noopener"; + linkBtn.textContent = "원문 보기"; + + body.append(title, description, linkBtn); + card.append(image, body); + col.appendChild(card); + linkList.appendChild(col); + }); +} + +const PAGE_SIZE = 30; +let nextOffset = 0; +let hasMore = true; +let loading = false; + +function resetPagination() { + nextOffset = 0; + hasMore = true; + loading = false; + linkList.innerHTML = ""; +} + +async function loadNextPage() { + if (loading || !hasMore) return; + loading = true; + setLoading(true); + setEmptyStateVisible(false); + + try { + const res = await fetch(`/links?limit=${PAGE_SIZE}&offset=${nextOffset}`); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || "링크를 불러오지 못했습니다."); + } + + const items = Array.isArray(data) ? data : data.items || []; + appendLinks(items); + + if (Array.isArray(data)) { + nextOffset += items.length; + hasMore = items.length === PAGE_SIZE; + } else { + nextOffset = data.next_offset ?? nextOffset + items.length; + hasMore = Boolean(data.has_more); + } + + if (nextOffset === 0 && items.length === 0) { + setEmptyStateVisible(true, "아직 저장된 링크가 없습니다."); + } + } catch (err) { + if (nextOffset === 0) { + setEmptyStateVisible(true, err.message); + } + hasMore = false; + } finally { + loading = false; + setLoading(false); + } +} + +linkForm.addEventListener("submit", async (event) => { + event.preventDefault(); + formError.textContent = ""; + + const url = urlInput.value.trim(); + if (!url) { + formError.textContent = "URL을 입력해주세요."; + return; + } + + try { + const res = await fetch("/links", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url }), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.error || "저장에 실패했습니다."); + } + + resetPagination(); + await loadNextPage(); + + const modalElement = document.getElementById("linkModal"); + const modal = bootstrap.Modal.getOrCreateInstance(modalElement); + modal.hide(); + urlInput.value = ""; + } catch (err) { + formError.textContent = err.message; + } +}); + +if (urlInput) { + urlInput.addEventListener("paste", (event) => { + const text = event.clipboardData?.getData("text") ?? ""; + const extracted = extractFirstUrl(text); + if (!extracted) return; + event.preventDefault(); + urlInput.value = extracted; + urlInput.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +function setupInfiniteScroll() { + if (!scrollSentinel) return; + const observer = new IntersectionObserver( + (entries) => { + const entry = entries[0]; + if (entry && entry.isIntersecting) { + loadNextPage(); + } + }, + { root: null, rootMargin: "400px 0px", threshold: 0 } + ); + observer.observe(scrollSentinel); +} + +function init() { + resetPagination(); + setupInfiniteScroll(); + loadNextPage(); +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); +} else { + init(); +} diff --git a/static/placeholder.svg b/static/placeholder.svg new file mode 100644 index 0000000..a662fd9 --- /dev/null +++ b/static/placeholder.svg @@ -0,0 +1,7 @@ + + + + + + No Image + diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..551f71c --- /dev/null +++ b/static/styles.css @@ -0,0 +1,21 @@ +body { + background: #f8f9fb; +} + +.floating-btn { + position: fixed; + right: 24px; + bottom: 24px; + width: 56px; + height: 56px; + font-size: 32px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; +} + +.card-img-top { + height: 180px; + object-fit: cover; +} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..1ce44e0 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,127 @@ + + + + + + News Link + + + + +
+
+
+

뉴스 링크

+

저장한 링크의 요약을 한눈에 확인하세요.

+
+
+ + {% if error_message %} +
{{ error_message }}
+ {% endif %} + +
+ 아직 저장된 링크가 없습니다. +
+ + + +
+ 불러오는 중... +
+
+
+ + + + + + + + +