859 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import mimetypes
import threading
from pathlib import Path
from typing import Any
import requests
from django.conf import settings
from django.db.models import Count, Q
from django.http import FileResponse, Http404, HttpResponse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from .dataset_nonempty import (
comment_columns_for_api,
detail_columns_for_api,
merged_columns_for_api,
search_columns_for_api,
)
from .export_job import build_csv_bytes, build_json_bytes, build_xlsx_bytes
from .row_serialize import (
comment_row_to_dict,
detail_row_to_dict,
merged_row_to_dict,
search_row_to_dict,
)
from .brief_pack import build_brief_pack_zip_bytes
from .strategy_draft import build_strategy_draft_markdown
from .ingest import ingest_job_full
from .jd_runner import (
build_competitor_brief_for_job,
get_default_report_config,
merge_llm_supplement_with_rules_report,
regenerate_competitor_report,
write_competitor_analysis_markdown,
)
from .llm_generate import (
generate_competitor_report_markdown_llm,
generate_strategy_draft_markdown_llm,
)
from .md_document_export import markdown_to_docx_bytes, markdown_to_pdf_bytes
from .models import (
JdJobCommentRow,
JdJobDetailRow,
JdJobMergedRow,
JdJobSearchRow,
JdProduct,
JdProductSnapshot,
JobStatus,
PipelineJob,
)
from .serializers import (
CreatePipelineJobSerializer,
JdProductDetailSerializer,
JdProductListSerializer,
JdProductSnapshotBriefSerializer,
JdProductSnapshotDetailSerializer,
JobReportConfigPatchSerializer,
PipelineJobSerializer,
RegenerateReportRequestSerializer,
StrategyDraftRequestSerializer,
)
from .tasks import execute_job
# 在线预览最大字节(超出则截断并提示下载)
_PREVIEW_MAX_BYTES = 2 * 1024 * 1024
# 允许下载的相对文件名(均在 run_dir 下)
_DOWNLOAD_NAMES = frozenset(
{
"merged",
"pc_search",
"comments",
"detail_ware",
"report",
}
)
def _jd_data_root() -> Path:
root = (settings.LOW_GI_PROJECT_ROOT or "").strip()
if not root:
raise RuntimeError("LOW_GI_PROJECT_ROOT 未配置")
return (Path(root) / "data" / "JD").resolve()
def _safe_file_for_job(run_dir_str: str, name: str) -> Path:
if name not in _DOWNLOAD_NAMES:
raise Http404("unknown file")
base = Path(run_dir_str).resolve()
jd_root = _jd_data_root().resolve()
try:
base.relative_to(jd_root)
except ValueError:
raise Http404("invalid run_dir")
mapping = {
"merged": "keyword_pipeline_merged.csv",
"pc_search": "pc_search_export.csv",
"comments": "comments_flat.csv",
"detail_ware": "detail_ware_export.csv",
"report": "competitor_analysis.md",
}
f = base / mapping[name]
if not f.is_file():
raise Http404("file not found")
return f
def _job_run_dir_usable(job: PipelineJob) -> bool:
"""成功或已终止但已写入 run_dir 时,可预览/下载批次文件。"""
return bool((job.run_dir or "").strip()) and job.status in (
JobStatus.SUCCESS,
JobStatus.CANCELLED,
)
@method_decorator(csrf_exempt, name="dispatch")
class JobListCreateView(APIView):
def get(self, request):
qs = PipelineJob.objects.all()[:200]
return Response(PipelineJobSerializer(qs, many=True).data)
def post(self, request):
if not (settings.LOW_GI_PROJECT_ROOT or "").strip():
return Response(
{"detail": "请先在 market_assistant/.env 中配置 LOW_GI_PROJECT_ROOT"},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
ser = CreatePipelineJobSerializer(data=request.data)
ser.is_valid(raise_exception=True)
data = ser.validated_data
job = PipelineJob.objects.create(
platform=data["platform"],
keyword=data["keyword"],
max_skus=data.get("max_skus"),
page_start=data.get("page_start"),
page_to=data.get("page_to"),
pipeline_run_dir=data.get("pipeline_run_dir") or "",
cookie_file_path=data.get("cookie_file_path") or "",
cookie_text=data.get("cookie_text") or "",
pvid=data.get("pvid") or "",
request_delay=data.get("request_delay") or "",
list_pages=data.get("list_pages") or "",
scenario_filter_enabled=data.get("scenario_filter_enabled"),
report_config=data.get("report_config") or {},
status=JobStatus.PENDING,
)
t = threading.Thread(target=execute_job, args=(job.id,), daemon=True)
t.start()
return Response(
PipelineJobSerializer(job).data,
status=status.HTTP_201_CREATED,
)
@method_decorator(csrf_exempt, name="dispatch")
class JobDetailView(APIView):
def get(self, request, pk: int):
job = PipelineJob.objects.filter(pk=pk).first()
if not job:
raise Http404()
return Response(PipelineJobSerializer(job).data)
def patch(self, request, pk: int):
job = PipelineJob.objects.filter(pk=pk).first()
if not job:
raise Http404()
ser = JobReportConfigPatchSerializer(data=request.data)
ser.is_valid(raise_exception=True)
job.report_config = ser.validated_data["report_config"]
job.save(update_fields=["report_config", "updated_at"])
return Response(PipelineJobSerializer(job).data)
@method_decorator(csrf_exempt, name="dispatch")
class JobCancelView(APIView):
"""
终止:将 ``cancellation_requested`` 置位后,执行线程会尽快 ``terminate`` 采集子进程
(效果接近在终端对脚本按 Ctrl+C并保留已写入运行目录的文件。
"""
def post(self, request, pk: int):
job = PipelineJob.objects.filter(pk=pk).first()
if not job:
raise Http404()
if job.status not in (JobStatus.PENDING, JobStatus.RUNNING):
return Response(
{"detail": "仅待执行或执行中的任务可终止"},
status=status.HTTP_400_BAD_REQUEST,
)
job.cancellation_requested = True
job.save(update_fields=["cancellation_requested", "updated_at"])
return Response(PipelineJobSerializer(job).data)
class ReportConfigDefaultsView(APIView):
"""返回 ``jd_competitor_report`` 中与脚本常量一致的默认报告调参 JSON。"""
def get(self, request):
try:
return Response(get_default_report_config())
except FileNotFoundError as e:
return Response(
{"detail": str(e)},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
class JobDownloadView(APIView):
def get(self, request, pk: int):
job = PipelineJob.objects.filter(pk=pk).first()
if not job or not _job_run_dir_usable(job):
raise Http404()
name = (request.query_params.get("name") or "").strip().lower()
path = _safe_file_for_job(job.run_dir, name)
return FileResponse(
path.open("rb"),
as_attachment=True,
filename=path.name,
)
class JobPreviewView(APIView):
"""浏览器内联查看产出CSV / Markdown 文本),大文件截断。"""
def get(self, request, pk: int):
job = PipelineJob.objects.filter(pk=pk).first()
if not job or not _job_run_dir_usable(job):
raise Http404()
name = (request.query_params.get("name") or "").strip().lower()
fpath = _safe_file_for_job(job.run_dir, name)
raw = fpath.read_bytes()
truncated = len(raw) > _PREVIEW_MAX_BYTES
if truncated:
raw = raw[:_PREVIEW_MAX_BYTES]
text = raw.decode("utf-8-sig", errors="replace")
if truncated:
text += "\n\n... [内容已截断,完整文件请使用下载]\n"
if name == "report":
ctype = "text/markdown; charset=utf-8"
else:
ctype = "text/csv; charset=utf-8"
resp = HttpResponse(text, content_type=ctype)
resp["X-Preview-Truncated"] = "1" if truncated else "0"
resp["X-Preview-Filename"] = fpath.name
return resp
@method_decorator(csrf_exempt, name="dispatch")
class JobRegenerateReportView(APIView):
"""基于任务已有 ``run_dir`` 内 CSV 重新生成 ``competitor_analysis.md``(不重新爬取)。"""
def post(self, request, pk: int):
if not (settings.LOW_GI_PROJECT_ROOT or "").strip():
return Response(
{"detail": "请先在 market_assistant/.env 中配置 LOW_GI_PROJECT_ROOT"},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
job = PipelineJob.objects.filter(pk=pk).first()
if not job:
raise Http404()
if job.status != JobStatus.SUCCESS or not (job.run_dir or "").strip():
return Response(
{"detail": "仅可对已成功且已写入 run_dir 的任务重新生成报告"},
status=status.HTTP_400_BAD_REQUEST,
)
ser = RegenerateReportRequestSerializer(data=request.data or {})
ser.is_valid(raise_exception=True)
generator = ser.validated_data.get("generator") or "rules"
rc = job.report_config if isinstance(job.report_config, dict) else None
if generator == "llm":
try:
regenerate_competitor_report(
job.run_dir, job.keyword, report_config=rc
)
rules_md = (
Path(job.run_dir) / "competitor_analysis.md"
).read_text(encoding="utf-8")
brief = build_competitor_brief_for_job(
job.run_dir, job.keyword, report_config=rc
)
md = generate_competitor_report_markdown_llm(brief, job.keyword)
md = merge_llm_supplement_with_rules_report(md, rules_md)
write_competitor_analysis_markdown(job.run_dir, md)
except FileNotFoundError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
except ValueError as e:
return Response({"detail": str(e)}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
except requests.RequestException as e:
return Response(
{"detail": f"大模型网关错误:{e}"},
status=status.HTTP_502_BAD_GATEWAY,
)
else:
try:
regenerate_competitor_report(job.run_dir, job.keyword, report_config=rc)
except FileNotFoundError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
except ValueError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
return Response(PipelineJobSerializer(job).data)
class JobCompetitorBriefView(APIView):
"""单次任务的结构化竞品摘要JSON与 ``competitor_analysis.md`` 统计口径一致,规则驱动无 LLM"""
def get(self, request, pk: int):
if not (settings.LOW_GI_PROJECT_ROOT or "").strip():
return Response(
{"detail": "请先在 market_assistant/.env 中配置 LOW_GI_PROJECT_ROOT"},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
job = PipelineJob.objects.filter(pk=pk).first()
if not job:
raise Http404()
if job.status != JobStatus.SUCCESS or not (job.run_dir or "").strip():
return Response(
{"detail": "仅可对已成功且含 run_dir 的任务获取竞品摘要"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
data = build_competitor_brief_for_job(
job.run_dir,
job.keyword,
report_config=job.report_config
if isinstance(job.report_config, dict)
else None,
)
except FileNotFoundError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
except ValueError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
return Response(data)
class JobCompetitorBriefPackView(APIView):
"""ZIP完整 Markdown 报告 + 结构化 JSON + 要点摘录 Markdown + 说明文本。"""
def get(self, request, pk: int):
if not (settings.LOW_GI_PROJECT_ROOT or "").strip():
return Response(
{"detail": "请先在 market_assistant/.env 中配置 LOW_GI_PROJECT_ROOT"},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
job = PipelineJob.objects.filter(pk=pk).first()
if not job:
raise Http404()
if job.status != JobStatus.SUCCESS or not (job.run_dir or "").strip():
return Response(
{"detail": "仅可对已成功且含 run_dir 的任务导出简报包"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
brief = build_competitor_brief_for_job(
job.run_dir,
job.keyword,
report_config=job.report_config
if isinstance(job.report_config, dict)
else None,
)
zip_bytes = build_brief_pack_zip_bytes(Path(job.run_dir), brief)
except FileNotFoundError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
except ValueError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
filename_ascii = f"job_{pk}_competitor_brief_pack.zip"
resp = HttpResponse(zip_bytes, content_type="application/zip")
resp["Content-Disposition"] = f'attachment; filename="{filename_ascii}"'
return resp
@method_decorator(csrf_exempt, name="dispatch")
class JobStrategyDraftView(APIView):
"""
市场策略制定 Markdown策略框架 + 附录;默认规则生成,可选 ``generator=llm````AI_crawler.chat_completion_text``)。
"""
def post(self, request, pk: int):
if not (settings.LOW_GI_PROJECT_ROOT or "").strip():
return Response(
{"detail": "请先在 market_assistant/.env 中配置 LOW_GI_PROJECT_ROOT"},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
job = PipelineJob.objects.filter(pk=pk).first()
if not job:
raise Http404()
if job.status != JobStatus.SUCCESS or not (job.run_dir or "").strip():
return Response(
{"detail": "仅可对已成功且含 run_dir 的任务生成策略制定稿"},
status=status.HTTP_400_BAD_REQUEST,
)
ser = StrategyDraftRequestSerializer(data=request.data or {})
ser.is_valid(raise_exception=True)
vd = ser.validated_data
notes = (vd.get("business_notes") or "").strip()
strategy_decisions = {
"product_role": vd.get("product_role") or "",
"time_horizon": vd.get("time_horizon") or "",
"success_criteria": vd.get("success_criteria") or "",
"non_goals": vd.get("non_goals") or "",
"battlefield_one_line": vd.get("battlefield_one_line") or "",
"positioning_choice": vd.get("positioning_choice") or "",
"competitive_stance": vd.get("competitive_stance") or "",
"pillar_product": vd.get("pillar_product") or "",
"pillar_price": vd.get("pillar_price") or "",
"pillar_channel": vd.get("pillar_channel") or "",
"pillar_comm": vd.get("pillar_comm") or "",
"ack_risk_keywords": bool(vd.get("ack_risk_keywords")),
"ack_risk_price": bool(vd.get("ack_risk_price")),
"ack_risk_concentration": bool(vd.get("ack_risk_concentration")),
}
try:
brief = build_competitor_brief_for_job(
job.run_dir,
job.keyword,
report_config=job.report_config
if isinstance(job.report_config, dict)
else None,
)
except FileNotFoundError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
except ValueError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
gen_at = timezone.now().isoformat()
generator = (vd.get("generator") or "rules").strip()
try:
if generator == "llm":
md = generate_strategy_draft_markdown_llm(
job_id=job.id,
keyword=job.keyword,
brief=brief,
business_notes=notes,
generated_at_iso=gen_at,
strategy_decisions=strategy_decisions,
)
src = "llm_text_ai_crawler_v1"
else:
md = build_strategy_draft_markdown(
job_id=job.id,
keyword=job.keyword,
brief=brief,
business_notes=notes,
generated_at_iso=gen_at,
strategy_decisions=strategy_decisions,
)
src = "structured_summary_rules_v1"
except ValueError as e:
return Response({"detail": str(e)}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
except requests.RequestException as e:
return Response(
{"detail": f"大模型网关错误:{e}"},
status=status.HTTP_502_BAD_GATEWAY,
)
return Response(
{
"schema_version": 1,
"job_id": job.id,
"keyword": job.keyword,
"generated_at": gen_at,
"source": src,
"markdown": md,
}
)
@method_decorator(csrf_exempt, name="dispatch")
class JobExportDocumentView(APIView):
"""
将 Markdown 导出为 Word.docx或简易 PDF。
- GET``kind=report``,读取 ``run_dir/competitor_analysis.md``。
- POST``kind=strategy``,请求体 JSON 字段 ``markdown`` 为策略稿正文(与前端 sessionStorage 一致)。
PDF 依赖本机中文字体或环境变量 ``MA_PDF_FONT`` 指向 .ttf。
"""
def get(self, request, pk: int):
if not (settings.LOW_GI_PROJECT_ROOT or "").strip():
return Response(
{"detail": "请先在 market_assistant/.env 中配置 LOW_GI_PROJECT_ROOT"},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
job = PipelineJob.objects.filter(pk=pk).first()
if not job:
raise Http404()
if not _job_run_dir_usable(job):
return Response(
{"detail": "仅可对已成功或已终止且含 run_dir 的任务导出"},
status=status.HTTP_400_BAD_REQUEST,
)
fmt = (request.query_params.get("fmt") or "docx").strip().lower()
kind = (request.query_params.get("kind") or "report").strip().lower()
if kind != "report":
return Response(
{"detail": "GET 仅支持 kind=report策略稿请用 POST 提交 markdown"},
status=status.HTTP_400_BAD_REQUEST,
)
if fmt not in ("docx", "pdf"):
return Response(
{"detail": "fmt 须为 docx 或 pdf"},
status=status.HTTP_400_BAD_REQUEST,
)
path = Path(job.run_dir) / "competitor_analysis.md"
if not path.is_file():
return Response(
{"detail": "报告文件不存在,请先在「报告生成」重新生成"},
status=status.HTTP_404_NOT_FOUND,
)
md = path.read_text(encoding="utf-8")
asset_root = Path(job.run_dir).resolve()
try:
if fmt == "docx":
data = markdown_to_docx_bytes(md, asset_root=asset_root)
ct = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
fn = f"job_{pk}_competitor_report.docx"
else:
data = markdown_to_pdf_bytes(md, asset_root=asset_root)
ct = "application/pdf"
fn = f"job_{pk}_competitor_report.pdf"
except ValueError as e:
return Response({"detail": str(e)}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
resp = HttpResponse(data, content_type=ct)
resp["Content-Disposition"] = f'attachment; filename="{fn}"'
return resp
def post(self, request, pk: int):
if not (settings.LOW_GI_PROJECT_ROOT or "").strip():
return Response(
{"detail": "请先在 market_assistant/.env 中配置 LOW_GI_PROJECT_ROOT"},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
job = PipelineJob.objects.filter(pk=pk).first()
if not job:
raise Http404()
if not _job_run_dir_usable(job):
return Response(
{"detail": "仅可对已成功或已终止且含 run_dir 的任务导出"},
status=status.HTTP_400_BAD_REQUEST,
)
body = request.data if isinstance(request.data, dict) else {}
kind = (body.get("kind") or "strategy").strip().lower()
fmt = (body.get("fmt") or "docx").strip().lower()
md = (body.get("markdown") or "").strip()
if kind != "strategy":
return Response(
{"detail": "POST 仅支持 kind=strategy"},
status=status.HTTP_400_BAD_REQUEST,
)
if not md:
return Response(
{"detail": "markdown 不能为空"},
status=status.HTTP_400_BAD_REQUEST,
)
if fmt not in ("docx", "pdf"):
return Response(
{"detail": "fmt 须为 docx 或 pdf"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
if fmt == "docx":
data = markdown_to_docx_bytes(md)
ct = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
fn = f"job_{pk}_strategy_draft.docx"
else:
data = markdown_to_pdf_bytes(md)
ct = "application/pdf"
fn = f"job_{pk}_strategy_draft.pdf"
except ValueError as e:
return Response({"detail": str(e)}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
resp = HttpResponse(data, content_type=ct)
resp["Content-Disposition"] = f'attachment; filename="{fn}"'
return resp
class JobReportAssetView(APIView):
"""安全读取 ``run_dir/report_assets/*`` 下的 PNG 等(供 Markdown 预览插图)。"""
def get(self, request, pk: int):
job = PipelineJob.objects.filter(pk=pk).first()
if not job or not _job_run_dir_usable(job):
raise Http404()
rel = (request.query_params.get("path") or "").strip().replace("\\", "/")
if not rel or ".." in Path(rel).parts:
return Response(
{"detail": "path 非法"},
status=status.HTTP_400_BAD_REQUEST,
)
base = Path(job.run_dir).resolve()
assets_root = (base / "report_assets").resolve()
target = (base / rel).resolve()
try:
target.relative_to(assets_root)
except ValueError:
raise Http404()
if not target.is_file():
raise Http404()
ctype, _ = mimetypes.guess_type(str(target))
return FileResponse(
target.open("rb"),
content_type=ctype or "application/octet-stream",
)
def _dataset_job(pk: int) -> PipelineJob:
job = PipelineJob.objects.filter(pk=pk).first()
if not job:
raise Http404()
return job
def _read_page_params(request) -> tuple[int, int]:
page_size = min(max(int(request.query_params.get("page_size", 50)), 1), 200)
page = max(int(request.query_params.get("page", 1)), 1)
return page, page_size
class JobDatasetSummaryView(APIView):
"""任务在库中的搜索/详情/评价行数(入库后可用)。"""
def get(self, request, pk: int):
job = _dataset_job(pk)
return Response(
{
"job_id": job.id,
"keyword": job.keyword,
"status": job.status,
"search_rows": JdJobSearchRow.objects.filter(job=job).count(),
"detail_rows": JdJobDetailRow.objects.filter(job=job).count(),
"comment_rows": JdJobCommentRow.objects.filter(job=job).count(),
"merged_rows": JdJobMergedRow.objects.filter(job=job).count(),
"search_columns": search_columns_for_api(job),
"detail_columns": detail_columns_for_api(job),
"comment_columns": comment_columns_for_api(job),
"merged_columns": merged_columns_for_api(job),
}
)
class JobDatasetSearchView(APIView):
def get(self, request, pk: int):
job = _dataset_job(pk)
page, page_size = _read_page_params(request)
qs = JdJobSearchRow.objects.filter(job=job)
total = qs.count()
start = (page - 1) * page_size
rows = qs.order_by("row_index")[start : start + page_size]
return Response(
{
"total": total,
"page": page,
"page_size": page_size,
"results": [search_row_to_dict(r) for r in rows],
}
)
class JobDatasetDetailView(APIView):
def get(self, request, pk: int):
job = _dataset_job(pk)
page, page_size = _read_page_params(request)
qs = JdJobDetailRow.objects.filter(job=job)
total = qs.count()
start = (page - 1) * page_size
rows = qs.order_by("row_index")[start : start + page_size]
return Response(
{
"total": total,
"page": page,
"page_size": page_size,
"results": [detail_row_to_dict(r) for r in rows],
}
)
class JobDatasetCommentsView(APIView):
def get(self, request, pk: int):
job = _dataset_job(pk)
page, page_size = _read_page_params(request)
sku_id = (request.query_params.get("sku_id") or "").strip()
qs = JdJobCommentRow.objects.filter(job=job)
if sku_id:
qs = qs.filter(sku_id=sku_id)
total = qs.count()
start = (page - 1) * page_size
rows = qs.order_by("row_index")[start : start + page_size]
return Response(
{
"total": total,
"page": page,
"page_size": page_size,
"sku_filter": sku_id or None,
"results": [comment_row_to_dict(r) for r in rows],
}
)
class JobDatasetMergedView(APIView):
def get(self, request, pk: int):
job = _dataset_job(pk)
page, page_size = _read_page_params(request)
qs = JdJobMergedRow.objects.filter(job=job)
total = qs.count()
start = (page - 1) * page_size
rows = qs.order_by("row_index")[start : start + page_size]
return Response(
{
"total": total,
"page": page,
"page_size": page_size,
"results": [merged_row_to_dict(r) for r in rows],
}
)
class JobDatasetExportView(APIView):
"""下载kind=search|detail|comments|merged|allexport_fmt=json|csv|xlsx。
``merged``:库内合并宽表行(与 lean 合并 CSV 列一致,入库后导出)。
注意:勿使用查询参数名 ``format``DRF 会将其用于内容协商,非 json 时易在进视图前 404。
"""
def get(self, request, pk: int):
job = _dataset_job(pk)
kind = (request.query_params.get("kind") or "search").strip().lower()
fmt = (request.query_params.get("export_fmt") or "json").strip().lower()
if kind not in ("search", "detail", "comments", "all", "merged"):
return Response(
{"detail": "kind 须为 search / detail / comments / all / merged"},
status=status.HTTP_400_BAD_REQUEST,
)
if fmt not in ("json", "csv", "xlsx"):
return Response(
{"detail": "export_fmt 须为 json / csv / xlsx"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
if fmt == "json":
data, filename = build_json_bytes(job=job, kind=kind)
resp = HttpResponse(data, content_type="application/json; charset=utf-8")
elif fmt == "csv":
data, filename = build_csv_bytes(job=job, kind=kind)
resp = HttpResponse(data, content_type="text/csv; charset=utf-8")
else:
data, filename = build_xlsx_bytes(job=job, kind=kind)
resp = HttpResponse(
data,
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
except ValueError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
resp["Content-Disposition"] = f'attachment; filename="{filename}"'
return resp
class JdProductListView(APIView):
"""已入库 SKU 分页列表;支持按标题/SKU/品牌模糊搜、按合并表中的 pipeline_keyword 精确筛。"""
def get(self, request):
limit = min(max(int(request.query_params.get("limit", 50)), 1), 200)
offset = max(int(request.query_params.get("offset", 0)), 0)
q = (request.query_params.get("q") or "").strip()
kw = (request.query_params.get("keyword") or "").strip()
qs = JdProduct.objects.annotate(snapshot_count=Count("snapshots"))
if q:
qs = qs.filter(
Q(sku_id__icontains=q)
| Q(title__icontains=q)
| Q(detail_brand__icontains=q)
)
if kw:
qs = qs.filter(current_payload__pipeline_keyword=kw)
total = qs.count()
page = qs.order_by("-updated_at")[offset : offset + limit]
return Response(
{
"total": total,
"limit": limit,
"offset": offset,
"results": JdProductListSerializer(page, many=True).data,
}
)
class JdProductDetailView(APIView):
def get(self, request, sku_id: str):
platform = (request.query_params.get("platform") or "jd").strip() or "jd"
obj = (
JdProduct.objects.annotate(snapshot_count=Count("snapshots"))
.filter(platform=platform, sku_id=sku_id)
.first()
)
if not obj:
raise Http404()
return Response(JdProductDetailSerializer(obj).data)
class JdProductSnapshotListView(APIView):
"""某 SKU 的历史快照列表(不含整包 payload便于时间线"""
def get(self, request, sku_id: str):
platform = (request.query_params.get("platform") or "jd").strip() or "jd"
product = JdProduct.objects.filter(platform=platform, sku_id=sku_id).first()
if not product:
raise Http404()
snaps = (
product.snapshots.select_related("job")
.order_by("-captured_at")
.all()
)
return Response(
{
"platform": platform,
"sku_id": sku_id,
"count": snaps.count(),
"results": JdProductSnapshotBriefSerializer(snaps, many=True).data,
}
)
class JdProductSnapshotDetailView(APIView):
"""单条快照完整 payload用于历史回放与字段级对比。"""
def get(self, request, pk: int):
snap = (
JdProductSnapshot.objects.select_related("product", "job")
.filter(pk=pk)
.first()
)
if not snap:
raise Http404()
return Response(JdProductSnapshotDetailSerializer(snap).data)
@method_decorator(csrf_exempt, name="dispatch")
class JobImportMergedView(APIView):
"""将指定任务目录下搜索/详情/评价 CSV 与合并表重新写入数据库(幂等:先清空该任务三类行再全量插入)。"""
def post(self, request, pk: int):
job = PipelineJob.objects.filter(pk=pk).first()
if not job:
raise Http404()
if job.status != JobStatus.SUCCESS or not (job.run_dir or "").strip():
return Response(
{"detail": "仅可对已成功且含 run_dir 的任务执行入库"},
status=status.HTTP_400_BAD_REQUEST,
)
try:
stats = ingest_job_full(job)
except FileNotFoundError as e:
return Response({"detail": str(e)}, status=status.HTTP_400_BAD_REQUEST)
return Response(stats)