match-engine-pg.js 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  1. #!/usr/bin/env node
  2. /**
  3. * 小牛看房 — 房源智能匹配引擎 v2 (PostgreSQL)
  4. * 用法: node src/assets/data/match-engine-pg.js [--buyer buyer_001] [--top 5]
  5. */
  6. const { Client } = require('pg');
  7. const PG_CONFIG = {
  8. host: 'localhost', port: 5432,
  9. user: 'postgres', password: '20061003',
  10. database: 'huaxiangpipei'
  11. };
  12. // ============================================================
  13. // 数据加载(从 PostgreSQL)
  14. // ============================================================
  15. async function loadBuyers(pg) {
  16. const res = await pg.query('SELECT * FROM buyers ORDER BY id');
  17. if (res.rows.length === 0) {
  18. // 没有buyers数据,从JSON加载默认5个客户
  19. const json = require('./buyers.json');
  20. return json.clients;
  21. }
  22. return res.rows.map(r => ({
  23. id: r.buyer_code || ('buyer_' + r.id),
  24. type: r.buyer_type,
  25. name: r.name,
  26. targetDistricts: r.target_districts || [],
  27. budgetMin: parseFloat(r.budget_min) || 0,
  28. budgetMax: parseFloat(r.budget_max) || 0,
  29. preferredLayouts: r.preferred_layouts || [],
  30. areaMin: parseFloat(r.area_min) || 0,
  31. areaMax: parseFloat(r.area_max) || 0,
  32. floorPreference: r.floor_preference || '中',
  33. preferredOrientations: r.preferred_orientations || [],
  34. purpose: r.purpose || '',
  35. decorationRequirement: r.decoration_requirement || '不限',
  36. buildingAgeMax: r.building_age_max || 30,
  37. schoolDistrictRequired: r.school_district_required || false,
  38. targetSchools: r.target_schools || [],
  39. decisionStyle: r.decision_style || '对比型',
  40. familyStructure: { members: r.family_members || 2, hasElderly: r.has_elderly || false, hasKids: r.has_kids || false },
  41. coreConcerns: r.core_concerns || [],
  42. resistFactors: r.resist_factors || [],
  43. specialRequirements: r.special_requirements || [],
  44. surfaceBudget: parseFloat(r.surface_budget) || 0,
  45. downPayment: parseFloat(r.down_payment) || 0,
  46. monthlyPaymentCapacity: parseFloat(r.monthly_payment_capacity) || 0,
  47. oldHouseEstimatedValue: 0,
  48. tags: r.tags || []
  49. }));
  50. }
  51. async function loadProperties(pg) {
  52. const res = await pg.query('SELECT * FROM properties ORDER BY total_price');
  53. return res.rows;
  54. }
  55. // ============================================================
  56. // 权重模板(不变)
  57. // ============================================================
  58. // POI别名→名称映射
  59. const POI_ALIASES = {
  60. '三中': '常州市第三中学',
  61. '常州三中': '常州市第三中学',
  62. '第三中学': '常州市第三中学',
  63. '局小': '局前街小学',
  64. '局前街': '局前街小学',
  65. '博小': '博爱路小学',
  66. '博爱路': '博爱路小学',
  67. '解小': '解放路小学',
  68. '文化宫': '文化宫商圈',
  69. '万达': '万达商圈(新北)',
  70. '吾悦': '吾悦广场(武进)',
  71. '常州站': '地铁1号线常州火车站',
  72. '火车站': '地铁1号线常州火车站',
  73. '红梅公园': '红梅公园',
  74. };
  75. const WEIGHT_TEMPLATES = {
  76. '婚房刚需型': { priceAdvantage:0.30, layoutMatch:0.15, areaMatch:0.10, decoration:0.10, schoolMatch:0.05, transport:0.10, community:0.05, floorMatch:0.05, orientation:0.05, surrounding:0.05, poiProximity:0.00 },
  77. '学区焦虑型': { priceAdvantage:0.15, layoutMatch:0.10, areaMatch:0.05, decoration:0.05, schoolMatch:0.45, transport:0.10, community:0.05, floorMatch:0.02, orientation:0.02, surrounding:0.01, poiProximity:0.00 },
  78. '置换改善型': { priceAdvantage:0.20, layoutMatch:0.20, areaMatch:0.20, decoration:0.15, schoolMatch:0.05, transport:0.05, community:0.10, floorMatch:0.03, orientation:0.02, surrounding:0.00, poiProximity:0.00 },
  79. '隐性需求型': { priceAdvantage:0.20, layoutMatch:0.10, areaMatch:0.10, decoration:0.25, schoolMatch:0.05, transport:0.10, community:0.10, floorMatch:0.05, orientation:0.03, surrounding:0.02, poiProximity:0.00 },
  80. '刚需升级型': { priceAdvantage:0.25, layoutMatch:0.15, areaMatch:0.15, decoration:0.20, schoolMatch:0.05, transport:0.05, community:0.05, floorMatch:0.05, orientation:0.03, surrounding:0.02, poiProximity:0.00 },
  81. };
  82. const HIGHLIGHT_BONUS = {
  83. '豪装': { '隐性需求型':3, '刚需升级型':3 },
  84. '精装': { '隐性需求型':2, '刚需升级型':2, '置换改善型':1 },
  85. '满五唯一': { '婚房刚需型':2 },
  86. '急售': { '婚房刚需型':2, '置换改善型':-1 },
  87. '南北通透': { '婚房刚需型':1, '置换改善型':1, '刚需升级型':1, '隐性需求型':1, '学区焦虑型':1 },
  88. '近地铁': { '婚房刚需型':2 },
  89. '地铁': { '婚房刚需型':2 },
  90. '人车分流': { '置换改善型':2 },
  91. '总价低': { '婚房刚需型':2, '刚需升级型':1 },
  92. '品牌开发商': { '置换改善型':1, '刚需升级型':1 },
  93. '次新房': { '婚房刚需型':1, '置换改善型':1, '刚需升级型':1, '隐性需求型':1 },
  94. };
  95. // ============================================================
  96. // 匹配核心(与原版完全一致)
  97. // ============================================================
  98. function hardFilter(buyer, properties) {
  99. const loanCoefficient = 180;
  100. const maxLoan = buyer.monthlyPaymentCapacity * loanCoefficient / 10000;
  101. const realBudgetMax = Math.max(buyer.budgetMax, (buyer.downPayment + maxLoan) * 0.9);
  102. return properties.filter(p => {
  103. const totalPrice = parseFloat(p.total_price);
  104. if (totalPrice > realBudgetMax * 1.1) return false;
  105. const districtMatch = buyer.targetDistricts.length === 0 || buyer.targetDistricts.some(d => {
  106. const short = d.split('-')[0]; // "新北区-薛家" → "新北区"
  107. return (p.district || '').includes(short) || short.includes(p.district || '');
  108. });
  109. if (!districtMatch) return false;
  110. if (p.building_age && p.building_age > buyer.buildingAgeMax) return false;
  111. // 学区:同区有学区房则严格过滤,无则放行(真实房源学区数据不完整)
  112. if (buyer.schoolDistrictRequired && !p.is_school_district) {
  113. const hasSchoolInDistrict = properties.some(x => x.is_school_district && x.district === p.district);
  114. if (hasSchoolInDistrict) return false;
  115. }
  116. if (buyer.resistFactors.includes('底层') && p.is_ground_floor) return false;
  117. if (buyer.resistFactors.includes('顶楼') && p.is_top_floor) return false;
  118. // 人车分流降为软偏好(贝壳数据无此标注)
  119. if (buyer.areaMin > 0 && parseFloat(p.area) < buyer.areaMin * 0.85) return false;
  120. // POI距离硬过滤:客户说"三中附近1公里内"
  121. if (buyer.nearbyPoi && buyer.nearbyPoi.name) {
  122. const poiName = POI_ALIASES[buyer.nearbyPoi.name] || buyer.nearbyPoi.name;
  123. const dists = p.poi_distances ? (typeof p.poi_distances === 'string' ? JSON.parse(p.poi_distances) : p.poi_distances) : {};
  124. const dist = parseFloat(dists[poiName]);
  125. if (dists[poiName] !== undefined && buyer.nearbyPoi.maxDistance && dist > buyer.nearbyPoi.maxDistance) return false;
  126. }
  127. return true;
  128. });
  129. }
  130. function scoreProperty(buyer, p, weights) {
  131. const s = {};
  132. const totalPrice = parseFloat(p.total_price);
  133. const area = parseFloat(p.area);
  134. const tags = p.highlight_tags || [];
  135. s.priceAdvantage = (p.price_advantage || 5) / 10;
  136. s.layoutMatch = buyer.preferredLayouts.includes(p.layout) ? 1.0 :
  137. buyer.preferredLayouts.some(pl => pl.split('室')[0] === (p.layout||'').split('室')[0]) ? 0.6 : 0.3;
  138. s.areaMatch = area >= buyer.areaMin && area <= buyer.areaMax ? 1.0 :
  139. area >= buyer.areaMin * 0.85 && area <= buyer.areaMax * 1.15 ? 0.6 : 0.2;
  140. const decoMap = { '豪装':1.0, '精装':0.75, '简装':0.4, '毛坯':0.2 };
  141. const reqLevel = buyer.decorationRequirement === '不限' ? 0 : (decoMap[buyer.decorationRequirement] || 0.5);
  142. const actLevel = decoMap[p.decoration] || 0.5;
  143. s.decoration = actLevel >= reqLevel ? 1.0 : actLevel / Math.max(reqLevel, 0.1);
  144. s.schoolMatch = !buyer.schoolDistrictRequired ? 1.0 : (p.is_school_district ? 1.0 : 0.0);
  145. s.transport = (p.transport_score || 5) / 10;
  146. s.community = (p.community_quality || 5) / 10;
  147. const floorMap = { '低':0, '中':1, '高':2 };
  148. const bf = floorMap[buyer.floorPreference] ?? 1;
  149. const pf = floorMap[p.floor_level] ?? 1;
  150. s.floorMatch = Math.abs(bf - pf) === 0 ? 1.0 : Math.abs(bf - pf) === 1 ? 0.6 : 0.3;
  151. if (buyer.preferredOrientations.includes('不限')) s.orientation = 0.8;
  152. else s.orientation = buyer.preferredOrientations.some(o => (p.orientation||'').includes(o)) ? 1.0 : 0.3;
  153. s.surrounding = (p.surrounding_score || 5) / 10;
  154. // POI距离评分:客户说"三中附近"→ 越近分越高
  155. if (buyer.nearbyPoi && buyer.nearbyPoi.name && buyer.nearbyPoi.maxDistance) {
  156. const poiName = POI_ALIASES[buyer.nearbyPoi.name] || buyer.nearbyPoi.name;
  157. const dists = p.poi_distances ? (typeof p.poi_distances === 'string' ? JSON.parse(p.poi_distances) : p.poi_distances) : {};
  158. const dist = parseFloat(dists[poiName]);
  159. if (!isNaN(dist) && buyer.nearbyPoi.maxDistance > 0) {
  160. s.poiProximity = Math.max(0, 1 - dist / buyer.nearbyPoi.maxDistance);
  161. } else {
  162. s.poiProximity = 0.5;
  163. }
  164. } else {
  165. s.poiProximity = 0.5;
  166. }
  167. let total = 0, totalW = 0;
  168. for (const [k, w] of Object.entries(weights)) {
  169. if (s[k] !== undefined) { total += s[k] * w; totalW += w; }
  170. }
  171. return totalW > 0 ? (total / totalW) * 100 : 0;
  172. }
  173. function calcHighlightBonus(buyerType, tags) {
  174. let bonus = 0;
  175. for (const tag of (tags || [])) {
  176. const b = HIGHLIGHT_BONUS[tag];
  177. if (b && b[buyerType]) bonus += b[buyerType];
  178. }
  179. return Math.min(bonus, 5);
  180. }
  181. function calcPsychBonus(buyer, p) {
  182. let bonus = 0;
  183. const tags = p.highlight_tags || [];
  184. if (buyer.decisionStyle === '对比型' && (p.price_advantage || 5) >= 7) bonus += 2;
  185. if (buyer.decisionStyle === '谨慎型' && !p.is_ground_floor && !p.is_top_floor) bonus += 2;
  186. if ((buyer.decisionStyle === '冲动型' || buyer.type === '隐性需求型') && tags.some(t => t.includes('豪装'))) bonus += 3;
  187. if (buyer.type === '置换改善型' && tags.includes('人车分流') && (p.community_quality || 5) >= 8) bonus += 1;
  188. return Math.min(bonus, 3);
  189. }
  190. function generateCons(p) {
  191. const cons = [];
  192. if (p.building_age >= 20) cons.push('房龄较老,需关注管道老化和渗水情况');
  193. if (p.building_age >= 25) cons.push('房龄超过25年,贷款年限可能受限');
  194. if (p.decoration === '简装') cons.push('装修简单,入住前可能需翻新');
  195. if (p.decoration === '毛坯') cons.push('毛坯房,需额外准备装修预算约10-15万');
  196. if (p.is_ground_floor) cons.push('底层,需关注防潮和隐私问题');
  197. if (p.is_top_floor) cons.push('顶楼,夏季较热,需关注防水');
  198. if ((p.parking || '无') === '无') cons.push('无车位,周边停车可能不便');
  199. if ((p.community_quality || 5) <= 4) cons.push('小区品质一般');
  200. if ((p.transport_score || 5) <= 5) cons.push('交通便利度一般');
  201. if ((p.floor_level === '低') && !p.is_ground_floor) cons.push('低楼层,采光可能受遮挡影响');
  202. return cons.length > 0 ? cons : ['无明显硬伤,整体较为均衡'];
  203. }
  204. function match(buyer, properties, topN = 5) {
  205. const weights = WEIGHT_TEMPLATES[buyer.type] || WEIGHT_TEMPLATES['婚房刚需型'];
  206. const candidates = hardFilter(buyer, properties);
  207. const scored = candidates.map(p => {
  208. const s = scoreProperty(buyer, p, weights);
  209. const hb = calcHighlightBonus(buyer.type, p.highlight_tags || []);
  210. const pb = calcPsychBonus(buyer, p);
  211. const final = s + hb + pb;
  212. return { property: p, finalScore: final, stage2: s, highlightBonus: hb, psychBonus: pb,
  213. level: final >= 82 ? '强烈推荐' : final >= 72 ? '推荐' : final >= 62 ? '备选' : '不推荐' };
  214. });
  215. scored.sort((a, b) => b.finalScore - a.finalScore);
  216. return scored.slice(0, topN);
  217. }
  218. // ============================================================
  219. // 输出
  220. // ============================================================
  221. function printResults(buyer, results) {
  222. console.log(`\n${'='.repeat(70)}`);
  223. console.log(`🎯 ${buyer.name}(${buyer.type})| 预算${buyer.surfaceBudget}万 | 首付${buyer.downPayment}万`);
  224. console.log(` 区域: ${buyer.targetDistricts.join('、')} | 关注: ${buyer.coreConcerns.join(' > ')}`);
  225. console.log(`${'='.repeat(70)}`);
  226. if (results.length === 0) {
  227. console.log('\n⚠️ 无匹配房源');
  228. return;
  229. }
  230. results.forEach((r, i) => {
  231. const p = r.property;
  232. const cons = generateCons(p);
  233. console.log(`\n${'─'.repeat(70)}`);
  234. console.log(`🏠 #${i+1} [${r.level}] ${p.community} · ${p.layout} · ${p.total_price}万`);
  235. console.log(` 综合 ${r.finalScore.toFixed(1)} (基础${r.stage2.toFixed(1)}+卖点${r.highlightBonus}+心理${r.psychBonus})`);
  236. console.log(` ${p.district} | ${p.area}㎡ | ${p.floor_info||p.floor_level} | ${p.orientation} | ${p.decoration} | ${p.building_age}年`);
  237. console.log(` 亮点: ${(p.highlight_tags||[]).join(' · ')}`);
  238. console.log(` ⚠️ ${cons.join(';')}`);
  239. console.log(` 来源: ${p.source}`);
  240. });
  241. console.log(`\n${'─'.repeat(70)}`);
  242. console.log(`📊 ${results.length}条结果\n`);
  243. }
  244. // ============================================================
  245. // 入口
  246. // ============================================================
  247. async function main() {
  248. const args = process.argv.slice(2);
  249. const buyerId = args.includes('--buyer') ? args[args.indexOf('--buyer')+1] : null;
  250. const topN = args.includes('--top') ? parseInt(args[args.indexOf('--top')+1]) || 5 : 5;
  251. const pg = new Client(PG_CONFIG);
  252. await pg.connect();
  253. const buyers = await loadBuyers(pg);
  254. const properties = await loadProperties(pg);
  255. console.log(`📦 数据库: ${buyers.length} 客户, ${properties.length} 房源 (${properties.filter(p=>p.source==='scraped').length}真实 + ${properties.filter(p=>p.source==='mock').length}模拟)\n`);
  256. const targets = buyerId ? buyers.filter(b => b.id === buyerId || b.name?.includes(buyerId)) : buyers;
  257. if (targets.length === 0) {
  258. console.error(`❌ 未找到: ${buyerId}`);
  259. console.error(` 可用: ${buyers.map(b => b.id + ':' + b.name).join(', ')}`);
  260. process.exit(1);
  261. }
  262. for (const buyer of targets) {
  263. const results = match(buyer, properties, topN);
  264. printResults(buyer, results);
  265. }
  266. await pg.end();
  267. }
  268. if (require.main === module) {
  269. main().catch(e => { console.error(e.message); process.exit(1); });
  270. }