// ==UserScript== // @name 合肥商品房房源汇总助手 // @namespace https://www.hfzfzlw.com/spf/ // @version 1.0 // @description 合肥住房房源汇总,数据来源于合肥房产局公开数据,本脚本只做汇总下载。 // @author 柠檬真酸 // @match https://www.hfzfzlw.com/spf/* // @icon https://www.hfzfzlw.com/favicon.ico // @require https://cdn.jsdelivr.net/npm/xlsx-js-style@1.2.0/dist/xlsx.min.js // @grant GM_addStyle // @grant GM_download // @run-at document-idle // @license MIT // ==/UserScript== (function () { 'use strict'; if (window.top !== window.self) return; const SCRIPT_VERSION = '1.0'; const PANEL_LOGO_URL = 'https://huaweicloudobs.ahjxjy.cn/895789f9086469785b846d30c0ed95f9.png'; const PANEL_POS_KEY = 'hf_spf_panel_pos_v1'; const PANEL_COLLAPSED_KEY = 'hf_spf_panel_collapsed_v1'; const BASE = location.origin; const DEFAULT_ENRICH = true; const DEFAULT_ENRICH_GAP_MS = 8; const DEFAULT_CONCURRENCY = 32; const DEFAULT_FORMATS = [ 'presale', 'by_status', 'by_use', 'cross', 'net_filing', 'per_building', 'units', 'buildings', ]; const FORMAT_OPTIONS = [ { value: 'presale', label: '预售汇总' }, { value: 'by_status', label: '按状态' }, { value: 'by_use', label: '按用途' }, { value: 'cross', label: '状态×用途' }, { value: 'net_filing', label: '排除配套' }, { value: 'per_building', label: '分栋明细' }, { value: 'units', label: '全部房源' }, { value: 'buildings', label: '分幢清单' }, ]; const NSCALER = { 0: '0', 1: '2', 2: '5', 3: '8', 4: '6', 5: '1', 6: '3', 7: '4', 8: '9', 9: '7' }; const COLOR_STATUS = { '#00ff00': '可售', '#0001fe': '已签约', '#8a048b': '网签备案单', '#fd0002': '备案', '#006500': '已办产权', '#febbff': '抵押可售', '#ffa500': '摇号销售', '#38a4ff': '现房销售', '#bbf588': '摇号冻结', '#bebebe': '已限制', '#cc6600': '限制销售', '#33cc99': '自建房', '#01fffe': '拆迁安置', '#d0208f': '抵押不可售', '#0571a0': '配套、物管、社区用房', '#eeb422': '附条件销售', }; const EXCLUDE_SALE = '配套、物管、社区用房'; const EXCLUDE_FILING = new Set(['配套、物管、社区用房', '配套用房']); const state = { community: '', buildings: [], units: [], running: false, abort: false, progress: { buildingDone: 0, buildingTotal: 0, enrichDone: 0, enrichTotal: 0 }, doneBuildingIdx: new Set(), }; function nscaler(id) { return String(id).split('').map((c) => NSCALER[c] ?? c).join(''); } function setObjNum(n) { let a = ''; for (let i = 0; i < n; i += 1) a += Math.floor(Math.random() * 10); return a; } function recode(id, stamp) { const a = String(id); let n = nscaler(a); const c = setObjNum(a.length); const d = setObjNum(a.length); n = parseInt(n, 10) + parseInt(d, 10); const b = nscaler(String(stamp)); return `${c}-${n}-${d}-${b}`; } function parseStampFromHtml(html) { const doc = new DOMParser().parseFromString(html, 'text/html'); const el = doc.querySelector('#iptstamp'); if (el && el.value) return el.value; const m = html.match(/id=["']iptstamp["'][^>]*value=["']([^"']+)/i) || html.match(/value=["']([^"']+)["'][^>]*id=["']iptstamp["']/i); return m ? m[1] : ''; } function parseProjectLinksFromHtml(html) { const doc = new DOMParser().parseFromString(html, 'text/html'); return [...doc.querySelectorAll('a[onclick*="reurl"]')].map((a) => ({ id: a.id || '', title: (a.getAttribute('title') || a.textContent || '').trim(), })).filter((x) => /^\d+$/.test(x.id) && x.title); } function pickProjectLink(links, name) { if (!links.length) return null; const exact = links.filter((x) => x.title === name); if (exact.length) return exact[0]; const soft = links.filter((x) => x.title.includes(name) || name.includes(x.title)); if (soft.length) return soft[0]; return links[0]; } function num(v) { if (v == null || v === '') return 0; if (typeof v === 'number') return v; const n = parseFloat(String(v).replace(/[㎡m²,\s]/gi, '')); return Number.isFinite(n) ? n : 0; } function round2(n) { return Math.round(num(n) * 100) / 100; } function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } function log(msg, level = 'info') { const box = document.getElementById('hf-spf-log'); if (!box) return; if (box.querySelector('.tzss-empty-state')) box.innerHTML = ''; const line = document.createElement('div'); const cls = level === 'ok' ? 'tzss-log-done' : level === 'warn' ? 'tzss-log-progress' : level === 'err' ? 'tzss-log-progress' : 'tzss-log-info'; line.className = `tzss-log-row ${cls}`; if (level === 'warn') line.style.setProperty('--log-c', '#f59e0b'); if (level === 'err') line.style.setProperty('--log-c', '#ef4444'); const ts = new Date().toLocaleTimeString(); line.innerHTML = `${ts} ${String(msg).replace(/= 3 ? n.slice(0, -2) : n[0] || ''; } if (/^\d+$/.test(label)) { if (label.length >= 4) return label.slice(0, -2); if (label.length === 3) return label[0]; if (label.length === 2) return label[0]; } const m = label.match(/(\d{2,})/); if (m && m[1].length >= 3) return m[1].slice(0, -2); return ''; } function guessCategory(label, building) { const bn = building || ''; if (/车库|车位|地下车库/.test(bn)) return '车位'; if (/商铺/.test(bn)) return '商铺'; if (/商业/.test(bn)) return '商业'; if (/公寓/.test(bn)) return '公寓'; if (/^商/.test(label)) return '商铺'; if (/^储|储藏/.test(label)) return '储藏室'; if (/车/.test(label)) return '车位'; if (/消控|物管|社区|配套/.test(label)) return '配套用房'; if (/办公/.test(label)) return '办公'; return '住宅'; } function displayBuildingName(bn) { const s = String(bn); if (/^\d+$/.test(s)) return `${s}号楼`; if (/^\d+#$/.test(s)) return `${s.slice(0, -1)}号楼`; return s; } function detectCommunityName(doc = document) { const title = (doc.querySelector('title') || {}).textContent || doc.title || ''; const parts = title.split(/\s*[-—\-]+\s*/).map((s) => s.trim()).filter(Boolean); if (parts.length >= 2 && !/合肥|房产|平台|备案/.test(parts[1])) { return parts[1]; } if (parts[0] && !/合肥|房产|平台/.test(parts[0])) { let name = parts[0].replace(/(分幢信息|销控|预售|商品房备案).*$/g, '').trim(); if (name.length >= 2) return name; } const crumb = (doc.querySelector('.dh, .breadcrumb, .my-location') || {}).textContent || ''; const cm = crumb.match(/[>>]\s*([^\s>>]+)\s*$/); if (cm && cm[1].length >= 2 && !/备案|楼幢|项目/.test(cm[1])) return cm[1].trim(); const h = (doc.querySelector('.pro_title, .project-title, h1, .tit') || {}).textContent || ''; return (h.trim().split(/\s+/)[0] || '').replace(/小区$/, '') || '未命名小区'; } function parseBuildingsFromPermitHtml(html) { const doc = new DOMParser().parseFromString(html, 'text/html'); const map = new Map(); // path -> building let community = ''; doc.querySelectorAll('table.tab_con01 tr').forEach((tr) => { const tds = tr.querySelectorAll('td'); if (tds.length < 3) return; const permitA = tds[0].querySelector('a[href*="/spf/details/"]'); const projectA = tds[1].querySelector('a[title], a[onclick*="reurl"]'); const buildA = tds[2].querySelector('a[href*="/spf/details/"]'); if (!buildA) return; const path = buildA.getAttribute('href'); if (!path || map.has(path)) return; const 栋号 = (buildA.getAttribute('title') || buildA.textContent || '').trim(); const 许可证号 = permitA ? (permitA.getAttribute('title') || permitA.textContent || '').trim() : ''; if (projectA && !community) { community = (projectA.getAttribute('title') || projectA.textContent || '').trim(); } map.set(path, { path, 栋号: 栋号 || `楼栋${map.size + 1}`, 许可证号, 住宅: 0, 商业: 0, 办公: 0, 其它: 0, 地上楼层: 0, 地下楼层: 0, }); }); return { community, buildings: [...map.values()] }; } function parseBuildingsFromHtml(html) { const buildings = []; const re = /href="(\/spf\/details\/[^"]+)"[^>]*>\s*