471 lines
18 KiB
Python
471 lines
18 KiB
Python
"""
|
||
extract_service.py
|
||
=================
|
||
将爬虫原始响应(/api/v1/xhs/wen/overview · /api/v1/xhs/wen/sources)
|
||
清洗为标准结构化格式,与 extract_wenyiwen.py 逻辑一致。
|
||
|
||
在业务后端层面做此转换的好处:
|
||
- 任务结果存入 DB 时已是最干净的格式
|
||
- 前端查看文件、AI 读取数据均直接拿到结构化结果
|
||
- 无需前端再依赖原始爬虫字段名
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from datetime import datetime, timedelta, timezone
|
||
from typing import Any
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 辅助:类型安全取值
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def as_dict(value: Any, path: str, warnings: list[str]) -> dict[str, Any]:
|
||
if isinstance(value, dict):
|
||
return value
|
||
warnings.append(f"{path} 缺失或不是对象")
|
||
return {}
|
||
|
||
|
||
def as_list(value: Any, path: str, warnings: list[str]) -> list[Any]:
|
||
if isinstance(value, list):
|
||
return value
|
||
warnings.append(f"{path} 缺失或不是数组")
|
||
return []
|
||
|
||
|
||
def field(item: dict[str, Any], key: str, path: str, warnings: list[str]) -> Any:
|
||
if key not in item:
|
||
warnings.append(f"{path}.{key} 缺失")
|
||
return None
|
||
return item[key]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 辅助:数值解析
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def first_integer(value: Any) -> int | None:
|
||
if not isinstance(value, str):
|
||
return None
|
||
match = re.search(r"\d[\d,]*", value)
|
||
if not match:
|
||
return None
|
||
return int(match.group(0).replace(",", ""))
|
||
|
||
|
||
def numeric_sort_value(value: Any) -> float | None:
|
||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||
return float(value)
|
||
parsed = first_integer(value)
|
||
return float(parsed) if parsed is not None else None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 辅助:时间 / 视频时长格式化
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def format_publish_time(
|
||
value: Any, path: str, warnings: list[str]
|
||
) -> str | None:
|
||
if (
|
||
not isinstance(value, (int, float))
|
||
or isinstance(value, bool)
|
||
or value <= 0
|
||
):
|
||
if value != 0:
|
||
warnings.append(f"{path}.time 缺失或不是有效 Unix 时间戳")
|
||
return None
|
||
try:
|
||
china_tz = timezone(timedelta(hours=8))
|
||
return datetime.fromtimestamp(value, china_tz).strftime("%Y-%m-%d %H:%M:%S")
|
||
except (OverflowError, OSError, ValueError):
|
||
warnings.append(f"{path}.time 超出可转换范围")
|
||
return None
|
||
|
||
|
||
def format_video_duration(
|
||
value: Any, path: str, warnings: list[str]
|
||
) -> str | None:
|
||
if (
|
||
not isinstance(value, (int, float))
|
||
or isinstance(value, bool)
|
||
or value < 0
|
||
):
|
||
warnings.append(f"{path}.duration 缺失或不是非负数")
|
||
return None
|
||
total_seconds = int(value / 1000) if value >= 1000 else int(value)
|
||
hours, remainder = divmod(total_seconds, 3600)
|
||
minutes, seconds = divmod(remainder, 60)
|
||
if hours:
|
||
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
||
return f"{minutes:02d}:{seconds:02d}"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 辅助:正文清洗
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def clean_full_body(desc: Any, title: Any) -> str | None:
|
||
if not isinstance(desc, str):
|
||
return None
|
||
body = desc.replace("\r\n", "\n").replace("\r", "\n")
|
||
stripped_body = body.lstrip()
|
||
if isinstance(title, str) and title and stripped_body.startswith(title):
|
||
remainder = stripped_body[len(title):]
|
||
if not remainder or remainder[0].isspace():
|
||
body = remainder.lstrip()
|
||
topic_pattern = r"#[^#\n]*?\[话题\]#?"
|
||
body = re.sub(rf"^(?:\s*{topic_pattern})+\s*", "", body)
|
||
trailing_topic = re.search(topic_pattern, body)
|
||
if trailing_topic:
|
||
topic_start = trailing_topic.start()
|
||
prior_topic_block = re.search(
|
||
r"(?s)(?:\s+#[^\n]*)+\s*$", body[:topic_start]
|
||
)
|
||
if prior_topic_block:
|
||
topic_start = prior_topic_block.start()
|
||
else:
|
||
while topic_start > 0 and body[topic_start - 1] in " \t":
|
||
topic_start -= 1
|
||
body = body[:topic_start]
|
||
else:
|
||
body = re.sub(r"(?s)#[^\n]*(?:\n[ \t]*#[^\n]*)*\s*$", "", body)
|
||
body = re.sub(r"(?m)^[ \t]*#+[ \t]*$", "", body)
|
||
body = re.sub(r"(?m)(?:^|\n)[ \t]*(?:标签|话题)[::][ \t]*$", "", body)
|
||
return body.strip()
|
||
|
||
|
||
def body_char_count(body: str | None) -> int | None:
|
||
if body is None:
|
||
return None
|
||
return len(re.sub(r"\s+", "", body))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 召回明细:建立 (product_name, tag_name) → notes 的查找表
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def build_recall_lookup(
|
||
core_data: dict[str, Any], base_path: str, warnings: list[str]
|
||
) -> tuple[dict[tuple[str, str], dict[str, Any]], bool]:
|
||
raw_note_details = core_data.get("note_details")
|
||
if raw_note_details is None:
|
||
return {}, False
|
||
note_details = as_dict(raw_note_details, f"{base_path}.note_details", warnings)
|
||
products = as_list(
|
||
note_details.get("products"),
|
||
f"{base_path}.note_details.products",
|
||
warnings,
|
||
)
|
||
lookup: dict[tuple[str, str], dict[str, Any]] = {}
|
||
for product_index, raw_product in enumerate(products):
|
||
product_path = f"{base_path}.note_details.products[{product_index}]"
|
||
product = as_dict(raw_product, product_path, warnings)
|
||
product_name = field(product, "product_name", product_path, warnings)
|
||
content_tags = as_list(
|
||
product.get("content_tags"),
|
||
f"{product_path}.content_tags",
|
||
warnings,
|
||
)
|
||
for tag_index, raw_tag in enumerate(content_tags):
|
||
tag_path = f"{product_path}.content_tags[{tag_index}]"
|
||
tag = as_dict(raw_tag, tag_path, warnings)
|
||
tag_name = field(tag, "content_tag", tag_path, warnings)
|
||
if not isinstance(product_name, str) or not isinstance(tag_name, str):
|
||
continue
|
||
key = (product_name, tag_name)
|
||
if key in lookup:
|
||
warnings.append(
|
||
f"召回明细出现重复的产品标签组合:{product_name} / {tag_name}"
|
||
)
|
||
continue
|
||
lookup[key] = {
|
||
"count": field(tag, "count", tag_path, warnings),
|
||
"notes": as_list(tag.get("notes"), f"{tag_path}.notes", warnings),
|
||
"path": tag_path,
|
||
}
|
||
return lookup, True
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 召回内容提取
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def extract_recall_notes(
|
||
detail: dict[str, Any], warnings: list[str]
|
||
) -> list[dict[str, Any]]:
|
||
notes = detail["notes"]
|
||
detail_path = detail["path"]
|
||
extracted: list[dict[str, Any]] = []
|
||
for note_index, raw_note in enumerate(notes):
|
||
note_path = f"{detail_path}.notes[{note_index}]"
|
||
note = as_dict(raw_note, note_path, warnings)
|
||
title = note.get("title")
|
||
body = clean_full_body(note.get("desc"), title)
|
||
recall_type = note.get("type")
|
||
recall_content = field(note, "wechat_share_desc", note_path, warnings)
|
||
publish_timestamp = field(note, "time", note_path, warnings)
|
||
publish_time = format_publish_time(publish_timestamp, note_path, warnings)
|
||
if recall_type == "comment":
|
||
extracted.append({
|
||
"召回类型": "comment",
|
||
"评论内容": recall_content,
|
||
"所属笔记点赞数量": field(note, "liked_count", note_path, warnings),
|
||
"发布时间": publish_time,
|
||
})
|
||
continue
|
||
recall_metrics: dict[str, Any] = {}
|
||
if recall_type == "image":
|
||
images_list = as_list(
|
||
field(note, "images_list", note_path, warnings),
|
||
f"{note_path}.images_list",
|
||
warnings,
|
||
)
|
||
recall_metrics = {
|
||
"图片数量": len(images_list),
|
||
"点赞数量": field(note, "liked_count", note_path, warnings),
|
||
"收藏数量": field(note, "collected_count", note_path, warnings),
|
||
}
|
||
elif recall_type == "video":
|
||
recall_metrics = {
|
||
"视频时长": format_video_duration(
|
||
field(note, "duration", note_path, warnings), note_path, warnings
|
||
),
|
||
"点赞": field(note, "liked_count", note_path, warnings),
|
||
"收藏": field(note, "collected_count", note_path, warnings),
|
||
}
|
||
extracted.append({
|
||
"序号": note_index + 1,
|
||
"召回类型": recall_type,
|
||
"笔记标题": title,
|
||
"发布时间": publish_time,
|
||
"召回内容": recall_content,
|
||
"正文内容": body,
|
||
"字数": body_char_count(body),
|
||
"评论数量": note.get("comments_count"),
|
||
**recall_metrics,
|
||
**(
|
||
{} if recall_type in {"image", "video"} else {"评论内容": None}
|
||
),
|
||
"笔记ID": note.get("note_id"),
|
||
"笔记链接": note.get("note_url"),
|
||
})
|
||
return extracted
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 核心:定位 data / data.data.complex_detail
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def locate_core_data(
|
||
root: dict[str, Any], warnings: list[str]
|
||
) -> tuple[dict[str, Any], str]:
|
||
outer_data = as_dict(root.get("data"), "data", warnings)
|
||
if isinstance(outer_data.get("complex_detail"), dict):
|
||
return outer_data, "data"
|
||
inner_data = outer_data.get("data")
|
||
if isinstance(inner_data, dict) and isinstance(
|
||
inner_data.get("complex_detail"), dict
|
||
):
|
||
return inner_data, "data.data"
|
||
warnings.append("未在 data 或 data.data 下找到 complex_detail")
|
||
return {}, "data"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 顶层 extract:对外入口
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def extract(payload: dict[str, Any]) -> dict[str, Any]:
|
||
"""
|
||
对爬虫原始响应 JSON 执行完整清洗,追加 result.extracted 字段。
|
||
|
||
输入 payload:crawler_client 返回的原始爬虫 JSON(即存入 result_json 的内容)
|
||
返回值:在原 payload 上追加 { "extracted": { ...清洗后结构... } }
|
||
仅在 data.complex_detail 存在时注入 extracted
|
||
"""
|
||
warnings: list[str] = []
|
||
root = as_dict(payload, "$", warnings)
|
||
data, base_path = locate_core_data(root, warnings)
|
||
|
||
complex_detail = as_dict(
|
||
data.get("complex_detail"), f"{base_path}.complex_detail", warnings
|
||
)
|
||
brand_info = as_dict(
|
||
complex_detail.get("brand_info"),
|
||
f"{base_path}.complex_detail.brand_info",
|
||
warnings,
|
||
)
|
||
brand_sub_title = field(
|
||
brand_info, "sub_title",
|
||
f"{base_path}.complex_detail.brand_info", warnings,
|
||
)
|
||
|
||
# ---------- 榜单 ----------
|
||
components = as_list(
|
||
complex_detail.get("component_list"),
|
||
f"{base_path}.complex_detail.component_list", warnings,
|
||
)
|
||
ranking: list[dict[str, Any]] = []
|
||
for index, raw_item in enumerate(components):
|
||
item_path = f"{base_path}.complex_detail.component_list[{index}]"
|
||
item = as_dict(raw_item, item_path, warnings)
|
||
ranking.append({
|
||
"产品名称": field(item, "content", item_path, warnings),
|
||
"推荐比例": field(item, "desc", item_path, warnings),
|
||
"当前排名": field(item, "index", item_path, warnings),
|
||
})
|
||
|
||
# ---------- 产品详情 ----------
|
||
data_list = as_list(
|
||
complex_detail.get("data_list"),
|
||
f"{base_path}.complex_detail.data_list", warnings,
|
||
)
|
||
recall_lookup, has_recall_details = build_recall_lookup(data, base_path, warnings)
|
||
matched_recall_keys: set[tuple[str, str]] = set()
|
||
count_mismatch_total = 0
|
||
recall_total = 0
|
||
products: list[dict[str, Any]] = []
|
||
for index, raw_item in enumerate(data_list):
|
||
item_path = f"{base_path}.complex_detail.data_list[{index}]"
|
||
item = as_dict(raw_item, item_path, warnings)
|
||
product_name = field(item, "text", item_path, warnings)
|
||
tag_list = as_list(
|
||
item.get("onepage_tag_list"),
|
||
f"{item_path}.onepage_tag_list", warnings,
|
||
)
|
||
product_tags: list[dict[str, Any]] = []
|
||
for tag_index, raw_tag in enumerate(tag_list):
|
||
tag_path = f"{item_path}.onepage_tag_list[{tag_index}]"
|
||
tag = as_dict(raw_tag, tag_path, warnings)
|
||
tag_name = field(tag, "content", tag_path, warnings)
|
||
experience_count = field(tag, "count", tag_path, warnings)
|
||
tag_output: dict[str, Any] = {
|
||
"内容标签": tag_name,
|
||
"经验数量": experience_count,
|
||
}
|
||
if (
|
||
has_recall_details
|
||
and isinstance(product_name, str)
|
||
and isinstance(tag_name, str)
|
||
):
|
||
key = (product_name, tag_name)
|
||
detail = recall_lookup.get(key)
|
||
if detail is None:
|
||
warnings.append(f"未找到召回明细:{product_name} / {tag_name}")
|
||
tag_output["实际召回数量"] = 0
|
||
tag_output["召回内容"] = []
|
||
else:
|
||
matched_recall_keys.add(key)
|
||
recall_notes = extract_recall_notes(detail, warnings)
|
||
actual_count = len(recall_notes)
|
||
recall_total += actual_count
|
||
if experience_count != actual_count:
|
||
count_mismatch_total += 1
|
||
tag_output["实际召回数量"] = actual_count
|
||
tag_output["召回内容"] = recall_notes
|
||
product_tags.append(tag_output)
|
||
|
||
ranked_tags = sorted(
|
||
enumerate(product_tags),
|
||
key=lambda pair: (
|
||
numeric_sort_value(pair[1].get("经验数量")) is None,
|
||
-(numeric_sort_value(pair[1].get("经验数量")) or 0),
|
||
pair[0],
|
||
),
|
||
)
|
||
tag_top3 = [
|
||
{
|
||
"排名": rank,
|
||
"内容标签": tag["内容标签"],
|
||
"经验数量": tag["经验数量"],
|
||
}
|
||
for rank, (_, tag) in enumerate(ranked_tags[:3], start=1)
|
||
]
|
||
products.append({
|
||
"当前排行": index + 1,
|
||
"参考经验人数": field(item, "desc", item_path, warnings),
|
||
"问点点输出": field(item, "recommend_words", item_path, warnings),
|
||
"推荐比例": field(item, "sub_title", item_path, warnings),
|
||
"产品名称": product_name,
|
||
"标签数量Top3": tag_top3,
|
||
"内容标签": product_tags,
|
||
})
|
||
|
||
if has_recall_details:
|
||
unmatched_keys = set(recall_lookup) - matched_recall_keys
|
||
for product_name, tag_name in sorted(unmatched_keys):
|
||
warnings.append(
|
||
f"召回明细未匹配到榜单标签:{product_name} / {tag_name}"
|
||
)
|
||
|
||
# ---------- 召回统计 ----------
|
||
all_recall_notes = [
|
||
note
|
||
for product in products
|
||
for tag in product["内容标签"]
|
||
for note in tag.get("召回内容", [])
|
||
]
|
||
recall_type_counts: dict[str, int] = {}
|
||
for note in all_recall_notes:
|
||
recall_type = note.get("召回类型")
|
||
recall_type_counts[recall_type] = (
|
||
recall_type_counts.get(recall_type, 0) + 1
|
||
)
|
||
filter_tags = as_list(
|
||
data.get("filter_tags"), f"{base_path}.filter_tags", warnings
|
||
)
|
||
normal_names: list[Any] = []
|
||
for index, raw_tag in enumerate(filter_tags):
|
||
tag_path = f"{base_path}.filter_tags[{index}]"
|
||
tag = as_dict(raw_tag, tag_path, warnings)
|
||
if tag.get("word_type") == "normal":
|
||
normal_names.append(field(tag, "name", tag_path, warnings))
|
||
|
||
extracted = {
|
||
"schema_version": "1.17",
|
||
"提取元数据": {
|
||
"包含召回明细": has_recall_details,
|
||
"召回统计": {
|
||
"标签总数": sum(len(product["内容标签"]) for product in products),
|
||
"实际召回内容总数": recall_total,
|
||
"数量不一致标签数": count_mismatch_total,
|
||
"召回类型统计": recall_type_counts,
|
||
},
|
||
},
|
||
"参考来源笔记总量": {
|
||
"原始文本": brand_sub_title,
|
||
"提取数量": first_integer(brand_sub_title),
|
||
},
|
||
"榜单": ranking,
|
||
"产品详情": products,
|
||
"普通筛选标签": normal_names,
|
||
"提取警告": warnings,
|
||
}
|
||
return extracted
|
||
|
||
|
||
def transform_result(result: dict[str, Any]) -> dict[str, Any]:
|
||
"""
|
||
对任务结果调用 extract 并返回合并后的结果。
|
||
不修改原始 result,追加 result.extracted(当爬虫返回了 complex_detail 时)。
|
||
|
||
用法:
|
||
saved_result = json_loads(row["result_json"]) or {}
|
||
enriched = transform_result(saved_result)
|
||
data["result"] = enriched
|
||
"""
|
||
if not isinstance(result, dict):
|
||
return result
|
||
if "complex_detail" not in str(result.get("data", {})):
|
||
# 尝试 data.data.complex_detail(两级嵌套场景)
|
||
inner = result.get("data", {})
|
||
if isinstance(inner, dict) and "complex_detail" not in inner:
|
||
inner = inner.get("data", {})
|
||
if not isinstance(inner, dict) or "complex_detail" not in inner:
|
||
return result
|
||
extracted = extract(result)
|
||
return {**result, "extracted": extracted}
|