# ----------------------------------------------------------------------------- # Rent-Ka — Rental listings aggregator (Canada, outside Québec) # Author: Simon-Pierre Boucher — contact@spboucher.ai # scripts/merge_candidates.py : merge data/_candidates.json (output # of the discovery campaigns) into the live registries + sources.json. # Platforms: appfolio (apf_), buildium (bld_). Dedup by id/subdomain and by # normalized name across every platform registry (a manager already covered # via LiftSystem/RentCafe/etc. must not be ingested twice). Idempotent. # Usage: python scripts/merge_candidates.py # ----------------------------------------------------------------------------- from __future__ import annotations import json import re from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SOURCES = ROOT / "data" / "sources.json" PLATFORMS = { # candidates file -> (registry file, source_id prefix) "appfolio": ("appfolio_clients.json", "apf_"), "buildium": ("buildium_clients.json", "bld_"), } def norm(s: str) -> str: return re.sub(r"[^a-z0-9]", "", (s or "").lower()) def main() -> None: sources = json.loads(SOURCES.read_text("utf-8")) have_names = {norm(s.get("name")) for s in sources["sources"]} all_regs = ["appfolio_clients.json", "buildium_clients.json", "liftsystem_clients.json", "rentcafe_clients.json", "rentsyncgw_clients.json"] for reg in all_regs: try: d = json.loads((ROOT / "data" / reg).read_text("utf-8")) have_names |= {norm(c.get("name")) for c in d.get("clients") or []} except (OSError, ValueError): pass total_added = 0 for plat, (reg_name, prefix) in PLATFORMS.items(): cand_path = ROOT / "data" / f"{plat}_candidates.json" if not cand_path.exists(): print(f"[merge] {plat}: no candidates file, skipped") continue cands = json.loads(cand_path.read_text("utf-8")).get("clients") or [] reg_path = ROOT / "data" / reg_name reg = json.loads(reg_path.read_text("utf-8")) have_ids = {c.get("id") for c in reg["clients"]} have_subs = {c.get("subdomain") for c in reg["clients"]} added = 0 for c in cands: if c.get("status") != "valide" or not c.get("subdomain"): continue if c["id"] in have_ids or c["subdomain"] in have_subs or \ norm(c.get("name")) in have_names: continue reg["clients"].append(c) have_ids.add(c["id"]) have_subs.add(c["subdomain"]) have_names.add(norm(c.get("name"))) if plat == "appfolio": listing_url = f"https://{c['subdomain']}.appfolio.com/listings" else: listing_url = (f"https://{c['subdomain']}.managebuilding.com" f"/Resident/public/rentals") regions = c.get("regions") or [] sources["sources"].append({ "id": f"{prefix}{c['id']}", "name": c.get("name") or c["id"], "url": c.get("site") or listing_url, "listing_url": listing_url, "sectors": ", ".join(regions) if isinstance(regions, list) else str(regions), "connector": f"{prefix}{c['id']}", "status": "actif", "region": "Canada (outside Québec)", "notes": f"canada-expansion 2026-08-28 — {plat} — " f"{c.get('notes', '')}"[:300], }) added += 1 reg["updated"] = "2026-08-28" reg_path.write_text(json.dumps(reg, ensure_ascii=False, indent=1)) print(f"[merge] {plat}: +{added} clients " f"(registry now {len(reg['clients'])})") total_added += added SOURCES.write_text(json.dumps(sources, ensure_ascii=False, indent=2)) print(f"[merge] sources.json now {len(sources['sources'])} entries " f"(+{total_added})") if __name__ == "__main__": main()