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:
2026-09-09 08:05:57 +00:00
co-authored by Claude Opus 4.6
parent cf92ce902c
commit e332d346f6
5 changed files with 943 additions and 0 deletions
+89
View File
@@ -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 行数/末行日期/末行收盘不变,此方法保留所有原始行。
**问题 2SMA 周期 `* 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-2329 天,~21 个有效交易日)。24 只标的,XSNOW/XSMH 因数据不足被跳过。
| 维度 | V5-Stock(旧代码+旧参数) | V6 默认(新代码+默认参数) | V6 优化(新代码+Hyperopt 参数) |
|------|--------------------------|---------------------------|-------------------------------|
| 交易数 | 32 | 33 | 51 |
| 收益率 | +4.74% | +1.56% | -1.14% |
| 胜率 | 43.8% | 57.6% | 62.7% |
| Sharpeclosed | 2.35 | 1.46 | -1.59 |
| 最大回撤 | 4.71% | 3.74% | 5.73% |
| 被拒入场信号 | 119 | 77 | 21 |
| 平均持仓 | 3d 9h | 2d 20h | 16h 28m |
**Hyperopt 最优参数(Epoch 295/300Objective=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
# ROIhyperopt 新发现,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. 综合推荐
按"聚焦策略不写框架"的目标排序: 按"聚焦策略不写框架"的目标排序:
+123
View File
@@ -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 | 类目 ID15=法拍房) |
| `--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`,各自独立保留
+1
View File
@@ -0,0 +1 @@
playwright>=1.40
+551
View File
@@ -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 1API 只返回 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,
)
+179
View File
@@ -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 | 租赁情况 |