Add V6 stock strategy analysis and JD fapai scrape skill
- Add V6 strategy section to us-stock-trading-evaluation: fixes indicator dilution, SMA period hardcode, and stoploss tier issues; includes hyperopt results and backtest comparison - Add jd-fapai-scrape skill: Playwright-based scraper for JD judicial auction properties with incremental update support Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -593,6 +593,95 @@ OKX 现货 API ◄──── freqtrade 执行下单 ◄───────
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 4.9 V6 策略:修代码硬伤 + 调止损 + Hyperopt + 回测验证
|
||||||
|
|
||||||
|
V5-Stock 跑通后深度分析发现 3 个问题,V6 针对性修复并跑 hyperopt 验证。
|
||||||
|
|
||||||
|
#### 4.9.1 三个问题与 V6 修复
|
||||||
|
|
||||||
|
**问题 1:指标被非开盘 K 线稀释(代码硬伤)**
|
||||||
|
|
||||||
|
V5-Stock 的 `populate_indicators` 在完整 24/7 dataframe 上计算 ATR/ADX/SMA/通道。OKX 代币化股票 96 根/天 K 线中仅 ~19% 在美股交易时段(9:30-16:00 ET),62% 周末 K 线近似 0 波动。ATR 被稀释 ~16%,通道高低点被平盘 K 线拉偏。
|
||||||
|
|
||||||
|
V6 修复:在 `populate_indicators` 中提取交易时段子集 `mh`,仅在 `mh` 上计算全部指标,然后用 `reindex(dataframe.index, method='ffill')` forward-fill 回完整 dataframe。实测验证:ATR 从稀释值恢复 1.16x,无 NaN 结构问题。Freqtrade 的 `StrategyResultValidator` 要求返回的 dataframe 行数/末行日期/末行收盘不变,此方法保留所有原始行。
|
||||||
|
|
||||||
|
**问题 2:SMA 周期 `* 96` 硬编码**
|
||||||
|
|
||||||
|
V5 原版 `ma_span_long_days * 96` 假设 96 根/天全是有效 K 线(加密 24/7 语义)。代币化股票仅 26 根/天是交易时段。V6 改为 `* 26`(`MARKET_CANDLES_PER_DAY = 26`),`ma_span_long_days=2` → 52 根交易时段 K 线 = 2 个交易日,与 V5 加密版语义一致。
|
||||||
|
|
||||||
|
**问题 3:止损阶梯为加密尺度**
|
||||||
|
|
||||||
|
V5 的 `custom_stoploss` 利润阈值 5%/15%/30% 适合加密 10x 杠杆。股票 1x 下单笔 30% 利润极罕见。V6 下调到 3%/8%/15%(保本+0.5%/允许 5% 回撤/允许 8% 回撤)。
|
||||||
|
|
||||||
|
#### 4.9.2 V6 参数搜索范围
|
||||||
|
|
||||||
|
| 参数 | V5-Stock 范围 | V6 范围 | 原因 |
|
||||||
|
|------|-------------|---------|------|
|
||||||
|
| `up_line_span` | 100-400 | 50-150 | 交易时段 K 线,50-150 = 2-6 交易日 |
|
||||||
|
| `buy_stop_profit_span` | 50-200 | 20-80 | 1-3 交易日 |
|
||||||
|
| `ma_span_long_days` | 1-5 | 1-5 | ×26 后 = 26-130 |
|
||||||
|
| `adx_threshold` | 15-40 | 15-35 | 股票 ADX 常态 15-30 |
|
||||||
|
| `up_line_offset` | -0.2~0.4 | -0.3~0.3 | 真实 ATR 下范围居中 |
|
||||||
|
| `buy_stop_profit_offset` | -0.2~0.2 | -0.2~0.4 | 同上 |
|
||||||
|
| `max_slippage` | 0.005-0.03 | 0.005-0.03 | 不变 |
|
||||||
|
|
||||||
|
`startup_candle_count` 600 → 800(覆盖 up_line_span=150 的 ~781 根预热,剩余 ~21 交易日用于信号生成)。
|
||||||
|
|
||||||
|
#### 4.9.3 三方回测对比
|
||||||
|
|
||||||
|
回测区间:2026-07-24 ~ 2026-08-23(29 天,~21 个有效交易日)。24 只标的,XSNOW/XSMH 因数据不足被跳过。
|
||||||
|
|
||||||
|
| 维度 | V5-Stock(旧代码+旧参数) | V6 默认(新代码+默认参数) | V6 优化(新代码+Hyperopt 参数) |
|
||||||
|
|------|--------------------------|---------------------------|-------------------------------|
|
||||||
|
| 交易数 | 32 | 33 | 51 |
|
||||||
|
| 收益率 | +4.74% | +1.56% | -1.14% |
|
||||||
|
| 胜率 | 43.8% | 57.6% | 62.7% |
|
||||||
|
| Sharpe(closed) | 2.35 | 1.46 | -1.59 |
|
||||||
|
| 最大回撤 | 4.71% | 3.74% | 5.73% |
|
||||||
|
| 被拒入场信号 | 119 | 77 | 21 |
|
||||||
|
| 平均持仓 | 3d 9h | 2d 20h | 16h 28m |
|
||||||
|
|
||||||
|
**Hyperopt 最优参数(Epoch 295/300,Objective=0.05952)**
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Buy
|
||||||
|
adx_threshold = 25, buy_stop_profit_span = 38, ma_span_long_days = 5
|
||||||
|
max_slippage = 0.027, up_line_offset = 0.26, up_line_span = 104
|
||||||
|
# Sell
|
||||||
|
buy_stop_profit_offset = 0.17
|
||||||
|
# ROI(hyperopt 新发现,V5/V5-Stock 原为 {"0": 100} 禁用)
|
||||||
|
{"0": 0.257, "89": 0.063, "255": 0.018, "545": 0}
|
||||||
|
# Stoploss
|
||||||
|
-0.084
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.9.4 分析与结论
|
||||||
|
|
||||||
|
**V6 代码改进有效但未带来收益提升:**
|
||||||
|
- V6 默认参数 vs V5-Stock:胜率从 43.8% → 57.6%,回撤从 4.71% → 3.74%,但收益从 +4.74% → +1.56%。指标修复(ATR 恢复真实水平)和止损阶梯下调改善了风险控制,但默认参数(`up_line_span=80, adx=20, up_line_offset=0.0`)较 V5-Stock 默认值(`340, 25, -0.18`)更保守,入场更少、利润更小。
|
||||||
|
- 交易时段指标计算确实生效——被拒信号从 119 降到 77(V5-Stock 拒了更多噪声信号),说明 ATR/通道基于真实交易时段波动后,`confirm_trade_entry` 的滑点检查更精准。
|
||||||
|
|
||||||
|
**Hyperopt 过拟合(核心问题):**
|
||||||
|
- 300 轮 hyperopt 在 29 天数据上收敛,最优 epoch 的 Objective 从 2.57 → 0.059(持续下降,收敛正常)。
|
||||||
|
- 但最优参数回测收益 -1.14%,远不如 V6 默认 +1.56% 和 V5-Stock +4.74%。
|
||||||
|
- 根因:Hyperopt 找到一组高胜率(62.7%)但负期望参数。ROI 止盈(39 笔 ROI 退出,82.1% 胜率,avg +0.95%)快速锁利,但被 ROI 截断的赢家平均仅赚 0.95%,而 11 笔 channel_exit 亏损平均亏 -3.06%——盈亏比倒挂。
|
||||||
|
- `ma_span_long_days=5`(搜索范围上限)和 `up_line_offset=0.26`(偏积极)说明 hyperopt 在小样本上过拟合到了少数大波动标的(XSOXL 亏 -2.80%,XTSM 亏 -1.00%)。
|
||||||
|
- **结论:29 天 / ~21 个有效交易日的数据量不足以支撑可靠的 hyperopt。需要至少 3-6 个月历史数据。**
|
||||||
|
|
||||||
|
**配置覆盖陷阱(已修复):**
|
||||||
|
- 初次回测 V6 优化参数时,config 的 `minimal_roi: {"0": 100}` 覆盖了 hyperopt 找到的 ROI 表,导致只有 buy/sell 参数生效(25 trades, -0.32%)。
|
||||||
|
- Freqtrade 参数加载顺序:策略类默认 → hyperopt JSON → config 覆盖。config 中的 `minimal_roi`/`stoploss` 会覆盖 hyperopt 结果。
|
||||||
|
- 修复:config 中改为 hyperopt 找到的值后方才生效(51 trades, -1.14%)。
|
||||||
|
|
||||||
|
#### 4.9.5 建议
|
||||||
|
|
||||||
|
1. **V6 代码保留**:指标计算修复(交易时段子集 + forward-fill)、`* 26` 修复、股票止损阶梯——这些是正确的工程改进
|
||||||
|
2. **Hyperopt 结果暂不用**:29 天数据过拟合严重,等 OKX 积累 3-6 个月历史数据后重跑
|
||||||
|
3. **短期用 V6 默认参数或 V5-Stock 参数**:V5-Stock 参数(`up_line_span=340, adx=25, up_line_offset=-0.18`)在当前数据上收益最高,可作为 V6 的备选参数
|
||||||
|
4. **XSOXL 是持续性亏损标的**:在 V5-Stock、V6 默认、V6 优化三个版本中均亏损,考虑从 whitelist 移除
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 5. 综合推荐
|
## 5. 综合推荐
|
||||||
|
|
||||||
按"聚焦策略不写框架"的目标排序:
|
按"聚焦策略不写框架"的目标排序:
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
---
|
||||||
|
name: jd-fapai-scrape
|
||||||
|
description: 抓取京东法拍房搜索结果并支持增量更新。通过 Playwright 渲染京东法拍 H5 页面,拦截 API 响应,提取完整房源数据。支持项目链接、拍卖轮次、智能增量更新(遇到已知数据即停止,不全量下载)。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 京东法拍房源抓取
|
||||||
|
|
||||||
|
## 用途
|
||||||
|
|
||||||
|
从京东司法拍卖频道搜索结果页抓取全部房源信息:
|
||||||
|
- 房源标题与地址(完整不截断)
|
||||||
|
- 项目链接(`https://m.jd.com/product/{skuId}.html`)
|
||||||
|
- 起拍价 / 当前价 / 均价
|
||||||
|
- 户型 / 面积 / 用途 / 小区名称
|
||||||
|
- 拍卖轮次(一拍/二拍/变卖,从 API 标签或折扣率推断)
|
||||||
|
- 拍卖状态 / 出价次数 / 围观人数
|
||||||
|
- 拍卖开始与结束时间
|
||||||
|
- 增量追踪字段(`_first_seen` / `_last_seen`)
|
||||||
|
|
||||||
|
## 前置条件
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install playwright
|
||||||
|
python3 -m playwright install chromium
|
||||||
|
# 或 apt install chromium-browser
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 首次抓取
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 默认抓取塘厦镇
|
||||||
|
python3 skills/jd-fapai-scrape/scrape.py
|
||||||
|
|
||||||
|
# 抓取东莞市全部法拍房(~4000 条,约 3 分钟)
|
||||||
|
python3 skills/jd-fapai-scrape/scrape.py --keyword 东莞市 --output-dir ../法拍/data
|
||||||
|
|
||||||
|
# 自定义参数
|
||||||
|
python3 skills/jd-fapai-scrape/scrape.py --keyword 长安 --sort-field 11 --cate-id 15
|
||||||
|
```
|
||||||
|
|
||||||
|
### 增量更新
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 加 -i 标志,只下载新房源,遇到已知 paimaiId 即停止
|
||||||
|
python3 skills/jd-fapai-scrape/scrape.py --keyword 东莞市 --output-dir ../法拍/data -i
|
||||||
|
```
|
||||||
|
|
||||||
|
增量模式的智能行为:
|
||||||
|
- 读取已有 `{keyword}_法拍房源.json` 中的所有 `paimaiId`
|
||||||
|
- 按最新发布排序加载(`spo_sortField=11`)
|
||||||
|
- **每加载一批(~40 条)检查是否全部已知**,如果是则立即停止
|
||||||
|
- 典型增量更新只需加载 1-2 批(~30 秒),而非全量下载 100 批(~3 分钟)
|
||||||
|
- 新房源添加 `_first_seen` 和 `_last_seen` 时间戳
|
||||||
|
- 已有房源更新可变字段(当前价、状态、出价次数等)并刷新 `_last_seen`
|
||||||
|
|
||||||
|
## 去重机制
|
||||||
|
|
||||||
|
**去重 key = `paimaiId`(拍卖ID)**
|
||||||
|
|
||||||
|
- 每次拍卖有唯一的 `paimaiId`,同一房产的一拍和二拍是不同的 `paimaiId`,各自保留
|
||||||
|
- 不会因为标题相同而合并不同拍卖轮次
|
||||||
|
- `paimaiId` 对应唯一的详情页 URL(`https://m.jd.com/product/{skuId}.html`),等价于按 URL 去重
|
||||||
|
|
||||||
|
## 命令行参数
|
||||||
|
|
||||||
|
| 参数 | 默认值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `--keyword` | 塘厦 | 搜索关键词 |
|
||||||
|
| `--cate-id` | 15 | 类目 ID(15=法拍房) |
|
||||||
|
| `--sort-field` | 11 | 排序(11=最新发布) |
|
||||||
|
| `--max-scrolls` | 100 | 最大滚动次数 |
|
||||||
|
| `--output-dir` | . | 输出目录 |
|
||||||
|
| `--incremental` / `-i` | false | 增量更新模式 |
|
||||||
|
|
||||||
|
## 输出文件
|
||||||
|
|
||||||
|
| 文件 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| `{keyword}_法拍房源.csv` | CSV 表格(26 列,最新发布在前) |
|
||||||
|
| `{keyword}_法拍房源.json` | 完整原始 JSON(含增量追踪字段) |
|
||||||
|
| `{keyword}_增量日志.log` | 每次增量更新的新增/变更记录(仅增量模式) |
|
||||||
|
|
||||||
|
### CSV 列说明
|
||||||
|
|
||||||
|
| 列 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| 序号 / 拍卖ID / SKU ID | 唯一标识 |
|
||||||
|
| 标题 | 完整房产标题 |
|
||||||
|
| **项目链接** | `https://m.jd.com/product/{skuId}.html` |
|
||||||
|
| 位置 / 用途 / 户型 / 面积(㎡) / 小区 / 均价 | 房产属性 |
|
||||||
|
| 起拍价 / 当前价 / 折扣率 | 价格信息 |
|
||||||
|
| **拍卖轮次** | 一拍/二拍/变卖(API 标签或折扣率推断) |
|
||||||
|
| **标签** | 全部标签(诉讼资产、低于评估价等) |
|
||||||
|
| 状态 / 出价次数 / 围观人数 | 拍卖状态 |
|
||||||
|
| 开始时间 / 结束时间 | 拍卖时间 |
|
||||||
|
| 拍卖类型 | 司法拍卖等 |
|
||||||
|
| **首次发现** / **最后更新** | 增量追踪时间戳 |
|
||||||
|
|
||||||
|
## 工作原理
|
||||||
|
|
||||||
|
1. **Playwright 渲染**:headless Chromium 打开京东法拍 H5 页面
|
||||||
|
2. **SSR 数据提取**:页面加载后从 `<script>` 标签中解析内嵌的 page 1 数据(约 40 条)
|
||||||
|
3. **API 拦截**:监听 `api.m.jd.com` 响应,捕获三个 API:
|
||||||
|
- `getSearchData`(appid=paimai)→ 房源列表 page 2+(标题、价格、户型、面积等)
|
||||||
|
- `getPaimaiCurrentInfoByIdsForApi` → 实时拍卖状态(当前价、出价、围观)
|
||||||
|
- `getAuctionLabelConfigs` → 拍卖标签(一拍/二拍/变卖等)
|
||||||
|
4. **无限滚动**:自动滚动触发加载更多
|
||||||
|
5. **智能停止**:增量模式下,检测到一批数据全部已知即停止(SSR page 1 和 API 各批都检查)
|
||||||
|
6. **拍卖轮次推断**:优先用 API 标签,无标签时从折扣率推断(7折≈一拍, 5.6折≈二拍/变卖)
|
||||||
|
7. **排序**:保持 API 返回顺序(spo_sortField=11 已按发布时间降序),不重新排序
|
||||||
|
8. **增量合并**:按 `paimaiId` 去重合并,新数据在前,旧数据在后
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 京东法拍 API 有风控验证,必须通过浏览器渲染,无法直接调用 API
|
||||||
|
- 页面是 JS SPA,但第一页数据通过 SSR 内嵌在 HTML `<script>` 标签中,需单独提取
|
||||||
|
- API 只返回 page 2+,只拦截 API 会漏掉 page 1(包括最新发布的房源)
|
||||||
|
- URL 中的位置参数(`tttparams`、`spo_lat`、`spo_lng`)影响搜索结果范围
|
||||||
|
- API 每页约 40 条,东莞市全量约 4000 条(可能为 API 上限)
|
||||||
|
- 拍卖标签(一拍/二拍)仅对部分数据能从 API 获取,其余从折扣率推断
|
||||||
|
- 同一房产的多次拍卖(一拍流拍后二拍)有不同的 `paimaiId`,各自独立保留
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
playwright>=1.40
|
||||||
@@ -0,0 +1,551 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
京东法拍房源抓取脚本 v3
|
||||||
|
- 项目链接 (detail_url): https://m.jd.com/product/{skuId}.html
|
||||||
|
- 拍卖轮次 (auction_round): 优先从 getAuctionLabelConfigs API 获取,无标签时从折扣率推断
|
||||||
|
- 去重 key = paimaiId(每次拍卖唯一,同一房产的一拍/二拍为不同 paimaiId,各自保留)
|
||||||
|
- 增量更新:遇到已知 paimaiId 即停止滚动,不全量下载
|
||||||
|
- 按发布时间降序排序(最新在前)
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python3 scrape.py --keyword 东莞市 --output-dir data # 首次抓取
|
||||||
|
python3 scrape.py --keyword 东莞市 --output-dir data -i # 增量更新
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
BASE_URL = (
|
||||||
|
"https://pro.m.jd.com/mall/active/3Rja7L2jmC72Ta8eoa31VFDvaAjf/index.html"
|
||||||
|
"?pageParamMiddle=judicature_search_middle"
|
||||||
|
"&pageParam=judicature_icon_search_after"
|
||||||
|
"&pageFrom=judicature_search_home"
|
||||||
|
"&tabParam=all"
|
||||||
|
"&fixSearchParams=false"
|
||||||
|
"&navh=49"
|
||||||
|
"&stath=33"
|
||||||
|
"&tttparams=W6cw9AU4eyJhZGRyZXNzSWQiOjEzODUwMzYwMCwiYXJlYUNvZGUiOjAsImNvcm5lciI6MSwiZF9icmFuZCI6IkhPTk9SIiwiZGwiOjIsImdMYXQiOiIyMi42MjM4MjkiLCJnTG5nIjoiMTE0LjAyNzk0OCIsImdwc19hcmVhIjoiMTlfMTYwN18zMTU1XzYyMTE5IiwibGF0IjoyMi41OTkwMzcsImxic0FyZWEiOiIxOV8xNjA3XzMxNTVfNjIxMTkiLCJsYnNMYXQiOiIyMi42MDA1MzciLCJsYnNMbmciOiIxMTQuMDE0Njk4IiwibG5nIjoxMTQuMDE2ODksIm1vZGVsIjoiVkVSLUFOMTAiLCJvcyI6IjE2IiwicG9zTGF0IjoiMjIuNjIzODI5IiwicG9zTG5nIjoiMTE0LjAyNzk0OCIsInByc3RhdGUiOiIwIiwic2NhbGUiOjMsInVlbXBzIjoiMC0yLTk5OSIsInVuX2FyZWEiOiIxOV8xNjA3XzQ3Mzg4XzYyMTM5Iiwid2lkdGgiOjEwNj8B9"
|
||||||
|
"&spo_lng=114.01689"
|
||||||
|
"&spo_lbsEffect=2"
|
||||||
|
"&spo_reqSource=1"
|
||||||
|
"&spo_multiThirdCateIds={cate_id}"
|
||||||
|
"&spo_lat=22.599037"
|
||||||
|
"&spo_keyword={keyword}"
|
||||||
|
"&spo_sortField={sort_field}"
|
||||||
|
)
|
||||||
|
|
||||||
|
STATUS_MAP = {0: "未开始", 1: "进行中", 2: "已结束", 3: "已撤回", 4: "已流拍"}
|
||||||
|
AUCTION_TYPE_MAP = {1: "诉讼拍卖", 5: "司法拍卖", 7: "商业拍卖"}
|
||||||
|
|
||||||
|
|
||||||
|
def find_chromium():
|
||||||
|
for path in [
|
||||||
|
"/usr/bin/chromium-browser", "/usr/bin/chromium",
|
||||||
|
"/usr/bin/google-chrome", "/usr/bin/google-chrome-stable",
|
||||||
|
]:
|
||||||
|
if os.path.isfile(path):
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def ts_to_str(ts):
|
||||||
|
if ts:
|
||||||
|
try:
|
||||||
|
return datetime.datetime.fromtimestamp(ts / 1000).strftime(
|
||||||
|
"%Y-%m-%d %H:%M:%S"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return str(ts)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def infer_round_from_discount(discount_rate):
|
||||||
|
"""从折扣率推断拍卖轮次(7折≈一拍, 5.6折≈二拍/变卖, 10折≈无折扣)"""
|
||||||
|
if not discount_rate:
|
||||||
|
return ""
|
||||||
|
if discount_rate >= 9.5:
|
||||||
|
return "无折扣"
|
||||||
|
if discount_rate >= 6.5:
|
||||||
|
return "一拍(推断)"
|
||||||
|
if discount_rate >= 5.0:
|
||||||
|
return "二拍/变卖(推断)"
|
||||||
|
return "低折扣"
|
||||||
|
|
||||||
|
|
||||||
|
def load_existing(json_path):
|
||||||
|
if not os.path.exists(json_path):
|
||||||
|
return [], set()
|
||||||
|
with open(json_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
known_ids = {item["paimaiId"] for item in data if "paimaiId" in item}
|
||||||
|
return data, known_ids
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_listings(keyword, cate_id, sort_field, max_scrolls,
|
||||||
|
known_paimai_ids=None, early_stop=True):
|
||||||
|
url = BASE_URL.format(cate_id=cate_id, keyword=keyword, sort_field=sort_field)
|
||||||
|
print(f"搜索 URL: {url}")
|
||||||
|
print(f"关键词: {keyword} | 类目ID: {cate_id} | 排序: {sort_field}")
|
||||||
|
|
||||||
|
if known_paimai_ids is not None and early_stop:
|
||||||
|
print(f"增量模式: 已知 {len(known_paimai_ids)} 个 paimaiId,遇到已知项即停止")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
except ImportError:
|
||||||
|
print("错误: 未安装 Playwright,请运行: pip install playwright")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
chromium_path = find_chromium()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
launch_args = ["--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"]
|
||||||
|
if chromium_path:
|
||||||
|
browser = p.chromium.launch(
|
||||||
|
executable_path=chromium_path, headless=True, args=launch_args
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
browser = p.chromium.launch(headless=True, args=launch_args)
|
||||||
|
except Exception:
|
||||||
|
print("错误: 未找到 Chromium,请运行:")
|
||||||
|
print(" python3 -m playwright install chromium")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
context = browser.new_context(
|
||||||
|
user_agent=(
|
||||||
|
"Mozilla/5.0 (Linux; Android 13; VER-AN10) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/116.0.0.0 Mobile Safari/537.36"
|
||||||
|
),
|
||||||
|
viewport={"width": 375, "height": 812},
|
||||||
|
is_mobile=True,
|
||||||
|
)
|
||||||
|
page = context.new_page()
|
||||||
|
|
||||||
|
# 用 dict 在闭包中累积数据
|
||||||
|
all_listings = {}
|
||||||
|
current_info = {}
|
||||||
|
label_configs = {}
|
||||||
|
state = {"search_call_count": 0, "stopped_early": False}
|
||||||
|
|
||||||
|
def handle_response(response):
|
||||||
|
resp_url = response.url
|
||||||
|
if "api.m.jd.com" not in resp_url or "functionId=" not in resp_url:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = response.text()
|
||||||
|
if not body or len(body) <= 50:
|
||||||
|
return
|
||||||
|
data = json.loads(body)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
if "getSearchData" in resp_url:
|
||||||
|
state["search_call_count"] += 1
|
||||||
|
result_data = data.get("data", {}).get("resultData", [])
|
||||||
|
for item in result_data:
|
||||||
|
d = item.get("data", {})
|
||||||
|
if d and "paimaiId" in d:
|
||||||
|
all_listings[d["paimaiId"]] = d
|
||||||
|
|
||||||
|
# 增量模式:本批全部已知就停
|
||||||
|
if known_paimai_ids is not None and early_stop and result_data:
|
||||||
|
new_in_batch = [
|
||||||
|
item for item in result_data
|
||||||
|
if item.get("data", {}).get("paimaiId") not in known_paimai_ids
|
||||||
|
]
|
||||||
|
if len(new_in_batch) == 0:
|
||||||
|
print(f" 第 {state['search_call_count']} 批数据全部已知,停止滚动")
|
||||||
|
state["stopped_early"] = True
|
||||||
|
|
||||||
|
elif "getPaimaiCurrentInfoByIdsForApi" in resp_url:
|
||||||
|
if data.get("statusCode") == 200:
|
||||||
|
for pid, info in data.get("data", {}).items():
|
||||||
|
current_info[pid] = info
|
||||||
|
|
||||||
|
elif "getAuctionLabelConfigs" in resp_url:
|
||||||
|
if data.get("code") == 0:
|
||||||
|
for pid, config in data.get("data", {}).items():
|
||||||
|
labels = config.get("labelConfigs", [])
|
||||||
|
label_configs[pid] = [
|
||||||
|
l.get("labelName", "") for l in labels
|
||||||
|
]
|
||||||
|
|
||||||
|
page.on("response", handle_response)
|
||||||
|
|
||||||
|
print("正在打开页面...")
|
||||||
|
page.goto(url, wait_until="networkidle", timeout=60000)
|
||||||
|
print("页面加载完成。")
|
||||||
|
|
||||||
|
# 从 SSR 内嵌数据中提取 page 1(API 只返回 page 2+,page 1 在 HTML 里)
|
||||||
|
ssr_listings = page.evaluate("""() => {
|
||||||
|
const scripts = document.querySelectorAll('script');
|
||||||
|
for (const s of scripts) {
|
||||||
|
const text = s.textContent || '';
|
||||||
|
if (!text.includes('resultData') || !text.includes('paimaiId')) continue;
|
||||||
|
const idx = text.indexOf('"resultData"');
|
||||||
|
if (idx === -1) continue;
|
||||||
|
const arrStart = text.indexOf('[', idx);
|
||||||
|
if (arrStart === -1) continue;
|
||||||
|
let depth = 0, arrEnd = -1;
|
||||||
|
for (let i = arrStart; i < text.length; i++) {
|
||||||
|
if (text[i] === '[') depth++;
|
||||||
|
else if (text[i] === ']') { depth--; if (depth === 0) { arrEnd = i + 1; break; } }
|
||||||
|
}
|
||||||
|
if (arrEnd === -1) continue;
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(text.substring(arrStart, arrEnd));
|
||||||
|
return arr.map(item => item.data || item).filter(d => d && d.paimaiId);
|
||||||
|
} catch(e) { continue; }
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}""")
|
||||||
|
if ssr_listings:
|
||||||
|
for d in ssr_listings:
|
||||||
|
if "paimaiId" in d:
|
||||||
|
all_listings[d["paimaiId"]] = d
|
||||||
|
# 增量模式:检查 page 1 是否全部已知
|
||||||
|
if known_paimai_ids is not None and early_stop:
|
||||||
|
new_in_ssr = [d for d in ssr_listings
|
||||||
|
if d.get("paimaiId") not in known_paimai_ids]
|
||||||
|
if len(new_in_ssr) == 0:
|
||||||
|
print(f" SSR page 1: {len(ssr_listings)} 条全部已知,停止")
|
||||||
|
state["stopped_early"] = True
|
||||||
|
print(f" SSR page 1: 提取 {len(ssr_listings)} 条")
|
||||||
|
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
# 无限滚动
|
||||||
|
prev_count = 0
|
||||||
|
no_progress = 0
|
||||||
|
|
||||||
|
for i in range(max_scrolls):
|
||||||
|
if state["stopped_early"]:
|
||||||
|
break
|
||||||
|
|
||||||
|
page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
curr_count = len(all_listings)
|
||||||
|
if curr_count > prev_count:
|
||||||
|
label_count = len(label_configs)
|
||||||
|
print(f" 第 {i+1} 次滚动: 累计 {curr_count} 条 (标签 {label_count})")
|
||||||
|
prev_count = curr_count
|
||||||
|
no_progress = 0
|
||||||
|
else:
|
||||||
|
no_progress += 1
|
||||||
|
|
||||||
|
page_text = page.evaluate("document.body.innerText")
|
||||||
|
if "没有更多" in page_text or "到底了" in page_text:
|
||||||
|
print(f" 第 {i+1} 次滚动时到达底部")
|
||||||
|
break
|
||||||
|
|
||||||
|
if no_progress >= 5:
|
||||||
|
print(f" 连续 {no_progress} 次无新数据,停止")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not state["stopped_early"]:
|
||||||
|
page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
||||||
|
time.sleep(3)
|
||||||
|
|
||||||
|
# 滚动结束后等待标签 API 响应
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
# 合并三个 API 的数据
|
||||||
|
results = []
|
||||||
|
for pid, listing in all_listings.items():
|
||||||
|
# 构造项目链接
|
||||||
|
sku_id = listing.get("skuId", "")
|
||||||
|
listing["detail_url"] = (
|
||||||
|
f"https://m.jd.com/product/{sku_id}.html" if sku_id else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
# 合并实时信息
|
||||||
|
if str(pid) in current_info:
|
||||||
|
info = current_info[str(pid)]
|
||||||
|
listing["_currentPrice"] = info.get("currentPrice")
|
||||||
|
listing["_auctionStatus"] = info.get("auctionStatus")
|
||||||
|
listing["_bidCount"] = info.get("bidCount")
|
||||||
|
listing["_accessNumber"] = info.get("accessNumber")
|
||||||
|
listing["_startTime"] = info.get("startTime")
|
||||||
|
listing["_endTime"] = info.get("endTime")
|
||||||
|
|
||||||
|
# 拍卖轮次:优先用 API 标签,无标签时从折扣率推断
|
||||||
|
labels = label_configs.get(str(pid), [])
|
||||||
|
round_labels = [
|
||||||
|
l for l in labels if l in ("一拍", "二拍", "三拍", "变卖", "重新拍卖")
|
||||||
|
]
|
||||||
|
if round_labels:
|
||||||
|
listing["auction_round"] = "、".join(round_labels)
|
||||||
|
else:
|
||||||
|
listing["auction_round"] = infer_round_from_discount(
|
||||||
|
listing.get("discountRate")
|
||||||
|
)
|
||||||
|
listing["all_labels"] = "、".join(labels) if labels else ""
|
||||||
|
|
||||||
|
results.append(listing)
|
||||||
|
|
||||||
|
return results, state["stopped_early"]
|
||||||
|
|
||||||
|
|
||||||
|
def sort_by_publish_time(listings):
|
||||||
|
# API (spo_sortField=11) 已按发布时间降序返回,直接保持原顺序
|
||||||
|
return list(listings)
|
||||||
|
|
||||||
|
|
||||||
|
def merge_incremental(new_listings, existing_path, now_str):
|
||||||
|
with open(existing_path, "r", encoding="utf-8") as f:
|
||||||
|
existing = json.load(f)
|
||||||
|
|
||||||
|
existing_map = {item["paimaiId"]: item for item in existing if "paimaiId" in item}
|
||||||
|
new_paimai_ids = set()
|
||||||
|
new_items = []
|
||||||
|
updated_items = []
|
||||||
|
|
||||||
|
for item in new_listings:
|
||||||
|
pid = item.get("paimaiId")
|
||||||
|
if pid is None:
|
||||||
|
continue
|
||||||
|
new_paimai_ids.add(pid)
|
||||||
|
if pid not in existing_map:
|
||||||
|
item["_first_seen"] = now_str
|
||||||
|
item["_last_seen"] = now_str
|
||||||
|
new_items.append(item)
|
||||||
|
else:
|
||||||
|
old = existing_map[pid]
|
||||||
|
old["_last_seen"] = now_str
|
||||||
|
for key in ["_currentPrice", "_auctionStatus", "_bidCount",
|
||||||
|
"_accessNumber", "_startTime", "_endTime",
|
||||||
|
"currentPrice", "currentPriceCN", "paimaiStatus",
|
||||||
|
"displayStatus", "auction_round", "all_labels"]:
|
||||||
|
if key in item:
|
||||||
|
old[key] = item[key]
|
||||||
|
updated_items.append(old)
|
||||||
|
|
||||||
|
merged = []
|
||||||
|
seen = set()
|
||||||
|
for item in new_listings:
|
||||||
|
pid = item.get("paimaiId")
|
||||||
|
if pid and pid not in seen:
|
||||||
|
if pid in existing_map:
|
||||||
|
merged.append(existing_map[pid])
|
||||||
|
else:
|
||||||
|
merged.append(item)
|
||||||
|
seen.add(pid)
|
||||||
|
for item in existing:
|
||||||
|
pid = item.get("paimaiId")
|
||||||
|
if pid and pid not in seen:
|
||||||
|
merged.append(item)
|
||||||
|
seen.add(pid)
|
||||||
|
|
||||||
|
return merged, new_items, updated_items
|
||||||
|
|
||||||
|
|
||||||
|
def save_csv(listings, csv_path):
|
||||||
|
with open(csv_path, "w", newline="", encoding="utf-8-sig") as f:
|
||||||
|
writer = csv.writer(f)
|
||||||
|
writer.writerow([
|
||||||
|
"序号", "拍卖ID", "标题", "项目链接", "位置", "用途", "户型",
|
||||||
|
"面积(㎡)", "小区", "均价", "起拍价(元)", "当前价(元)",
|
||||||
|
"当前价(万)", "折扣率", "拍卖轮次", "标签", "状态",
|
||||||
|
"出价次数", "围观次数", "开始时间", "结束时间",
|
||||||
|
"SKU ID", "拍卖类型", "首次发现", "最后更新",
|
||||||
|
])
|
||||||
|
|
||||||
|
for i, item in enumerate(listings):
|
||||||
|
house = item.get("houseAttributes", {})
|
||||||
|
writer.writerow([
|
||||||
|
i + 1,
|
||||||
|
item.get("paimaiId", ""),
|
||||||
|
item.get("title", ""),
|
||||||
|
item.get("detail_url", ""),
|
||||||
|
"、".join(item.get("location", [])),
|
||||||
|
house.get("housePurpose", ""),
|
||||||
|
house.get("houseTypeInfo", ""),
|
||||||
|
house.get("houseArea", ""),
|
||||||
|
house.get("plotName", ""),
|
||||||
|
house.get("averagePrice", ""),
|
||||||
|
item.get("startPrice", ""),
|
||||||
|
item.get("_currentPrice", item.get("currentPrice", "")),
|
||||||
|
item.get("currentPriceCN", ""),
|
||||||
|
item.get("discountRate", ""),
|
||||||
|
item.get("auction_round", ""),
|
||||||
|
item.get("all_labels", ""),
|
||||||
|
STATUS_MAP.get(
|
||||||
|
item.get("_auctionStatus", item.get("paimaiStatus")), ""
|
||||||
|
),
|
||||||
|
item.get("_bidCount", ""),
|
||||||
|
item.get("_accessNumber", ""),
|
||||||
|
ts_to_str(item.get("_startTime")),
|
||||||
|
ts_to_str(item.get("_endTime")),
|
||||||
|
item.get("skuId", ""),
|
||||||
|
AUCTION_TYPE_MAP.get(
|
||||||
|
item.get("auctionType"),
|
||||||
|
str(item.get("auctionType", "")),
|
||||||
|
),
|
||||||
|
item.get("_first_seen", ""),
|
||||||
|
item.get("_last_seen", ""),
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def print_stats(listings, new_items=None, updated_items=None, early_stopped=False):
|
||||||
|
print(f"\n{'=' * 60}")
|
||||||
|
print(f"房源总数: {len(listings)} 条")
|
||||||
|
if new_items is not None:
|
||||||
|
print(f"本次新增: {len(new_items)} 条")
|
||||||
|
if updated_items is not None:
|
||||||
|
print(f"本次更新: {len(updated_items)} 条")
|
||||||
|
if early_stopped:
|
||||||
|
print(f"(增量模式: 遇到已知数据即停止,未全量下载)")
|
||||||
|
print(f"{'=' * 60}")
|
||||||
|
|
||||||
|
prices = [
|
||||||
|
item.get("_currentPrice") or item.get("currentPrice", 0)
|
||||||
|
for item in listings
|
||||||
|
if item.get("_currentPrice") or item.get("currentPrice")
|
||||||
|
]
|
||||||
|
if prices:
|
||||||
|
print(f"\n价格统计:")
|
||||||
|
print(f" 最低: ¥{min(prices):,.2f}")
|
||||||
|
print(f" 最高: ¥{max(prices):,.2f}")
|
||||||
|
print(f" 平均: ¥{sum(prices) / len(prices):,.2f}")
|
||||||
|
print(f" 中位: ¥{sorted(prices)[len(prices) // 2]:,.2f}")
|
||||||
|
|
||||||
|
statuses = Counter(
|
||||||
|
STATUS_MAP.get(
|
||||||
|
item.get("_auctionStatus", item.get("paimaiStatus")), "未知"
|
||||||
|
)
|
||||||
|
for item in listings
|
||||||
|
)
|
||||||
|
print(f"\n状态分布:")
|
||||||
|
for s, c in statuses.most_common():
|
||||||
|
print(f" {s}: {c} 条")
|
||||||
|
|
||||||
|
rounds = Counter(item.get("auction_round", "") for item in listings)
|
||||||
|
print(f"\n拍卖轮次分布:")
|
||||||
|
for r, c in rounds.most_common():
|
||||||
|
if r:
|
||||||
|
print(f" {r}: {c} 条")
|
||||||
|
|
||||||
|
if new_items:
|
||||||
|
print(f"\n新增房源 (前 10 条):")
|
||||||
|
for i, item in enumerate(new_items[:10]):
|
||||||
|
price = item.get("_currentPrice") or item.get("currentPrice", 0)
|
||||||
|
print(f" [{i+1}] {item.get('title', 'N/A')[:50]}")
|
||||||
|
print(f" ¥{price:,.0f} | {item.get('auction_round','')} | "
|
||||||
|
f"{ts_to_str(item.get('_startTime'))}")
|
||||||
|
print(f" {item.get('detail_url','')}")
|
||||||
|
|
||||||
|
|
||||||
|
def scrape(keyword="塘厦", cate_id="15", sort_field="11",
|
||||||
|
max_scrolls=100, output_dir=".", incremental=False):
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
|
now_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
print(f"运行时间: {now_str}")
|
||||||
|
print(f"模式: {'增量更新' if incremental else '首次抓取'}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
json_path = os.path.join(output_dir, f"{keyword}_法拍房源.json")
|
||||||
|
csv_path = os.path.join(output_dir, f"{keyword}_法拍房源.csv")
|
||||||
|
|
||||||
|
known_ids = None
|
||||||
|
if incremental:
|
||||||
|
existing, known_ids = load_existing(json_path)
|
||||||
|
if not known_ids:
|
||||||
|
print("未找到已有数据,转为首次抓取模式")
|
||||||
|
incremental = False
|
||||||
|
else:
|
||||||
|
print(f"已有数据: {len(existing)} 条, 已知 {len(known_ids)} 个 paimaiId")
|
||||||
|
|
||||||
|
new_listings, early_stopped = fetch_listings(
|
||||||
|
keyword, cate_id, sort_field, max_scrolls,
|
||||||
|
known_paimai_ids=known_ids if incremental else None,
|
||||||
|
early_stop=incremental,
|
||||||
|
)
|
||||||
|
print(f"\n本次抓取: {len(new_listings)} 条")
|
||||||
|
|
||||||
|
if incremental:
|
||||||
|
merged, new_items, updated_items = merge_incremental(
|
||||||
|
new_listings, json_path, now_str
|
||||||
|
)
|
||||||
|
print(f"合并后总数: {len(merged)} 条")
|
||||||
|
print(f"新增: {len(new_items)} 条 | 更新: {len(updated_items)} 条")
|
||||||
|
|
||||||
|
with open(json_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(merged, f, indent=2, ensure_ascii=False)
|
||||||
|
save_csv(merged, csv_path)
|
||||||
|
|
||||||
|
log_path = os.path.join(output_dir, f"{keyword}_增量日志.log")
|
||||||
|
with open(log_path, "a", encoding="utf-8") as f:
|
||||||
|
f.write(f"\n{'=' * 60}\n")
|
||||||
|
f.write(f"时间: {now_str}\n")
|
||||||
|
f.write(f"新增: {len(new_items)} 条 | 更新: {len(updated_items)} 条\n")
|
||||||
|
f.write(f"提前停止: {'是' if early_stopped else '否'}\n")
|
||||||
|
for item in new_items:
|
||||||
|
price = item.get("_currentPrice") or item.get("currentPrice", 0)
|
||||||
|
f.write(f" [新] {item.get('title', 'N/A')[:60]} | "
|
||||||
|
f"{item.get('auction_round','')} | ¥{price:,.0f} | "
|
||||||
|
f"{ts_to_str(item.get('_startTime'))}\n"
|
||||||
|
f" {item.get('detail_url','')}\n")
|
||||||
|
for item in updated_items:
|
||||||
|
price = item.get("_currentPrice") or item.get("currentPrice", 0)
|
||||||
|
f.write(f" [更] {item.get('title', 'N/A')[:60]} | "
|
||||||
|
f"{item.get('auction_round','')} | ¥{price:,.0f} | "
|
||||||
|
f"{ts_to_str(item.get('_startTime'))}\n")
|
||||||
|
|
||||||
|
print(f"\n输出文件:")
|
||||||
|
print(f" JSON: {json_path}")
|
||||||
|
print(f" CSV: {csv_path}")
|
||||||
|
print(f" 日志: {log_path}")
|
||||||
|
|
||||||
|
print_stats(merged, new_items, updated_items, early_stopped=early_stopped)
|
||||||
|
return merged
|
||||||
|
else:
|
||||||
|
sorted_listings = sort_by_publish_time(new_listings)
|
||||||
|
for item in sorted_listings:
|
||||||
|
item["_first_seen"] = now_str
|
||||||
|
item["_last_seen"] = now_str
|
||||||
|
|
||||||
|
with open(json_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(sorted_listings, f, indent=2, ensure_ascii=False)
|
||||||
|
save_csv(sorted_listings, csv_path)
|
||||||
|
|
||||||
|
print(f"\n输出文件:")
|
||||||
|
print(f" JSON: {json_path}")
|
||||||
|
print(f" CSV: {csv_path}")
|
||||||
|
|
||||||
|
print_stats(sorted_listings)
|
||||||
|
return sorted_listings
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description="京东法拍房源抓取(支持增量更新)")
|
||||||
|
parser.add_argument("--keyword", default="塘厦", help="搜索关键词 (默认: 塘厦)")
|
||||||
|
parser.add_argument("--cate-id", default="15", help="类目ID (默认: 15=法拍房)")
|
||||||
|
parser.add_argument("--sort-field", default="11", help="排序 (默认: 11=最新发布)")
|
||||||
|
parser.add_argument("--max-scrolls", type=int, default=100, help="最大滚动次数")
|
||||||
|
parser.add_argument("--output-dir", default=".", help="输出目录")
|
||||||
|
parser.add_argument("--incremental", "-i", action="store_true",
|
||||||
|
help="增量更新:只下载新房源,遇到已知项即停止")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
scrape(
|
||||||
|
keyword=args.keyword,
|
||||||
|
cate_id=args.cate_id,
|
||||||
|
sort_field=args.sort_field,
|
||||||
|
max_scrolls=args.max_scrolls,
|
||||||
|
output_dir=args.output_dir,
|
||||||
|
incremental=args.incremental,
|
||||||
|
)
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
# 京东法拍房搜索 URL 结构说明
|
||||||
|
|
||||||
|
## 一、URL 总体结构
|
||||||
|
|
||||||
|
```
|
||||||
|
https://pro.m.jd.com/mall/active/{activityId}/index.html?{query_params}
|
||||||
|
```
|
||||||
|
|
||||||
|
| 部分 | 值 | 说明 |
|
||||||
|
|------|-----|------|
|
||||||
|
| 协议/域名 | `https://pro.m.jd.com` | 京东移动端 H5 活动页服务器 |
|
||||||
|
| 路径 | `/mall/active/3Rja7L2jmC72Ta8eoa31VFDvaAjf/index.html` | 活动 ID,对应司法拍卖(法拍)频道首页 |
|
||||||
|
|
||||||
|
> **最小可用 URL**:路径中的活动 ID 本身就标识了法拍频道,即使不带任何查询参数也能打开法拍首页。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、查询参数详解
|
||||||
|
|
||||||
|
### 2.1 页面上下文参数(控制页面渲染状态)
|
||||||
|
|
||||||
|
| 参数 | 示例值 | 必填 | 说明 |
|
||||||
|
|------|--------|------|------|
|
||||||
|
| `pageParam` | `judicature_icon_search_after` | ✅ | 页面状态:从司法图标搜索进入结果页 |
|
||||||
|
| `pageFrom` | `judicature_search_home` | ✅ | 来源页面:司法搜索首页 |
|
||||||
|
| `tabParam` | `all` | ✅ | 当前 Tab:"all" = 全部 |
|
||||||
|
| `pageParamMiddle` | `judicature_search_middle` | ✅ | 中间页参数(SSR 渲染需要) |
|
||||||
|
| `fixSearchParams` | `false` | ❌ | 是否固定搜索条件 |
|
||||||
|
| `navh` | `49` | ❌ | 导航栏高度(px) |
|
||||||
|
| `stath` | `33` | ❌ | 状态栏高度(px) |
|
||||||
|
|
||||||
|
### 2.2 搜索条件参数(`spo_` 前缀,核心筛选字段)
|
||||||
|
|
||||||
|
| 参数 | 示例值 | 必填 | 说明 |
|
||||||
|
|------|--------|------|------|
|
||||||
|
| `spo_keyword` | `塘厦` | ✅ | **搜索关键词**(URL 编码) |
|
||||||
|
| `spo_sortField` | `11` | ✅ | **排序字段**:`11` = 按最新发布排序 |
|
||||||
|
| `spo_multiThirdCateIds` | `15` | ✅ | **类目 ID**:`15` = 法拍房 |
|
||||||
|
| `spo_lat` | `22.599037` | ✅ | 定位纬度(影响搜索结果) |
|
||||||
|
| `spo_lng` | `114.01689` | ✅ | 定位经度(影响搜索结果) |
|
||||||
|
| `spo_lbsEffect` | `2` | ✅ | LBS 效果模式 |
|
||||||
|
| `spo_reqSource` | `1` | ✅ | 请求来源标识 |
|
||||||
|
|
||||||
|
### 2.3 设备/位置信息(`tttparams`,base64 编码 JSON)
|
||||||
|
|
||||||
|
原始值是一段 base64 编码,解码后为 JSON 对象,包含:
|
||||||
|
|
||||||
|
| 字段 | 示例值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `addressId` | `138503600` | 京东地址 ID |
|
||||||
|
| `d_brand` | `HONOR` | 手机品牌 |
|
||||||
|
| `model` | `VER-AN10` | 手机型号 |
|
||||||
|
| `gLat` / `gLng` | `22.623829` / `114.027948` | GPS 定位 |
|
||||||
|
| `lbsLat` / `lbsLng` | `22.600537` / `114.014698` | LBS 基站定位 |
|
||||||
|
| `gps_area` | `19_1607_3155_62119` | GPS 区域编码(省_市_区_街道) |
|
||||||
|
| `un_area` | `19_1607_47388_62139` | 用户区域编码 |
|
||||||
|
| `scale` | `3` | 屏幕缩放 |
|
||||||
|
| `os` | `16` | 系统版本 |
|
||||||
|
|
||||||
|
> 区域编码 `19_1607_3155_62119` 对应:广东(19)_深圳(1607)_塘厦镇(3155)
|
||||||
|
|
||||||
|
### 2.4 时间戳
|
||||||
|
|
||||||
|
| 参数 | 示例值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `_ts` | `1788869426803` | 毫秒级时间戳 |
|
||||||
|
|
||||||
|
### 2.5 UTM 分享/推广追踪
|
||||||
|
|
||||||
|
| 参数 | 示例值 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `utm_user` | `plusmember` | 用户身份 |
|
||||||
|
| `utm_source` | `androidapp` | 来源平台 |
|
||||||
|
| `utm_medium` | `appshare` | 分享媒介 |
|
||||||
|
| `utm_campaign` | `t_335139774` | 活动 ID |
|
||||||
|
| `utm_term` | `Wxfriends_shareid...` | 微信好友分享 ID |
|
||||||
|
| `ad_od` | `share` | 广告类型 |
|
||||||
|
| `gx` / `gxd` | `RnAonzoTNmGdh8lR5Q` / `...` | 京东内部追踪参数 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、完整 URL(脚本实际使用)
|
||||||
|
|
||||||
|
```
|
||||||
|
https://pro.m.jd.com/mall/active/3Rja7L2jmC72Ta8eoa31VFDvaAjf/index.html
|
||||||
|
?pageParamMiddle=judicature_search_middle
|
||||||
|
&pageParam=judicature_icon_search_after
|
||||||
|
&pageFrom=judicature_search_home
|
||||||
|
&tabParam=all
|
||||||
|
&fixSearchParams=false
|
||||||
|
&navh=49
|
||||||
|
&stath=33
|
||||||
|
&tttparams=<base64编码的位置信息>
|
||||||
|
&spo_lng=114.01689
|
||||||
|
&spo_lbsEffect=2
|
||||||
|
&spo_reqSource=1
|
||||||
|
&spo_multiThirdCateIds=15
|
||||||
|
&spo_lat=22.599037
|
||||||
|
&spo_keyword=塘厦
|
||||||
|
&spo_sortField=11
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ `tttparams`、`spo_lat`、`spo_lng` 等位置参数影响搜索结果范围。早期使用最小 URL 时会漏掉部分房源(如塘厦搜索漏掉 51 条)。
|
||||||
|
|
||||||
|
### 可替换的变量
|
||||||
|
|
||||||
|
| 变量 | 替换示例 | 说明 |
|
||||||
|
|------|----------|------|
|
||||||
|
| `spo_keyword` | `塘厦` → `长安` | 搜索其他镇/区域 |
|
||||||
|
| `spo_sortField` | `11`(最新) → 其他值 | 排序方式 |
|
||||||
|
| `spo_multiThirdCateIds` | `15`(法拍房) → 其他 | 法拍类目 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、排序字段 `spo_sortField` 已知值
|
||||||
|
|
||||||
|
| 值 | 排序方式 |
|
||||||
|
|----|----------|
|
||||||
|
| `11` | 按最新发布排序 |
|
||||||
|
| 其他值待补充 | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、底层 API 接口
|
||||||
|
|
||||||
|
页面通过 JavaScript 动态加载数据,但**第一页数据通过 SSR 内嵌在 HTML 的 `<script>` 标签中**,不经过 API 调用。API 只负责 page 2 及之后的加载。
|
||||||
|
|
||||||
|
### 数据来源
|
||||||
|
|
||||||
|
| 来源 | 范围 | 获取方式 |
|
||||||
|
|------|------|----------|
|
||||||
|
| SSR 内嵌数据 | page 1(约 40 条) | 页面加载后从 `<script>` 标签解析 JSON |
|
||||||
|
| `getSearchData` API | page 2+ | `page.on("response")` 拦截 API 响应 |
|
||||||
|
| `getPaimaiCurrentInfoByIdsForApi` | 全部 | 实时拍卖状态(当前价、出价、围观) |
|
||||||
|
| `getAuctionLabelConfigs` | 部分可见项 | 拍卖标签(一拍、二拍、变卖等) |
|
||||||
|
|
||||||
|
> ⚠️ 如果只拦截 API 响应而不提取 SSR 数据,会漏掉 page 1 的全部房源(包括最新发布的房源)。
|
||||||
|
|
||||||
|
### API 请求格式
|
||||||
|
|
||||||
|
```
|
||||||
|
POST https://api.m.jd.com/api?appid=paimai&functionId=getSearchData&loginType=2
|
||||||
|
Content-Type: application/x-www-form-urlencoded
|
||||||
|
|
||||||
|
body=<URL编码的JSON>
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ API 有风险控制(`shieldResult.intercept: true`),直接调用返回 "no access"。需通过浏览器渲染执行风险验证后方可获取数据。
|
||||||
|
|
||||||
|
### getSearchData 响应字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `paimaiId` | int | 拍卖 ID(唯一标识) |
|
||||||
|
| `skuId` | int | 商品 SKU ID |
|
||||||
|
| `title` | string | 房源标题/地址 |
|
||||||
|
| `startPrice` | float | 起拍价(元) |
|
||||||
|
| `currentPrice` | float | 当前价(元) |
|
||||||
|
| `currentPriceCN` | string | 当前价(中文,如"125.8945万") |
|
||||||
|
| `discountRate` | float | 折扣率 |
|
||||||
|
| `paimaiStatus` | int | 拍卖状态:0=未开始, 1=进行中, 2=已结束, 3=已撤回 |
|
||||||
|
| `auctionType` | int | 拍卖类型:5=司法拍卖 |
|
||||||
|
| `location` | array | 位置信息 |
|
||||||
|
| `houseAttributes` | object | 房产属性(见下表) |
|
||||||
|
| `productImage` | string | 图片路径(CDN) |
|
||||||
|
|
||||||
|
### houseAttributes 子字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `houseTypeInfo` | string | 户型(如"4室1厅1卫") |
|
||||||
|
| `housePurpose` | string | 用途(如"住宅") |
|
||||||
|
| `houseArea` | float | 面积(㎡) |
|
||||||
|
| `plotName` | string | 小区名称 |
|
||||||
|
| `averagePrice` | string | 均价(如"8254.40元/平") |
|
||||||
|
| `houseFloorInfo` | int | 楼层信息 |
|
||||||
|
| `houseEmpty` | int | 是否空置 |
|
||||||
|
| `houseCertificate` | int | 权属证书类型 |
|
||||||
|
| `houseLease` | int | 租赁情况 |
|
||||||
Reference in New Issue
Block a user