14 changed files with 766 additions and 158 deletions
+5 -1
View File
@@ -5,6 +5,7 @@ from datetime import UTC, datetime
from sqlalchemy import Engine, create_engine, event from sqlalchemy import Engine, create_engine, event
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
from sqlalchemy.pool import NullPool
from app.config import get_settings from app.config import get_settings
@@ -20,7 +21,10 @@ def create_sqlite_engine(database_url: str):
db_path.parent.mkdir(parents=True, exist_ok=True) db_path.parent.mkdir(parents=True, exist_ok=True)
connect_args = {"check_same_thread": False, "timeout": 10} connect_args = {"check_same_thread": False, "timeout": 10}
engine = create_engine(database_url, connect_args=connect_args) engine_kwargs = {"connect_args": connect_args}
if database_url.startswith("sqlite:///") and database_url != "sqlite:///:memory:":
engine_kwargs["poolclass"] = NullPool
engine = create_engine(database_url, **engine_kwargs)
engine.dialect.connect_args = connect_args engine.dialect.connect_args = connect_args
@event.listens_for(engine, "connect") @event.listens_for(engine, "connect")
+26 -5
View File
@@ -94,15 +94,36 @@ class TikHubClient:
status_code=response.status_code, status_code=response.status_code,
) )
if response.is_error: if response.is_error:
raise PlatformAPIError( if attempt >= self.max_retries:
f"External API returned HTTP {response.status_code}", raise PlatformAPIError(
error_type="api_error", self._format_api_error(response),
status_code=response.status_code, error_type="api_error",
) status_code=response.status_code,
)
time.sleep(2**attempt)
continue
return response.json() return response.json()
raise PlatformAPIError("External API request failed", error_type="api_error", status_code=last_status) raise PlatformAPIError("External API request failed", error_type="api_error", status_code=last_status)
def _format_api_error(self, response: httpx.Response) -> str:
message = f"External API returned HTTP {response.status_code}"
detail = self._response_error_detail(response)
return f"{message}: {detail}" if detail else message
def _response_error_detail(self, response: httpx.Response) -> str | None:
try:
payload = response.json()
except ValueError:
text = response.text.strip()
return text[:200] if text else None
if isinstance(payload, dict):
for key in ("message_zh", "message", "error", "detail"):
value = payload.get(key)
if value:
return str(value)[:200]
return None
def parse_timestamp(value: Any) -> datetime | None: def parse_timestamp(value: Any) -> datetime | None:
if value in (None, ""): if value in (None, ""):
+4 -5
View File
@@ -3,6 +3,7 @@ import json
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from collections.abc import Callable from collections.abc import Callable
from datetime import UTC, datetime from datetime import UTC, datetime
from zoneinfo import ZoneInfo
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session, sessionmaker from sqlalchemy.orm import Session, sessionmaker
@@ -22,6 +23,7 @@ RUNNING_TASK_MESSAGE = "当前有正在运行的任务,请稍后再试"
RESTART_ERROR_MESSAGE = "系统重启,任务被中断" RESTART_ERROR_MESSAGE = "系统重启,任务被中断"
STALE_PROGRESS_THRESHOLD_SECONDS = 10 * 60 STALE_PROGRESS_THRESHOLD_SECONDS = 10 * 60
STALE_PROGRESS_ERROR_TYPE = "stale_progress_timeout" STALE_PROGRESS_ERROR_TYPE = "stale_progress_timeout"
DISPLAY_TIMEZONE = ZoneInfo("Asia/Shanghai")
task_executor = ThreadPoolExecutor(max_workers=1) task_executor = ThreadPoolExecutor(max_workers=1)
@@ -183,10 +185,7 @@ def hydrate_task_progress(session: Session, task: Task) -> Task:
task.display_number = display_number task.display_number = display_number
task.display_id = str(display_number) task.display_id = str(display_number)
task.created_at_label = format_datetime_minute(task.created_at) task.created_at_label = format_datetime_minute(task.created_at)
task.scale_label = ( task.scale_label = f"{task.hotspot_limit}热点 × {task.item_limit_per_hotspot}内容 × {task.comment_limit_per_item}评论"
f"{PLATFORM_LABELS.get(task.platform, task.platform)} · "
f"{task.hotspot_limit}热点 × {task.item_limit_per_hotspot}内容 × {task.comment_limit_per_item}评论"
)
task.error_summary = build_task_error_summary(task) task.error_summary = build_task_error_summary(task)
task.hotspots_count = session.scalar(select(func.count(Hotspot.id)).where(Hotspot.task_id == task.id)) or 0 task.hotspots_count = session.scalar(select(func.count(Hotspot.id)).where(Hotspot.task_id == task.id)) or 0
task.comments_count = session.scalar(select(func.count(Comment.id)).where(Comment.task_id == task.id)) or 0 task.comments_count = session.scalar(select(func.count(Comment.id)).where(Comment.task_id == task.id)) or 0
@@ -230,7 +229,7 @@ def calculate_task_display_number(session: Session, task: Task) -> int:
def format_datetime_minute(value: datetime | None) -> str: def format_datetime_minute(value: datetime | None) -> str:
value = ensure_utc_datetime(value) value = ensure_utc_datetime(value)
return value.strftime("%Y-%m-%d %H:%M") if value else "" return value.astimezone(DISPLAY_TIMEZONE).strftime("%Y-%m-%d %H:%M") if value else ""
def build_task_error_summary(task: Task) -> str | None: def build_task_error_summary(task: Task) -> str | None:
+502 -66
View File
@@ -1,101 +1,537 @@
:root {
--background: #f8fafc;
--foreground: #0f172a;
--muted: #f1f5f9;
--muted-foreground: #64748b;
--card: #ffffff;
--card-foreground: #0f172a;
--border: #e2e8f0;
--input: #cbd5e1;
--primary: #0f172a;
--primary-foreground: #ffffff;
--secondary: #f1f5f9;
--secondary-foreground: #0f172a;
--accent: #f8fafc;
--accent-foreground: #0f172a;
--destructive: #dc2626;
--success: #16a34a;
--warning: #d97706;
--ring: rgba(15, 23, 42, 0.18);
--radius: 8px;
}
body { body {
background: #f5f7fb; background: var(--background);
color: #172033; color: var(--foreground);
} font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
.card {
border-radius: 8px;
border: 1px solid #dfe5ef;
box-shadow: 0 10px 28px rgba(31, 42, 68, 0.06);
}
.navbar {
background: #fff !important;
}
.hero-band {
align-items: flex-end;
background:
linear-gradient(135deg, rgba(12, 22, 40, 0.92), rgba(29, 80, 108, 0.78)),
url("https://images.unsplash.com/photo-1551288049-bebda4e38f71?auto=format&fit=crop&w=1600&q=80");
background-position: center;
background-size: cover;
border-radius: 8px;
color: #fff;
display: flex;
justify-content: space-between;
min-height: 300px;
padding: 42px;
}
.hero-copy {
max-width: 680px;
}
.hero-copy h1 {
font-size: 48px;
font-weight: 700;
letter-spacing: 0; letter-spacing: 0;
margin-bottom: 14px; min-height: 100vh;
} }
.hero-copy p:not(.eyebrow) { a {
color: rgba(255, 255, 255, 0.84); color: inherit;
font-size: 18px; }
line-height: 1.7;
.container {
max-width: 1180px;
}
.app-navbar {
background: rgba(255, 255, 255, 0.92) !important;
border-bottom: 1px solid var(--border);
backdrop-filter: blur(14px);
}
.app-navbar .container {
min-height: 56px;
}
.navbar-brand {
color: var(--foreground);
font-size: 15px;
letter-spacing: 0;
}
.nav-link {
border-radius: 6px;
color: var(--muted-foreground);
font-size: 14px;
padding: 6px 10px;
}
.nav-link:hover {
background: var(--secondary);
color: var(--foreground);
}
.breadcrumb {
color: var(--muted-foreground);
font-size: 13px;
margin-bottom: 20px;
}
.breadcrumb a {
color: var(--muted-foreground);
text-decoration: none;
}
.breadcrumb a:hover {
color: var(--foreground);
}
.tool-card,
.card,
.accordion-item {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
color: var(--card-foreground);
overflow: hidden;
}
.card-body {
padding: 20px;
}
.card-header,
.section-heading {
background: var(--card);
border-bottom: 1px solid var(--border);
padding: 16px 20px;
}
.section-heading {
align-items: center;
display: flex;
gap: 16px;
justify-content: space-between;
}
.section-heading h2,
.page-heading h1,
.page-title {
color: var(--foreground);
font-size: 22px;
font-weight: 650;
letter-spacing: 0;
line-height: 1.25;
margin: 0; margin: 0;
} }
.hero-actions { .page-heading {
align-items: flex-start;
display: flex; display: flex;
flex-wrap: wrap; gap: 16px;
gap: 12px; justify-content: space-between;
margin-bottom: 18px;
} }
.section-kicker,
.eyebrow { .eyebrow {
color: #71d4c7; color: var(--muted-foreground);
font-size: 13px; font-size: 12px;
font-weight: 700; font-weight: 650;
letter-spacing: 0; letter-spacing: 0;
margin: 0 0 6px;
text-transform: uppercase; text-transform: uppercase;
} }
.metric-box { .hero-band {
background: #f8fafc; background: var(--card);
border: 1px solid #e3e9f2; border: 1px solid var(--border);
border-radius: 8px; border-radius: var(--radius);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
color: var(--foreground);
padding: 30px;
}
.hero-shell {
align-items: flex-end;
display: flex;
gap: 28px;
justify-content: space-between;
}
.hero-copy {
max-width: 700px;
}
.hero-copy h1 {
color: var(--foreground);
font-size: 34px;
font-weight: 700;
letter-spacing: 0;
line-height: 1.15;
margin: 0 0 12px;
}
.hero-copy p:not(.eyebrow) {
color: var(--muted-foreground);
font-size: 15px;
line-height: 1.75;
margin: 0;
}
.hero-metrics {
display: grid;
gap: 10px;
grid-template-columns: repeat(3, minmax(84px, 1fr));
min-width: 320px;
}
.hero-metric {
background: var(--muted);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px;
}
.hero-metric strong {
display: block;
font-size: 18px;
line-height: 1.2;
}
.hero-metric span {
color: var(--muted-foreground);
display: block;
font-size: 12px;
margin-top: 4px;
}
.btn {
border-radius: 6px;
font-size: 14px;
font-weight: 500;
}
.btn-primary {
background: var(--primary);
border-color: var(--primary);
color: var(--primary-foreground);
}
.btn-primary:hover,
.btn-primary:focus {
background: #1e293b;
border-color: #1e293b;
}
.btn-outline-primary,
.btn-outline-secondary {
background: var(--card);
border-color: var(--border);
color: var(--foreground);
}
.btn-outline-primary:hover,
.btn-outline-secondary:hover {
background: var(--secondary);
border-color: var(--border);
color: var(--foreground);
}
.form-label {
color: var(--foreground);
font-size: 13px;
font-weight: 600;
}
.form-control,
.form-select {
border-color: var(--input);
border-radius: 6px;
color: var(--foreground);
font-size: 14px;
min-height: 40px;
}
.form-control:focus,
.form-select:focus {
border-color: var(--foreground);
box-shadow: 0 0 0 3px var(--ring);
}
.form-text {
color: var(--muted-foreground) !important;
}
.badge {
border-radius: 999px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0;
padding: 5px 9px;
}
.bg-success,
.text-bg-success {
background-color: #dcfce7 !important;
color: #166534 !important;
}
.bg-danger,
.text-bg-danger {
background-color: #fee2e2 !important;
color: #991b1b !important;
}
.bg-warning,
.text-bg-warning {
background-color: #fef3c7 !important;
color: #92400e !important;
}
.bg-secondary,
.text-bg-secondary {
background-color: var(--secondary) !important;
color: var(--secondary-foreground) !important;
}
.text-bg-light {
background: var(--secondary) !important;
color: var(--foreground) !important;
}
.alert {
border-radius: var(--radius);
font-size: 14px;
margin-bottom: 16px;
}
.alert-info {
background: #eff6ff;
border-color: #bfdbfe;
color: #1e3a8a;
}
.alert-warning {
background: #fffbeb;
border-color: #fde68a;
color: #92400e;
}
.alert-danger {
background: #fef2f2;
border-color: #fecaca;
color: #991b1b;
}
.alert-secondary {
background: var(--secondary);
border-color: var(--border);
color: var(--foreground);
}
.metric-box,
.stat-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
height: 100%; height: 100%;
padding: 14px; padding: 16px;
} }
.status-panel { .metric-box strong,
background: #fff; .stat-card strong {
border: 1px solid #dfe5ef; color: var(--foreground);
border-radius: 8px; display: block;
padding: 32px; font-size: 18px;
font-weight: 650;
line-height: 1.35;
margin: 4px 0;
} }
.status-panel-danger { .app-table {
border-color: #f1b8b8; color: var(--foreground);
min-width: 760px;
}
.app-table thead th {
background: var(--muted);
border-bottom: 1px solid var(--border);
color: var(--muted-foreground);
font-size: 12px;
font-weight: 650;
height: 42px;
letter-spacing: 0;
text-transform: none;
white-space: nowrap;
}
.app-table td {
border-color: var(--border);
padding-bottom: 14px;
padding-top: 14px;
vertical-align: middle;
}
.app-table tbody tr:hover {
background: var(--accent);
}
.task-number {
align-items: center;
background: var(--secondary);
border: 1px solid var(--border);
border-radius: 999px;
color: var(--foreground);
display: inline-flex;
font-weight: 650;
height: 30px;
justify-content: center;
min-width: 30px;
padding: 0 9px;
} }
.task-progress { .task-progress {
height: 10px; background: var(--secondary);
border-radius: 999px;
height: 8px;
}
.progress-bar {
background-color: var(--primary);
} }
.report-progress { .report-progress {
height: 10px; background: var(--secondary);
height: 8px;
}
.task-overview {
border-top: 0;
}
.progress-summary {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.hotspot-item {
border-color: var(--border);
margin-bottom: 10px;
}
.accordion-button {
background: var(--card);
color: var(--foreground);
font-size: 15px;
font-weight: 600;
gap: 10px;
padding: 16px 18px;
}
.accordion-button:not(.collapsed) {
background: var(--muted);
box-shadow: none;
color: var(--foreground);
}
.accordion-button:focus {
border-color: var(--border);
box-shadow: 0 0 0 3px var(--ring);
}
.content-item-row {
align-items: center;
display: flex;
gap: 14px;
justify-content: space-between;
}
.content-item-title {
color: var(--foreground);
display: block;
font-weight: 600;
line-height: 1.45;
overflow-wrap: anywhere;
}
.content-item-meta {
color: var(--muted-foreground);
display: block;
font-size: 12px;
margin-top: 3px;
overflow-wrap: anywhere;
}
.report-summary {
background: var(--muted);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
}
.comment-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px;
}
.empty-state,
.status-panel {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 32px;
text-align: center;
}
.status-panel-danger {
border-color: #fecaca;
}
@media (max-width: 992px) {
.hero-shell {
align-items: flex-start;
flex-direction: column;
}
.hero-metrics {
min-width: 0;
width: 100%;
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
main.container {
padding-left: 14px;
padding-right: 14px;
}
.hero-band { .hero-band {
align-items: flex-start; padding: 22px;
flex-direction: column;
min-height: 360px;
padding: 28px;
} }
.hero-copy h1 { .hero-copy h1 {
font-size: 36px; font-size: 28px;
}
.hero-metrics {
grid-template-columns: 1fr;
}
.section-heading,
.page-heading,
.content-item-row {
align-items: flex-start;
flex-direction: column;
}
.card-body {
padding: 16px;
}
.content-item-row .btn {
width: 100%;
}
.accordion-button {
align-items: flex-start;
} }
} }
+3 -3
View File
@@ -5,14 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}热榜评论分析工具{% endblock %}</title> <title>{% block title %}热榜评论分析工具{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="/static/app.css" rel="stylesheet"> <link href="/static/app.css?v=shadcn-ui-refresh" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="/static/app.js" defer></script> <script src="/static/app.js" defer></script>
</head> </head>
<body> <body>
<nav class="navbar navbar-expand-lg bg-body-tertiary border-bottom"> <nav class="navbar navbar-expand-lg app-navbar">
<div class="container"> <div class="container">
<a class="navbar-brand" href="/">热榜评论分析工具</a> <a class="navbar-brand fw-semibold" href="/">热榜评论雷达</a>
<div class="navbar-nav"> <div class="navbar-nav">
<a class="nav-link" href="/">任务列表</a> <a class="nav-link" href="/">任务列表</a>
</div> </div>
+8 -4
View File
@@ -8,15 +8,19 @@
</ol></nav> </ol></nav>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="page-heading">
<h1 class="h3 mb-0">{{ hotspot.title }} 汇总报告</h1> <div>
<p class="section-kicker">热点报告</p>
<h1>{{ hotspot.title }} 汇总报告</h1>
</div>
<span class="badge text-bg-light border">热点 #{{ hotspot.rank }}</span>
</div> </div>
{% if report %} {% if report %}
{% include "partials/report_panel.html" %} {% include "partials/report_panel.html" %}
{% else %} {% else %}
<div class="text-center text-muted py-5"> <div class="empty-state text-muted py-5">
<div class="spinner-border text-warning mb-3" role="status"></div> <div class="spinner-border text-warning mb-3" role="status"></div>
<p>报告生成中,请稍候...</p> <p class="mb-0">报告生成中,请稍候...</p>
</div> </div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
+36 -15
View File
@@ -3,19 +3,37 @@
{% block title %}热榜评论雷达 - 热榜评论分析工具{% endblock %} {% block title %}热榜评论雷达 - 热榜评论分析工具{% endblock %}
{% block content %} {% block content %}
<section class="hero-band mb-4"> <section class="hero-band mb-4">
<div class="hero-copy"> <div class="hero-shell">
<p class="eyebrow">Hot Comment Radar</p> <div class="hero-copy">
<h1>热榜评论雷达</h1> <p class="eyebrow">Hot Comment Radar</p>
<p>从小红书和抖音热点出发,抓取真实评论,生成 AI 情绪、标签和可导出的分析报告。</p> <h1>热榜评论雷达</h1>
</div> <p>从小红书和抖音热点出发,抓取真实评论,生成 AI 情绪、标签和可导出的分析报告。</p>
<div class="hero-actions"> </div>
<a class="btn btn-light" href="#create-task">创建新任务</a> <div class="hero-metrics" aria-label="默认任务规模">
<a class="btn btn-outline-light" href="#task-history">查看 Demo 数据</a> <div class="hero-metric">
<strong>5</strong>
<span>默认热点</span>
</div>
<div class="hero-metric">
<strong>25</strong>
<span>默认内容</span>
</div>
<div class="hero-metric">
<strong>1,250</strong>
<span>评论上限</span>
</div>
</div>
</div> </div>
</section> </section>
<section class="card mb-4" id="create-task"> <section class="tool-card mb-4" id="create-task">
<div class="card-header">创建抓取任务</div> <div class="section-heading">
<div>
<p class="section-kicker">采集配置</p>
<h2>创建抓取任务</h2>
</div>
<span class="badge text-bg-light border">默认规模 5 × 5 × 50</span>
</div>
<div class="card-body"> <div class="card-body">
<div id="form-error" class="alert alert-danger d-none" role="alert"></div> <div id="form-error" class="alert alert-danger d-none" role="alert"></div>
<form id="task-form" onsubmit="submitTask(event)"> <form id="task-form" onsubmit="submitTask(event)">
@@ -50,13 +68,16 @@
</div> </div>
</section> </section>
<section class="card" id="task-history"> <section class="tool-card" id="task-history">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="section-heading">
<span>最近任务与 Demo 数据</span> <div>
<button class="btn btn-outline-secondary btn-sm" onclick="window.location.href='/'">手动刷新</button> <p class="section-kicker">任务历史</p>
<h2>最近任务</h2>
</div>
{% if has_running_tasks %}<span class="badge text-bg-warning">自动更新中</span>{% endif %}
</div> </div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle mb-0"> <table class="table app-table align-middle mb-0">
<thead> <thead>
<tr> <tr>
<th>任务 ID</th> <th>任务 ID</th>
+10 -6
View File
@@ -9,21 +9,25 @@
</ol></nav> </ol></nav>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="page-heading">
<h1 class="h3 mb-0">{{ item.title or item.source_item_id }}</h1> <div>
<p class="section-kicker">内容详情</p>
<h1>{{ item.title or item.source_item_id }}</h1>
</div>
<span class="badge text-bg-light border">{{ item.status }}</span>
</div> </div>
{% if report %} {% if report %}
{% include "partials/report_panel.html" %} {% include "partials/report_panel.html" %}
{% else %} {% else %}
<div class="text-center text-muted py-5"> <div class="empty-state text-muted py-5">
<div class="spinner-border text-warning mb-3" role="status"></div> <div class="spinner-border text-warning mb-3" role="status"></div>
<p>报告生成中,请稍候...</p> <p class="mb-0">报告生成中,请稍候...</p>
</div> </div>
{% endif %} {% endif %}
<section class="card"><div class="card-header">评论明细</div><div class="table-responsive"> <section class="card"><div class="card-header">评论明细</div><div class="table-responsive">
<table class="table mb-0"><thead><tr><th>评论内容</th><th>情绪</th><th>标签</th><th>点赞</th></tr></thead><tbody> <table class="table app-table mb-0"><thead><tr><th>评论内容</th><th>情绪</th><th>标签</th><th>点赞</th></tr></thead><tbody>
{% for comment in comments %} {% for comment in comments %}
<tr><td>{{ comment.content }}</td><td>{{ comment.sentiment }}</td><td>{% for label in comment.labels | from_json %}<span class="badge bg-secondary me-1">{{ label }}</span>{% else %}-{% endfor %}</td><td>{{ comment.like_count or 0 }}</td></tr> <tr><td>{{ comment.content }}</td><td><span class="badge text-bg-light border">{{ comment.sentiment }}</span></td><td>{% for label in comment.labels | from_json %}<span class="badge text-bg-light border me-1">{{ label }}</span>{% else %}<span class="text-muted">-</span>{% endfor %}</td><td>{{ comment.like_count or 0 }}</td></tr>
{% else %} {% else %}
<tr><td colspan="4" class="text-center text-muted">暂无评论数据(该内容无评论或评论抓取为空)</td></tr> <tr><td colspan="4" class="text-center text-muted">暂无评论数据(该内容无评论或评论抓取为空)</td></tr>
{% endfor %} {% endfor %}
+5 -5
View File
@@ -13,28 +13,28 @@
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
<div class="col-md-3"> <div class="col-md-3">
<div class="border rounded p-3 h-100"> <div class="stat-card">
<div class="text-muted small">评论样本</div> <div class="text-muted small">评论样本</div>
<div class="h4 mb-0">{{ sample_count }}</div> <div class="h4 mb-0">{{ sample_count }}</div>
</div> </div>
</div> </div>
{% if item_count is not none %} {% if item_count is not none %}
<div class="col-md-3"> <div class="col-md-3">
<div class="border rounded p-3 h-100"> <div class="stat-card">
<div class="text-muted small">关联内容</div> <div class="text-muted small">关联内容</div>
<div class="h4 mb-0">{{ item_count }}</div> <div class="h4 mb-0">{{ item_count }}</div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
<div class="col-md-3"> <div class="col-md-3">
<div class="border rounded p-3 h-100"> <div class="stat-card">
<div class="text-muted small">AI 成功率</div> <div class="text-muted small">AI 成功率</div>
<div class="h4 mb-0">{{ rate_percent(task.analysis_success_rate) }}%</div> <div class="h4 mb-0">{{ rate_percent(task.analysis_success_rate) }}%</div>
</div> </div>
</div> </div>
</div> </div>
<div class="p-3 bg-info-subtle border border-info-subtle rounded mb-4"> <div class="report-summary mb-4">
<div class="fw-semibold mb-2">AI 总结</div> <div class="fw-semibold mb-2">AI 总结</div>
<p class="mb-0">{{ report.summary or "总结生成失败,请查看上方统计数据。" }}</p> <p class="mb-0">{{ report.summary or "总结生成失败,请查看上方统计数据。" }}</p>
</div> </div>
@@ -77,7 +77,7 @@
<div class="col-md-4"> <div class="col-md-4">
<h3 class="h6">{{ sentiment_label(key) }}</h3> <h3 class="h6">{{ sentiment_label(key) }}</h3>
{% for comment in typical.get(key, []) %} {% for comment in typical.get(key, []) %}
<div class="border rounded p-2 mb-2"> <div class="comment-card mb-2">
<p class="mb-1">{{ comment.get("content") }}</p> <p class="mb-1">{{ comment.get("content") }}</p>
<small class="text-muted">点赞 {{ comment.get("like_count", 0) }}</small> <small class="text-muted">点赞 {{ comment.get("like_count", 0) }}</small>
</div> </div>
+10 -10
View File
@@ -4,14 +4,11 @@
{% set ai_percent = rate_percent(task.analysis_success_rate) %} {% set ai_percent = rate_percent(task.analysis_success_rate) %}
<tr> <tr>
<td> <td>
<strong>{{ task.display_id or loop.index }}</strong> <span class="task-number">{{ task.display_id or loop.index }}</span>
{% if task.is_demo %}
<br><span class="badge text-bg-info">Demo 数据</span>
{% endif %}
</td> </td>
<td>{{ task.platform | platform_label }}</td> <td><span class="badge text-bg-light border">{{ task.platform | platform_label }}</span></td>
<td>{{ task.created_at_label }}</td> <td><span class="text-muted small">{{ task.created_at_label }}</span></td>
<td><small class="text-muted">{{ task.scale_label }}</small></td> <td><span class="text-muted small">{{ task.scale_label }}</span></td>
<td class="task-progress-cell"> <td class="task-progress-cell">
<div class="d-flex justify-content-between align-items-center gap-2"> <div class="d-flex justify-content-between align-items-center gap-2">
<span class="small">已处理 {{ task.processed_items_count }} / 共 {{ task.total_items_count }} 条内容</span> <span class="small">已处理 {{ task.processed_items_count }} / 共 {{ task.total_items_count }} 条内容</span>
@@ -20,9 +17,12 @@
<div class="progress task-progress mt-1" role="progressbar" aria-label="任务进度" aria-valuenow="{{ percent }}" aria-valuemin="0" aria-valuemax="100"> <div class="progress task-progress mt-1" role="progressbar" aria-label="任务进度" aria-valuenow="{{ percent }}" aria-valuemin="0" aria-valuemax="100">
<div class="progress-bar" style="width: {{ percent }}%"></div> <div class="progress-bar" style="width: {{ percent }}%"></div>
</div> </div>
<div class="small text-muted mt-1">成功 {{ task.successful_items_count }} / 失败 {{ task.failed_items_count }}</div> <div class="progress-summary mt-2">
<span class="small text-muted">成功 {{ task.successful_items_count }} / 失败 {{ task.failed_items_count }}</span>
<span class="small text-muted">评论 {{ task.comments_count or 0 }}</span>
<span class="small text-muted">报告 {{ task.reports_count or 0 }}</span>
</div>
<div class="small text-muted mt-1">阶段:{{ task.current_stage_label or "等待启动" }}</div> <div class="small text-muted mt-1">阶段:{{ task.current_stage_label or "等待启动" }}</div>
<div class="small text-muted mt-1">评论 {{ task.comments_count or 0 }} / 报告 {{ task.reports_count or 0 }}</div>
<div class="small {% if task.analysis_status == 'insufficient' %}text-warning{% else %}text-muted{% endif %}"> <div class="small {% if task.analysis_status == 'insufficient' %}text-warning{% else %}text-muted{% endif %}">
AI 成功率 {{ ai_percent }}% AI 成功率 {{ ai_percent }}%
{% if task.analysis_status == "insufficient" %} {% if task.analysis_status == "insufficient" %}
@@ -36,7 +36,7 @@
<br><small class="text-danger">{{ task.error_summary }}</small> <br><small class="text-danger">{{ task.error_summary }}</small>
{% endif %} {% endif %}
</td> </td>
<td><a class="btn btn-sm btn-outline-primary" href="/tasks/{{ task.id }}">查看</a></td> <td><a class="btn btn-sm btn-outline-secondary" href="/tasks/{{ task.id }}">查看</a></td>
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
+42 -26
View File
@@ -7,21 +7,27 @@
</ol></nav> </ol></nav>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="page-heading mb-3">
<h1 class="h3 mb-0">任务 {{ task.display_id }}</h1> <div>
<button class="btn btn-outline-secondary btn-sm" onclick="window.location.reload()">手动刷新</button> <p class="section-kicker">任务详情</p>
</div> <h1>任务 {{ task.display_id }}</h1>
<section class="card mb-4" {% if task.status == "running" %}data-task-id="{{ task.id }}" data-auto-poll="true"{% endif %}><div class="card-body"> </div>
{% set label, cls = status_badge_config(task.status) %} {% set label, cls = status_badge_config(task.status) %}
<span class="badge {{ cls }}">{{ label }}</span>
</div>
<section class="tool-card task-overview mb-4" {% if task.status == "running" %}data-task-id="{{ task.id }}" data-auto-poll="true"{% endif %}>
<div class="card-body">
{% set percent = progress_percent(task.processed_items_count, task.total_items_count) %} {% set percent = progress_percent(task.processed_items_count, task.total_items_count) %}
{% set ai_percent = rate_percent(task.analysis_success_rate) %} {% set ai_percent = rate_percent(task.analysis_success_rate) %}
{% set target_comments = task.hotspot_limit * task.item_limit_per_hotspot * task.comment_limit_per_item %} {% set target_comments = task.hotspot_limit * task.item_limit_per_hotspot * task.comment_limit_per_item %}
<div class="d-flex flex-wrap justify-content-between gap-3 mb-3"> <div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
<p class="mb-0">平台:{{ task.platform | platform_label }} <span class="badge {{ cls }}">{{ label }}</span>{% if task.is_demo %}<span class="badge text-bg-info ms-1">Demo 数据</span>{% endif %}</p> <div>
<p class="mb-0 text-muted">阶段:{{ task.current_stage_label or "等待启动" }}</p> <span class="badge text-bg-light border">{{ task.platform | platform_label }}</span>
<span class="badge text-bg-light border ms-1">{{ task.current_stage_label or "等待启动" }}</span>
</div>
<p class="mb-0 text-muted small">创建时间 {{ task.created_at_label }} · 阶段 {{ task.current_stage_label or "等待启动" }}</p>
</div> </div>
<div class="small text-muted mb-3">创建时间 {{ task.created_at_label }} · 完整 ID {{ task.id }}</div> <div class="mb-4">
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center gap-2"> <div class="d-flex justify-content-between align-items-center gap-2">
<span>已处理 {{ task.processed_items_count }} / 共 {{ task.total_items_count }} 条内容</span> <span>已处理 {{ task.processed_items_count }} / 共 {{ task.total_items_count }} 条内容</span>
<span class="text-muted">{{ percent }}%</span> <span class="text-muted">{{ percent }}%</span>
@@ -29,7 +35,10 @@
<div class="progress task-progress mt-2" role="progressbar" aria-label="任务进度" aria-valuenow="{{ percent }}" aria-valuemin="0" aria-valuemax="100"> <div class="progress task-progress mt-2" role="progressbar" aria-label="任务进度" aria-valuenow="{{ percent }}" aria-valuemin="0" aria-valuemax="100">
<div class="progress-bar" style="width: {{ percent }}%"></div> <div class="progress-bar" style="width: {{ percent }}%"></div>
</div> </div>
<div class="small text-muted mt-2">成功 {{ task.successful_items_count }} / 失败 {{ task.failed_items_count }}</div> <div class="progress-summary mt-2">
<span class="small text-muted">成功 {{ task.successful_items_count }} / 失败 {{ task.failed_items_count }}</span>
<span class="small text-muted">AI 成功率 {{ ai_percent }}%</span>
</div>
</div> </div>
<div class="row g-3 mb-3"> <div class="row g-3 mb-3">
<div class="col-md-4"> <div class="col-md-4">
@@ -67,33 +76,40 @@
{% if task.status == "success" and (task.comments_count or 0) < target_comments %} {% if task.status == "success" and (task.comments_count or 0) < target_comments %}
<div class="alert alert-info">少于理论上限通常是内容本身评论不足或平台返回不足,不直接代表任务失败。若失败内容数大于 0,请结合失败原因判断。</div> <div class="alert alert-info">少于理论上限通常是内容本身评论不足或平台返回不足,不直接代表任务失败。若失败内容数大于 0,请结合失败原因判断。</div>
{% endif %} {% endif %}
<div class="mb-2"> {% if task.analysis_status == "insufficient" %}
<span class="{% if task.analysis_status == 'insufficient' %}text-warning{% else %}text-muted{% endif %}">AI 成功率 {{ ai_percent }}%</span> <div class="alert alert-warning">AI 成功率 {{ ai_percent }}%,当前样本可能不足,报告结论请结合评论明细判断。</div>
{% if task.analysis_status == "insufficient" %} {% endif %}
<span class="badge text-bg-warning ms-1">AI 样本不足</span>
{% endif %}
</div>
{% if task.error_summary %}<div class="alert alert-danger">{{ task.error_summary }}</div>{% endif %} {% if task.error_summary %}<div class="alert alert-danger">{{ task.error_summary }}</div>{% endif %}
</div></section> </div>
</section>
{% if task.status == "running" and not hotspots %} {% if task.status == "running" and not hotspots %}
<div class="text-center text-muted py-5"><div class="spinner-border text-warning mb-3"></div><p>正在抓取热点数据,请稍候...</p></div> <div class="empty-state text-muted py-5"><div class="spinner-border text-warning mb-3"></div><p class="mb-0">正在抓取热点数据,请稍候...</p></div>
{% endif %} {% endif %}
<div class="accordion" id="hotspot-list"> <div class="accordion" id="hotspot-list">
{% for hotspot in hotspots %} {% for hotspot in hotspots %}
<div class="accordion-item"> <div class="accordion-item hotspot-item">
<h2 class="accordion-header"> <h2 class="accordion-header">
<button class="accordion-button {% if not loop.first %}collapsed{% endif %}" type="button" data-bs-toggle="collapse" data-bs-target="#hotspot-{{ hotspot.id }}"> <button class="accordion-button {% if not loop.first %}collapsed{% endif %}" type="button" data-bs-toggle="collapse" data-bs-target="#hotspot-{{ hotspot.id }}">
热点 {{ hotspot.rank }}{{ hotspot.title }} <span class="badge text-bg-light border">#{{ hotspot.rank }}</span>
<span>热点 {{ hotspot.rank }}{{ hotspot.title }}</span>
</button> </button>
</h2> </h2>
<div id="hotspot-{{ hotspot.id }}" class="accordion-collapse collapse {% if loop.first %}show{% endif %}" data-bs-parent="#hotspot-list"> <div id="hotspot-{{ hotspot.id }}" class="accordion-collapse collapse {% if loop.first %}show{% endif %}" data-bs-parent="#hotspot-list">
<div class="accordion-body"> <div class="accordion-body">
<a class="btn btn-sm btn-outline-primary mb-2" href="/hotspots/{{ hotspot.id }}/report">查看热点级汇总报告</a> <div class="d-flex justify-content-between align-items-center gap-3 mb-3">
<ul class="list-group"> <span class="text-muted small">内容条目 {{ hotspot.content_items | length }}</span>
<a class="btn btn-sm btn-outline-primary" href="/hotspots/{{ hotspot.id }}/report">查看汇总报告</a>
</div>
<ul class="list-group list-group-flush">
{% for item in hotspot.content_items %} {% for item in hotspot.content_items %}
<li class="list-group-item d-flex justify-content-between"> <li class="list-group-item px-0">
<span>{{ item.title or item.summary or item.source_item_id }} <small class="text-muted">{{ item.status }}</small></span> <div class="content-item-row">
<a class="btn btn-sm btn-outline-secondary" href="/items/{{ item.id }}">查看详情</a> <span>
<span class="content-item-title">{{ item.title or item.summary or item.source_item_id }}</span>
<span class="content-item-meta">状态 {{ item.status }} · ID {{ item.source_item_id }}</span>
</span>
<a class="btn btn-sm btn-outline-secondary" href="/items/{{ item.id }}">查看详情</a>
</div>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
+51 -12
View File
@@ -18,7 +18,10 @@ def test_index_page_renders_task_form_empty_state_and_default_scale():
assert 'name="item_limit_per_hotspot"' in response.text assert 'name="item_limit_per_hotspot"' in response.text
assert 'name="comment_limit_per_item"' in response.text assert 'name="comment_limit_per_item"' in response.text
assert "1250" in response.text assert "1250" in response.text
assert 'onclick="window.location.href=\'/\'"' in response.text assert "手动刷新" not in response.text
assert 'onclick="window.location.href=\'/\'"' not in response.text
assert 'href="#create-task"' not in response.text
assert 'href="#task-history"' not in response.text
assert "还没有任何任务" in response.text assert "还没有任何任务" in response.text
@@ -104,10 +107,13 @@ def test_index_page_uses_short_task_numbers_compact_fields_and_friendly_error_co
assert "#" not in visible_text assert "#" not in visible_text
assert first_id not in visible_text assert first_id not in visible_text
assert second_id not in visible_text assert second_id not in visible_text
assert "2026-07-03 10:30" in visible_text assert "2026-07-03 18:30" in visible_text
assert "2026-07-03 10:35" in visible_text assert "2026-07-03 18:35" in visible_text
assert "小红书 · 5热点 × 5内容 × 50评论" in visible_text assert "Demo 数据" not in visible_text
assert "抖音 · 1热点 × 1内容 × 10评论" in visible_text assert "5热点 × 5内容 × 50评论" in visible_text
assert "1热点 × 1内容 × 10评论" in visible_text
assert "小红书 · 5热点" not in visible_text
assert "抖音 · 1热点" not in visible_text
assert "系统重启,任务被中断" in visible_text assert "系统重启,任务被中断" in visible_text
assert "system / unexpected_restart" not in visible_text assert "system / unexpected_restart" not in visible_text
assert "unexpected_restart" not in visible_text assert "unexpected_restart" not in visible_text
@@ -167,12 +173,13 @@ def test_running_task_detail_page_auto_polls_current_task():
assert response.status_code == 200 assert response.status_code == 200
assert 'data-task-id="task-running"' in response.text assert 'data-task-id="task-running"' in response.text
assert "pollTaskDetailStatus" in response.text assert "pollTaskDetailStatus" in response.text
assert 'onclick="window.location.reload()"' in response.text assert "手动刷新" not in response.text
assert 'onclick="window.location.reload()"' not in response.text
assert "已处理 1 / 共 2 条内容" in response.text assert "已处理 1 / 共 2 条内容" in response.text
assert "AI 成功率 100%" in response.text assert "AI 成功率 100%" in response.text
def test_task_detail_uses_short_number_title_and_keeps_full_uuid_for_diagnostics(): def test_task_detail_uses_short_number_title_and_hides_full_uuid():
task_id = "33333333-3333-4333-8333-333333333333" task_id = "33333333-3333-4333-8333-333333333333"
with make_test_client() as (client, engine): with make_test_client() as (client, engine):
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -197,11 +204,42 @@ def test_task_detail_uses_short_number_title_and_keeps_full_uuid_for_diagnostics
visible_text = BeautifulSoup(response.text, "html.parser").get_text(" ") visible_text = BeautifulSoup(response.text, "html.parser").get_text(" ")
assert "<title>任务 1 - 热榜评论分析工具</title>" in response.text assert "<title>任务 1 - 热榜评论分析工具</title>" in response.text
assert "任务 1" in visible_text assert "任务 1" in visible_text
assert "完整 ID" in visible_text assert "完整 ID" not in visible_text
assert task_id in visible_text assert task_id not in visible_text
assert "任务 #33333333-3333-4333-8333-333333333333" not in visible_text assert "任务 #33333333-3333-4333-8333-333333333333" not in visible_text
assert "#" not in visible_text assert "#" not in visible_text
assert "创建时间 2026-07-03 10:30" in visible_text assert "创建时间 2026-07-03 18:30" in visible_text
assert "Demo 数据" not in visible_text
def test_demo_task_flag_is_kept_but_demo_copy_is_hidden_from_pages():
with make_test_client() as (client, engine):
from sqlalchemy.orm import Session
with Session(engine) as session:
session.add(
Task(
id="demo-task-hidden-copy",
platform="xiaohongshu",
status="success",
created_at=datetime(2026, 7, 3, 10, 30, tzinfo=UTC),
hotspot_limit=1,
item_limit_per_hotspot=1,
comment_limit_per_item=10,
)
)
session.commit()
index_response = client.get("/")
detail_response = client.get("/tasks/demo-task-hidden-copy")
api_response = client.get("/api/tasks/demo-task-hidden-copy")
assert index_response.status_code == 200
assert detail_response.status_code == 200
assert api_response.status_code == 200
assert api_response.json()["is_demo"] is True
assert "Demo 数据" not in BeautifulSoup(index_response.text, "html.parser").get_text(" ")
assert "Demo 数据" not in BeautifulSoup(detail_response.text, "html.parser").get_text(" ")
def test_running_task_detail_page_keeps_polling_after_hotspots_exist(): def test_running_task_detail_page_keeps_polling_after_hotspots_exist():
@@ -296,7 +334,7 @@ def test_running_task_detail_page_shows_stage_runtime_and_stale_progress_warning
response = client.get("/tasks/task-stale") response = client.get("/tasks/task-stale")
assert response.status_code == 200 assert response.status_code == 200
assert "阶段AI 分析中" in response.text assert "阶段 AI 分析中" in response.text
assert "运行时长" in response.text assert "运行时长" in response.text
assert "2小时5分钟" in response.text assert "2小时5分钟" in response.text
assert "最近进度" in response.text assert "最近进度" in response.text
@@ -419,7 +457,8 @@ def test_result_pages_render_seeded_data():
assert task_response.status_code == 200 assert task_response.status_code == 200
assert "热点标题" in task_response.text assert "热点标题" in task_response.text
assert "热点 1:热点标题" in BeautifulSoup(task_response.text, "html.parser").get_text(" ") assert "热点 1:热点标题" in BeautifulSoup(task_response.text, "html.parser").get_text(" ")
assert 'onclick="window.location.reload()"' in task_response.text assert 'onclick="window.location.reload()"' not in task_response.text
assert "手动刷新" not in task_response.text
assert hotspot_response.status_code == 200 assert hotspot_response.status_code == 200
assert "热点总结" in hotspot_response.text assert "热点总结" in hotspot_response.text
assert "downloadExport(" not in hotspot_response.text assert "downloadExport(" not in hotspot_response.text
+55
View File
@@ -53,6 +53,61 @@ def test_tikhub_client_raises_structured_error_after_retries(monkeypatch):
assert sleeps == [1, 2, 4] assert sleeps == [1, 2, 4]
def test_tikhub_client_retries_transient_api_error(monkeypatch):
sleeps = []
monkeypatch.setattr("app.platforms.base.time.sleep", sleeps.append)
transport = SequenceTransport(
[
httpx.Response(400, json={"message_zh": "临时请求失败"}),
httpx.Response(200, json={"ok": True}),
]
)
http_client = httpx.Client(transport=httpx.MockTransport(transport))
client = TikHubClient(base_url="https://api.test", api_key="secret-token", http_client=http_client)
result = client.get("/demo")
assert result == {"ok": True}
assert sleeps == [1]
assert len(transport.requests) == 2
def test_tikhub_client_includes_response_summary_after_api_error_retries(monkeypatch):
sleeps = []
monkeypatch.setattr("app.platforms.base.time.sleep", sleeps.append)
transport = SequenceTransport(
[httpx.Response(400, json={"message_zh": "笔记评论暂不可用"}) for _ in range(4)]
)
http_client = httpx.Client(transport=httpx.MockTransport(transport))
client = TikHubClient(base_url="https://api.test", api_key="secret-token", http_client=http_client)
with pytest.raises(PlatformAPIError) as exc_info:
client.get("/demo")
assert exc_info.value.error_type == "api_error"
assert exc_info.value.status_code == 400
assert "External API returned HTTP 400" in str(exc_info.value)
assert "笔记评论暂不可用" in str(exc_info.value)
assert "secret-token" not in str(exc_info.value)
assert sleeps == [1, 2, 4]
def test_tikhub_client_includes_text_response_summary_after_api_error_retries(monkeypatch):
sleeps = []
monkeypatch.setattr("app.platforms.base.time.sleep", sleeps.append)
transport = SequenceTransport([httpx.Response(502, text="upstream temporary failure") for _ in range(4)])
http_client = httpx.Client(transport=httpx.MockTransport(transport))
client = TikHubClient(base_url="https://api.test", api_key="secret-token", http_client=http_client)
with pytest.raises(PlatformAPIError) as exc_info:
client.get("/demo")
assert exc_info.value.error_type == "api_error"
assert exc_info.value.status_code == 502
assert str(exc_info.value) == "External API returned HTTP 502: upstream temporary failure"
assert sleeps == [1, 2, 4]
def test_tikhub_client_reports_401_as_auth_error_without_leaking_token(): def test_tikhub_client_reports_401_as_auth_error_without_leaking_token():
transport = SequenceTransport([httpx.Response(401, json={"message": "Unauthorized"})]) transport = SequenceTransport([httpx.Response(401, json={"message": "Unauthorized"})])
http_client = httpx.Client(transport=httpx.MockTransport(transport)) http_client = httpx.Client(transport=httpx.MockTransport(transport))
+9
View File
@@ -1,4 +1,5 @@
import pytest import pytest
from sqlalchemy.pool import NullPool
from app.db import check_database_integrity, checkpoint_sqlite_wal, create_sqlite_engine, ensure_sqlite_schema_compat from app.db import check_database_integrity, checkpoint_sqlite_wal, create_sqlite_engine, ensure_sqlite_schema_compat
@@ -11,6 +12,14 @@ def test_check_database_integrity_returns_ok_for_valid_sqlite_database():
engine.dispose() engine.dispose()
def test_file_sqlite_engine_does_not_reuse_connections_after_operational_errors(tmp_path):
engine = create_sqlite_engine(f"sqlite:///{tmp_path / 'app.db'}")
try:
assert isinstance(engine.pool, NullPool)
finally:
engine.dispose()
def test_check_database_integrity_raises_when_sqlite_reports_problem(monkeypatch): def test_check_database_integrity_raises_when_sqlite_reports_problem(monkeypatch):
class FakeCursor: class FakeCursor:
def execute(self, _sql): def execute(self, _sql):