Add 东莞法拍房 historical data: 31 sub-regions, 24,949 records (2017-2026)

Scraped all 31 Dongguan sub-regions using sortField=2 (end-time ascending)
to bypass JD's ~4000-item API cap. Merged 34 CSV files by paimaiId into
24,949 unique records covering 2017-07 to 2026-11.

Key findings:
- 上架量 grew ~50x: 98 (2017) → 4,813 (2026)
- 流拍率 peaked at 81.6% (2024), eased to 67.4% (2026)
- 樟木头: 558 records, failure rate peaked 94.2% (2024)
- 塘厦: 205 records, 2026 failure rate 51.0%

Includes: scrape_history.py, batch_scrape_towns.sh, analyze_trends.py,
yearly_stats.py, and updated SKILL.md + url_structure.md documenting
the 4000-item cap and sub-region scraping strategy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
wiki-agent
2026-09-12 02:59:54 +00:00
co-authored by Claude Opus 4.6
parent bfbb4e6a26
commit f06d84a003
40 changed files with 25953 additions and 5 deletions
+54 -1
View File
@@ -112,12 +112,65 @@ python3 skills/jd-fapai-scrape/scrape.py --keyword 东莞市 --output-dir ../法
7. **排序**:保持 API 返回顺序(spo_sortField=11 已按发布时间降序),不重新排序
8. **增量合并**:按 `paimaiId` 去重合并,新数据在前,旧数据在后
## 历史数据抓取(sortField=2
默认的 `sortField=11`(最新发布)只返回近期上架的房源。京东平台实际保留了从 **2017年7月** 至今的全部历史成交数据,但需要用 `sortField=2`(按结束时间升序)才能获取。
### ⚠️ 4000条数据上限
京东法拍无限滚动有 **~4000条硬性上限**(约100次滚动后API停止返回新数据),无论排序方式如何。这意味着:
- `sortField=11`(最新发布):返回最近~4000条(约覆盖最近10个月)
- `sortField=2`(结束时间升序):返回最早的~4000条(约覆盖2017-07至~2022
**无法通过一次搜索获取全量历史数据。** 对于总量超过4000条的城市(如东莞市住宅~20,276条),需要按镇/区分区域抓取。
### 按镇/区分区域抓取(推荐)
```bash
# 单个镇(数据量通常<4000,可一次抓全)
python3 skills/jd-fapai-scrape/scrape_history.py \
--keyword 樟木头 --batch-label 樟木头_all \
--max-scrolls 200 --output-dir "output/法拍"
# 批量抓取所有镇(shell脚本循环)
for town in 莞城 东城 南城 万江 ... ; do
python3 skills/jd-fapai-scrape/scrape_history.py \
--keyword "$town" --batch-label "${town}_all" \
--max-scrolls 200 --output-dir "output/法拍"
done
```
合并时用 `analyze_trends.py``paimaiId` 去重:
```bash
python3 skills/jd-fapai-scrape/analyze_trends.py
```
### 大镇的数据缺口
部分大镇(如东城~3998条、南城~3780条)会接近4000条上限,`sortField=2` 的数据覆盖到~2025年。2025年末至2026年的近期数据可通过原始 `scrape.py``sortField=11`)补充,中间有约1年缺口。
### 数据量参考
| 关键词 | 类目 | 总记录数 | sortField=2 实际获取 |
|--------|------|----------|---------------------|
| 东莞市 | 住宅(cate_id=15 | ~20,276 | ~3,9952017至~2022 |
| 樟木头 | 住宅 | ~558 | 5582018至2026,全覆盖) |
| 东城 | 住宅 | ~3,998 | 3,9982017至~2025,触顶) |
| 南城 | 住宅 | ~3,780 | 3,7802018至~2025,触顶) |
> 每页约40条,上限约100次滚动≈4000条。单个镇通常1-3分钟完成。
## 注意事项
- 京东法拍 API 有风控验证,必须通过浏览器渲染,无法直接调用 API
- 页面是 JS SPA,但第一页数据通过 SSR 内嵌在 HTML `<script>` 标签中,需单独提取
- API 只返回 page 2+,只拦截 API 会漏掉 page 1(包括最新发布的房源)
- URL 中的位置参数(`tttparams``spo_lat``spo_lng`)影响搜索结果范围
- API 每页约 40 条,东莞市全量约 4000 条(可能为 API 上限)
- API 每页约 40 条,无限滚动约 100 次后停止返回新数据(~4000 条硬性上限),非滚动次数限制
- 历史数据(`sortField=2`)最早到 2017年7月,更早的数据平台已下架
- 京东法拍无独立"已结束"tab`sortField=2` 是获取历史成交的唯一方式
- 大城市(如东莞市)总量超过4000条,需按镇/区分区域抓取才能获取全量历史数据
- `scrape_history.py``getPaimaiCurrentInfoByIdsForApi` 响应捕获需要足够等待时间(初始5秒、滚动间隔3秒),否则结束时间等字段可能为空
- 拍卖标签(一拍/二拍)仅对部分数据能从 API 获取,其余从折扣率推断
- 同一房产的多次拍卖(一拍流拍后二拍)有不同的 `paimaiId`,各自独立保留
+520
View File
@@ -0,0 +1,520 @@
#!/usr/bin/env python3
"""
Generate trend analysis HTML from JD法拍 CSV files.
Reads all *_法拍房源*.csv files in output/法拍/, merges by paimaiId,
computes monthly trends (上架量/流拍量), and generates an interactive HTML chart.
"""
import csv
import json
import os
import glob
from collections import Counter
CSV_DIR = "output/法拍"
OUTPUT_HTML = os.path.join(CSV_DIR, "东莞法拍房趋势分析.html")
def read_all_csvs(csv_dir):
all_items = {}
csv_files = glob.glob(os.path.join(csv_dir, "*_法拍房源*.csv"))
for csv_path in csv_files:
try:
with open(csv_path, encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
pid = row.get("拍卖ID", "")
if not pid:
continue
if pid not in all_items:
all_items[pid] = row
else:
existing = all_items[pid]
if row.get("结束时间") and not existing.get("结束时间"):
all_items[pid] = row
except Exception as e:
print(f"Warning: failed to read {csv_path}: {e}")
return list(all_items.values())
def compute_monthly_trends(items, region_filter=None):
if region_filter:
items = [r for r in items if region_filter in r.get("标题", "")]
listed_by_month = Counter()
failed_by_month = Counter()
ended_by_month = Counter()
for r in items:
start = r.get("开始时间", "")[:7]
end = r.get("结束时间", "")[:7]
status = r.get("状态", "")
bid_count = r.get("出价次数", "")
if start:
listed_by_month[start] += 1
if end and status == "已结束":
ended_by_month[end] += 1
if bid_count in ("0", ""):
failed_by_month[end] += 1
month_set = (
set(listed_by_month.keys())
| set(ended_by_month.keys())
| set(failed_by_month.keys())
)
if not month_set:
return []
all_months = sorted(month_set)
start_m = all_months[0]
end_m = all_months[-1]
full_months = []
y, m = int(start_m[:4]), int(start_m[5:7])
ey, em = int(end_m[:4]), int(end_m[5:7])
while (y, m) <= (ey, em):
full_months.append(f"{y:04d}-{m:02d}")
m += 1
if m > 12:
m = 1
y += 1
results = []
for month in full_months:
listed = listed_by_month.get(month, 0)
failed = failed_by_month.get(month, 0)
ended = ended_by_month.get(month, 0)
rate = round(failed / ended * 100, 1) if ended > 0 else None
results.append(
{
"month": month,
"listed": listed,
"failed": failed,
"ended": ended,
"rate": rate,
}
)
return results
def generate_svg(data, panel_idx):
n = len(data)
if n == 0:
return "<svg></svg>"
W, H = 1120, 240
padL, padR, padT, padB = 52, 24, 16, 40
plotW = W - padL - padR
plotH = H - padT - padB
max_val = max((max(d["listed"], d["failed"]) for d in data), default=1)
max_val = max(max_val, 5)
y_ticks = 5
y_step_val = max_val / y_ticks if max_val > 0 else 1
def y_pos(val):
if max_val == 0:
return padT + plotH
return padT + plotH - (val / max_val) * plotH
def x_pos(i):
if n == 1:
return padL + plotW / 2
return padL + (i / (n - 1)) * plotW
svg_parts = []
# Y-axis grid lines and labels
for t in range(y_ticks + 1):
val = t * y_step_val
y = y_pos(val)
svg_parts.append(
f'<line x1="{padL}" y1="{y:.1f}" x2="{W-padR}" y2="{y:.1f}" '
f'stroke="var(--gridline)" stroke-width="1"/>'
)
svg_parts.append(
f'<text x="{padL-8}" y="{y+4:.1f}" text-anchor="end" font-size="11" '
f'fill="var(--text-muted)" font-family="system-ui" '
f'font-variant-numeric="tabular-nums">{int(val)}</text>'
)
# X-axis labels (year markers)
prev_year = None
for i, d in enumerate(data):
year = d["month"][:4]
if year != prev_year:
x = x_pos(i)
svg_parts.append(
f'<text x="{x:.1f}" y="{H-12}" text-anchor="middle" font-size="11" '
f'fill="var(--text-muted)" font-family="system-ui">{year}</text>'
)
if prev_year is not None:
svg_parts.append(
f'<line x1="{x:.1f}" y1="{padT}" x2="{x:.1f}" y2="{padT+plotH}" '
f'stroke="var(--gridline)" stroke-width="0.5" stroke-dasharray="2 4"/>'
)
prev_year = year
# Axis line
svg_parts.append(
f'<line x1="{padL}" y1="{padT+plotH}" x2="{W-padR}" y2="{padT+plotH}" '
f'stroke="var(--axis-line)" stroke-width="1"/>'
)
# Area fill for listed (blue)
area_pts = [f"{x_pos(0):.1f},{y_pos(data[0]['listed']):.1f}"]
for i, d in enumerate(data):
area_pts.append(f"{x_pos(i):.1f},{y_pos(d['listed']):.1f}")
area_pts.append(f"{x_pos(n-1):.1f},{padT+plotH:.1f}")
area_pts.append(f"{x_pos(0):.1f},{padT+plotH:.1f}")
svg_parts.append(
f'<path d="M{" L".join(area_pts)} Z" fill="var(--series-1)" opacity="0.06"/>'
)
# Area fill for failed (orange)
area_pts2 = [f"{x_pos(0):.1f},{y_pos(data[0]['failed']):.1f}"]
for i, d in enumerate(data):
area_pts2.append(f"{x_pos(i):.1f},{y_pos(d['failed']):.1f}")
area_pts2.append(f"{x_pos(n-1):.1f},{padT+plotH:.1f}")
area_pts2.append(f"{x_pos(0):.1f},{padT+plotH:.1f}")
svg_parts.append(
f'<path d="M{" L".join(area_pts2)} Z" fill="var(--series-2)" opacity="0.06"/>'
)
# Line for listed
line_pts = [f"{x_pos(i):.1f},{y_pos(d['listed']):.1f}" for i, d in enumerate(data)]
svg_parts.append(
f'<path d="M{" L".join(line_pts)}" fill="none" stroke="var(--series-1)" '
f'stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>'
)
# Line for failed
line_pts2 = [f"{x_pos(i):.1f},{y_pos(d['failed']):.1f}" for i, d in enumerate(data)]
svg_parts.append(
f'<path d="M{" L".join(line_pts2)}" fill="none" stroke="var(--series-2)" '
f'stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>'
)
# Data point circles + hit areas
hit_w = plotW / n
for i, d in enumerate(data):
x = x_pos(i)
y1 = y_pos(d["listed"])
y2 = y_pos(d["failed"])
r = 2.5 if n > 50 else 3.5
svg_parts.append(
f'<circle cx="{x:.1f}" cy="{y1:.1f}" r="{r}" fill="var(--series-1)" '
f'stroke="var(--surface-1)" stroke-width="1.5"/>'
)
svg_parts.append(
f'<circle cx="{x:.1f}" cy="{y2:.1f}" r="{r}" fill="var(--series-2)" '
f'stroke="var(--surface-1)" stroke-width="1.5"/>'
)
hx = x - hit_w / 2
svg_parts.append(
f'<rect x="{hx:.1f}" y="{padT}" width="{hit_w:.1f}" height="{plotH}" '
f'fill="transparent" class="hit" data-idx="{i}"/>'
)
# Last point data labels
last = data[-1]
lx = x_pos(n - 1)
svg_parts.append(
f'<text x="{lx-6:.1f}" y="{y_pos(last["listed"])-8:.1f}" text-anchor="end" '
f'font-size="11" fill="var(--text-primary)" font-family="system-ui" '
f'font-variant-numeric="tabular-nums" font-weight="600">{last["listed"]}</text>'
)
if last["failed"] > 0:
svg_parts.append(
f'<text x="{lx-6:.1f}" y="{y_pos(last["failed"])+14:.1f}" text-anchor="end" '
f'font-size="11" fill="var(--text-primary)" font-family="system-ui" '
f'font-variant-numeric="tabular-nums">{last["failed"]}</text>'
)
# Crosshair
svg_parts.append(
f'<line class="crosshair" x1="0" y1="{padT}" x2="0" y2="{padT+plotH}" '
f'stroke="var(--text-muted)" stroke-width="1" stroke-dasharray="3 3" opacity="0"/>'
)
return (
f'<svg viewBox="0 0 {W} {H}" width="100%" height="{H}">'
+ "".join(svg_parts)
+ "</svg>"
)
def generate_table_row_html(region, data):
rows = [f'<tr class="group-header"><td colspan="6">{region}(共 {len(data)} 个月)</td></tr>']
for d in data:
rate_str = f'{d["rate"]:.1f}%' if d["rate"] is not None else ""
rows.append(
f'<tr><td></td><td style="text-align:left">{d["month"]}</td>'
f'<td>{d["listed"]}</td><td>{d["failed"]}</td>'
f'<td>{d["ended"]}</td><td>{rate_str}</td></tr>'
)
return "\n".join(rows)
def generate_html(panels, items_count):
svgs = {}
for key, data in panels.items():
idx = list(panels.keys()).index(key)
svgs[key] = generate_svg(data, idx)
data_json = json.dumps(panels, ensure_ascii=False)
panel_keys = list(panels.keys())
# Date range
all_months = []
for data in panels.values():
all_months.extend([d["month"] for d in data])
date_range = f"{min(all_months)} {max(all_months)}" if all_months else ""
# Sample counts
sample_counts = {}
for key in panel_keys:
if key == "东莞全市":
sample_counts[key] = items_count
else:
sample_counts[key] = sum(
1 for d in panels[key] for _ in range(d["listed"])
)
table_html = "\n".join(
generate_table_row_html(key, data) for key, data in panels.items()
)
panel_html = ""
for i, key in enumerate(panel_keys):
subtitle = f"样本量 {sample_counts[key]}" if key != "东莞全市" else f"全市法拍住宅 · 共 {items_count} 条记录"
panel_html += f"""
<div class="panel">
<div class="panel-header">
<span class="panel-title">{key}</span>
<span class="panel-subtitle">{subtitle}</span>
</div>
<div class="chart-wrap" id="panel-{i}">
{svgs[key]}
<div class="tooltip" id="tooltip-{i}"></div>
</div>
</div>"""
html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>东莞法拍房上架量与流拍量趋势</title>
<style>
.viz-root {{
color-scheme: light;
--surface-1: #fcfcfb;
--page-plane: #f9f9f7;
--text-primary: #0b0b0b;
--text-secondary: #52514e;
--text-muted: #898781;
--gridline: #e1e0d9;
--axis-line: #c3c2b7;
--series-1: #2a78d6;
--series-2: #eb6834;
--border-ring: rgba(11,11,11,0.10);
}}
@media (prefers-color-scheme: dark) {{
:root:where(:not([data-theme="light"])) .viz-root {{
color-scheme: dark;
--surface-1: #1a1a19;
--page-plane: #0d0d0d;
--text-primary: #ffffff;
--text-secondary: #c3c2b7;
--text-muted: #898781;
--gridline: #2c2c2a;
--axis-line: #383835;
--series-1: #3987e5;
--series-2: #d95926;
--border-ring: rgba(255,255,255,0.10);
}}
}}
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
background: var(--page-plane);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
color: var(--text-primary);
padding: 32px 24px;
line-height: 1.5;
}}
.viz-root {{
max-width: 1200px;
margin: 0 auto;
background: var(--surface-1);
border-radius: 8px;
padding: 36px 40px 40px;
}}
h1 {{ font-size: 20px; font-weight: 600; margin-bottom: 6px; }}
.subtitle {{ font-size: 13px; color: var(--text-secondary); margin-bottom: 28px; }}
.legend {{ display: flex; gap: 24px; margin-bottom: 24px; font-size: 13px; color: var(--text-secondary); }}
.legend-item {{ display: flex; align-items: center; gap: 8px; }}
.legend-swatch {{ width: 20px; height: 3px; border-radius: 2px; }}
.legend-swatch.s1 {{ background: var(--series-1); }}
.legend-swatch.s2 {{ background: var(--series-2); }}
.panels {{ display: grid; grid-template-columns: 1fr; gap: 36px; }}
.panel {{ border-top: 1px solid var(--border-ring); padding-top: 20px; }}
.panel:first-child {{ border-top: none; padding-top: 0; }}
.panel-header {{ display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 14px; }}
.panel-title {{ font-size: 15px; font-weight: 600; }}
.panel-subtitle {{ font-size: 12px; color: var(--text-muted); }}
.chart-wrap {{ position: relative; overflow-x: auto; }}
svg {{ display: block; width: 100%; min-width: 600px; height: auto; overflow: visible; }}
.tooltip {{
position: absolute; pointer-events: none;
background: var(--surface-1);
border: 1px solid var(--border-ring);
border-radius: 6px;
padding: 10px 14px;
font-size: 12px;
color: var(--text-primary);
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
opacity: 0;
transition: opacity 0.12s;
z-index: 10;
white-space: nowrap;
}}
.tt-month {{ font-weight: 600; margin-bottom: 6px; }}
.tt-row {{ display: flex; justify-content: space-between; gap: 16px; margin-bottom: 2px; }}
.tt-row span {{ display: flex; align-items: center; gap: 6px; }}
.tt-dot {{ width: 8px; height: 8px; border-radius: 50%; display: inline-block; }}
.tt-val {{ font-variant-numeric: tabular-nums; }}
.note {{ font-size: 12px; color: var(--text-muted); margin-top: 24px; line-height: 1.6; }}
.data-table-wrap {{ margin-top: 28px; }}
.data-table-toggle {{
font-size: 13px; color: var(--series-1); cursor: pointer;
background: none; border: none; padding: 4px 0;
font-family: inherit;
}}
.data-table {{
max-height: 0; overflow: hidden; transition: max-height 0.3s;
margin-top: 12px;
}}
.data-table.visible {{ max-height: 600px; overflow-y: auto; }}
.data-table table {{ width: 100%; border-collapse: collapse; font-size: 12px; }}
.data-table th, .data-table td {{ padding: 6px 10px; text-align: right; border-bottom: 1px solid var(--gridline); }}
.data-table th {{ color: var(--text-secondary); font-weight: 600; }}
.data-table td:first-child, .data-table th:first-child {{ text-align: left; }}
.data-table tr.group-header td {{ font-weight: 600; color: var(--text-primary); background: var(--page-plane); }}
</style>
</head>
<body>
<div class="viz-root">
<h1>东莞法拍房上架量与流拍量趋势</h1>
<p class="subtitle">{date_range} · 按拍卖开始时间月度汇总 · 流拍 = 已结束且出价次数为0</p>
<div class="legend">
<div class="legend-item"><span class="legend-swatch s1"></span>上架量</div>
<div class="legend-item"><span class="legend-swatch s2"></span>流拍量</div>
</div>
<div class="panels">
{panel_html}
</div>
<p class="note">
注:上架量按「开始时间」归入对应月份,流拍量按「结束时间」归入对应月份。流拍率 = 流拍量 / 已结束量。近期月份(最近2-3个月)的已结束量和流拍量可能不完整(部分拍卖尚未结束)。
</p>
<div class="data-table-wrap">
<button class="data-table-toggle" id="tableToggle">显示/隐藏数据表</button>
<div class="data-table" id="dataTable">
<table>
<thead><tr><th>区域</th><th>月份</th><th>上架量</th><th>流拍量</th><th>已结束</th><th>流拍率</th></tr></thead>
<tbody>
{table_html}
</tbody>
</table>
</div>
</div>
</div>
<script>
const DATA = {data_json};
const PANEL_KEYS = {json.dumps(panel_keys, ensure_ascii=False)};
function getCss(prop) {{
const root = document.querySelector('.viz-root');
return getComputedStyle(root).getPropertyValue(prop).trim();
}}
function setupPanel(key, idx) {{
const wrap = document.getElementById('panel-' + idx);
const svg = wrap.querySelector('svg');
const crosshair = wrap.querySelector('.crosshair');
const tt = document.getElementById('tooltip-' + idx);
const data = DATA[key];
if (!data || data.length === 0) return;
function showTooltip(i) {{
const d = data[i];
if (!d) return;
const s1 = getCss('--series-1') || '#2a78d6';
const s2 = getCss('--series-2') || '#eb6834';
tt.innerHTML =
'<div class="tt-month">' + d.month + '</div>' +
'<div class="tt-row"><span><span class="tt-dot" style="background:' + s1 + '"></span>上架量</span><span class="tt-val">' + d.listed + ' 套</span></div>' +
'<div class="tt-row"><span><span class="tt-dot" style="background:' + s2 + '"></span>流拍量</span><span class="tt-val">' + d.failed + ' 套</span></div>' +
'<div class="tt-row"><span>已结束</span><span class="tt-val">' + d.ended + ' 套</span></div>' +
'<div class="tt-row"><span>流拍率</span><span class="tt-val">' + (d.rate !== null ? d.rate + '%' : '') + '</span></div>';
const rect = svg.getBoundingClientRect();
const viewBox = svg.viewBox.baseVal;
const scaleX = rect.width / viewBox.width;
const hitEl = wrap.querySelectorAll('.hit')[i];
const hitRect = hitEl.getBoundingClientRect();
tt.style.left = (hitRect.left - rect.left + hitRect.width / 2 + 12) + 'px';
tt.style.top = '10px';
tt.style.opacity = 1;
const cx = parseFloat(hitEl.getAttribute('x')) + parseFloat(hitEl.getAttribute('width')) / 2;
crosshair.setAttribute('x1', cx);
crosshair.setAttribute('x2', cx);
crosshair.style.opacity = 1;
}}
function hide() {{
tt.style.opacity = 0;
crosshair.style.opacity = 0;
}}
wrap.querySelectorAll('.hit').forEach(hit => {{
hit.addEventListener('mouseenter', () => showTooltip(parseInt(hit.dataset.idx)));
hit.addEventListener('mouseleave', hide);
}});
}}
PANEL_KEYS.forEach((k, i) => setupPanel(k, i));
document.getElementById('tableToggle').addEventListener('click', () => {{
document.getElementById('dataTable').classList.toggle('visible');
}});
</script>
</body>
</html>"""
with open(OUTPUT_HTML, "w", encoding="utf-8") as f:
f.write(html)
print(f"Generated: {OUTPUT_HTML}")
for key, data in panels.items():
total_listed = sum(d["listed"] for d in data)
total_failed = sum(d["failed"] for d in data)
print(f" {key}: {len(data)} months, {total_listed} listed, {total_failed} failed")
if __name__ == "__main__":
items = read_all_csvs(CSV_DIR)
print(f"Total unique items: {len(items)}")
panels = {
"东莞全市": compute_monthly_trends(items),
"樟木头": compute_monthly_trends(items, "樟木头"),
"塘厦": compute_monthly_trends(items, "塘厦"),
}
generate_html(panels, len(items))
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash
# Batch scrape all Dongguan sub-regions with sortField=2
# Skips 樟木头 and 塘厦 (already scraped)
cd /root/agents/wiki
TOWNS="莞城 东城 南城 万江 石碣 石龙 茶山 石排 企石 桥头 凤岗 清溪 大朗 黄江 长安 虎门 厚街 沙田 道滘 洪梅 麻涌 中堂 高埗 大岭山 望牛墩 常平 寮步 东坑 横沥 谢岗 松山湖"
for town in $TOWNS; do
echo "=== $(date '+%H:%M:%S') Starting $town ==="
PYTHONUNBUFFERED=1 python3 -u skills/jd-fapai-scrape/scrape_history.py \
--keyword "$town" --batch-label "${town}_all" \
--max-scrolls 200 --output-dir "output/法拍"
echo "=== $(date '+%H:%M:%S') Done $town ==="
done
echo "=== ALL DONE ==="
+432
View File
@@ -0,0 +1,432 @@
#!/usr/bin/env python3
"""
京东法拍历史数据分批抓取脚本(一次性使用)
用 sortField=2(按结束时间升序,只返回已结束拍卖)滚动全量历史数据。
支持 date range 过滤:快速跳过不需要的早期数据,到达目标区间后开始收集。
用法:
# Batch 1: 2023-2026
python3 scrape_history.py --keyword 东莞市 --start-date 2023-01-01 --output-dir output/法拍
# Batch 2: 2017-2022
python3 scrape_history.py --keyword 东莞市 --end-date 2023-01-01 --output-dir output/法拍
"""
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"
"&spo_multiThirdCateIds={cate_id}"
"&spo_keyword={keyword}"
"&spo_sortField=2"
)
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 ts_to_date(ts):
if ts:
try:
return datetime.datetime.fromtimestamp(ts / 1000).date()
except Exception:
return None
return None
def infer_round_from_discount(discount_rate):
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 parse_date(s):
if not s:
return None
try:
return datetime.datetime.strptime(s, "%Y-%m-%d").date()
except ValueError:
return None
def fetch_history(keyword, cate_id, start_date, end_date, max_scrolls, output_dir):
url = BASE_URL.format(cate_id=cate_id, keyword=keyword)
print(f"搜索 URL: {url}")
print(f"关键词: {keyword} | 类目ID: {cate_id} | 排序: sortField=2 (结束时间升序)")
if start_date:
print(f"起始日期: {start_date} (跳过此日期之前的拍卖)")
if end_date:
print(f"截止日期: {end_date} (到达此日期后停止)")
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("错误: 未安装 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:
browser = p.chromium.launch(headless=True, args=launch_args)
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()
all_listings = {}
current_info = {}
label_configs = {}
state = {
"stopped_early": False,
"skipped": 0,
"collected": 0,
"first_collected_date": None,
"last_collected_date": None,
}
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 and "Num" not in resp_url:
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
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("页面加载完成。")
# Extract SSR page 1
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
print(f" SSR page 1: 提取 {len(ssr_listings)}")
time.sleep(5)
# Infinite scroll with date filtering
prev_count = 0
no_progress = 0
scroll_count = 0
collected_pids = set()
for i in range(max_scrolls):
if state["stopped_early"]:
break
page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
time.sleep(3)
curr_count = len(all_listings)
# Check end dates of newly seen items
new_pids = set(all_listings.keys()) - collected_pids
for pid in new_pids:
collected_pids.add(pid)
info = current_info.get(str(pid), {})
end_ts = info.get("endTime")
end_dt = ts_to_date(end_ts)
if end_dt:
if end_date and end_dt >= end_date:
# Past our end date - stop
print(f" 到达截止日期 {end_date},停止")
state["stopped_early"] = True
break
if start_date and end_dt < start_date:
state["skipped"] += 1
else:
state["collected"] += 1
if state["first_collected_date"] is None:
state["first_collected_date"] = end_dt
print(f" 开始收集: {end_dt} (paimaiId={pid})")
state["last_collected_date"] = end_dt
else:
# No end time yet - might be upcoming or missing info
state["skipped"] += 1
if curr_count > prev_count:
print(f" 滚动 {i+1}: 累计 {curr_count} 条 | 收集 {state['collected']} | 跳过 {state['skipped']} | "
f"日期范围: {state['first_collected_date']} ~ {state['last_collected_date']}")
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 >= 10:
print(f" 连续 {no_progress} 次无新数据,停止")
break
scroll_count = i + 1
# Final scroll to catch remaining API responses
page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
time.sleep(3)
browser.close()
# Build results: only items within date range
results = []
for pid, listing in all_listings.items():
info = current_info.get(str(pid), {})
end_ts = info.get("endTime")
end_dt = ts_to_date(end_ts)
if start_date and end_dt and end_dt < start_date:
continue
if end_date and end_dt and end_dt >= end_date:
continue
# Build listing with merged info
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")
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)
# Sort by end time
results.sort(key=lambda x: x.get("_endTime", 0) or 0)
return results, state
def save_csv(listings, csv_path, batch_label):
now_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
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", "")),
),
now_str,
now_str,
])
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("--start-date", default=None, help="起始日期 YYYY-MM-DD (含)")
parser.add_argument("--end-date", default=None, help="截止日期 YYYY-MM-DD (不含)")
parser.add_argument("--max-scrolls", type=int, default=800, help="最大滚动次数")
parser.add_argument("--output-dir", default="output/法拍", help="输出目录")
parser.add_argument("--batch-label", default="", help="批次标签 (用于文件名)")
args = parser.parse_args()
start_date = parse_date(args.start_date)
end_date = parse_date(args.end_date)
batch_label = args.batch_label or (
f"{args.start_date or 'start'}_{args.end_date or 'end'}"
)
now_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
print(f"运行时间: {now_str}")
print(f"批次: {batch_label}")
print()
listings, state = fetch_history(
keyword=args.keyword,
cate_id=args.cate_id,
start_date=start_date,
end_date=end_date,
max_scrolls=args.max_scrolls,
output_dir=args.output_dir,
)
csv_path = os.path.join(
args.output_dir, f"{args.keyword}_法拍房源_{batch_label}.csv"
)
save_csv(listings, csv_path, batch_label)
print(f"\n{'=' * 60}")
print(f"批次: {batch_label}")
print(f"收集: {len(listings)}")
print(f"跳过: {state['skipped']}")
if state['first_collected_date']:
print(f"日期范围: {state['first_collected_date']} ~ {state['last_collected_date']}")
print(f"输出: {csv_path}")
print(f"{'=' * 60}")
# Stats
statuses = Counter(
STATUS_MAP.get(
l.get("_auctionStatus", l.get("paimaiStatus")), "未知"
)
for l in listings
)
print(f"\n状态分布:")
for s, c in statuses.most_common():
print(f" {s}: {c}")
rounds = Counter(l.get("auction_round", "") for l in listings)
print(f"\n拍卖轮次分布:")
for r, c in rounds.most_common():
if r:
print(f" {r}: {c}")
+30 -4
View File
@@ -114,10 +114,36 @@ https://pro.m.jd.com/mall/active/3Rja7L2jmC72Ta8eoa31VFDvaAjf/index.html
## 四、排序字段 `spo_sortField` 已知值
| 值 | 排序方式 |
|----|----------|
| `11` | 按最新发布排序 |
| 其他值待补充 | — |
| 值 | 排序方式 | 返回状态 | 说明 |
|----|----------|----------|------|
| `0` | 结束时间升序 | 已结束 | 同 `2`,最早在前(2017年起) |
| `1` | — | 进行中 | 当前正在拍卖的房源 |
| `2` | **结束时间升序** | **已结束** | **历史数据抓取用**,最早2017-07,按结束时间从早到晚 |
| `4` | — | 已结束 | 2020年附近数据 |
| `5` | — | 已结束 | 混合年份(2018-2026 |
| `7` | — | 已结束+少量未开始 | 近年数据(2020-2026),混合状态 |
| `8` | — | 进行中 | 当前进行中(不同于 `1` 的排序) |
| `9` | — | 未开始 | 即将开拍的房源 |
| `10` | 结束时间升序 | 已结束 | 同 `2`/`0` |
| `11` | **最新发布** | 未开始/已结束 | **默认值**,按发布时间降序,含即将开拍和近期已结束 |
| `12` | — | 已结束 | 2021-2025年数据 |
| `13` | — | 已结束 | 同 `12` |
| `14` | — | 已结束 | 混合年份(2017-2025 |
| `15` | — | 已结束 | 同 `12` |
| `16`+ | — | — | 无数据 |
### sortField=2 的用途:历史成交数据抓取
`sortField=2` 是获取历史已结束拍卖的关键参数:
- **主要返回已结束(`paimaiStatus=2`)的拍卖**,也会混入少量未开始/进行中的拍卖
- 按**结束时间升序**排列,最早的在前
- 京东平台**最早保留到 2017年7月**(2017-07-04 结束的第一条记录)
- 东莞市关键词总记录数约 46,270 条(全部类目),住宅用房约 20,276 条
- `getSearchDataNum` API 返回 `totalNum` 字段可查询总数
- **⚠️ 无限滚动约100次后停止返回新数据(~4000条硬性上限)**,无法一次获取全量
- 需要按镇/区分区域抓取来突破4000条限制(单个镇通常<4000条,可一次抓全)
> ⚠️ 默认的 `sortField=11`(最新发布)只返回近期上架的房源,无法获取早期历史数据。要抓全量历史成交,必须改用 `sortField=2`。但对于总量>4000条的城市,需按子区域分别抓取。
---
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Compute yearly statistics from all JD法拍 CSV files."""
import csv, glob, os
from collections import Counter
all_items = dict()
for csv_path in sorted(glob.glob("output/法拍/*_法拍房源*.csv")):
try:
with open(csv_path, encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
for row in reader:
pid = row.get("拍卖ID", "")
if not pid:
continue
if pid not in all_items:
all_items[pid] = row
elif row.get("结束时间") and not all_items[pid].get("结束时间"):
all_items[pid] = row
except Exception as e:
print(f"Warning: {csv_path}: {e}")
items = list(all_items.values())
def compute_yearly(rows, region_filter=None):
if region_filter:
rows = [r for r in rows if region_filter in r.get("标题", "")]
listed = Counter()
failed = Counter()
ended = Counter()
for r in rows:
start = r.get("开始时间", "")[:7]
end = r.get("结束时间", "")[:7]
status = r.get("状态", "")
bid = r.get("出价次数", "")
if start:
listed[start[:4]] += 1
if end and status == "已结束":
ended[end[:4]] += 1
if bid in ("0", ""):
failed[end[:4]] += 1
years = sorted(listed.keys() | ended.keys())
results = []
for y in years:
l = listed.get(y, 0)
e = ended.get(y, 0)
f = failed.get(y, 0)
rate = round(f / e * 100, 1) if e else None
results.append((y, l, e, f, rate))
return results
for region_name, region_filter in [("东莞全市", None), ("樟木头", "樟木头"), ("塘厦", "塘厦")]:
data = compute_yearly(items, region_filter)
total = sum(r[1] for r in data)
total_f = sum(r[3] for r in data)
total_e = sum(r[2] for r in data)
overall_rate = round(total_f / total_e * 100, 1) if total_e else 0
print(f"\n{'='*60}")
print(f"{region_name}(共 {total} 条,流拍 {total_f} 条,整体流拍率 {overall_rate}%")
print(f"{'Year':>6} {'上架':>6} {'已结束':>6} {'流拍':>6} {'流拍率':>8}")
for y, l, e, f, rate in data:
rate_str = f"{rate}%" if rate is not None else ""
print(f"{y:>6} {l:>6} {e:>6} {f:>6} {rate_str:>8}")
print(f"\n总记录数: {len(items)}")
csv_count = len(glob.glob("output/法拍/*_法拍房源*.csv"))
print(f"CSV文件数: {csv_count}")