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}
|