mirror of
https://github.com/primedigitaltech/market-assistant.git
synced 2026-07-21 23:41:39 +08:00
refactor(jd_pc_search): 拆分搜索与评论的解析/落盘与请求脚本
评论:新增 jd_item_comment_parse、jd_item_comment_export;jd_h5_item_comment_requests 仅保留 Node 与 Playwright 与 main。搜索:新增 jd_h5_search_parse;jd_h5_search_requests 精简并 re-export CSV_FIELDS 等以兼容流水线与 collect_pc_search_items。 Made-with: Cursor
This commit is contained in:
parent
1431ed3204
commit
179c9b2659
@ -12,17 +12,18 @@
|
|||||||
|
|
||||||
|
|
||||||
用法(本仓库默认): 修改下方「运行配置」后 ``python jd_h5_item_comment_requests.py``(无命令行参数)。
|
用法(本仓库默认): 修改下方「运行配置」后 ``python jd_h5_item_comment_requests.py``(无命令行参数)。
|
||||||
|
|
||||||
|
**模块划分**:评价 JSON 解析见 ``jd_item_comment_parse.py``;CSV/JSONL 落盘见 ``jd_item_comment_export.py``;
|
||||||
|
本文件保留 Node 签请求、Playwright 采集与 ``main``,并 re-export 解析/导出符号以兼容 ``jd_keyword_pipeline`` 等导入。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import csv
|
|
||||||
import json
|
import json
|
||||||
import random
|
import random
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from io import StringIO
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@ -32,14 +33,27 @@ from playwright.sync_api import sync_playwright
|
|||||||
_JD_PKG_ROOT = Path(__file__).resolve().parent.parent
|
_JD_PKG_ROOT = Path(__file__).resolve().parent.parent
|
||||||
if str(_JD_PKG_ROOT) not in sys.path:
|
if str(_JD_PKG_ROOT) not in sys.path:
|
||||||
sys.path.insert(0, str(_JD_PKG_ROOT))
|
sys.path.insert(0, str(_JD_PKG_ROOT))
|
||||||
|
_JD_COMMENT_DIR = Path(__file__).resolve().parent
|
||||||
|
if str(_JD_COMMENT_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_JD_COMMENT_DIR))
|
||||||
_BACKEND_ROOT = Path(__file__).resolve().parents[3]
|
_BACKEND_ROOT = Path(__file__).resolve().parents[3]
|
||||||
if str(_BACKEND_ROOT) not in sys.path:
|
if str(_BACKEND_ROOT) not in sys.path:
|
||||||
sys.path.insert(0, str(_BACKEND_ROOT))
|
sys.path.insert(0, str(_BACKEND_ROOT))
|
||||||
from common.jd_delay_utils import parse_request_delay_range
|
from common.jd_delay_utils import parse_request_delay_range
|
||||||
from pipeline.csv_schema import COMMENT_CSV_COLUMNS, COMMENT_ROW_DICT_KEYS # noqa: E402
|
|
||||||
from _low_gi_root import low_gi_project_root # noqa: E402
|
from _low_gi_root import low_gi_project_root # noqa: E402
|
||||||
|
|
||||||
_JD_COMMENT_DIR = Path(__file__).resolve().parent
|
from jd_item_comment_export import ( # noqa: E402
|
||||||
|
append_comments_jsonl,
|
||||||
|
write_comments_flat_csv,
|
||||||
|
)
|
||||||
|
from jd_item_comment_parse import ( # noqa: E402
|
||||||
|
category_and_first_guid_from_lego,
|
||||||
|
extract_comment_rows_from_jsonl_file,
|
||||||
|
extract_comment_rows_from_parsed,
|
||||||
|
jd_business_ok,
|
||||||
|
loads_jd_plain_json,
|
||||||
|
parse_list_pages_spec,
|
||||||
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 运行配置(按需改这里)
|
# 运行配置(按需改这里)
|
||||||
@ -193,40 +207,6 @@ def _sleep_between_jd_requests(
|
|||||||
time.sleep(sec)
|
time.sleep(sec)
|
||||||
|
|
||||||
|
|
||||||
def parse_list_pages_spec(spec: str) -> list[str]:
|
|
||||||
"""
|
|
||||||
--list-pages:``1-5`` → 1..5;``1,3,5`` → 单页序列;单数字 ``2`` → [\"2\"]。
|
|
||||||
"""
|
|
||||||
s = (spec or "").strip()
|
|
||||||
if not s:
|
|
||||||
return ["1"]
|
|
||||||
if "," in s:
|
|
||||||
return [p.strip() for p in s.split(",") if p.strip()]
|
|
||||||
if "-" in s:
|
|
||||||
parts = s.split("-", 1)
|
|
||||||
lo, hi = int(parts[0].strip()), int(parts[1].strip())
|
|
||||||
if lo > hi:
|
|
||||||
lo, hi = hi, lo
|
|
||||||
return [str(i) for i in range(lo, hi + 1)]
|
|
||||||
return [s]
|
|
||||||
|
|
||||||
|
|
||||||
def category_and_first_guid_from_lego(parsed: Any) -> tuple[str, str]:
|
|
||||||
"""从 getLegoWareDetailComment 的 commentInfoList[0] 取 category(maidianInfo 前缀)与 guid。"""
|
|
||||||
if not isinstance(parsed, dict):
|
|
||||||
return "", ""
|
|
||||||
lst = parsed.get("commentInfoList")
|
|
||||||
if not isinstance(lst, list) or not lst:
|
|
||||||
return "", ""
|
|
||||||
first = lst[0]
|
|
||||||
if not isinstance(first, dict):
|
|
||||||
return "", ""
|
|
||||||
guid = str(first.get("guid") or "").strip()
|
|
||||||
maidian = str(first.get("maidianInfo") or "").strip()
|
|
||||||
category = maidian.split("_", 1)[0].strip() if maidian else ""
|
|
||||||
return category, guid
|
|
||||||
|
|
||||||
|
|
||||||
def _read_sku_lines(path: str) -> list[str]:
|
def _read_sku_lines(path: str) -> list[str]:
|
||||||
p = Path(path)
|
p = Path(path)
|
||||||
if not p.is_file():
|
if not p.is_file():
|
||||||
@ -241,162 +221,6 @@ def _read_sku_lines(path: str) -> list[str]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _loads_jd_plain_json(text: str) -> Any:
|
|
||||||
s = (text or "").strip()
|
|
||||||
if not s:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return json.loads(s)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _jd_business_ok(parsed: Any) -> bool:
|
|
||||||
if not isinstance(parsed, dict):
|
|
||||||
return False
|
|
||||||
if parsed.get("success") is False:
|
|
||||||
return False
|
|
||||||
c = parsed.get("code")
|
|
||||||
if c is None:
|
|
||||||
return True
|
|
||||||
return c == 0 or str(c) == "0"
|
|
||||||
|
|
||||||
|
|
||||||
def _clean_text(v: Any) -> str:
|
|
||||||
if v is None:
|
|
||||||
return ""
|
|
||||||
s = str(v).strip()
|
|
||||||
return " ".join(s.split()) if s else ""
|
|
||||||
|
|
||||||
|
|
||||||
def _large_pic_urls_from_picture_list(pil: Any) -> list[str]:
|
|
||||||
out: list[str] = []
|
|
||||||
if not isinstance(pil, list):
|
|
||||||
return out
|
|
||||||
for p in pil:
|
|
||||||
if not isinstance(p, dict):
|
|
||||||
continue
|
|
||||||
u = p.get("largePicURL") or p.get("largePicUrl")
|
|
||||||
if u:
|
|
||||||
t = str(u).strip()
|
|
||||||
if t and t not in out:
|
|
||||||
out.append(t)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def _is_jd_single_comment_dict(d: dict) -> bool:
|
|
||||||
"""区分「一条评价」与标签/楼层等对象(getCommentListPage 里多为 commentInfo 扁平结构)。"""
|
|
||||||
cid = d.get("commentId")
|
|
||||||
if cid is None or str(cid).strip() == "":
|
|
||||||
return False
|
|
||||||
if d.get("userNickName") is None and not (
|
|
||||||
d.get("tagCommentContent") or d.get("commentData")
|
|
||||||
):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _walk_collect_comment_dicts(obj: Any, acc: list[dict[str, Any]]) -> None:
|
|
||||||
"""深度遍历 JSON,收集所有像单条评价的 dict(含 Lego 的 commentInfoList 项与列表页的 commentInfo)。"""
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
if _is_jd_single_comment_dict(obj):
|
|
||||||
acc.append(obj)
|
|
||||||
for v in obj.values():
|
|
||||||
_walk_collect_comment_dicts(v, acc)
|
|
||||||
elif isinstance(obj, list):
|
|
||||||
for x in obj:
|
|
||||||
_walk_collect_comment_dicts(x, acc)
|
|
||||||
|
|
||||||
|
|
||||||
def _row_from_comment_dict(sku: str, item: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
text = _clean_text(
|
|
||||||
item.get("tagCommentContent") or item.get("commentData")
|
|
||||||
)
|
|
||||||
buy = _clean_text(
|
|
||||||
item.get("buyCountText") or item.get("repurchaseInfo")
|
|
||||||
)
|
|
||||||
date = _clean_text(
|
|
||||||
item.get("commentDate") or item.get("newCommentDate")
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"sku": str(sku).strip(),
|
|
||||||
"commentId": str(item.get("commentId") or "").strip(),
|
|
||||||
"userNickName": _clean_text(item.get("userNickName")),
|
|
||||||
"tagCommentContent": text,
|
|
||||||
"commentDate": date,
|
|
||||||
"buyCountText": buy,
|
|
||||||
"largePicURLs": _large_pic_urls_from_picture_list(
|
|
||||||
item.get("pictureInfoList")
|
|
||||||
),
|
|
||||||
"commentScore":str(item.get("commentScore") or "").strip(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def extract_comment_rows_from_parsed(sku: str, parsed: Any) -> list[dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
从整段 parsed 深度遍历抽取评价:
|
|
||||||
- getLegoWareDetailComment:commentInfoList / lastCommentInfoList
|
|
||||||
- getCommentListPage:result.floors → data 里 { commentInfo: {...} } 已拍平为内层字段,同上
|
|
||||||
"""
|
|
||||||
if not isinstance(parsed, dict):
|
|
||||||
return []
|
|
||||||
acc: list[dict[str, Any]] = []
|
|
||||||
_walk_collect_comment_dicts(parsed, acc)
|
|
||||||
seen: set[str] = set()
|
|
||||||
rows: list[dict[str, Any]] = []
|
|
||||||
for item in acc:
|
|
||||||
cid = str(item.get("commentId") or "").strip()
|
|
||||||
dedup_key = f"{sku}:{cid}" if cid else f"{sku}:{id(item)}"
|
|
||||||
if dedup_key in seen:
|
|
||||||
continue
|
|
||||||
seen.add(dedup_key)
|
|
||||||
rows.append(_row_from_comment_dict(sku, item))
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def _write_comments_csv(path: Path, rows: list[dict[str, Any]]) -> None:
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
buf = StringIO()
|
|
||||||
fn = list(COMMENT_CSV_COLUMNS)
|
|
||||||
w = csv.DictWriter(buf, fieldnames=fn, extrasaction="ignore")
|
|
||||||
w.writeheader()
|
|
||||||
for r in rows:
|
|
||||||
line: dict[str, Any] = {}
|
|
||||||
for h, api_k in zip(COMMENT_CSV_COLUMNS, COMMENT_ROW_DICT_KEYS):
|
|
||||||
if api_k == "largePicURLs":
|
|
||||||
line[h] = json.dumps(r.get("largePicURLs") or [], ensure_ascii=False)
|
|
||||||
else:
|
|
||||||
line[h] = r.get(api_k, "")
|
|
||||||
w.writerow(line)
|
|
||||||
path.write_text("\ufeff" + buf.getvalue(), encoding="utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def write_comments_flat_csv(path: Path | str, rows: list[dict[str, Any]]) -> None:
|
|
||||||
"""与 ``COMMENTS_OUT`` 为 ``.csv`` 时相同格式(UTF-8 BOM),供流水线等复用。"""
|
|
||||||
_write_comments_csv(Path(path), rows)
|
|
||||||
|
|
||||||
|
|
||||||
def _append_comments_jsonl(f, rows: list[dict[str, Any]]) -> None:
|
|
||||||
for r in rows:
|
|
||||||
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_rows_from_jsonl_file(path: Path) -> list[dict[str, Any]]:
|
|
||||||
all_rows: list[dict[str, Any]] = []
|
|
||||||
for line in path.read_text(encoding="utf-8").splitlines():
|
|
||||||
s = line.strip()
|
|
||||||
if not s or s.startswith("#"):
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
rec = json.loads(s)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
continue
|
|
||||||
sku = str(rec.get("sku") or "").strip()
|
|
||||||
parsed = rec.get("parsed")
|
|
||||||
all_rows.extend(extract_comment_rows_from_parsed(sku, parsed))
|
|
||||||
return all_rows
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
args = SimpleNamespace(
|
args = SimpleNamespace(
|
||||||
sku=(SKU or "").strip(),
|
sku=(SKU or "").strip(),
|
||||||
@ -429,14 +253,14 @@ def main() -> None:
|
|||||||
print("离线模式:请配置 FROM_JSONL 与 COMMENTS_OUT", file=sys.stderr)
|
print("离线模式:请配置 FROM_JSONL 与 COMMENTS_OUT", file=sys.stderr)
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
co_path = Path(comments_out)
|
co_path = Path(comments_out)
|
||||||
rows = _extract_rows_from_jsonl_file(Path(from_jsonl))
|
rows = extract_comment_rows_from_jsonl_file(Path(from_jsonl))
|
||||||
suf = co_path.suffix.lower()
|
suf = co_path.suffix.lower()
|
||||||
if suf == ".csv":
|
if suf == ".csv":
|
||||||
_write_comments_csv(co_path, rows)
|
write_comments_flat_csv(co_path, rows)
|
||||||
elif suf == ".jsonl":
|
elif suf == ".jsonl":
|
||||||
co_path.parent.mkdir(parents=True, exist_ok=True)
|
co_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with co_path.open("w", encoding="utf-8") as cf:
|
with co_path.open("w", encoding="utf-8") as cf:
|
||||||
_append_comments_jsonl(cf, rows)
|
append_comments_jsonl(cf, rows)
|
||||||
else:
|
else:
|
||||||
print("COMMENTS_OUT 请使用 .csv 或 .jsonl 扩展名", file=sys.stderr)
|
print("COMMENTS_OUT 请使用 .csv 或 .jsonl 扩展名", file=sys.stderr)
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
@ -519,13 +343,13 @@ def main() -> None:
|
|||||||
print(f"[京东] HTTP {status},--raise-http 已启用", file=sys.stderr)
|
print(f"[京东] HTTP {status},--raise-http 已启用", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
parsed = _loads_jd_plain_json(text)
|
parsed = loads_jd_plain_json(text)
|
||||||
http_ok = 200 <= status < 300
|
http_ok = 200 <= status < 300
|
||||||
row = {
|
row = {
|
||||||
"sku": sku,
|
"sku": sku,
|
||||||
"http_status": status,
|
"http_status": status,
|
||||||
"http_ok": http_ok,
|
"http_ok": http_ok,
|
||||||
"ok": http_ok and _jd_business_ok(parsed),
|
"ok": http_ok and jd_business_ok(parsed),
|
||||||
"parsed": parsed,
|
"parsed": parsed,
|
||||||
"raw": text if parsed is None else None,
|
"raw": text if parsed is None else None,
|
||||||
}
|
}
|
||||||
@ -545,7 +369,7 @@ def main() -> None:
|
|||||||
flat = extract_comment_rows_from_parsed(sku, parsed)
|
flat = extract_comment_rows_from_parsed(sku, parsed)
|
||||||
if comments_path is not None:
|
if comments_path is not None:
|
||||||
if comments_jsonl_f is not None:
|
if comments_jsonl_f is not None:
|
||||||
_append_comments_jsonl(comments_jsonl_f, flat)
|
append_comments_jsonl(comments_jsonl_f, flat)
|
||||||
else:
|
else:
|
||||||
comments_csv_rows.extend(flat)
|
comments_csv_rows.extend(flat)
|
||||||
|
|
||||||
@ -614,7 +438,7 @@ def main() -> None:
|
|||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
parsed_p = _loads_jd_plain_json(text_p)
|
parsed_p = loads_jd_plain_json(text_p)
|
||||||
http_ok_p = 200 <= st_p < 300
|
http_ok_p = 200 <= st_p < 300
|
||||||
row_p = {
|
row_p = {
|
||||||
"sku": sku,
|
"sku": sku,
|
||||||
@ -623,7 +447,7 @@ def main() -> None:
|
|||||||
"http_status": st_p,
|
"http_status": st_p,
|
||||||
"http_ok": http_ok_p,
|
"http_ok": http_ok_p,
|
||||||
"ok": http_ok_p
|
"ok": http_ok_p
|
||||||
and _jd_business_ok(parsed_p),
|
and jd_business_ok(parsed_p),
|
||||||
"parsed": parsed_p,
|
"parsed": parsed_p,
|
||||||
"raw": text_p if parsed_p is None else None,
|
"raw": text_p if parsed_p is None else None,
|
||||||
}
|
}
|
||||||
@ -638,7 +462,7 @@ def main() -> None:
|
|||||||
)
|
)
|
||||||
if comments_path is not None:
|
if comments_path is not None:
|
||||||
if comments_jsonl_f is not None:
|
if comments_jsonl_f is not None:
|
||||||
_append_comments_jsonl(
|
append_comments_jsonl(
|
||||||
comments_jsonl_f, flat_p
|
comments_jsonl_f, flat_p
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@ -652,7 +476,7 @@ def main() -> None:
|
|||||||
if comments_jsonl_f:
|
if comments_jsonl_f:
|
||||||
comments_jsonl_f.close()
|
comments_jsonl_f.close()
|
||||||
if comments_path is not None and comments_path.suffix.lower() == ".csv":
|
if comments_path is not None and comments_path.suffix.lower() == ".csv":
|
||||||
_write_comments_csv(comments_path, comments_csv_rows)
|
write_comments_flat_csv(comments_path, comments_csv_rows)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@ -0,0 +1,46 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
评价扁平结果**落盘**(UTF-8 BOM CSV / JSONL 行追加)。
|
||||||
|
|
||||||
|
解析见 ``jd_item_comment_parse``。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from io import StringIO
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_BACKEND_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
if str(_BACKEND_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_BACKEND_ROOT))
|
||||||
|
from pipeline.csv_schema import COMMENT_CSV_COLUMNS, COMMENT_ROW_DICT_KEYS # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def write_comments_flat_csv(path: Path | str, rows: list[dict[str, Any]]) -> None:
|
||||||
|
"""与 COMMENTS_OUT 为 ``.csv`` 时相同格式(UTF-8 BOM),供流水线等复用。"""
|
||||||
|
_write_comments_csv(Path(path), rows)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_comments_csv(path: Path, rows: list[dict[str, Any]]) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
buf = StringIO()
|
||||||
|
fn = list(COMMENT_CSV_COLUMNS)
|
||||||
|
w = csv.DictWriter(buf, fieldnames=fn, extrasaction="ignore")
|
||||||
|
w.writeheader()
|
||||||
|
for r in rows:
|
||||||
|
line: dict[str, Any] = {}
|
||||||
|
for h, api_k in zip(COMMENT_CSV_COLUMNS, COMMENT_ROW_DICT_KEYS):
|
||||||
|
if api_k == "largePicURLs":
|
||||||
|
line[h] = json.dumps(r.get("largePicURLs") or [], ensure_ascii=False)
|
||||||
|
else:
|
||||||
|
line[h] = r.get(api_k, "")
|
||||||
|
w.writerow(line)
|
||||||
|
path.write_text("\ufeff" + buf.getvalue(), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def append_comments_jsonl(f: Any, rows: list[dict[str, Any]]) -> None:
|
||||||
|
for r in rows:
|
||||||
|
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
||||||
@ -0,0 +1,174 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
京东商品评价 JSON 的**纯解析**:从 Lego / 列表页响应中抽取扁平评价行。
|
||||||
|
|
||||||
|
请求签名与 Playwright 见 ``jd_h5_item_comment_requests``。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
def parse_list_pages_spec(spec: str) -> list[str]:
|
||||||
|
"""
|
||||||
|
--list-pages:``1-5`` → 1..5;``1,3,5`` → 单页序列;单数字 ``2`` → [\"2\"]。
|
||||||
|
"""
|
||||||
|
s = (spec or "").strip()
|
||||||
|
if not s:
|
||||||
|
return ["1"]
|
||||||
|
if "," in s:
|
||||||
|
return [p.strip() for p in s.split(",") if p.strip()]
|
||||||
|
if "-" in s:
|
||||||
|
parts = s.split("-", 1)
|
||||||
|
lo, hi = int(parts[0].strip()), int(parts[1].strip())
|
||||||
|
if lo > hi:
|
||||||
|
lo, hi = hi, lo
|
||||||
|
return [str(i) for i in range(lo, hi + 1)]
|
||||||
|
return [s]
|
||||||
|
|
||||||
|
|
||||||
|
def category_and_first_guid_from_lego(parsed: Any) -> tuple[str, str]:
|
||||||
|
"""从 getLegoWareDetailComment 的 commentInfoList[0] 取 category(maidianInfo 前缀)与 guid。"""
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
return "", ""
|
||||||
|
lst = parsed.get("commentInfoList")
|
||||||
|
if not isinstance(lst, list) or not lst:
|
||||||
|
return "", ""
|
||||||
|
first = lst[0]
|
||||||
|
if not isinstance(first, dict):
|
||||||
|
return "", ""
|
||||||
|
guid = str(first.get("guid") or "").strip()
|
||||||
|
maidian = str(first.get("maidianInfo") or "").strip()
|
||||||
|
category = maidian.split("_", 1)[0].strip() if maidian else ""
|
||||||
|
return category, guid
|
||||||
|
|
||||||
|
|
||||||
|
def loads_jd_plain_json(text: str) -> Any:
|
||||||
|
s = (text or "").strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(s)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def jd_business_ok(parsed: Any) -> bool:
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
return False
|
||||||
|
if parsed.get("success") is False:
|
||||||
|
return False
|
||||||
|
c = parsed.get("code")
|
||||||
|
if c is None:
|
||||||
|
return True
|
||||||
|
return c == 0 or str(c) == "0"
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_text(v: Any) -> str:
|
||||||
|
if v is None:
|
||||||
|
return ""
|
||||||
|
s = str(v).strip()
|
||||||
|
return " ".join(s.split()) if s else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _large_pic_urls_from_picture_list(pil: Any) -> list[str]:
|
||||||
|
out: list[str] = []
|
||||||
|
if not isinstance(pil, list):
|
||||||
|
return out
|
||||||
|
for p in pil:
|
||||||
|
if not isinstance(p, dict):
|
||||||
|
continue
|
||||||
|
u = p.get("largePicURL") or p.get("largePicUrl")
|
||||||
|
if u:
|
||||||
|
t = str(u).strip()
|
||||||
|
if t and t not in out:
|
||||||
|
out.append(t)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _is_jd_single_comment_dict(d: dict) -> bool:
|
||||||
|
"""区分「一条评价」与标签/楼层等对象(getCommentListPage 里多为 commentInfo 扁平结构)。"""
|
||||||
|
cid = d.get("commentId")
|
||||||
|
if cid is None or str(cid).strip() == "":
|
||||||
|
return False
|
||||||
|
if d.get("userNickName") is None and not (
|
||||||
|
d.get("tagCommentContent") or d.get("commentData")
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _walk_collect_comment_dicts(obj: Any, acc: list[dict[str, Any]]) -> None:
|
||||||
|
"""深度遍历 JSON,收集所有像单条评价的 dict(含 Lego 的 commentInfoList 项与列表页的 commentInfo)。"""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
if _is_jd_single_comment_dict(obj):
|
||||||
|
acc.append(obj)
|
||||||
|
for v in obj.values():
|
||||||
|
_walk_collect_comment_dicts(v, acc)
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
for x in obj:
|
||||||
|
_walk_collect_comment_dicts(x, acc)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_from_comment_dict(sku: str, item: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
text = _clean_text(
|
||||||
|
item.get("tagCommentContent") or item.get("commentData")
|
||||||
|
)
|
||||||
|
buy = _clean_text(
|
||||||
|
item.get("buyCountText") or item.get("repurchaseInfo")
|
||||||
|
)
|
||||||
|
date = _clean_text(
|
||||||
|
item.get("commentDate") or item.get("newCommentDate")
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"sku": str(sku).strip(),
|
||||||
|
"commentId": str(item.get("commentId") or "").strip(),
|
||||||
|
"userNickName": _clean_text(item.get("userNickName")),
|
||||||
|
"tagCommentContent": text,
|
||||||
|
"commentDate": date,
|
||||||
|
"buyCountText": buy,
|
||||||
|
"largePicURLs": _large_pic_urls_from_picture_list(
|
||||||
|
item.get("pictureInfoList")
|
||||||
|
),
|
||||||
|
"commentScore": str(item.get("commentScore") or "").strip(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def extract_comment_rows_from_parsed(sku: str, parsed: Any) -> list[dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
从整段 parsed 深度遍历抽取评价:
|
||||||
|
- getLegoWareDetailComment:commentInfoList / lastCommentInfoList
|
||||||
|
- getCommentListPage:result.floors → data 里 { commentInfo: {...} } 已拍平为内层字段,同上
|
||||||
|
"""
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
return []
|
||||||
|
acc: list[dict[str, Any]] = []
|
||||||
|
_walk_collect_comment_dicts(parsed, acc)
|
||||||
|
seen: set[str] = set()
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
for item in acc:
|
||||||
|
cid = str(item.get("commentId") or "").strip()
|
||||||
|
dedup_key = f"{sku}:{cid}" if cid else f"{sku}:{id(item)}"
|
||||||
|
if dedup_key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(dedup_key)
|
||||||
|
rows.append(_row_from_comment_dict(sku, item))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def extract_comment_rows_from_jsonl_file(path: Path) -> list[dict[str, Any]]:
|
||||||
|
"""离线:从采集 JSONL 每行 { sku, parsed } 抽取扁平评价行。"""
|
||||||
|
all_rows: list[dict[str, Any]] = []
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
s = line.strip()
|
||||||
|
if not s or s.startswith("#"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rec = json.loads(s)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
sku = str(rec.get("sku") or "").strip()
|
||||||
|
parsed = rec.get("parsed")
|
||||||
|
all_rows.extend(extract_comment_rows_from_parsed(sku, parsed))
|
||||||
|
return all_rows
|
||||||
1310
backend/crawler_copy/jd_pc_search/search/jd_h5_search_parse.py
Normal file
1310
backend/crawler_copy/jd_pc_search/search/jd_h5_search_parse.py
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user