Files
holy-python/extract_service.py

471 lines
18 KiB
Python
Raw Normal View History

2026-08-04 14:02:45 +08:00
"""
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 字段
输入 payloadcrawler_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}