@rubytech/create-maxy-code 0.1.60 → 0.1.61
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/platform/plugins/admin/PLUGIN.md +1 -1
- package/payload/platform/plugins/brochures/skills/property-brochure/SKILL.md +26 -3
- package/payload/platform/plugins/brochures/skills/property-brochure/references/registers.md +1 -1
- package/payload/platform/plugins/docs/references/admin-session.md +2 -2
- package/payload/platform/plugins/docs/references/internals.md +1 -1
- package/payload/platform/plugins/docs/references/plugins-guide.md +1 -1
- package/payload/platform/plugins/epc/.claude-plugin/plugin.json +17 -0
- package/payload/platform/plugins/epc/PLUGIN.md +85 -0
- package/payload/platform/plugins/epc/mcp/dist/index.d.ts +2 -0
- package/payload/platform/plugins/epc/mcp/dist/index.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/index.js +117 -0
- package/payload/platform/plugins/epc/mcp/dist/index.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/crypto.d.ts +3 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/crypto.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/crypto.js +72 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/crypto.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.d.ts +60 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.js +181 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/epc-api.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.d.ts +3 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.js +49 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/file-crypto.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/key-store.d.ts +15 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/key-store.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/key-store.js +130 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/key-store.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.d.ts +5 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.js +38 -0
- package/payload/platform/plugins/epc/mcp/dist/lib/neo4j.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.d.ts +4 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.js +9 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-deregister.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-list.d.ts +4 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-list.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-list.js +10 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-list.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-register.d.ts +5 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-register.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-register.js +36 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/key-register.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/lookup.d.ts +10 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/lookup.d.ts.map +1 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/lookup.js +30 -0
- package/payload/platform/plugins/epc/mcp/dist/tools/lookup.js.map +1 -0
- package/payload/platform/plugins/epc/mcp/package-lock.json +2566 -0
- package/payload/platform/plugins/epc/mcp/package.json +21 -0
- package/payload/platform/plugins/epc/mcp/src/__tests__/epc-api.test.ts +251 -0
- package/payload/platform/plugins/epc/mcp/src/__tests__/key-roundtrip.test.ts +113 -0
- package/payload/platform/plugins/epc/mcp/src/__tests__/lookup.test.ts +181 -0
- package/payload/platform/plugins/epc/mcp/src/__tests__/schema-parity.test.ts +54 -0
- package/payload/platform/plugins/epc/mcp/src/index.ts +156 -0
- package/payload/platform/plugins/epc/mcp/src/lib/crypto.ts +79 -0
- package/payload/platform/plugins/epc/mcp/src/lib/epc-api.ts +241 -0
- package/payload/platform/plugins/epc/mcp/src/lib/file-crypto.ts +55 -0
- package/payload/platform/plugins/epc/mcp/src/lib/key-store.ts +172 -0
- package/payload/platform/plugins/epc/mcp/src/lib/neo4j.ts +47 -0
- package/payload/platform/plugins/epc/mcp/src/tools/key-deregister.ts +9 -0
- package/payload/platform/plugins/epc/mcp/src/tools/key-list.ts +10 -0
- package/payload/platform/plugins/epc/mcp/src/tools/key-register.ts +44 -0
- package/payload/platform/plugins/epc/mcp/src/tools/lookup.ts +43 -0
- package/payload/platform/plugins/epc/mcp/tsconfig.json +20 -0
- package/payload/platform/plugins/epc/mcp/vitest.config.ts +8 -0
- package/payload/platform/plugins/preval/.claude-plugin/plugin.json +1 -1
- package/payload/platform/plugins/preval/PLUGIN.md +23 -17
- package/payload/platform/plugins/preval/skills/property-preval/SKILL.md +78 -38
- package/payload/platform/plugins/preval/skills/property-preval/references/render.py +288 -81
- package/payload/platform/plugins/preval/skills/property-preval/references/template-inputs.schema.json +125 -80
- package/payload/platform/plugins/preval/skills/property-preval/references/template.html +184 -124
- package/payload/platform/services/claude-session-manager/dist/http-server.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js +6 -1
- package/payload/platform/services/claude-session-manager/dist/http-server.js.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts +7 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.d.ts.map +1 -1
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js +8 -0
- package/payload/platform/services/claude-session-manager/dist/pty-spawner.js.map +1 -1
- package/payload/platform/templates/agents/admin/IDENTITY.md +1 -1
- package/payload/platform/templates/agents/admin/SOUL.md +1 -1
- package/payload/premium-plugins/real-agent/BUNDLE.md +4 -2
- package/payload/premium-plugins/real-agent/agents/valuer.md +1 -1
- package/payload/premium-plugins/real-agent/plugins/.claude-plugin/marketplace.json +5 -0
- package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/SKILL.md +26 -3
- package/payload/premium-plugins/real-agent/plugins/brochures/skills/property-brochure/references/registers.md +1 -1
- package/payload/premium-plugins/real-agent/plugins/epc/.claude-plugin/plugin.json +17 -0
- package/payload/premium-plugins/real-agent/plugins/epc/PLUGIN.md +85 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.d.ts +2 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.js +117 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/index.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.d.ts +3 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.js +72 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/crypto.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.d.ts +60 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.js +181 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/epc-api.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.d.ts +3 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.js +49 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/file-crypto.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.d.ts +15 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.js +130 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/key-store.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.d.ts +5 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.js +38 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/lib/neo4j.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.d.ts +4 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.js +9 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-deregister.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.d.ts +4 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.js +10 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-list.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.d.ts +5 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.js +36 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/key-register.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.d.ts +10 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.d.ts.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.js +30 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/dist/tools/lookup.js.map +1 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/package-lock.json +2566 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/package.json +21 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/epc-api.test.ts +251 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/key-roundtrip.test.ts +113 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/lookup.test.ts +181 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/__tests__/schema-parity.test.ts +54 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/index.ts +156 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/crypto.ts +79 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/epc-api.ts +241 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/file-crypto.ts +55 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/key-store.ts +172 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/lib/neo4j.ts +47 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/key-deregister.ts +9 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/key-list.ts +10 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/key-register.ts +44 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/src/tools/lookup.ts +43 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/tsconfig.json +20 -0
- package/payload/premium-plugins/real-agent/plugins/epc/mcp/vitest.config.ts +8 -0
- package/payload/premium-plugins/real-agent/plugins/preval/.claude-plugin/plugin.json +1 -1
- package/payload/premium-plugins/real-agent/plugins/preval/PLUGIN.md +23 -17
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/SKILL.md +78 -38
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/render.py +288 -81
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template-inputs.schema.json +125 -80
- package/payload/premium-plugins/real-agent/plugins/preval/skills/property-preval/references/template.html +184 -124
- package/payload/server/public/assets/{admin-Bp-BjBCX.js → admin-DhN3G8W7.js} +1 -1
- package/payload/server/public/assets/{data-BGUAGVkV.js → data-B2ZVXOcE.js} +1 -1
- package/payload/server/public/assets/{graph-MvYxZOBF.js → graph-BAMGPHrK.js} +1 -1
- package/payload/server/public/assets/{graph-labels-D865qb3K.js → graph-labels-D9eBbvxo.js} +1 -1
- package/payload/server/public/assets/{page-C2b1nlOc.js → page-CV27Al6Z.js} +1 -1
- package/payload/server/public/assets/{page--hOVRrgN.js → page-DjdVMWCz.js} +1 -1
- package/payload/server/public/data.html +3 -3
- package/payload/server/public/graph.html +3 -3
- package/payload/server/public/index.html +4 -4
- package/payload/server/server.js +3 -16
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Substitutes <!-- REPLACE: <slot> --> placeholders in template.html with values
|
|
4
4
|
derived from one inputs.json (shape: template-inputs.schema.json), drives
|
|
5
|
-
Chrome headless to produce a
|
|
5
|
+
Chrome headless to produce a 5-page A4 PDF, and asserts page count == 5.
|
|
6
6
|
|
|
7
7
|
Usage:
|
|
8
8
|
python3 render.py inputs.json
|
|
@@ -12,10 +12,25 @@ import html, json, re, shutil, subprocess, sys
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
14
|
|
|
15
|
+
M2_TO_SQFT = 10.7639
|
|
16
|
+
|
|
17
|
+
|
|
15
18
|
def _money(n: int) -> str:
|
|
16
19
|
return f"£{int(n):,}"
|
|
17
20
|
|
|
18
21
|
|
|
22
|
+
def _range_money(low: int, high: int) -> str:
|
|
23
|
+
return f"£{int(low):,} – £{int(high):,}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------- helpers ----------------
|
|
27
|
+
|
|
28
|
+
def _short_address(address: str) -> str:
|
|
29
|
+
"""First two address tokens for the cover meta strip."""
|
|
30
|
+
parts = [p.strip() for p in address.split(",")]
|
|
31
|
+
return ", ".join(parts[:2]) if len(parts) >= 2 else address
|
|
32
|
+
|
|
33
|
+
|
|
19
34
|
def _sold_comps_rows(rows: list[dict]) -> str:
|
|
20
35
|
if not rows:
|
|
21
36
|
return '<tr><td colspan="4" class="muted">No sold transactions in the sample.</td></tr>'
|
|
@@ -49,31 +64,6 @@ def _asking_comps_tiles(rows: list[dict]) -> str:
|
|
|
49
64
|
return "".join(out)
|
|
50
65
|
|
|
51
66
|
|
|
52
|
-
def _crime_rows(rows: list[dict]) -> str:
|
|
53
|
-
if not rows:
|
|
54
|
-
return '<div class="muted">No crime data.</div>'
|
|
55
|
-
top = sorted(rows, key=lambda r: r["count"], reverse=True)[:6]
|
|
56
|
-
cap = max((r["count"] for r in top), default=1) or 1
|
|
57
|
-
return "".join(
|
|
58
|
-
f'<div class="dist-row">'
|
|
59
|
-
f'<div class="name">{html.escape(r["category"])}</div>'
|
|
60
|
-
f'<div class="bar"><span{" class=gold" if i == 0 else ""} style="width:{int(100 * r["count"] / cap)}%"></span></div>'
|
|
61
|
-
f'<div class="pct">{r["count"]}</div>'
|
|
62
|
-
f"</div>"
|
|
63
|
-
for i, r in enumerate(top)
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def _band_rows(rows: list[dict]) -> str:
|
|
68
|
-
if not rows:
|
|
69
|
-
return '<tr><td colspan="2" class="muted">No banding data.</td></tr>'
|
|
70
|
-
return "".join(
|
|
71
|
-
f"<tr><td>Band {html.escape(r['band'])}</td>"
|
|
72
|
-
f"<td class=\"num\">{_money(r['amount'])}</td></tr>"
|
|
73
|
-
for r in rows
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
|
|
77
67
|
def _type_dist_rows(rows: list[dict]) -> str:
|
|
78
68
|
if not rows:
|
|
79
69
|
return '<div class="muted">No property-type mix data.</div>'
|
|
@@ -89,7 +79,7 @@ def _type_dist_rows(rows: list[dict]) -> str:
|
|
|
89
79
|
|
|
90
80
|
|
|
91
81
|
def _spark_svg(price_series: list[dict], psf_series: list[dict]) -> str:
|
|
92
|
-
"""Two-line sparkline in a 1000x380 viewBox SVG, no external
|
|
82
|
+
"""Two-line sparkline in a 1000x380 viewBox SVG, no external deps."""
|
|
93
83
|
W, H = 1000, 380
|
|
94
84
|
pad_l, pad_r, pad_t, pad_b = 70, 30, 30, 50
|
|
95
85
|
iw, ih = W - pad_l - pad_r, H - pad_t - pad_b
|
|
@@ -102,9 +92,7 @@ def _spark_svg(price_series: list[dict], psf_series: list[dict]) -> str:
|
|
|
102
92
|
if ymax == ymin:
|
|
103
93
|
ymax = ymin + 1
|
|
104
94
|
n = len(series)
|
|
105
|
-
pts = []
|
|
106
|
-
circles = []
|
|
107
|
-
labels = []
|
|
95
|
+
pts, circles, labels = [], [], []
|
|
108
96
|
for i, p in enumerate(series):
|
|
109
97
|
x = pad_l + (i * iw / max(1, n - 1))
|
|
110
98
|
y = pad_t + ih - ((float(p[key]) - ymin) / (ymax - ymin)) * ih
|
|
@@ -115,8 +103,7 @@ def _spark_svg(price_series: list[dict], psf_series: list[dict]) -> str:
|
|
|
115
103
|
)
|
|
116
104
|
return (
|
|
117
105
|
f'<polyline class="{css_pl}" points="{" ".join(pts)}"/>'
|
|
118
|
-
+ "".join(circles)
|
|
119
|
-
+ "".join(labels)
|
|
106
|
+
+ "".join(circles) + "".join(labels)
|
|
120
107
|
)
|
|
121
108
|
|
|
122
109
|
axis = (
|
|
@@ -128,54 +115,283 @@ def _spark_svg(price_series: list[dict], psf_series: list[dict]) -> str:
|
|
|
128
115
|
)
|
|
129
116
|
|
|
130
117
|
|
|
118
|
+
def _summary_paragraphs(paras: list[str]) -> str:
|
|
119
|
+
if not paras:
|
|
120
|
+
return ""
|
|
121
|
+
out = [f'<p class="dropcap">{html.escape(paras[0])}</p>']
|
|
122
|
+
for p in paras[1:]:
|
|
123
|
+
out.append(f"<p>{html.escape(p)}</p>")
|
|
124
|
+
return "".join(out)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _wordmark(brand: dict, variant: str, out_dir: Path) -> str:
|
|
128
|
+
"""variant ∈ {'light', 'dark'}. Mirrors property-market-report's wordmark."""
|
|
129
|
+
key = "logo_light" if variant == "light" else "logo_dark"
|
|
130
|
+
logo_path = brand.get(key)
|
|
131
|
+
if logo_path:
|
|
132
|
+
src = Path(logo_path).expanduser()
|
|
133
|
+
dest_dir = out_dir / "img"
|
|
134
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
dest = dest_dir / src.name
|
|
136
|
+
if src.resolve() != dest.resolve():
|
|
137
|
+
shutil.copy2(src, dest)
|
|
138
|
+
rel = f"img/{src.name}"
|
|
139
|
+
height = "14mm" if variant == "light" else "9mm"
|
|
140
|
+
return f'<img src="{html.escape(rel)}" alt="{html.escape(brand["name"])}" style="height:{height};width:auto;display:block">'
|
|
141
|
+
cls = "wm light" if variant == "light" else "wm small"
|
|
142
|
+
return (
|
|
143
|
+
f'<div class="{cls}">'
|
|
144
|
+
f'<span class="wm-name">{html.escape(brand["name"])}</span>'
|
|
145
|
+
f'<span class="wm-sub">{html.escape(brand["tagline"])}</span>'
|
|
146
|
+
f"</div>"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _hero_asset(subject: dict, out_dir: Path) -> str:
|
|
151
|
+
"""Copy subject.cover_hero into img/ and return the relative path. URLs pass through."""
|
|
152
|
+
hero = subject["cover_hero"]
|
|
153
|
+
if hero.startswith(("http://", "https://", "data:")):
|
|
154
|
+
return hero
|
|
155
|
+
src = Path(hero).expanduser()
|
|
156
|
+
if not src.exists():
|
|
157
|
+
raise FileNotFoundError(f"subject.cover_hero not found: {src}")
|
|
158
|
+
dest_dir = out_dir / "img"
|
|
159
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
160
|
+
dest = dest_dir / f"hero{src.suffix.lower()}"
|
|
161
|
+
if src.resolve() != dest.resolve():
|
|
162
|
+
shutil.copy2(src, dest)
|
|
163
|
+
return f"img/{dest.name}"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _listing_card(p: dict, index: int, out_dir: Path) -> str:
|
|
167
|
+
addr_parts = [x.strip() for x in p["address"].split(",")]
|
|
168
|
+
addr = html.escape(addr_parts[0])
|
|
169
|
+
chips = []
|
|
170
|
+
if p.get("beds"): chips.append(f"{p['beds']} bed")
|
|
171
|
+
if p.get("type"): chips.append(html.escape(p["type"]))
|
|
172
|
+
chips_html = "".join(f"<span>{c}</span>" for c in chips)
|
|
173
|
+
img_src = ""
|
|
174
|
+
if p.get("image"):
|
|
175
|
+
src = Path(p["image"]).expanduser() if not str(p["image"]).startswith(("http://", "https://", "data:")) else None
|
|
176
|
+
if src and src.exists():
|
|
177
|
+
dest_dir = out_dir / "img"
|
|
178
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
dest = dest_dir / f"agent-{index:02d}-{p['slug']}{src.suffix.lower()}"
|
|
180
|
+
if src.resolve() != dest.resolve():
|
|
181
|
+
shutil.copy2(src, dest)
|
|
182
|
+
img_src = f"img/{dest.name}"
|
|
183
|
+
elif src is None:
|
|
184
|
+
img_src = str(p["image"])
|
|
185
|
+
status = p.get("status", "")
|
|
186
|
+
badge_cls = {"Sold": "sold", "Under offer": "under"}.get(status, "")
|
|
187
|
+
photo = (
|
|
188
|
+
f'<div class="photo">'
|
|
189
|
+
f'{f"<img src=\"{html.escape(img_src)}\" alt=\"{addr}\">" if img_src else ""}'
|
|
190
|
+
f'<div class="badge {badge_cls}">{html.escape(status)}</div>'
|
|
191
|
+
f"</div>"
|
|
192
|
+
)
|
|
193
|
+
meta = (
|
|
194
|
+
f'<div class="meta">'
|
|
195
|
+
f'<div class="addr">{addr}</div>'
|
|
196
|
+
f'<div class="price">{_money(p["price"])}</div>'
|
|
197
|
+
f'<div class="kfs">{chips_html}</div>'
|
|
198
|
+
f"</div>"
|
|
199
|
+
)
|
|
200
|
+
inner = photo + meta
|
|
201
|
+
if p.get("url"):
|
|
202
|
+
return f'<a class="listing" href="{html.escape(p["url"])}" target="_blank" rel="noopener">{inner}</a>'
|
|
203
|
+
return f'<div class="listing">{inner}</div>'
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _agent_listings_cards(rows: list[dict], out_dir: Path) -> str:
|
|
207
|
+
if not rows:
|
|
208
|
+
return '<div class="muted" style="margin-top:5mm">No recent listings supplied.</div>'
|
|
209
|
+
return "".join(_listing_card(p, i, out_dir) for i, p in enumerate(rows[:6]))
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# ---------------- valuation maths ----------------
|
|
213
|
+
|
|
214
|
+
def _valuation_block(subject: dict, valuation: dict) -> str:
|
|
215
|
+
"""Build the page-2 valuation panel: headline range, adjustments, final range.
|
|
216
|
+
|
|
217
|
+
Falls back to the 'requires floor area' state when subject.sqft is missing.
|
|
218
|
+
"""
|
|
219
|
+
sqft = subject.get("sqft")
|
|
220
|
+
if not sqft:
|
|
221
|
+
return (
|
|
222
|
+
'<div class="val-panel"><div class="val-empty">'
|
|
223
|
+
"Estimate requires floor area.<br>"
|
|
224
|
+
"<span style=\"font-size:10pt;color:var(--text-muted)\">"
|
|
225
|
+
f"Benchmark: sold £/sqft = {_money(valuation['sold_psf'])}, "
|
|
226
|
+
f"asking £/sqft = {_money(valuation['asking_psf'])}. "
|
|
227
|
+
"Paste subject.sqft to compute the range."
|
|
228
|
+
"</span></div></div>"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
low_psf = valuation["sold_psf_low"]
|
|
232
|
+
mid_psf = valuation["sold_psf"]
|
|
233
|
+
high_psf = valuation["sold_psf_high"]
|
|
234
|
+
ask_psf = valuation["asking_psf"]
|
|
235
|
+
|
|
236
|
+
low_raw = int(sqft * low_psf)
|
|
237
|
+
mid_raw = int(sqft * mid_psf)
|
|
238
|
+
high_raw = int(sqft * high_psf)
|
|
239
|
+
ask_raw = int(sqft * ask_psf)
|
|
240
|
+
|
|
241
|
+
rows = [
|
|
242
|
+
('<div class="val-row headline">'
|
|
243
|
+
f'<div class="lbl">Headline range<small>{sqft:,} sqft × £{low_psf}–£{high_psf} sold £/sqft (Land Registry 70pc band)</small></div>'
|
|
244
|
+
f'<div class="num">{_range_money(low_raw, high_raw)}</div></div>'),
|
|
245
|
+
('<div class="val-row">'
|
|
246
|
+
f'<div class="lbl">Cross-check · asking £/sqft<small>{sqft:,} sqft × £{ask_psf} mean asking £/sqft</small></div>'
|
|
247
|
+
f'<div class="num">{_money(ask_raw)}</div></div>'),
|
|
248
|
+
]
|
|
249
|
+
|
|
250
|
+
adjustments = subject.get("adjustments") or []
|
|
251
|
+
if adjustments:
|
|
252
|
+
rows.append('<div class="val-row"><div class="lbl" style="font-family:var(--sans);font-weight:600;font-size:8pt;letter-spacing:2pt;text-transform:uppercase;color:var(--text-accent)">Adjustments</div><div class="num"> </div></div>')
|
|
253
|
+
for adj in adjustments:
|
|
254
|
+
delta = float(adj["delta_pct"])
|
|
255
|
+
cls = "pos" if delta > 0 else ("neg" if delta < 0 else "")
|
|
256
|
+
sign = "+" if delta >= 0 else ""
|
|
257
|
+
rows.append(
|
|
258
|
+
'<div class="val-row adj">'
|
|
259
|
+
f'<div class="lbl">{html.escape(adj["label"])}</div>'
|
|
260
|
+
f'<div class="num {cls}">{sign}{delta:.1f}%</div>'
|
|
261
|
+
"</div>"
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
delta_sum = sum(float(a["delta_pct"]) for a in adjustments)
|
|
265
|
+
factor = 1.0 + delta_sum / 100.0
|
|
266
|
+
low_final = int(low_raw * factor)
|
|
267
|
+
high_final = int(high_raw * factor)
|
|
268
|
+
|
|
269
|
+
final = (
|
|
270
|
+
'<div class="val-final">'
|
|
271
|
+
f'<div class="lbl">Final indicative range</div>'
|
|
272
|
+
f'<div class="num">{_range_money(low_final, high_final)}</div>'
|
|
273
|
+
"</div>"
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
return '<div class="val-panel">' + "".join(rows) + "</div>" + final
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _kpi_subject_sqft(subject: dict) -> tuple[str, str]:
|
|
280
|
+
sqft = subject.get("sqft")
|
|
281
|
+
m2 = subject.get("sqft_m2")
|
|
282
|
+
if sqft:
|
|
283
|
+
sub = f"≈ {m2:.0f} m² · from EPC register" if m2 else "Square feet (operator)"
|
|
284
|
+
return f"{int(sqft):,}", sub
|
|
285
|
+
return "—", "No EPC on file"
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _kpi_subject_epc(subject: dict) -> tuple[str, str]:
|
|
289
|
+
current = subject.get("epc_current")
|
|
290
|
+
potential = subject.get("epc_potential")
|
|
291
|
+
lodgement = subject.get("epc_lodgement")
|
|
292
|
+
if not current:
|
|
293
|
+
return "—", "No EPC on file"
|
|
294
|
+
sub_bits = []
|
|
295
|
+
if potential:
|
|
296
|
+
sub_bits.append(f"potential {potential}")
|
|
297
|
+
if lodgement:
|
|
298
|
+
sub_bits.append(f"lodged {lodgement}")
|
|
299
|
+
return current, " · ".join(sub_bits) or "EPC register"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ---------------- render ----------------
|
|
303
|
+
|
|
131
304
|
def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Path:
|
|
132
305
|
template_path = template_path or (Path(__file__).parent / "template.html")
|
|
133
306
|
tmpl = template_path.read_text()
|
|
134
307
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
308
|
+
brand = inputs["brand"]
|
|
309
|
+
subject = inputs["subject"]
|
|
310
|
+
v = inputs["valuation"]
|
|
311
|
+
a = inputs["area"]
|
|
312
|
+
d = inputs["demand_trend"]
|
|
313
|
+
ms = inputs["market_summary"]
|
|
314
|
+
r = d["rental"]
|
|
315
|
+
|
|
316
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
317
|
+
|
|
318
|
+
subject_sqft, subject_sqft_sub = _kpi_subject_sqft(subject)
|
|
319
|
+
subject_epc, subject_epc_sub = _kpi_subject_epc(subject)
|
|
320
|
+
|
|
321
|
+
yoy = d.get("yoy_growth_pct")
|
|
322
|
+
if yoy is None and len(d.get("growth_series", [])) >= 2:
|
|
323
|
+
last, prev = d["growth_series"][-1]["price"], d["growth_series"][-2]["price"]
|
|
324
|
+
yoy = (last - prev) / prev * 100.0
|
|
325
|
+
yoy_str = f"{yoy:+.1f}%" if yoy is not None else "—"
|
|
326
|
+
|
|
327
|
+
psf = d.get("sales_per_month")
|
|
328
|
+
if psf is None:
|
|
329
|
+
psf = max(1, round(d["for_sale"] / 7)) # crude fallback only when not provided
|
|
139
330
|
|
|
140
331
|
slots = {
|
|
332
|
+
# branding
|
|
333
|
+
"brand_name": brand["name"],
|
|
334
|
+
"brand_tagline": brand["tagline"],
|
|
335
|
+
"brand_primary": brand["primary"],
|
|
336
|
+
"brand_primary_dark": brand["primary_dark"],
|
|
337
|
+
"brand_accent": brand["accent"],
|
|
338
|
+
"brand_paper": brand["paper"],
|
|
339
|
+
"brand_paper_banded": brand["paper_banded"],
|
|
340
|
+
"brand_rule": brand["rule"],
|
|
341
|
+
"wordmark_light": _wordmark(brand, "light", out_dir),
|
|
342
|
+
"wordmark_dark": _wordmark(brand, "dark", out_dir),
|
|
343
|
+
|
|
344
|
+
# cover + header
|
|
345
|
+
"subject_hero_path": _hero_asset(subject, out_dir),
|
|
141
346
|
"address": inputs["address"],
|
|
347
|
+
"address_short": _short_address(inputs["address"]),
|
|
142
348
|
"postcode": inputs["postcode"],
|
|
143
349
|
"generated": inputs["generated"],
|
|
144
350
|
|
|
145
|
-
|
|
146
|
-
"
|
|
147
|
-
"
|
|
148
|
-
"
|
|
149
|
-
"
|
|
150
|
-
"
|
|
151
|
-
|
|
152
|
-
"
|
|
153
|
-
"
|
|
154
|
-
"
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
"
|
|
158
|
-
"
|
|
159
|
-
|
|
160
|
-
"
|
|
161
|
-
"
|
|
162
|
-
"
|
|
163
|
-
"
|
|
164
|
-
"
|
|
165
|
-
|
|
166
|
-
"
|
|
167
|
-
"
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
"
|
|
351
|
+
# page 2 — subject valuation
|
|
352
|
+
"kpi_subject_sqft": subject_sqft,
|
|
353
|
+
"kpi_subject_sqft_sub": subject_sqft_sub,
|
|
354
|
+
"kpi_subject_beds": str(subject.get("beds") or "—"),
|
|
355
|
+
"kpi_subject_epc": subject_epc,
|
|
356
|
+
"kpi_subject_epc_sub": subject_epc_sub,
|
|
357
|
+
"kpi_subject_type": subject.get("type") or "—",
|
|
358
|
+
"kpi_subject_tenure": subject.get("tenure") or "Tenure unknown",
|
|
359
|
+
"valuation_block": _valuation_block(subject, v),
|
|
360
|
+
"subject_narrative": subject.get("narrative") or "",
|
|
361
|
+
|
|
362
|
+
# page 3 — market context
|
|
363
|
+
"summary_headline": ms["headline"],
|
|
364
|
+
"summary_paragraphs": _summary_paragraphs(ms["paragraphs"]),
|
|
365
|
+
"kpi_avg_asking": _money(v["avg_asking"]),
|
|
366
|
+
"kpi_avg_sold": _money(v["avg_sold"]),
|
|
367
|
+
"kpi_psf": _money(v["asking_psf"]),
|
|
368
|
+
"kpi_yoy": yoy_str,
|
|
369
|
+
"demand_rating": d["demand_rating"],
|
|
370
|
+
"gauge_position": ms["demand_gauge_position"],
|
|
371
|
+
"price_growth_note": ms["price_growth_note"],
|
|
372
|
+
"demand_note": ms["demand_note"],
|
|
373
|
+
"spark_svg": _spark_svg(d.get("growth_series", []), d.get("growth_psf_series", [])),
|
|
374
|
+
|
|
375
|
+
# page 4 — comparables
|
|
376
|
+
"sold_comps_rows": _sold_comps_rows(v.get("sold_comps", [])),
|
|
377
|
+
"asking_comps_tiles": _asking_comps_tiles(v.get("asking_comps", [])),
|
|
378
|
+
|
|
379
|
+
# page 5 — agent listings + sources
|
|
380
|
+
"agent_listings_cards": _agent_listings_cards(inputs["agent_listings"], out_dir),
|
|
172
381
|
}
|
|
173
382
|
|
|
174
|
-
# Escape
|
|
175
|
-
text_slots = {
|
|
176
|
-
|
|
383
|
+
# Escape only the plain-text slots; HTML fragments pass through untouched.
|
|
384
|
+
text_slots = {
|
|
385
|
+
"address", "address_short", "postcode", "generated",
|
|
386
|
+
"brand_name", "brand_tagline",
|
|
387
|
+
"kpi_subject_beds", "kpi_subject_epc", "kpi_subject_epc_sub",
|
|
388
|
+
"kpi_subject_sqft", "kpi_subject_sqft_sub",
|
|
389
|
+
"kpi_subject_type", "kpi_subject_tenure",
|
|
390
|
+
"summary_headline", "demand_rating",
|
|
391
|
+
"price_growth_note", "demand_note", "subject_narrative",
|
|
392
|
+
}
|
|
177
393
|
for k in text_slots:
|
|
178
|
-
slots[k] = html.escape(slots[k])
|
|
394
|
+
slots[k] = html.escape(str(slots[k]))
|
|
179
395
|
|
|
180
396
|
out = tmpl
|
|
181
397
|
for k, val in slots.items():
|
|
@@ -187,7 +403,6 @@ def render(inputs: dict, out_dir: Path, template_path: Path | None = None) -> Pa
|
|
|
187
403
|
"template has unfilled placeholders: " + ", ".join(sorted(set(leftovers)))
|
|
188
404
|
)
|
|
189
405
|
|
|
190
|
-
out_dir.mkdir(parents=True, exist_ok=True)
|
|
191
406
|
out_path = out_dir / "index.html"
|
|
192
407
|
out_path.write_text(out)
|
|
193
408
|
return out_path
|
|
@@ -212,11 +427,10 @@ _PAGE_OBJ_RE = re.compile(rb"/Type\s*/Page(?![sA-Za-z])")
|
|
|
212
427
|
|
|
213
428
|
|
|
214
429
|
def _count_pages(pdf_path: Path) -> int:
|
|
215
|
-
"""
|
|
216
|
-
raw
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
page-object streams — hence pdfinfo first when available.
|
|
430
|
+
"""pdfinfo first (handles /ObjStm-compressed page-object streams), with a
|
|
431
|
+
raw /Type /Page byte scan as fallback. The fallback is correct for current
|
|
432
|
+
Chrome --print-to-pdf output but would undercount if Chrome ever switches
|
|
433
|
+
to compressed page-object streams — hence pdfinfo first when present.
|
|
220
434
|
"""
|
|
221
435
|
pdfinfo = shutil.which("pdfinfo")
|
|
222
436
|
if pdfinfo:
|
|
@@ -231,13 +445,6 @@ def _count_pages(pdf_path: Path) -> int:
|
|
|
231
445
|
|
|
232
446
|
|
|
233
447
|
def assert_page_count(pdf_path: Path, expected: int) -> int:
|
|
234
|
-
"""Assert PDF page count == expected; raise RuntimeError otherwise.
|
|
235
|
-
|
|
236
|
-
Synchronous and reliable — does not depend on Spotlight (`mdls` returns
|
|
237
|
-
`(null)` until indexing finishes, which is async). Operators can still
|
|
238
|
-
verify externally with `mdls -name kMDItemNumberOfPages <pdf>` once
|
|
239
|
-
Spotlight catches up.
|
|
240
|
-
"""
|
|
241
448
|
n = _count_pages(pdf_path)
|
|
242
449
|
if n != expected:
|
|
243
450
|
raise RuntimeError(
|
|
@@ -256,7 +463,7 @@ def main():
|
|
|
256
463
|
base = inputs["filename_stem"]
|
|
257
464
|
render(inputs, out_dir)
|
|
258
465
|
pdf = render_pdf(out_dir, f"{base}.pdf")
|
|
259
|
-
pages = assert_page_count(pdf,
|
|
466
|
+
pages = assert_page_count(pdf, 5)
|
|
260
467
|
print(f"HTML → {out_dir/'index.html'}")
|
|
261
468
|
print(f"PDF → {pdf} ({pages} pages)")
|
|
262
469
|
|