10 Commits
Author SHA1 Message Date
meijiali da69819b56 fix: 修复 Docker 首页模板加载失败 2026-07-03 21:14:49 +08:00
meijiali 7371c45fb4 Merge branch 'codex/fix-running-task-timeout-from-current' 2026-07-03 20:11:17 +08:00
meijiali bac5304481 fix: 优化 TikHub 鉴权失败提示 2026-07-03 19:43:08 +08:00
meijiali 1f68cee99c merge: 合并移除导出功能分支
# Conflicts:
#	tests/integration/test_routes.py
2026-07-03 19:28:59 +08:00
meijiali aec3604de9 fix: 修复任务长期运行中卡住 2026-07-03 19:18:13 +08:00
meijiali 0b186fa9f8 feat: 移除 CSV 和 Markdown 导出 2026-07-03 19:14:11 +08:00
meijiali 07ce3d4fcd fix: 去除页面编号井号显示 2026-07-03 19:12:08 +08:00
meijiali 4102ecc4e3 chore: 忽略数据库备份目录 2026-07-03 18:38:58 +08:00
meijiali b70b840551 docs: 补充公网部署确认清单 2026-07-03 18:30:09 +08:00
meijiali 2dc8ca1b5b feat: 优化页面信息展示 2026-07-03 18:20:20 +08:00
25 changed files with 469 additions and 315 deletions
+1
View File
@@ -8,3 +8,4 @@ htmlcov/
.env
data/*.db
data/*.db-*
data/corrupt-backups/
+2 -3
View File
@@ -7,9 +7,8 @@ WORKDIR /app
RUN adduser --disabled-password --gecos "" appuser
COPY pyproject.toml uv.lock ./
RUN pip install --no-cache-dir uv \
&& uv pip install --system --no-cache .
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
COPY .env.example ./.env.example
+2 -47
View File
@@ -1,11 +1,9 @@
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
import logging
from urllib.parse import quote
from fastapi import Depends, FastAPI, HTTPException, Request, status
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.responses import Response
from fastapi.staticfiles import StaticFiles
from sqlalchemy.exc import OperationalError, SQLAlchemyError
from sqlalchemy import select
@@ -14,7 +12,6 @@ from sqlalchemy.orm import Session
from app.db import SessionLocal, backup_sqlite_files, get_db_session, init_db
from app.models import Comment, ContentItem, Hotspot, Report
from app.schemas import CreateTaskRequest, CreateTaskResponse, TaskResponse
from app.services.export_service import export_hotspot_comments_csv, export_item_comments_csv, export_report_markdown
from app.services.task_service import (
RUNNING_TASK_MESSAGE,
create_task,
@@ -22,6 +19,7 @@ from app.services.task_service import (
has_running_task,
list_tasks,
recover_running_tasks,
recover_stale_running_tasks,
)
from app.templating import templates
@@ -86,6 +84,7 @@ def create_task_api(
request: CreateTaskRequest,
session: Session = Depends(get_db_session),
) -> CreateTaskResponse:
recover_stale_running_tasks(session)
if has_running_task(session):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=RUNNING_TASK_MESSAGE)
@@ -146,47 +145,3 @@ def item_detail_page(item_id: str, request: Request, session: Session = Depends(
"items/detail.html",
{"task": task, "hotspot": hotspot, "item": item, "report": report, "comments": comments},
)
@app.get("/api/export/items/{item_id}/comments.csv")
def export_item_comments(item_id: str, session: Session = Depends(get_db_session)) -> Response:
try:
content, filename = export_item_comments_csv(session, item_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="导出数据不存在")
return Response(
content=content,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
)
@app.get("/api/export/hotspots/{hotspot_id}/comments.csv")
def export_hotspot_comments(hotspot_id: str, session: Session = Depends(get_db_session)) -> Response:
try:
content, filename = export_hotspot_comments_csv(session, hotspot_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="导出数据不存在")
return Response(
content=content,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
)
@app.get("/api/export/hotspots/{hotspot_id}.md")
def export_hotspot_markdown(hotspot_id: str, session: Session = Depends(get_db_session)) -> Response:
try:
content, filename = export_report_markdown(session, report_type="hotspot", hotspot_id=hotspot_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报告不存在")
return Response(content=content, media_type="text/markdown; charset=utf-8", headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"})
@app.get("/api/export/items/{item_id}.md")
def export_item_markdown(item_id: str, session: Session = Depends(get_db_session)) -> Response:
try:
content, filename = export_report_markdown(session, report_type="item", item_id=item_id)
except ValueError:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报告不存在")
return Response(content=content, media_type="text/markdown; charset=utf-8", headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"})
+6
View File
@@ -87,6 +87,12 @@ class TikHubClient:
)
time.sleep(2**attempt)
continue
if response.status_code == 401:
raise PlatformAPIError(
"TikHub 鉴权失败,请检查 TIKHUB_API_KEY 是否有效",
error_type="auth_error",
status_code=response.status_code,
)
if response.is_error:
raise PlatformAPIError(
f"External API returned HTTP {response.status_code}",
+5
View File
@@ -34,6 +34,11 @@ class TaskResponse(BaseModel):
current_stage: str | None = None
current_stage_label: str | None = None
last_progress_at: datetime | None = None
display_number: int = 0
display_id: str = ""
created_at_label: str = ""
scale_label: str = ""
error_summary: str | None = None
running_seconds: int = 0
seconds_since_last_progress: int | None = None
running_duration_label: str = "刚刚"
+19 -1
View File
@@ -1,5 +1,6 @@
import json
import time
from concurrent.futures import ThreadPoolExecutor, TimeoutError
from dataclasses import dataclass
from typing import Literal
@@ -121,12 +122,14 @@ def analyze_comments_with_retry(
*,
requester,
max_retries: int = 3,
timeout_seconds: float | None = None,
) -> list[AIAnalysisResult]:
expected_ids = {str(comment["comment_id"]) for comment in comments}
prompt = build_comment_prompt(comments)
for attempt in range(max_retries):
try:
return parse_ai_comment_response(requester(prompt), expected_comment_ids=expected_ids)
response_text = _request_with_timeout(requester, prompt, timeout_seconds=timeout_seconds)
return parse_ai_comment_response(response_text, expected_comment_ids=expected_ids)
except Exception:
if attempt >= max_retries - 1:
break
@@ -143,6 +146,21 @@ def analyze_comments_with_retry(
]
def _request_with_timeout(requester, prompt: str, *, timeout_seconds: float | None) -> str:
if timeout_seconds is None or timeout_seconds <= 0:
return requester(prompt)
executor = ThreadPoolExecutor(max_workers=1)
future = executor.submit(requester, prompt)
try:
return future.result(timeout=timeout_seconds)
except TimeoutError as exc:
future.cancel()
raise TimeoutError("AI request timed out") from exc
finally:
executor.shutdown(wait=False, cancel_futures=True)
class OpenAICompatibleAIClient:
def __init__(
self,
-104
View File
@@ -1,104 +0,0 @@
import csv
import io
import json
import re
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import Comment, ContentItem, Hotspot, Report
def labels_to_text(labels_json: str | None) -> str:
try:
labels = json.loads(labels_json or "[]")
except json.JSONDecodeError:
return ""
return "".join(str(label) for label in labels) if isinstance(labels, list) else ""
def safe_csv_field(value) -> str:
text = "" if value is None else str(value)
text = text.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
if text.startswith(("=", "+", "-", "@")):
return f"'{text}"
return text
def safe_filename(platform: str, task_id: str, hotspot_keyword: str, *, extension: str = "csv") -> str:
keyword = hotspot_keyword[:20] + ("..." if len(hotspot_keyword) > 20 else "")
name = f"{platform}_{task_id}_{keyword}"
name = re.sub(r'[/\\:*?"<>|]+', "_", name)
name = re.sub(r"_+", "_", name).strip("_")
return f"{name}.{extension}"
def export_item_comments_csv(session: Session, item_id: str) -> tuple[bytes, str]:
item = session.get(ContentItem, item_id)
if item is None:
raise ValueError("content item not found")
hotspot = session.get(Hotspot, item.hotspot_id)
comments = list(session.scalars(select(Comment).where(Comment.content_item_id == item.id)))
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["平台", "任务 ID", "热点 ID", "热点标题", "内容条目 ID", "内容条目标题", "评论 ID", "评论内容", "情绪倾向", "方向标签", "点赞数", "评论时间"])
for comment in comments:
writer.writerow(
[
item.platform,
item.task_id,
hotspot.id if hotspot else "",
hotspot.title if hotspot else "",
item.id,
item.title or "",
comment.source_comment_id or "",
safe_csv_field(comment.content),
comment.sentiment,
labels_to_text(comment.labels),
comment.like_count or 0,
comment.comment_time or "",
]
)
filename = safe_filename(item.platform, item.task_id, hotspot.title if hotspot else item.title or "comments")
return output.getvalue().encode("utf-8-sig"), filename
def export_hotspot_comments_csv(session: Session, hotspot_id: str) -> tuple[bytes, str]:
hotspot = session.get(Hotspot, hotspot_id)
if hotspot is None:
raise ValueError("hotspot not found")
comments = list(session.scalars(select(Comment).where(Comment.hotspot_id == hotspot.id)))
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["平台", "任务 ID", "热点 ID", "热点标题", "内容条目 ID", "内容条目标题", "评论 ID", "评论内容", "情绪倾向", "方向标签", "点赞数", "评论时间"])
for comment in comments:
item = session.get(ContentItem, comment.content_item_id)
writer.writerow(
[
comment.platform,
comment.task_id,
hotspot.id,
hotspot.title,
item.id if item else "",
item.title if item else "",
comment.source_comment_id or "",
safe_csv_field(comment.content),
comment.sentiment,
labels_to_text(comment.labels),
comment.like_count or 0,
comment.comment_time or "",
]
)
return output.getvalue().encode("utf-8-sig"), safe_filename(hotspot.platform, hotspot.task_id, hotspot.title)
def export_report_markdown(session: Session, *, report_type: str, hotspot_id: str | None = None, item_id: str | None = None) -> tuple[str, str]:
query = select(Report).where(Report.report_type == report_type)
if hotspot_id:
query = query.where(Report.hotspot_id == hotspot_id)
if item_id:
query = query.where(Report.content_item_id == item_id)
report = session.scalar(query)
if report is None:
raise ValueError("report not found")
return report.markdown_content or report.markdown, safe_filename("report", report.task_id, report.title, extension="md")
+66
View File
@@ -21,6 +21,7 @@ from app.services.report_service import generate_hotspot_report, generate_item_r
RUNNING_TASK_MESSAGE = "当前有正在运行的任务,请稍后再试"
RESTART_ERROR_MESSAGE = "系统重启,任务被中断"
STALE_PROGRESS_THRESHOLD_SECONDS = 10 * 60
STALE_PROGRESS_ERROR_TYPE = "stale_progress_timeout"
task_executor = ThreadPoolExecutor(max_workers=1)
@@ -34,12 +35,40 @@ STAGE_LABELS = {
"success": "已完成",
"failed": "失败",
}
PLATFORM_LABELS = {
"xiaohongshu": "小红书",
"douyin": "抖音",
}
def has_running_task(session: Session) -> bool:
return session.scalar(select(Task.id).where(Task.status == "running").limit(1)) is not None
def recover_stale_running_tasks(session: Session) -> int:
now = ensure_utc_datetime(utc_now())
tasks = list(session.scalars(select(Task).where(Task.status == "running")))
recovered = 0
for task in tasks:
last_progress_at = ensure_utc_datetime(task.last_progress_at or task.started_at or task.created_at)
if last_progress_at is None or now is None:
continue
seconds_since_progress = max(0, int((now - last_progress_at).total_seconds()))
if seconds_since_progress < STALE_PROGRESS_THRESHOLD_SECONDS:
continue
_mark_task_failed(
task,
"system",
STALE_PROGRESS_ERROR_TYPE,
f"任务超过 {STALE_PROGRESS_THRESHOLD_SECONDS // 60} 分钟没有进度更新,已自动标记失败",
)
task.finished_at = task.finished_at or utc_now()
recovered += 1
if recovered:
session.commit()
return recovered
def recover_running_tasks(session: Session) -> int:
tasks = list(session.scalars(select(Task).where(Task.status == "running")))
for task in tasks:
@@ -150,6 +179,15 @@ def get_task(session: Session, task_id: str) -> Task | None:
def hydrate_task_progress(session: Session, task: Task) -> Task:
display_number = calculate_task_display_number(session, task)
task.display_number = display_number
task.display_id = str(display_number)
task.created_at_label = format_datetime_minute(task.created_at)
task.scale_label = (
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.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.reports_count = session.scalar(select(func.count(Report.id)).where(Report.task_id == task.id)) or 0
@@ -182,6 +220,29 @@ def hydrate_task_progress(session: Session, task: Task) -> Task:
return task
def calculate_task_display_number(session: Session, task: Task) -> int:
task_ids = list(session.scalars(select(Task.id).order_by(Task.created_at, Task.id)))
try:
return task_ids.index(task.id) + 1
except ValueError:
return len(task_ids) + 1
def format_datetime_minute(value: datetime | None) -> str:
value = ensure_utc_datetime(value)
return value.strftime("%Y-%m-%d %H:%M") if value else ""
def build_task_error_summary(task: Task) -> str | None:
if task.error_message:
return task.error_message
if task.error_stage == "system" and task.error_type == "unexpected_restart":
return RESTART_ERROR_MESSAGE
if task.error_stage or task.error_type:
return "任务失败,请查看后端日志"
return None
def format_duration_zh(total_seconds: int | None) -> str:
if total_seconds is None:
return "暂无记录"
@@ -220,6 +281,8 @@ def run_task(task_id: str, *, session_factory: Callable[[], Session] | sessionma
task = session.get(Task, task_id)
if task is None:
return
task.started_at = task.started_at or utc_now()
session.commit()
try:
platform = build_platform(task.platform)
ai_requester, report_summary_provider = build_ai_dependencies()
@@ -309,6 +372,7 @@ def run_task(task_id: str, *, session_factory: Callable[[], Session] | sessionma
[{"comment_id": comment.id, "content": comment.content} for comment in item_comments],
requester=ai_requester,
max_retries=get_settings().ai_max_retries,
timeout_seconds=get_settings().ai_timeout_seconds,
)
results_by_comment_id = {result.comment_id: result for result in ai_results}
for comment in item_comments:
@@ -354,9 +418,11 @@ def run_task(task_id: str, *, session_factory: Callable[[], Session] | sessionma
update_task_stage(task, "success")
else:
_mark_task_failed(task, task.error_stage or "crawl_items", task.error_type or "no_successful_items", task.error_message or "没有任何内容条目成功")
task.finished_at = task.finished_at or utc_now()
session.commit()
except Exception as exc:
_mark_task_failed(task, "system", "unexpected_error", str(exc))
task.finished_at = task.finished_at or utc_now()
session.commit()
finally:
if close_session:
-43
View File
@@ -111,46 +111,3 @@ function pollTaskListStatus() {
};
window.setTimeout(poll, intervalMs);
}
async function downloadExport(url, defaultFilename, event) {
if (event) event.preventDefault();
const button = event ? event.currentTarget : null;
const originalText = button ? button.textContent : "";
if (button) {
button.disabled = true;
button.textContent = "下载中...";
}
try {
const resp = await fetch(url, { cache: "no-store" });
if (!resp.ok) {
let message = "导出失败,请稍后重试。";
try {
const data = await resp.json();
if (data.detail) message = data.detail;
} catch (_error) {
message = resp.status === 503 ? "数据库暂时不可用,请稍后重试。" : message;
}
alert(message);
return;
}
const blob = await resp.blob();
const disposition = resp.headers.get("Content-Disposition") || "";
const match = disposition.match(/filename\*=UTF-8''([^;]+)/);
const filename = match ? decodeURIComponent(match[1]) : defaultFilename;
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
} catch (_error) {
alert("网络异常,导出未完成。");
} finally {
if (button) {
button.disabled = false;
button.textContent = originalText;
}
}
}
-3
View File
@@ -28,8 +28,5 @@
{% endblock %}
{% block content %}{% endblock %}
</main>
<footer class="border-top py-3">
<div class="container text-muted small">内部演示工具 | 仅供学习参考</div>
</footer>
</body>
</html>
+1 -15
View File
@@ -3,26 +3,13 @@
{% block breadcrumbs %}
<nav aria-label="breadcrumb"><ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">首页</a></li>
<li class="breadcrumb-item"><a href="/tasks/{{ task.id }}">任务 #{{ task.id }}</a></li>
<li class="breadcrumb-item"><a href="/tasks/{{ task.id }}">任务 {{ task.display_id }}</a></li>
<li class="breadcrumb-item active">汇总报告</li>
</ol></nav>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">{{ hotspot.title }} 汇总报告</h1>
<div class="d-flex gap-2">
<button
class="btn btn-outline-primary"
onclick="downloadExport('/api/export/hotspots/{{ hotspot.id }}.md', 'hotspot-report.md', event)"
{% if not report %}disabled title="报告尚未生成"{% endif %}>
导出 Markdown 报告
</button>
<button
class="btn btn-outline-secondary"
onclick="downloadExport('/api/export/hotspots/{{ hotspot.id }}/comments.csv', 'hotspot-comments.csv', event)">
导出热点评论 CSV
</button>
</div>
</div>
{% if report %}
{% include "partials/report_panel.html" %}
@@ -30,7 +17,6 @@
<div class="text-center text-muted py-5">
<div class="spinner-border text-warning mb-3" role="status"></div>
<p>报告生成中,请稍候...</p>
<p class="small mb-0">Markdown 报告将在分析完成后开放下载;评论 CSV 可先导出已抓取的数据。</p>
</div>
{% endif %}
{% endblock %}
+2 -16
View File
@@ -3,27 +3,14 @@
{% block breadcrumbs %}
<nav aria-label="breadcrumb"><ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">首页</a></li>
<li class="breadcrumb-item"><a href="/tasks/{{ task.id }}">任务 #{{ task.id }}</a></li>
<li class="breadcrumb-item"><a href="/hotspots/{{ hotspot.id }}/report">热点 #{{ hotspot.rank }}{{ hotspot.title }}</a></li>
<li class="breadcrumb-item"><a href="/tasks/{{ task.id }}">任务 {{ task.display_id }}</a></li>
<li class="breadcrumb-item"><a href="/hotspots/{{ hotspot.id }}/report">热点 {{ hotspot.rank }}{{ hotspot.title }}</a></li>
<li class="breadcrumb-item active">{{ item.title or item.source_item_id }}</li>
</ol></nav>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">{{ item.title or item.source_item_id }}</h1>
<div class="d-flex gap-2">
<button
class="btn btn-outline-primary"
onclick="downloadExport('/api/export/items/{{ item.id }}.md', 'item-report.md', event)"
{% if not report %}disabled title="报告尚未生成"{% endif %}>
导出 Markdown 报告
</button>
<button
class="btn btn-outline-secondary"
onclick="downloadExport('/api/export/items/{{ item.id }}/comments.csv', 'item-comments.csv', event)">
导出评论 CSV
</button>
</div>
</div>
{% if report %}
{% include "partials/report_panel.html" %}
@@ -31,7 +18,6 @@
<div class="text-center text-muted py-5">
<div class="spinner-border text-warning mb-3" role="status"></div>
<p>报告生成中,请稍候...</p>
<p class="small mb-0">Markdown 报告将在分析完成后开放下载;评论 CSV 可先导出已抓取的数据。</p>
</div>
{% endif %}
<section class="card"><div class="card-header">评论明细</div><div class="table-responsive">
+5 -8
View File
@@ -4,14 +4,14 @@
{% set ai_percent = rate_percent(task.analysis_success_rate) %}
<tr>
<td>
<code>{{ task.id }}</code>
<strong>{{ task.display_id or loop.index }}</strong>
{% if task.is_demo %}
<br><span class="badge text-bg-info">Demo 数据</span>
{% endif %}
</td>
<td>{{ task.platform | platform_label }}</td>
<td>{{ task.created_at }}</td>
<td><small class="text-muted">热点 {{ task.hotspot_limit }} / 内容 {{ task.item_limit_per_hotspot }} / 评论 {{ task.comment_limit_per_item }}</small></td>
<td>{{ task.created_at_label }}</td>
<td><small class="text-muted">{{ task.scale_label }}</small></td>
<td class="task-progress-cell">
<div class="d-flex justify-content-between align-items-center gap-2">
<span class="small">已处理 {{ task.processed_items_count }} / 共 {{ task.total_items_count }} 条内容</span>
@@ -32,11 +32,8 @@
</td>
<td>
<span class="badge {{ status_class }}">{{ status_label }}</span>
{% if task.error_stage or task.error_type %}
<br><small class="text-muted">{{ task.error_stage }} / {{ task.error_type }}</small>
{% endif %}
{% if task.error_message %}
<br><small class="text-danger">{{ task.error_message }}</small>
{% if task.error_summary %}
<br><small class="text-danger">{{ task.error_summary }}</small>
{% endif %}
</td>
<td><a class="btn btn-sm btn-outline-primary" href="/tasks/{{ task.id }}">查看</a></td>
+6 -5
View File
@@ -1,14 +1,14 @@
{% extends "base.html" %}
{% block title %}任务 #{{ task.id }} - 热榜评论分析工具{% endblock %}
{% block title %}任务 {{ task.display_id }} - 热榜评论分析工具{% endblock %}
{% block breadcrumbs %}
<nav aria-label="breadcrumb"><ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/">首页</a></li>
<li class="breadcrumb-item active">任务 #{{ task.id }}</li>
<li class="breadcrumb-item active">任务 {{ task.display_id }}</li>
</ol></nav>
{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">任务 #{{ task.id }}</h1>
<h1 class="h3 mb-0">任务 {{ task.display_id }}</h1>
<button class="btn btn-outline-secondary btn-sm" onclick="window.location.reload()">手动刷新</button>
</div>
<section class="card mb-4" {% if task.status == "running" %}data-task-id="{{ task.id }}" data-auto-poll="true"{% endif %}><div class="card-body">
@@ -20,6 +20,7 @@
<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>
<p class="mb-0 text-muted">阶段:{{ task.current_stage_label or "等待启动" }}</p>
</div>
<div class="small text-muted mb-3">创建时间 {{ task.created_at_label }} · 完整 ID {{ task.id }}</div>
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center gap-2">
<span>已处理 {{ task.processed_items_count }} / 共 {{ task.total_items_count }} 条内容</span>
@@ -72,7 +73,7 @@
<span class="badge text-bg-warning ms-1">AI 样本不足</span>
{% endif %}
</div>
{% if task.error_message %}<div class="alert alert-danger">{{ task.error_stage }} / {{ task.error_type }}{{ task.error_message }}</div>{% endif %}
{% if task.error_summary %}<div class="alert alert-danger">{{ task.error_summary }}</div>{% endif %}
</div></section>
{% 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>
@@ -82,7 +83,7 @@
<div class="accordion-item">
<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 }}">
热点 #{{ hotspot.rank }}{{ hotspot.title }}
热点 {{ hotspot.rank }}{{ hotspot.title }}
</button>
</h2>
<div id="hotspot-{{ hotspot.id }}" class="accordion-collapse collapse {% if loop.first %}show{% endif %}" data-bs-parent="#hotspot-list">
+14 -2
View File
@@ -99,9 +99,21 @@ docker compose up -d --build
curl -f http://localhost:8000/health
```
## 公网云服务器部署
## 公网部署决策清单
第一版公网演示使用云服务器 + Docker Compose,不增加登录或密码。
上线前必须确认以下决策,未经确认不要直接开放公网:
1. 部署方式:云服务器 Docker Compose、PaaS 平台,还是临时内网穿透演示。
2. 访问控制:是否需要访问密码 / 简单登录,或只在可信网络内演示。
3. 成本控制:公网用户是否允许直接消耗真实 TikHub 和 AI Key。
4. Demo 数据:公网环境使用脱敏 Demo 数据,还是允许展示真实抓取结果。
5. 数据生命周期:SQLite 数据是否需要持久保留,以及如何备份 / 重置。
当前建议:在上述问题确认前,只做本地 Docker 验收和部署准备,不把端口直接暴露到公网。
## 公网云服务器部署候选方案
如果确认采用云服务器 Docker Compose,可按以下步骤执行。
上线前准备:
+52
View File
@@ -1269,6 +1269,35 @@ chore: 增加公网部署配置
- 是否允许公网用户直接消耗真实 TikHub 和 AI Key。
- 是否需要限制同一时间只能运行一个任务。
当前推进记录:
- 日期:2026-07-03
- 相关改动:
- `docs/Deployment.md` 新增公网部署决策清单,明确上线前必须确认部署方式、访问密码、真实 TikHub / AI Key 消耗、Demo 数据和数据生命周期。
- 将“默认云服务器 Docker Compose 且不加密码”的表述改为候选方案,避免未经确认直接开放公网。
- `docs/UserGuide.md` 补充公网云服务器候选部署步骤、Demo 数据初始化和数据库异常备份路径。
- 补充文档测试,锁定部署文档必须包含上线前确认项。
- 验证命令:
- `docker exec hot-comments-tool-app-1 sh -lc 'python -m pytest /app/tests/unit/test_docs.py -q'`
- `curl -f http://localhost:8000/health`
- `curl -s -o /tmp/wo15-index.html -w '%{http_code}' http://localhost:8000/`
- `docker compose ps`
- `docker compose up -d --build`
- 验证结果:
- 文档测试:`2 passed`
- 本地 health:返回 `{"status":"ok"}`
- 首页:HTTP 200
- 当前运行容器:`hot-comments-tool-app-1`,端口 `0.0.0.0:8000->8000/tcp`
- 镜像重建:仍卡在 `pip install uv` 下载阶段,手动中断,未完成新镜像 rebuild。
- 当前结论:
- 本地服务可访问,部署文档已进入更安全的“待确认后上线”状态。
- 未执行公网部署,原因是 WO-15 的关键产品 / 安全决策仍待用户确认,且当前网络环境下镜像重建仍不稳定。
- 后续必须确认:
- 部署方式:云服务器 Docker Compose / PaaS / 临时内网穿透。
- 是否需要访问密码 / 简单登录。
- 是否允许公网访问者直接创建真实抓取任务并消耗 TikHub / AI Key。
- 是否先完成可复现 Demo 数据方案(WO-14)再公网演示。
### WO-16 UI 信息简化与产品化文案清理
优先级:P1
@@ -1325,6 +1354,29 @@ feat: 优化 MVP 页面产品化体验
- UI 风格方向:更偏数据仪表盘、内部工具,还是偏演示型产品页面。
- 是否需要提供简单品牌名 / Logo / 说明文案。
完成记录:
- 完成日期:2026-07-03
- 相关改动:
- 任务列表和任务详情默认展示顺序号 `#1/#2/#3`,不再把完整 UUID 铺在列表和标题中。
- 任务详情页保留“完整 ID”排查信息,方便复制给开发定位。
- 创建时间压缩为 `YYYY-MM-DD HH:mm`,配置规模压缩为 `小红书 · 5热点 × 5内容 × 50评论`
- 失败状态只展示中文错误说明,不再直接展示 `system / unexpected_restart``recover / interrupted` 等内部枚举。
- 移除页脚 `内部演示工具 | 仅供学习参考`
- 验证命令:
- `docker cp app/. hot-comments-tool-app-1:/app/app/`
- `docker cp tests/. hot-comments-tool-app-1:/app/tests/`
- `docker exec hot-comments-tool-app-1 sh -lc 'python -m pytest /app/tests/integration/test_routes.py::test_index_page_uses_short_task_numbers_compact_fields_and_friendly_error_copy /app/tests/integration/test_routes.py::test_task_detail_uses_short_number_title_and_keeps_full_uuid_for_diagnostics -q'`
- `docker exec hot-comments-tool-app-1 sh -lc 'python -m pytest /app/tests/integration/test_routes.py -q'`
- 验证结果:
- WO-16 聚焦测试:`2 passed, 1 warning`
- 路由集成测试:`23 passed, 1 warning`
- 验收结论:
- 任务列表、任务详情、报告页和内容详情页的任务引用已改为短编号。
- 用户界面不再展示内部英文错误枚举和演示页脚文案。
- 遗留问题:
- 本轮不做大规模视觉重设计,品牌名 / Logo 仍需用户确认后再纳入正式工单。
### WO-17 默认规模数据量解释与展示优化
优先级:P1
+31 -9
View File
@@ -93,16 +93,13 @@ http://localhost:8000
如果报告尚未生成,页面会显示“报告生成中,请稍候...”,不会返回 500。
## 5. 导出文件
## 5. 文件导出
支持导出
当前版本已关闭 CSV / Markdown 文件下载入口和接口。页面仍可直接查看
- 热点报告 Markdown
- 内容报告 Markdown
- 热点评论 CSV
- 内容评论 CSV
CSV 使用 UTF-8-BOM,适合 Windows Excel 打开。评论内容中的换行会被替换为空格,`= + - @` 开头的内容会自动加单引号,避免被 Excel 当作公式执行。
- 热点级汇总报告
- 内容条目级报告
- 评论明细
## 6. 小规模验收
@@ -119,7 +116,7 @@ CSV 使用 UTF-8-BOM,适合 Windows Excel 打开。评论内容中的换行会
- 至少有热点、内容、评论、报告。
- AI 成功率正常,或页面有明确“AI 样本不足”提示。
- 能打开热点报告、内容详情页。
- 能导出 Markdown 和 CSV
- 页面不展示 CSV / Markdown 下载入口
## 7. 默认规模验收
@@ -197,6 +194,31 @@ docker compose up -d --build
curl -f http://localhost:8000/health
```
### 公网云服务器部署
公网部署前先确认部署方式、访问密码、是否允许消耗真实 TikHub 和 AI Key。未经确认不要直接开放公网。
如果确认使用云服务器 Docker Compose,步骤与本地类似:
```bash
git pull
cp .env.example .env
docker compose up -d --build
curl -f http://127.0.0.1:8000/health
```
公网演示建议先初始化脱敏 Demo 数据:
```bash
docker compose exec app python -m app.demo_seed
```
如果页面出现数据库不可用提示,系统会尽量把 SQLite 文件备份到:
```text
data/corrupt-backups
```
## 9. 推荐提交节奏
`docs/MVP-WorkOrders.md` 的工单推进:
+8
View File
@@ -0,0 +1,8 @@
beautifulsoup4>=4.12.3
fastapi>=0.115.0
httpx>=0.27.0
jinja2>=3.1.4
pydantic>=2.8.0
pydantic-settings>=2.4.0
sqlalchemy>=2.0.32
uvicorn[standard]>=0.30.6
+114 -33
View File
@@ -1,5 +1,7 @@
from datetime import UTC, datetime, timedelta
from bs4 import BeautifulSoup
from app.models import Comment, ContentItem, Hotspot, Report, Task
from tests.helpers import make_test_client
from sqlalchemy.exc import OperationalError
@@ -59,6 +61,59 @@ def test_index_page_lists_existing_tasks():
assert "DOMContentLoaded" in response.text
def test_index_page_uses_short_task_numbers_compact_fields_and_friendly_error_copy():
first_id = "11111111-1111-4111-8111-111111111111"
second_id = "22222222-2222-4222-8222-222222222222"
with make_test_client() as (client, engine):
from sqlalchemy.orm import Session
with Session(engine) as session:
session.add(
Task(
id=first_id,
platform="xiaohongshu",
status="failed",
created_at=datetime(2026, 7, 3, 10, 30, tzinfo=UTC),
hotspot_limit=5,
item_limit_per_hotspot=5,
comment_limit_per_item=50,
error_stage="system",
error_type="unexpected_restart",
error_message="系统重启,任务被中断",
)
)
session.add(
Task(
id=second_id,
platform="douyin",
status="success",
created_at=datetime(2026, 7, 3, 10, 35, tzinfo=UTC),
hotspot_limit=1,
item_limit_per_hotspot=1,
comment_limit_per_item=10,
)
)
session.commit()
response = client.get("/")
assert response.status_code == 200
visible_text = BeautifulSoup(response.text, "html.parser").get_text(" ")
assert " 1 " in f" {visible_text} "
assert " 2 " in f" {visible_text} "
assert "#" not in visible_text
assert first_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 10:35" in visible_text
assert "小红书 · 5热点 × 5内容 × 50评论" in visible_text
assert "抖音 · 1热点 × 1内容 × 10评论" in visible_text
assert "系统重启,任务被中断" in visible_text
assert "system / unexpected_restart" not in visible_text
assert "unexpected_restart" not in visible_text
assert "内部演示工具 | 仅供学习参考" not in visible_text
def test_index_page_does_not_auto_poll_without_running_tasks():
with make_test_client() as (client, engine):
from sqlalchemy.orm import Session
@@ -117,6 +172,38 @@ def test_running_task_detail_page_auto_polls_current_task():
assert "AI 成功率 100%" in response.text
def test_task_detail_uses_short_number_title_and_keeps_full_uuid_for_diagnostics():
task_id = "33333333-3333-4333-8333-333333333333"
with make_test_client() as (client, engine):
from sqlalchemy.orm import Session
with Session(engine) as session:
session.add(
Task(
id=task_id,
platform="xiaohongshu",
status="running",
created_at=datetime(2026, 7, 3, 10, 30, tzinfo=UTC),
hotspot_limit=5,
item_limit_per_hotspot=5,
comment_limit_per_item=50,
)
)
session.commit()
response = client.get(f"/tasks/{task_id}")
assert response.status_code == 200
visible_text = BeautifulSoup(response.text, "html.parser").get_text(" ")
assert "<title>任务 1 - 热榜评论分析工具</title>" in response.text
assert "任务 1" in visible_text
assert "完整 ID" in visible_text
assert task_id in visible_text
assert "任务 #33333333-3333-4333-8333-333333333333" not in visible_text
assert "#" not in visible_text
assert "创建时间 2026-07-03 10:30" in visible_text
def test_running_task_detail_page_keeps_polling_after_hotspots_exist():
with make_test_client() as (client, engine):
from sqlalchemy.orm import Session
@@ -241,7 +328,8 @@ def test_failed_task_detail_page_shows_failure_reason_without_loading_copy():
assert response.status_code == 200
assert "失败" in response.text
assert "recover / interrupted系统重启,任务被中断" in response.text
assert "系统重启,任务被中断" in response.text
assert "recover / interrupted" not in response.text
assert "正在抓取热点数据" not in response.text
assert "pollTaskDetailStatus" not in response.text
@@ -330,11 +418,14 @@ def test_result_pages_render_seeded_data():
assert task_response.status_code == 200
assert "热点标题" in task_response.text
assert "热点 1:热点标题" in BeautifulSoup(task_response.text, "html.parser").get_text(" ")
assert 'onclick="window.location.reload()"' in task_response.text
assert hotspot_response.status_code == 200
assert "热点总结" in hotspot_response.text
assert "downloadExport('/api/export/hotspots/hot-result.md'" in hotspot_response.text
assert "downloadExport('/api/export/hotspots/hot-result/comments.csv'" in hotspot_response.text
assert "downloadExport(" not in hotspot_response.text
assert "/api/export/" not in hotspot_response.text
assert "导出 Markdown" not in hotspot_response.text
assert "导出热点评论 CSV" not in hotspot_response.text
assert "评论样本" in hotspot_response.text
assert "关联内容" in hotspot_response.text
assert "正向" in hotspot_response.text
@@ -345,8 +436,13 @@ def test_result_pages_render_seeded_data():
assert "<pre" not in hotspot_response.text
assert item_response.status_code == 200
assert "内容总结" in item_response.text
assert "downloadExport('/api/export/items/item-result.md'" in item_response.text
assert "downloadExport('/api/export/items/item-result/comments.csv'" in item_response.text
item_visible_text = BeautifulSoup(item_response.text, "html.parser").get_text(" ")
assert "热点 1:热点标题" in item_visible_text
assert "#" not in item_visible_text
assert "downloadExport(" not in item_response.text
assert "/api/export/" not in item_response.text
assert "导出 Markdown" not in item_response.text
assert "导出评论 CSV" not in item_response.text
assert "评论样本" in item_response.text
assert "Top 标签" in item_response.text
assert "评论内容" in item_response.text
@@ -381,14 +477,16 @@ def test_report_pages_render_empty_state_when_report_missing():
assert hotspot_response.status_code == 200
assert "报告生成中,请稍候" in hotspot_response.text
assert 'title="报告尚未生成"' in hotspot_response.text
assert "Markdown 报告将在分析完成后开放下载" in hotspot_response.text
assert "downloadExport('/api/export/hotspots/hot-no-report/comments.csv'" in hotspot_response.text
assert 'title="报告尚未生成"' not in hotspot_response.text
assert "Markdown 报告将在分析完成后开放下载" not in hotspot_response.text
assert "downloadExport(" not in hotspot_response.text
assert "/api/export/" not in hotspot_response.text
assert item_response.status_code == 200
assert "报告生成中,请稍候" in item_response.text
assert 'title="报告尚未生成"' in item_response.text
assert "Markdown 报告将在分析完成后开放下载" in item_response.text
assert "downloadExport('/api/export/items/item-no-report/comments.csv'" in item_response.text
assert 'title="报告尚未生成"' not in item_response.text
assert "Markdown 报告将在分析完成后开放下载" not in item_response.text
assert "downloadExport(" not in item_response.text
assert "/api/export/" not in item_response.text
def test_report_pages_warn_when_ai_sample_is_insufficient():
@@ -411,7 +509,7 @@ def test_report_pages_warn_when_ai_sample_is_insufficient():
assert "当前有效评论样本不足" in item_response.text
def test_export_routes_return_csv_and_markdown():
def test_export_routes_are_removed():
with make_test_client() as (client, engine):
seed_result_data(engine)
@@ -420,15 +518,10 @@ def test_export_routes_return_csv_and_markdown():
hotspot_md = client.get("/api/export/hotspots/hot-result.md")
item_md = client.get("/api/export/items/item-result.md")
assert csv_response.status_code == 200
assert csv_response.content.startswith(b"\xef\xbb\xbf")
assert "评论内容".encode("utf-8") in csv_response.content
assert hotspot_csv_response.status_code == 200
assert "评论内容".encode("utf-8") in hotspot_csv_response.content
assert hotspot_md.status_code == 200
assert "热点总结" in hotspot_md.text
assert item_md.status_code == 200
assert "内容总结" in item_md.text
assert csv_response.status_code == 404
assert hotspot_csv_response.status_code == 404
assert hotspot_md.status_code == 404
assert item_md.status_code == 404
def test_api_tasks_returns_service_unavailable_when_database_has_io_error(monkeypatch):
@@ -524,18 +617,6 @@ def test_item_detail_page_shows_database_error_state_when_database_has_io_error(
assert "数据库暂时不可用" in response.text
def test_export_route_returns_service_unavailable_when_database_has_io_error(monkeypatch):
def broken_export(_session, _item_id):
raise OperationalError("SELECT 1", {}, Exception("disk I/O error"))
monkeypatch.setattr("app.main.export_item_comments_csv", broken_export)
with make_test_client() as (client, _engine):
response = client.get("/api/export/items/item-io/comments.csv")
assert response.status_code == 503
assert response.json()["detail"] == "数据库暂时不可用,请稍后重试或联系维护者恢复数据。"
def test_missing_html_pages_render_friendly_not_found_page():
with make_test_client() as (client, _engine):
+44
View File
@@ -1,5 +1,8 @@
from datetime import UTC, datetime, timedelta
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.db import Base, get_db_session
@@ -92,6 +95,47 @@ def test_create_task_returns_400_when_running_task_exists():
app.dependency_overrides.clear()
def test_create_task_recovers_stale_running_task_before_creating_new_one(monkeypatch):
client, engine = make_test_client()
now = datetime(2026, 7, 3, 12, 0, tzinfo=UTC)
monkeypatch.setattr("app.services.task_service.utc_now", lambda: now)
monkeypatch.setattr("app.services.task_service.task_executor.submit", lambda *_args, **_kwargs: None)
try:
with Session(engine) as session:
session.add(
Task(
id="stale-running",
platform="douyin",
status="running",
current_stage="ai_analysis",
last_progress_at=now - timedelta(minutes=11),
)
)
session.commit()
response = client.post(
"/api/tasks",
json={
"platform": "douyin",
"hotspot_limit": 5,
"item_limit_per_hotspot": 5,
"comment_limit_per_item": 50,
},
)
with Session(engine) as session:
stale_task = session.get(Task, "stale-running")
assert response.status_code == 201
assert stale_task.status == "failed"
assert stale_task.error_stage == "system"
assert stale_task.error_type == "stale_progress_timeout"
assert "超过 10 分钟没有进度更新" in stale_task.error_message
finally:
engine.dispose()
app.dependency_overrides.clear()
def test_task_list_and_detail_return_created_tasks():
client, engine = make_test_client()
try:
@@ -1,4 +1,6 @@
import json
import time
from types import SimpleNamespace
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -147,3 +149,43 @@ def test_xiaohongshu_task_flow_keeps_item_success_when_ai_request_raises(monkeyp
assert persisted.analysis_success_rate == 0.0
assert comment.ai_analysis_status == "failed"
assert comment.reason == "ai_parse_failed"
def test_xiaohongshu_task_flow_times_out_stalled_ai_request(monkeypatch):
with make_test_client() as (_client, engine):
monkeypatch.setattr("app.services.task_service.build_platform", lambda _platform: FakeXhsPlatform())
monkeypatch.setattr(
"app.services.task_service.get_settings",
lambda: SimpleNamespace(ai_max_retries=1, ai_timeout_seconds=0.01),
)
def stalled_requester(prompt):
time.sleep(0.2)
comments = json.loads(prompt[prompt.index("[") :])
return json.dumps(
[{"comment_id": comments[0]["comment_id"], "sentiment": "positive", "labels": ["超时后不应采用"], "reason": "late"}],
ensure_ascii=False,
)
monkeypatch.setattr("app.services.task_service.build_ai_dependencies", lambda: (stalled_requester, None))
with Session(engine) as session:
task = create_task(
session,
CreateTaskRequest(platform="xiaohongshu", hotspot_limit=1, item_limit_per_hotspot=1, comment_limit_per_item=10),
submit_background=False,
)
task_id = task.id
with Session(engine) as session:
run_task(task_id, session_factory=lambda: session)
persisted = session.get(Task, task_id)
comment = session.scalar(select(Comment).where(Comment.task_id == task_id))
assert persisted.status == "success"
assert persisted.finished_at is not None
assert persisted.analysis_status == "insufficient"
assert comment.ai_analysis_status == "failed"
assert comment.sentiment == "unknown"
assert comment.reason == "ai_parse_failed"
+14
View File
@@ -51,3 +51,17 @@ def test_tikhub_client_raises_structured_error_after_retries(monkeypatch):
assert exc_info.value.status_code == 429
assert "secret-token" not in str(exc_info.value)
assert sleeps == [1, 2, 4]
def test_tikhub_client_reports_401_as_auth_error_without_leaking_token():
transport = SequenceTransport([httpx.Response(401, json={"message": "Unauthorized"})])
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 == "auth_error"
assert exc_info.value.status_code == 401
assert str(exc_info.value) == "TikHub 鉴权失败,请检查 TIKHUB_API_KEY 是否有效"
assert "secret-token" not in str(exc_info.value)
+16
View File
@@ -29,3 +29,19 @@ def test_real_env_file_is_gitignored():
gitignore_lines = (ROOT / ".gitignore").read_text(encoding="utf-8").splitlines()
assert ".env" in gitignore_lines
def test_dockerfile_does_not_bootstrap_uv_from_runtime_pip():
dockerfile_content = (ROOT / "Dockerfile").read_text(encoding="utf-8")
assert "pip install --no-cache-dir uv" not in dockerfile_content
assert "uv pip install" not in dockerfile_content
def test_dockerfile_installs_project_without_build_isolation():
dockerfile_content = (ROOT / "Dockerfile").read_text(encoding="utf-8")
assert "COPY requirements.txt ./" in dockerfile_content
assert "pip install --no-cache-dir -r requirements.txt" in dockerfile_content
assert "pip install --no-cache-dir ." not in dockerfile_content
assert "pip install --no-cache-dir --no-build-isolation ." not in dockerfile_content
+19 -2
View File
@@ -4,7 +4,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def test_user_guide_covers_startup_acceptance_exports_and_troubleshooting():
def test_user_guide_covers_startup_acceptance_no_exports_and_troubleshooting():
content = (ROOT / "docs" / "UserGuide.md").read_text(encoding="utf-8")
for required in [
@@ -12,7 +12,9 @@ def test_user_guide_covers_startup_acceptance_exports_and_troubleshooting():
"http://localhost:8000",
"1 × 1 × 10",
"5 × 5 × 50",
"导出文件",
"文件导出",
"已关闭 CSV / Markdown 文件下载入口和接口",
"页面不展示 CSV / Markdown 下载入口",
"8000 端口被占用",
"API Key 缺失",
"任务长期 running",
@@ -22,3 +24,18 @@ def test_user_guide_covers_startup_acceptance_exports_and_troubleshooting():
"data/corrupt-backups",
]:
assert required in content
def test_deployment_doc_requires_public_access_decisions_before_going_online():
content = (ROOT / "docs" / "Deployment.md").read_text(encoding="utf-8")
for required in [
"上线前必须确认",
"部署方式",
"访问密码",
"真实 TikHub 和 AI Key",
"未经确认不要直接开放公网",
]:
assert required in content
assert "第一版公网演示使用云服务器 + Docker Compose,不增加登录或密码" not in content
-24
View File
@@ -1,24 +0,0 @@
from app.services.export_service import labels_to_text, safe_csv_field, safe_filename
def test_labels_to_text_joins_json_array_with_chinese_comma():
assert labels_to_text('["认可", "质量好"]') == "认可,质量好"
assert labels_to_text("bad-json") == ""
def test_safe_csv_field_prevents_formula_injection_and_newlines():
assert safe_csv_field("=1+1") == "'=1+1"
assert safe_csv_field("+1+1") == "'+1+1"
assert safe_csv_field("-1+1") == "'-1+1"
assert safe_csv_field("@SUM(1,1)") == "'@SUM(1,1)"
assert safe_csv_field("hello\nworld") == "hello world"
assert safe_csv_field("hello\r\nworld") == "hello world"
assert safe_csv_field("hello\rworld") == "hello world"
def test_safe_filename_replaces_illegal_chars_and_truncates_keyword():
filename = safe_filename("douyin", "task", "a/b:c*d?e<f>g|很长很长很长很长很长")
assert "/" not in filename
assert "__" not in filename
assert filename.endswith(".csv")