其它:(\d+)套<\/span>地下楼层:(\d+)<\/li>/gs;
let m;
while ((m = re.exec(html))) {
buildings.push({
path: m[1],
住宅: +m[2],
栋号: m[3].trim(),
商业: +m[4],
许可证号: (m[5] || '').trim(),
办公: +m[6],
地上楼层: +m[7],
其它: +m[8],
地下楼层: +m[9],
});
}
if (buildings.length) return buildings;
// fallback: any details links with nearby 栋号 text
const div = document.createElement('div');
div.innerHTML = html;
div.querySelectorAll('a[href^="/spf/details/"]').forEach((a) => {
const block = a.closest('li, .item, .list, div') || a.parentElement;
const text = (block && block.textContent) || a.textContent || '';
const bm = text.match(/栋号[::]\s*([^\s\n]+)/);
const pm = text.match(/许可证号[::]\s*([^\s\n]*)/);
const name = (bm && bm[1]) || a.getAttribute('title') || `楼栋${buildings.length + 1}`;
if (buildings.some((b) => b.path === a.getAttribute('href'))) return;
buildings.push({
path: a.getAttribute('href'),
栋号: name.trim(),
许可证号: (pm && pm[1]) || '',
住宅: 0, 商业: 0, 办公: 0, 其它: 0, 地上楼层: 0, 地下楼层: 0,
});
});
return buildings;
}
function parseUnitsFromHtml(html, building, community) {
const units = [];
const doc = new DOMParser().parseFromString(html, 'text/html');
const tds = doc.querySelectorAll('td[onclick]');
tds.forEach((td) => {
const oc = td.getAttribute('onclick') || '';
const m = oc.match(/s\(\s*'(\d+)'\s*,/i) || oc.match(/s\(\s*"(\d+)"\s*,/i);
if (!m) return;
const hid = m[1];
const label = (td.textContent || '').trim();
const style = td.getAttribute('style') || '';
const cm = style.match(/background-color\s*:\s*([^;"\s]+)/i);
const color = cm ? cm[1].trim().toLowerCase() : '';
if (hid === '0' || !label || label === '-' || /^层[::]/.test(label)) return;
if (color === 'sandybrown') return;
if (/^\d{1,2}$/.test(label) && color === 'sandybrown') return;
units.push({
小区: community,
栋号: building.栋号,
许可证号: building.许可证号 || '',
地上楼层数: building.地上楼层 || 0,
地下楼层数: building.地下楼层 || 0,
房号: label,
楼层: guessFloor(label),
类型: guessCategory(label, building.栋号),
销售状态: COLOR_STATUS[color] || color || '未知',
房屋ID: hid,
真实房屋ID: nscaler(hid),
建筑面积: '',
套内面积: '',
公摊面积: '',
户型: '',
房屋用途: '',
坐落: '',
备案状态: '',
申报价格: '',
_path: building.path,
});
});
if (!units.length) {
const re = /]*onclick="s\('(\d+)',[^"]*\)"[^>]*>([^<]*)<\/td>/gi;
let m;
while ((m = re.exec(html))) {
const hid = m[1];
const label = m[2].trim();
const full = m[0];
const cm = full.match(/background-color:([^;"\s]+)/i);
const color = cm ? cm[1].trim().toLowerCase() : '';
if (hid === '0' || !label || label === '-' || /^层[::]/.test(label)) continue;
if (color === 'sandybrown') continue;
units.push({
小区: community,
栋号: building.栋号,
许可证号: building.许可证号 || '',
地上楼层数: building.地上楼层 || 0,
地下楼层数: building.地下楼层 || 0,
房号: label,
楼层: guessFloor(label),
类型: guessCategory(label, building.栋号),
销售状态: COLOR_STATUS[color] || color || '未知',
房屋ID: hid,
真实房屋ID: nscaler(hid),
建筑面积: '',
套内面积: '',
公摊面积: '',
户型: '',
房屋用途: '',
坐落: '',
备案状态: '',
申报价格: '',
_path: building.path,
});
}
}
return units;
}
async function fetchText(url) {
const resp = await fetch(url, {
credentials: 'include',
headers: {
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9',
},
});
if (!resp.ok) throw new Error(`HTTP ${resp.status} ${url}`);
return resp.text();
}
async function fetchJson(url, referer) {
const resp = await fetch(url, {
credentials: 'include',
headers: {
Accept: 'application/json, text/javascript, */*; q=0.01',
'X-Requested-With': 'XMLHttpRequest',
Referer: referer || location.href,
},
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
return resp.json();
}
async function enrichUnit(u, retries = 2) {
const referer = BASE + u._path;
let lastErr = null;
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
if (attempt > 0) await sleep(120 * attempt);
const rsa = await fetchJson(`${BASE}/spf/details/getrsa/${u.真实房屋ID}`, referer);
if (!rsa.state || !rsa.id) throw new Error(`getrsa fail(${rsa?.msg || rsa?.message || 'no id'})`);
const detail = await fetchJson(`${BASE}/spf/details/house/${rsa.id}`, referer);
if (!detail.state) throw new Error(`house fail(${detail?.msg || detail?.message || 'state=false'})`);
const d = detail.data || {};
u.建筑面积 = d.lbBuildArea ?? '';
u.套内面积 = d.lbInsideArea ?? '';
u.公摊面积 = d.lbJoinArea ?? '';
u.户型 = d.lbHouseType ?? '';
u.房屋用途 = d.lbHouseUsefulness || u.类型 || '其它';
u.坐落 = d.lbLocation ?? '';
u.备案状态 = d.lbSellFlag ?? '';
u.申报价格 = d.iPrice ?? '';
u._enrichError = '';
return;
} catch (e) {
lastErr = e;
}
}
u._enrichError = String(lastErr?.message || lastErr || 'unknown');
throw lastErr;
}
async function mapPool(items, concurrency, worker) {
const ret = new Array(items.length);
let i = 0;
async function runner() {
while (i < items.length) {
if (state.abort) return;
const idx = i++;
ret[idx] = await worker(items[idx], idx);
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => runner()));
return ret;
}
function selectedFormats() {
const checked = [...document.querySelectorAll('#hf-spf-formats input[type=checkbox]:checked')]
.map((el) => el.value);
return checked.length ? checked : DEFAULT_FORMATS.slice();
}
function selectedBuildings() {
return [...document.querySelectorAll('#hf-spf-buildings input[type=checkbox]:checked')].map((el) => el.value);
}
function calcProgressPct() {
const p = state.progress;
const bTot = p.buildingTotal || 0;
const eTot = p.enrichTotal || 0;
if (!bTot && !eTot) return 0;
if (eTot > 0 && bTot > 0) {
return Math.round(
50 * (p.buildingDone / bTot) + 50 * (p.enrichDone / eTot),
);
}
if (eTot > 0) return Math.round(100 * (p.enrichDone / eTot));
return Math.round(100 * (p.buildingDone / bTot));
}
function updatePanelMeta() {
const name = (document.getElementById('hf-spf-name')?.value || '').trim()
|| state.community
|| '未选择小区';
const total = state.buildings.length;
const sel = selectedBuildings().length;
const units = state.units.length;
const p = state.progress;
let showDone = 0;
let showTot = sel || 0;
if (p.enrichTotal > 0) {
showDone = p.enrichDone;
showTot = p.enrichTotal;
} else if (p.buildingTotal > 0) {
showDone = p.buildingDone;
showTot = p.buildingTotal;
}
let pct = calcProgressPct();
if (!state.running && units > 0 && p.buildingTotal > 0 && p.buildingDone >= p.buildingTotal) {
if (!p.enrichTotal || p.enrichDone >= p.enrichTotal) pct = 100;
}
const selCount = document.getElementById('hf-spf-selected-count');
const doneEl = document.getElementById('hf-spf-queue-done');
const totalEl = document.getElementById('hf-spf-queue-total');
const pctEl = document.getElementById('hf-spf-queue-percent');
const barEl = document.getElementById('hf-spf-queue-progress');
const badge = document.getElementById('hf-spf-auth-badge');
if (selCount) selCount.textContent = String(sel);
if (doneEl) doneEl.textContent = String(showDone);
if (totalEl) totalEl.textContent = String(showTot);
if (pctEl) pctEl.textContent = `${pct}%`;
if (barEl) barEl.style.width = `${pct}%`;
if (badge) {
badge.textContent = state.running
? '抓取中'
: (units ? `已抓 ${units} 套` : (total ? `已选 ${sel} 栋` : '待搜索'));
}
const hero = document.getElementById('hf-spf-project-card');
if (hero) {
const badgeText = state.running ? '抓取中' : (units ? '可导出' : (total ? '待开始' : '待命'));
const meta = total
? `已选 ${sel}/${total} 栋${units ? ` · 已抓 ${units} 套` : ' · 未开始抓取'}`
: '输入小区名搜索,或打开项目页自动识别';
hero.innerHTML = `
${badgeText}
${pct}%
${name}
${meta}
`;
}
document.querySelectorAll('#hf-spf-buildings .tzss-cat-row').forEach((row) => {
const inp = row.querySelector('input');
const bar = row.querySelector('.tzss-bar > i');
if (!inp || !bar) return;
const idx = inp.value;
const done = state.doneBuildingIdx.has(String(idx));
bar.style.width = done ? '100%' : '0%';
bar.classList.toggle('full', done);
});
setCurrentTask(state.running ? '抓取中…' : (units ? `待导出(${units} 套)` : '待命'));
syncRunButtons();
}
function buildPresaleSummary(units, community) {
const agg = new Map(); // bn -> Map(use|status -> metrics)
const buildingTot = new Map();
const permitMap = new Map();
for (const u of units) {
const bn = u.栋号;
const use = u.房屋用途 || u.类型 || '其它';
const status = u.销售状态 || '未知';
const key = `${use}\t${status}`;
if (!agg.has(bn)) agg.set(bn, new Map());
const g = agg.get(bn).get(key) || { use, status, n: 0, 建: 0, 套: 0, 公: 0 };
g.n += 1;
g.建 += num(u.建筑面积);
g.套 += num(u.套内面积);
g.公 += num(u.公摊面积);
agg.get(bn).set(key, g);
const t = buildingTot.get(bn) || { n: 0, 建: 0, 套: 0, 公: 0 };
t.n += 1; t.建 += num(u.建筑面积); t.套 += num(u.套内面积); t.公 += num(u.公摊面积);
buildingTot.set(bn, t);
if (u.许可证号) permitMap.set(bn, u.许可证号);
}
const rows = [[
'序号', '楼号', '房屋用途', '销售状态',
'套数', '建筑面积(㎡)', '套内面积(㎡)', '公摊面积(㎡)', '预售证号', '备注',
]];
let seq = 0;
let grand = { n: 0, 建: 0, 套: 0, 公: 0 };
let exclude = { n: 0, 建: 0, 套: 0, 公: 0 };
const order = [...agg.keys()].sort((a, b) => String(a).localeCompare(String(b), 'zh'));
for (const bn of order) {
seq += 1;
const combos = [...agg.get(bn).values()].sort((a, b) =>
`${a.use}${a.status}`.localeCompare(`${b.use}${b.status}`, 'zh'));
let first = true;
for (const g of combos) {
rows.push([
first ? seq : '',
first ? displayBuildingName(bn) : '',
g.use, g.status, g.n, round2(g.建), round2(g.套), round2(g.公),
first ? (permitMap.get(bn) || '') : '',
g.status === EXCLUDE_SALE ? '配套标黄' : '',
]);
if (g.status === EXCLUDE_SALE) {
exclude.n += g.n; exclude.建 += g.建; exclude.套 += g.套; exclude.公 += g.公;
}
grand.n += g.n; grand.建 += g.建; grand.套 += g.套; grand.公 += g.公;
first = false;
}
const t = buildingTot.get(bn);
rows.push(['', `${displayBuildingName(bn)}小计`, '', '', t.n, round2(t.建), round2(t.套), round2(t.公), '', '']);
}
rows.push(['', '合计', '', '', grand.n, round2(grand.建), round2(grand.套), round2(grand.公), '', '']);
rows.push(['', '扣除', '', EXCLUDE_SALE, exclude.n, round2(exclude.建), round2(exclude.套), round2(exclude.公), '', '标黄行合计']);
rows.push([
'', '去除配套后合计', '', '',
grand.n - exclude.n, round2(grand.建 - exclude.建), round2(grand.套 - exclude.套), round2(grand.公 - exclude.公), '', '',
]);
return { title: `${community}预售建筑面积汇总表`, rows };
}
function groupSummary(units, keyFn, title) {
const map = new Map();
for (const u of units) {
const k = keyFn(u) || '未知';
const g = map.get(k) || { n: 0, 建: 0, 套: 0, 公: 0 };
g.n += 1;
g.建 += num(u.建筑面积);
g.套 += num(u.套内面积);
g.公 += num(u.公摊面积);
map.set(k, g);
}
const rows = [['分类', '套数', '建筑面积(㎡)', '套内面积(㎡)', '公摊面积(㎡)']];
let tot = { n: 0, 建: 0, 套: 0, 公: 0 };
for (const [k, g] of [...map.entries()].sort((a, b) => a[0].localeCompare(b[0], 'zh'))) {
rows.push([k, g.n, round2(g.建), round2(g.套), round2(g.公)]);
tot.n += g.n; tot.建 += g.建; tot.套 += g.套; tot.公 += g.公;
}
rows.push(['合计', tot.n, round2(tot.建), round2(tot.套), round2(tot.公)]);
return { title, rows };
}
function filteredByFiling(units) {
return units.filter((u) => !EXCLUDE_FILING.has(String(u.备案状态 || '').trim()));
}
function unitsToRows(units, title = '全部房源') {
const cols = [
'小区', '栋号', '许可证号', '房号', '楼层', '类型', '销售状态',
'建筑面积', '套内面积', '公摊面积', '房屋用途', '户型', '坐落', '备案状态', '申报价格', '房屋ID',
];
const rows = [cols];
let tot = { n: 0, 建: 0, 套: 0, 公: 0 };
for (const u of units) {
rows.push(cols.map((c) => (u[c] == null ? '' : u[c])));
tot.n += 1;
tot.建 += num(u.建筑面积);
tot.套 += num(u.套内面积);
tot.公 += num(u.公摊面积);
}
rows.push([
'合计', '', '', `${tot.n}套`, '', '', '',
round2(tot.建), round2(tot.套), round2(tot.公),
'', '', '', '', '', '',
]);
return { title, rows };
}
function buildingListRows(buildings) {
const rows = [['栋号', '许可证号', '住宅', '商业', '办公', '其它', '地上楼层', '地下楼层', '详情路径']];
let 住宅 = 0; let 商业 = 0; let 办公 = 0; let 其它 = 0;
for (const b of buildings) {
住宅 += num(b.住宅); 商业 += num(b.商业); 办公 += num(b.办公); 其它 += num(b.其它);
rows.push([b.栋号, b.许可证号, b.住宅, b.商业, b.办公, b.其它, b.地上楼层, b.地下楼层, b.path]);
}
rows.push(['合计', `${buildings.length}幢`, 住宅, 商业, 办公, 其它, '', '', '']);
return { title: '分幢清单', rows };
}
function toCsv(rows) {
return rows.map((r) => r.map((c) => {
const s = String(c ?? '');
if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
}).join(',')).join('\r\n');
}
function safeFilePart(name) {
return String(name || '未命名')
.replace(/[\\/:*?"<>|]/g, '_')
.replace(/\s+/g, '')
.slice(0, 40);
}
function safeSheetName(name, used) {
let raw = String(name || 'Sheet')
.replace(/[:\\/?*\[\]]/g, '_')
.trim();
if (!raw) raw = 'Sheet';
raw = raw.slice(0, 31);
let base = raw;
let n = 2;
while (used.has(raw)) {
const suffix = `_${n}`;
raw = (base.slice(0, 31 - suffix.length) + suffix);
n += 1;
}
used.add(raw);
return raw;
}
function downloadBlob(filename, blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 3000);
}
function downloadText(filename, text, mime = 'text/csv;charset=utf-8') {
downloadBlob(filename, new Blob(['\ufeff' + text], { type: mime }));
}
function isYellowSupportRow(row) {
if (!row || !row.length) return false;
const text = row.map((c) => String(c ?? '')).join('\t');
if (text.includes('配套标黄')) return true;
if (row.some((c) => String(c ?? '') === EXCLUDE_SALE)) return true;
if (row.some((c) => EXCLUDE_FILING.has(String(c ?? '').trim()))) return true;
if (row.some((c) => /配套/.test(String(c ?? '')) && !/去除配套后|扣除/.test(String(c ?? '')))) {
const head = String(row[0] ?? '') + String(row[1] ?? '');
if (/合计|小计|扣除|去除/.test(head)) return false;
return true;
}
return false;
}
function isTotalLikeRow(row) {
const head = `${row[0] ?? ''}${row[1] ?? ''}`;
return /合计|小计|扣除|去除配套/.test(head);
}
function thinBorder() {
const edge = { style: 'thin', color: { rgb: 'CFD8E3' } };
return { top: edge, bottom: edge, left: edge, right: edge };
}
function styleCell(base) {
return {
font: { name: '微软雅黑', sz: 10, color: { rgb: '1F2937' }, ...(base.font || {}) },
alignment: {
vertical: 'center',
horizontal: base.align || 'left',
wrapText: !!base.wrap,
},
border: thinBorder(),
...(base.fill ? { fill: { patternType: 'solid', fgColor: { rgb: base.fill } } } : {}),
...(base.numFmt ? { numFmt: base.numFmt } : {}),
};
}
function estimateColWidths(rows) {
const colCount = Math.max(0, ...(rows.map((r) => r.length)));
const widths = Array.from({ length: colCount }, () => 8);
for (const row of rows) {
for (let c = 0; c < colCount; c += 1) {
const s = String(row[c] ?? '');
let w = 0;
for (const ch of s) w += /[\u4e00-\u9fff]/.test(ch) ? 2 : 1;
widths[c] = Math.min(36, Math.max(widths[c], w + 2));
}
}
return widths.map((wch) => ({ wch }));
}
function beautifyWorksheet(ws, rows) {
if (!rows.length) return;
const colCount = Math.max(...rows.map((r) => r.length));
const rowCount = rows.length;
const range = {
s: { r: 0, c: 0 },
e: { r: rowCount - 1, c: colCount - 1 },
};
ws['!ref'] = XLSX.utils.encode_range(range);
ws['!autofilter'] = { ref: XLSX.utils.encode_range(range) };
ws['!cols'] = estimateColWidths(rows);
ws['!rows'] = [{ hpt: 22 }];
for (let r = 0; r < rowCount; r += 1) {
const row = rows[r] || [];
const yellow = r > 0 && isYellowSupportRow(row);
const total = r > 0 && isTotalLikeRow(row);
for (let c = 0; c < colCount; c += 1) {
const addr = XLSX.utils.encode_cell({ r, c });
if (!ws[addr]) ws[addr] = { t: 's', v: '' };
const val = ws[addr].v;
const isNum = typeof val === 'number' || (c >= 4 && r > 0 && val !== '' && Number.isFinite(Number(val)));
if (r === 0) {
ws[addr].s = styleCell({
fill: '1D4F91',
align: 'center',
font: { bold: true, color: { rgb: 'FFFFFF' }, sz: 10, name: '微软雅黑' },
});
} else if (total) {
ws[addr].s = styleCell({
fill: 'E8EEF7',
align: isNum ? 'right' : 'left',
font: { bold: true, name: '微软雅黑', sz: 10, color: { rgb: '1D4F91' } },
});
} else if (yellow) {
ws[addr].s = styleCell({
fill: 'FFF59D',
align: isNum ? 'right' : 'left',
font: { name: '微软雅黑', sz: 10, color: { rgb: '5D4037' } },
});
} else {
ws[addr].s = styleCell({
fill: r % 2 === 0 ? 'FFFFFF' : 'F8FAFC',
align: isNum ? 'right' : 'left',
});
}
}
}
}
function downloadOneWorkbook(sheets, community) {
const used = new Set();
if (typeof XLSX === 'undefined') {
const parts = sheets.map((sh) => {
const body = sh.rows.map((r, ri) => {
const yellow = ri > 0 && isYellowSupportRow(r);
const total = ri > 0 && isTotalLikeRow(r);
const bg = ri === 0 ? '#1D4F91' : yellow ? '#FFF59D' : total ? '#E8EEF7' : (ri % 2 ? '#F8FAFC' : '#fff');
const color = ri === 0 ? '#fff' : '#1F2937';
const weight = ri === 0 || total ? 'bold' : 'normal';
return ` | ${r.map((c) => `| ${String(c ?? '').replace(/`).join('')} |
`;
}).join('');
return `${sh.title}
`;
}).join('
');
const html = `${parts}`;
downloadText(`${safeFilePart(community)}_房源汇总.xls`, html, 'application/vnd.ms-excel;charset=utf-8');
return sheets.length;
}
const wb = XLSX.utils.book_new();
sheets.forEach((sh) => {
const ws = XLSX.utils.aoa_to_sheet(sh.rows);
beautifyWorksheet(ws, sh.rows);
XLSX.utils.book_append_sheet(wb, ws, safeSheetName(sh.title, used));
});
const out = XLSX.write(wb, { bookType: 'xlsx', type: 'array', cellStyles: true });
const blob = new Blob([out], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
});
downloadBlob(`${safeFilePart(community)}_房源汇总.xlsx`, blob);
return sheets.length;
}
function renderBuildingChecks(buildings) {
const box = document.getElementById('hf-spf-buildings');
if (!box) return;
state.doneBuildingIdx = new Set();
if (!state.running) {
state.progress = { buildingDone: 0, buildingTotal: 0, enrichDone: 0, enrichTotal: 0 };
}
if (!buildings.length) {
box.innerHTML = '未识别到楼栋。请输入小区名点「搜索小区」。
';
updatePanelMeta();
return;
}
box.innerHTML = buildings.map((b, i) => `
`).join('');
box.querySelectorAll('input').forEach((el) => {
el.addEventListener('change', () => {
el.closest('.tzss-cat-row')?.classList.toggle('active', el.checked);
updatePanelMeta();
});
});
updatePanelMeta();
}
async function loadFromCurrentPage() {
const html = document.documentElement.outerHTML;
const fromPermit = parseBuildingsFromPermitHtml(html);
let buildings = fromPermit.buildings;
let community = fromPermit.community;
if (!buildings.length) {
buildings = parseBuildingsFromHtml(html);
}
if (!buildings.length && /\/spf\/details\//i.test(location.pathname)) {
const bn = (document.title.split(/\s*[-—\-]+\s*/)[0] || '本栋').trim();
buildings = [{
path: location.pathname + location.search,
栋号: bn.replace(/楼$/, '') || '本栋',
许可证号: '',
住宅: 0, 商业: 0, 办公: 0, 其它: 0, 地上楼层: 0, 地下楼层: 0,
}];
}
state.buildings = buildings;
state.community = document.getElementById('hf-spf-name').value.trim()
|| community
|| detectCommunityName();
document.getElementById('hf-spf-name').value = state.community;
renderBuildingChecks(buildings);
if (buildings.length) {
log(`自动识别 ${buildings.length} 栋 · 小区「${state.community}」`, 'ok');
} else {
log('本页未识别到楼栋。可输入小区名点「搜索小区」', 'warn');
}
}
async function searchByCommunityName() {
const name = document.getElementById('hf-spf-name').value.trim();
if (!name) {
log('请先输入小区名称', 'warn');
return;
}
try {
sessionStorage.setItem('hf-spf-pending-name', name);
} catch (_) { /* ignore */ }
log(`正在调用平台搜索「${name}」…`);
const searchUrl = `${BASE}/spf/index?w=${encodeURIComponent(name)}`;
const resp = await fetch(searchUrl, {
credentials: 'include',
headers: { Accept: 'text/html,application/xhtml+xml' },
});
if (!resp.ok) throw new Error(`搜索失败 HTTP ${resp.status}`);
const html = await resp.text();
const stamp = parseStampFromHtml(html);
const links = parseProjectLinksFromHtml(html);
const hit = pickProjectLink(links, name);
if (!hit || !stamp) {
log(`未解析到项目页,跳转平台搜索页`, 'warn');
location.href = searchUrl;
return;
}
const token = recode(hit.id, stamp);
const itemUrl = `${BASE}/spf/item/${token}`;
log(`找到「${hit.title}」,跳转项目页…`, 'ok');
location.href = itemUrl;
}
async function runScrape() {
if (state.running) return;
const idxs = selectedBuildings().map(Number);
if (!idxs.length) {
log('请至少选择一栋楼', 'warn');
return;
}
const enrich = DEFAULT_ENRICH;
const concurrency = DEFAULT_CONCURRENCY;
const enrichGap = DEFAULT_ENRICH_GAP_MS;
const community = document.getElementById('hf-spf-name').value.trim() || '未命名小区';
state.community = community;
state.running = true;
state.abort = false;
state.units = [];
state.doneBuildingIdx = new Set();
state.progress = { buildingDone: 0, buildingTotal: idxs.length, enrichDone: 0, enrichTotal: 0 };
updatePanelMeta();
try {
const picked = idxs.map((i) => state.buildings[i]).filter(Boolean);
log(`开始抓取 ${picked.length} 栋销控图…`);
for (let i = 0; i < picked.length; i++) {
if (state.abort) break;
const b = picked[i];
const bi = idxs[i];
try {
const html = await fetchText(BASE + b.path);
const units = parseUnitsFromHtml(html, b, community);
state.units.push(...units);
state.doneBuildingIdx.add(String(bi));
log(` [${i + 1}/${picked.length}] ${b.栋号}: ${units.length} 套`);
} catch (e) {
log(` [${i + 1}/${picked.length}] ${b.栋号} 失败: ${e.message || e}`, 'err');
}
state.progress.buildingDone = i + 1;
updatePanelMeta();
await sleep(200);
}
if (enrich && state.units.length && !state.abort) {
state.progress.enrichTotal = state.units.length;
state.progress.enrichDone = 0;
updatePanelMeta();
log(`开始拉取面积明细(${state.units.length} 套,并发 ${concurrency})…`);
let done = 0;
let fail = 0;
const failed = [];
await mapPool(state.units, concurrency, async (u) => {
if (state.abort) return;
try {
await enrichUnit(u);
done += 1;
} catch (e) {
fail += 1;
u.房屋用途 = u.房屋用途 || u.类型 || '其它';
failed.push(u);
log(` 明细失败:${u.栋号} ${u.房号}(ID ${u.房屋ID})· ${e.message || e}`, 'warn');
}
state.progress.enrichDone = done + fail;
if ((done + fail) % 200 === 0 || done + fail === state.units.length) {
log(` 明细进度 ${done + fail}/${state.units.length}(成功 ${done} / 失败 ${fail})`);
updatePanelMeta();
}
if (enrichGap > 0) await sleep(enrichGap);
});
state.progress.enrichDone = state.units.length;
updatePanelMeta();
if (failed.length && !state.abort) {
log(`失败 ${failed.length} 套,正在重试…`, 'warn');
let recovered = 0;
for (const u of failed) {
if (state.abort) break;
try {
await sleep(200);
await enrichUnit(u, 3);
recovered += 1;
fail -= 1;
done += 1;
log(` 重试成功:${u.栋号} ${u.房号}`, 'ok');
} catch (e) {
log(` 重试仍失败:${u.栋号} ${u.房号} · ${e.message || e}`, 'err');
}
}
if (recovered) log(`重试挽回 ${recovered} 套,最终失败 ${fail} 套`, recovered === failed.length ? 'ok' : 'warn');
}
} else {
for (const u of state.units) {
u.房屋用途 = u.房屋用途 || u.类型 || '其它';
}
}
log(`完成:共 ${state.units.length} 套。可导出汇总。`, 'ok');
setExportEnabled(true);
} finally {
state.running = false;
updatePanelMeta();
}
}
function setExportEnabled(on) {
const el = document.getElementById('hf-spf-export');
if (!el) return;
el.disabled = !on;
el.classList.toggle('tzss-btn-off', !on);
}
function buildSheets() {
const formats = selectedFormats();
const community = state.community || '小区';
const units = state.units;
const sheets = [];
if (formats.includes('presale')) sheets.push(buildPresaleSummary(units, community));
if (formats.includes('by_status')) sheets.push(groupSummary(units, (u) => u.销售状态, '按销售状态汇总'));
if (formats.includes('by_use')) sheets.push(groupSummary(units, (u) => u.房屋用途 || u.类型, '按房屋用途汇总'));
if (formats.includes('cross')) {
sheets.push(groupSummary(units, (u) => `${u.销售状态 || '未知'} × ${u.房屋用途 || u.类型 || '其它'}`, '销售状态×房屋用途'));
}
if (formats.includes('net_filing')) {
const kept = filteredByFiling(units);
const excluded = units.filter((u) => EXCLUDE_FILING.has(String(u.备案状态 || '').trim()));
sheets.push({ title: '楼栋筛选后汇总', rows: groupSummary(kept, (u) => u.栋号, '楼栋筛选后汇总').rows });
sheets.push(unitsToRows(excluded, '已排除配套'));
if (formats.includes('per_building')) {
const byBn = new Map();
for (const u of kept) {
if (!byBn.has(u.栋号)) byBn.set(u.栋号, []);
byBn.get(u.栋号).push(u);
}
for (const bn of [...byBn.keys()].sort((a, b) => String(a).localeCompare(String(b), 'zh'))) {
sheets.push(unitsToRows(byBn.get(bn), `栋-${displayBuildingName(bn)}`));
}
}
}
if (formats.includes('units')) sheets.push(unitsToRows(units, '全部房源'));
if (formats.includes('buildings')) sheets.push(buildingListRows(
state.buildings.filter((_, i) => selectedBuildings().includes(String(i))),
));
if (formats.includes('per_building') && !formats.includes('net_filing')) {
const byBn = new Map();
for (const u of units) {
if (!byBn.has(u.栋号)) byBn.set(u.栋号, []);
byBn.get(u.栋号).push(u);
}
for (const bn of [...byBn.keys()].sort((a, b) => String(a).localeCompare(String(b), 'zh'))) {
sheets.push(unitsToRows(byBn.get(bn), `栋-${displayBuildingName(bn)}`));
}
}
if (!sheets.length) sheets.push(unitsToRows(units, '全部房源'));
return sheets;
}
function doExport() {
if (!state.units.length) {
log('请先抓取', 'warn');
return;
}
const community = state.community || '小区';
const sheets = buildSheets();
const n = downloadOneWorkbook(sheets, community);
log(`已导出 Excel(${n} 张工作表):${safeFilePart(community)}_房源汇总.xlsx`, 'ok');
}
function injectStyles() {
let st = document.getElementById('hf-spf-panel-style');
if (!st) {
st = document.createElement('style');
st.id = 'hf-spf-panel-style';
document.head.appendChild(st);
}
st.textContent = `
#hf-spf-panel{position:fixed;right:20px;top:80px;z-index:2147483646;width:380px;display:flex;flex-direction:column;background:#f4f7fb;border:1px solid #dbe4f0;border-radius:16px;box-shadow:0 16px 40px rgba(15,23,42,.17);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;font-size:12px;color:#0f172a;overflow:hidden;transition:max-height .22s ease,box-shadow .22s ease;}
#hf-spf-panel.tzss-panel-max{max-height:min(92vh,760px);}
#hf-spf-panel.tzss-panel-min #hf-spf-panel-body,#hf-spf-panel.tzss-panel-min #hf-spf-panel-footer,#hf-spf-panel.tzss-panel-min .tzss-footer-extra{display:none !important;}
#hf-spf-panel.tzss-panel-min #hf-spf-panel-header{border-bottom:none;}
#hf-spf-panel-header{flex:0 0 auto;padding:7px 10px;background:linear-gradient(180deg,#f8ecd5,#f4e8cf);border-bottom:1px solid #e5dbc6;display:flex;justify-content:space-between;align-items:center;cursor:move;user-select:none;}
#hf-spf-panel-brand{display:flex;align-items:center;gap:9px;min-width:0;flex:1;}
#hf-spf-panel-logo{width:30px;height:30px;border-radius:9px;display:block;object-fit:cover;background:#fff;border:1px solid rgba(148,163,184,.45);box-shadow:0 2px 9px rgba(15,23,42,.11);flex:0 0 auto;}
#hf-spf-panel-title{font-size:13px;font-weight:900;color:#9a3412;line-height:1.26;display:flex;align-items:flex-start;gap:5px;flex-wrap:wrap;}
.tzss-panel-title-text{flex:1 1 12em;min-width:0;}
.tzss-panel-version{font-size:11px;font-weight:900;color:#64748b;padding:2px 7px;border-radius:999px;background:#f1f5f9;border:1px solid #e2e8f0;}
#hf-spf-panel-sub{display:flex;flex-wrap:wrap;gap:3px 5px;margin-top:3px;}
.tzss-sub-chip{font-size:11px;color:#7c2d12;background:rgba(255,255,255,.62);padding:2px 7px;border-radius:999px;border:1px solid rgba(180,83,9,.18);font-weight:700;}
.tzss-sub-chip-em{color:#0f766e;background:rgba(236,253,245,.9);border-color:rgba(15,118,110,.28);}
.tzss-sub-chip-mute{color:#57534e;background:rgba(255,255,255,.55);border-color:rgba(120,113,108,.22);}
#hf-spf-panel-controls{display:flex;gap:5px;}
.tzss-panel-ctl{border:none;background:#fff;color:#64748b;width:28px;height:28px;border-radius:999px;cursor:pointer;box-shadow:0 1px 2px rgba(15,23,42,.1);font-weight:900;}
#hf-spf-panel-body{flex:1 1 auto;min-height:0;overflow-y:auto;padding:6px;}
.tzss-card{background:#fff;border:1px solid #d9e2ee;border-radius:10px;padding:5px 7px;margin-bottom:6px;}
.tzss-status-row{display:flex;justify-content:space-between;align-items:center;gap:7px;}
.tzss-status-label{font-size:10px;color:#64748b;font-weight:700;}
.tzss-status-badge{padding:1px 6px;border-radius:999px;font-weight:800;font-size:10px;background:#fff;border:1px solid #cbd5e1;color:#334155;}
.tzss-status-main{margin-top:2px;}
.tzss-status-metrics{display:flex;align-items:center;gap:5px;font-size:11px;color:#475569;min-width:0;flex:1;}
.tzss-status-metrics em{font-style:normal;font-weight:900;color:#0f172a;}
.tzss-progress-pct{font-size:13px;font-weight:900;color:#0369a1;flex:0 0 auto;}
.tzss-progress-bar{height:4px;border-radius:999px;background:#e2e8f0;overflow:hidden;margin-top:3px;}
.tzss-progress-bar>span{display:block;height:100%;width:0;background:linear-gradient(90deg,#22d3ee,#2563eb);transition:width .2s ease;}
.tzss-tabbar{display:flex;gap:6px;margin-bottom:7px;}
.tzss-tab-btn{flex:1;border:1px solid #cbd5e1;background:#f8fafc;color:#475569;padding:4px;border-radius:9px;cursor:pointer;font-weight:700;font-size:11px;}
.tzss-tab-btn.active{background:linear-gradient(135deg,#1d4ed8,#0ea5e9);color:#fff;border-color:transparent;}
.tzss-pane{display:none;}.tzss-pane.active{display:block;}
.tzss-list-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;gap:6px;}
.tzss-list-head-actions{display:flex;align-items:center;gap:5px;flex:0 0 auto;}
.tzss-mini-btn{border:1px solid #cbd5e1;background:#fff;color:#334155;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:700;cursor:pointer;line-height:1.4;white-space:nowrap;}
.tzss-mini-btn:hover{background:#f8fafc;}
.tzss-list-title{font-size:12px;color:#64748b;font-weight:700;}
.tzss-list-tag{font-size:11px;color:#1e40af;background:#dbeafe;border:1px solid #93c5fd;border-radius:999px;padding:2px 7px;}
#hf-spf-buildings,#hf-spf-log,#hf-spf-formats-wrap{max-height:210px;overflow-y:auto;background:#f8fafc;border:1px solid #dbe4f0;border-radius:10px;padding:4px;}
.tzss-empty-state{padding:12px 7px;text-align:center;color:#94a3b8;font-size:12px;font-weight:900;}
#hf-spf-project-card{margin-bottom:6px;}
.tzss-hero{position:relative;padding:10px 11px 9px;border-radius:12px;background:radial-gradient(100px 70px at 100% 0%,rgba(255,255,255,.5),transparent 70%),linear-gradient(135deg,#1d4ed8 0%,#2563eb 42%,#0ea5e9 100%);color:#fff;box-shadow:0 8px 20px rgba(37,99,235,.18);overflow:hidden;}
.tzss-hero::before{content:"";position:absolute;left:-20px;bottom:-30px;width:110px;height:110px;border-radius:50%;background:rgba(255,255,255,.12);pointer-events:none;}
.tzss-hero-top{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px;position:relative;z-index:1;}
.tzss-hero-badge{font-size:10px;font-weight:900;color:#1e3a8a;background:rgba(255,255,255,.92);padding:3px 9px;border-radius:999px;}
.tzss-hero-pct{font-size:16px;font-weight:900;letter-spacing:-.02em;}
.tzss-hero-title{font-size:13px;font-weight:900;line-height:1.35;position:relative;z-index:1;margin-top:2px;}
.tzss-hero-meta{font-size:10px;font-weight:700;opacity:.88;margin-top:5px;position:relative;z-index:1;}
.tzss-hero-bar{height:4px;border-radius:999px;background:rgba(255,255,255,.22);overflow:hidden;margin-top:7px;position:relative;z-index:1;}
.tzss-hero-bar>i{display:block;height:100%;background:#bbf7d0;border-radius:999px;}
.tzss-cat-row{display:flex;gap:7px;align-items:center;background:#fff;border:1px solid #e8eef6;border-radius:8px;padding:5px 7px;margin-bottom:4px;cursor:pointer;transition:border-color .12s,background .12s;}
.tzss-cat-row:hover{border-color:#bfdbfe;background:#f8fbff;}
.tzss-cat-row.active{border-color:#3b82f6;background:#eff6ff;}
.tzss-cat-row input{margin:0;flex:0 0 auto;transform:scale(.92);}
.tzss-cat-main{flex:1;min-width:0;}
.tzss-cat-top{display:flex;align-items:baseline;justify-content:space-between;gap:8px;}
.tzss-cat-name{display:block;font-size:11px;font-weight:800;color:#0f172a;line-height:1.3;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.tzss-cat-sub{flex:0 0 auto;font-size:10px;color:#64748b;font-weight:700;white-space:nowrap;}
.tzss-bar{display:block;height:3px;background:#e2e8f0;border-radius:999px;margin-top:4px;overflow:hidden;}
.tzss-bar>i{display:block;height:100%;background:linear-gradient(90deg,#3b82f6,#0ea5e9);border-radius:999px;}
.tzss-bar>i.full{background:linear-gradient(90deg,#16a34a,#22c55e);}
.tzss-btn-row{display:flex;gap:7px;margin-top:6px;}
.tzss-btn{flex:1;border:none;color:#fff;padding:7px 9px;border-radius:10px;cursor:pointer;font-weight:800;font-size:12px;}
.tzss-btn-primary{background:linear-gradient(135deg,#1d4ed8,#0ea5e9);}
.tzss-btn-start{background:#16a34a;}
.tzss-btn-stop{background:#ef4444;}
.tzss-btn-off,.tzss-btn:disabled{background:#e2e8f0 !important;color:#94a3b8 !important;cursor:not-allowed;box-shadow:none;}
.tzss-btn-ghost{border:1px solid #cbd5e1;background:#fff;color:#0f172a;}
.tzss-task-row{font-size:11px;color:#475569;margin-top:6px;line-height:1.35;}
.tzss-task-row em{font-style:normal;font-weight:800;color:#0f172a;}
.tzss-input{width:100%;border:1px solid #cbd5e1;border-radius:8px;padding:6px 8px;font-size:12px;box-sizing:border-box;}
.tzss-footer-extra{flex:0 0 auto;padding:6px 9px;background:#f8fafc;border-top:1px solid #e2e8f0;}
.tzss-ann{position:relative;font-size:12px;color:#334155;line-height:1.42;background:linear-gradient(180deg,#fffdf5,#fff7e6);border:1px solid #fcd34d;border-radius:11px;padding:7px 8px 7px 10px;}
.tzss-ann::before{content:"";position:absolute;left:0;top:0;bottom:0;width:3px;background:linear-gradient(180deg,#f59e0b,#ef4444);border-radius:11px 0 0 11px;}
#hf-spf-panel-footer{flex:0 0 auto;padding:7px 11px;background:#eef2f7;border-top:1px solid #dbe4f0;font-size:12px;}
.tzss-log-row{padding:4px 7px 4px 8px;margin-bottom:2px;border-bottom:1px dashed #e2e8f0;border-left:3px solid #94a3b8;border-radius:0 7px 7px 0;font-size:12px;line-height:1.42;background:#fff;}
.tzss-log-time{color:#94a3b8;font-weight:500;margin-right:2px;}
.tzss-log-done{background:rgba(16,185,129,.1);}
.tzss-log-info{background:#f8fafc;color:#64748b!important;border-left-color:#94a3b8!important;}
.tzss-log-progress{background:color-mix(in srgb, var(--log-c) 7%, #fff);border-left-color:var(--log-c);}
#hf-spf-formats{display:grid;grid-template-columns:1fr 1fr;gap:4px;}
#hf-spf-formats label{display:flex;align-items:center;gap:5px;margin:0;padding:5px 6px;border-radius:8px;background:#fff;border:1px solid #e8eef6;font-size:11px;font-weight:700;cursor:pointer;}
#hf-spf-formats label:hover{border-color:#bfdbfe;background:#f8fbff;}
.tzss-meta-row{display:flex;justify-content:space-between;gap:7px;font-size:12px;margin-bottom:5px;align-items:center;}
.tzss-meta-label{color:#64748b;}
.tzss-meta-value{font-weight:700;text-align:right;}
`;
}
function injectUI() {
document.querySelectorAll('#hf-spf-panel').forEach((el) => el.remove());
injectStyles();
const formatHtml = FORMAT_OPTIONS.map((opt) =>
``
).join('');
const panel = document.createElement('div');
panel.id = 'hf-spf-panel';
panel.className = 'tzss-panel-max';
panel.innerHTML = `
`;
document.body.appendChild(panel);
try {
const pos = JSON.parse(localStorage.getItem(PANEL_POS_KEY) || 'null');
if (pos?.left && pos?.top) {
panel.style.left = pos.left;
panel.style.top = pos.top;
panel.style.right = 'auto';
}
if (localStorage.getItem(PANEL_COLLAPSED_KEY) === '1') {
panel.classList.add('tzss-panel-min');
panel.classList.remove('tzss-panel-max');
panel.querySelector('#hf-spf-btn-min').style.display = 'none';
panel.querySelector('#hf-spf-btn-max').style.display = '';
}
} catch (_) {}
const header = panel.querySelector('#hf-spf-panel-header');
let dragging = false; let sx = 0; let sy = 0; let ox = 0; let oy = 0;
header.addEventListener('mousedown', (e) => {
if (e.target.closest('#hf-spf-panel-controls')) return;
dragging = true;
const rect = panel.getBoundingClientRect();
sx = e.clientX; sy = e.clientY; ox = rect.left; oy = rect.top;
panel.style.right = 'auto';
});
window.addEventListener('mousemove', (e) => {
if (!dragging) return;
panel.style.left = `${Math.max(0, ox + e.clientX - sx)}px`;
panel.style.top = `${Math.max(0, oy + e.clientY - sy)}px`;
});
window.addEventListener('mouseup', () => {
if (!dragging) return;
dragging = false;
try {
localStorage.setItem(PANEL_POS_KEY, JSON.stringify({ left: panel.style.left, top: panel.style.top }));
} catch (_) {}
});
panel.querySelector('#hf-spf-btn-min').onclick = () => {
panel.classList.add('tzss-panel-min');
panel.classList.remove('tzss-panel-max');
panel.querySelector('#hf-spf-btn-min').style.display = 'none';
panel.querySelector('#hf-spf-btn-max').style.display = '';
try { localStorage.setItem(PANEL_COLLAPSED_KEY, '1'); } catch (_) {}
};
panel.querySelector('#hf-spf-btn-max').onclick = () => {
panel.classList.remove('tzss-panel-min');
panel.classList.add('tzss-panel-max');
panel.querySelector('#hf-spf-btn-min').style.display = '';
panel.querySelector('#hf-spf-btn-max').style.display = 'none';
try { localStorage.setItem(PANEL_COLLAPSED_KEY, '0'); } catch (_) {}
};
panel.querySelectorAll('.tzss-tab-btn').forEach((btn) => {
btn.addEventListener('click', () => {
panel.querySelectorAll('.tzss-tab-btn').forEach((b) => b.classList.remove('active'));
panel.querySelectorAll('.tzss-pane').forEach((p) => p.classList.remove('active'));
btn.classList.add('active');
panel.querySelector(`.tzss-pane[data-pane="${btn.dataset.tab}"]`)?.classList.add('active');
});
});
document.getElementById('hf-spf-search').onclick = () => searchByCommunityName().catch((e) => log(String(e), 'err'));
document.getElementById('hf-spf-name').addEventListener('input', updatePanelMeta);
document.getElementById('hf-spf-name').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
searchByCommunityName().catch((err) => log(String(err), 'err'));
}
});
document.getElementById('hf-spf-all').onclick = () => {
document.querySelectorAll('#hf-spf-buildings input').forEach((el) => {
el.checked = true;
el.closest('.tzss-cat-row')?.classList.add('active');
});
updatePanelMeta();
};
document.getElementById('hf-spf-none').onclick = () => {
document.querySelectorAll('#hf-spf-buildings input').forEach((el) => {
el.checked = false;
el.closest('.tzss-cat-row')?.classList.remove('active');
});
updatePanelMeta();
};
document.getElementById('hf-spf-fmt-all').onclick = () => {
document.querySelectorAll('#hf-spf-formats input').forEach((el) => { el.checked = true; });
};
document.getElementById('hf-spf-fmt-none').onclick = () => {
document.querySelectorAll('#hf-spf-formats input').forEach((el) => { el.checked = false; });
};
document.getElementById('hf-spf-run').onclick = () => runScrape();
document.getElementById('hf-spf-stop').onclick = () => {
state.abort = true;
log('正在停止…', 'warn');
updatePanelMeta();
};
document.getElementById('hf-spf-export').onclick = () => doExport();
document.getElementById('hf-spf-clear-log').onclick = () => {
const box = document.getElementById('hf-spf-log');
if (box) box.innerHTML = '暂无日志
';
};
let pendingName = '';
try {
pendingName = sessionStorage.getItem('hf-spf-pending-name') || '';
if (pendingName) sessionStorage.removeItem('hf-spf-pending-name');
} catch (_) {}
document.getElementById('hf-spf-name').value = pendingName || detectCommunityName();
loadFromCurrentPage().catch((e) => log(String(e), 'err'));
updatePanelMeta();
}
injectUI();
})();