← back to Rentv
RENTV: full rentv.com article corpus crawler + news-posting playbook — TK-10250
f5984832fc74e1d5d0cc6344219dce987d73706c · 2026-08-05 13:23:50 -0700 · Steve Abrams
Load ALL rentv.com articles to analyze how to properly post CRE news events, shared across
crcp/homesonspec/usre/rentv.
- lib/parse-article.mjs: shared article parser (headline/date/txn/type/$/geo/size); skips empty stubs.
- pull-archive.mjs: resumable $0 archive backfill (ids ~20000->newest; <20000 are 302'd/gone).
- analyze-news-posts.mjs: corpus -> how-to-post playbook (cadence, category mix, headline formulas
by deal type, length, $/geo/size placement, complete-post checklist) + syncs into re-props.
- corpus jsonl + crawl-state gitignored (large/rebuildable); playbook .json/.md committed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M .gitignoreA data/news-posting-playbook.jsonA data/news-posting-playbook.mdA scripts/analyze-news-posts.mjsA scripts/lib/parse-article.mjsA scripts/pull-archive.mjs
Diff
commit f5984832fc74e1d5d0cc6344219dce987d73706c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Aug 5 13:23:50 2026 -0700
RENTV: full rentv.com article corpus crawler + news-posting playbook — TK-10250
Load ALL rentv.com articles to analyze how to properly post CRE news events, shared across
crcp/homesonspec/usre/rentv.
- lib/parse-article.mjs: shared article parser (headline/date/txn/type/$/geo/size); skips empty stubs.
- pull-archive.mjs: resumable $0 archive backfill (ids ~20000->newest; <20000 are 302'd/gone).
- analyze-news-posts.mjs: corpus -> how-to-post playbook (cadence, category mix, headline formulas
by deal type, length, $/geo/size placement, complete-post checklist) + syncs into re-props.
- corpus jsonl + crawl-state gitignored (large/rebuildable); playbook .json/.md committed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
.gitignore | 4 +
data/news-posting-playbook.json | 294 ++++++++++++++++++++++++++++++++++++++++
data/news-posting-playbook.md | 80 +++++++++++
scripts/analyze-news-posts.mjs | 178 ++++++++++++++++++++++++
scripts/lib/parse-article.mjs | 127 +++++++++++++++++
scripts/pull-archive.mjs | 97 +++++++++++++
6 files changed, 780 insertions(+)
diff --git a/.gitignore b/.gitignore
index 610a54a2..ad606b08 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,3 +22,7 @@ public/social-videos/
__pycache__/
*.pyc
+
+# rentv article corpus — large growing crawl artifact (rebuildable via pull-archive.mjs)
+data/articles-corpus.jsonl
+data/archive-crawl-state.json
diff --git a/data/news-posting-playbook.json b/data/news-posting-playbook.json
new file mode 100644
index 00000000..8d5b7f60
--- /dev/null
+++ b/data/news-posting-playbook.json
@@ -0,0 +1,294 @@
+{
+ "generated_at": "2026-08-05T20:21:52.081Z",
+ "corpus": {
+ "articles": 78,
+ "dated": 78,
+ "date_span": {
+ "from": "2026-06-01",
+ "to": "2026-08-05"
+ }
+ },
+ "cadence": {
+ "per_active_day": 1.73,
+ "by_weekday": {
+ "Sun": 16,
+ "Mon": 15,
+ "Tue": 19,
+ "Wed": 14,
+ "Thu": 14,
+ "Fri": 0,
+ "Sat": 0
+ },
+ "busiest_weekday": "Tue",
+ "recent_months": [
+ {
+ "month": "2026-06",
+ "posts": 38
+ },
+ {
+ "month": "2026-07",
+ "posts": 36
+ },
+ {
+ "month": "2026-08",
+ "posts": 4
+ }
+ ]
+ },
+ "category_mix": {
+ "txn_type": [
+ {
+ "k": "Sale",
+ "n": 53,
+ "share": 67.9
+ },
+ {
+ "k": "Development",
+ "n": 9,
+ "share": 11.5
+ },
+ {
+ "k": "Financing",
+ "n": 8,
+ "share": 10.3
+ },
+ {
+ "k": "Lease",
+ "n": 8,
+ "share": 10.3
+ }
+ ],
+ "property_type": [
+ {
+ "k": "Multifamily",
+ "n": 25,
+ "share": 32.1
+ },
+ {
+ "k": "Office",
+ "n": 21,
+ "share": 26.9
+ },
+ {
+ "k": "Industrial",
+ "n": 18,
+ "share": 23.1
+ },
+ {
+ "k": "Retail",
+ "n": 8,
+ "share": 10.3
+ },
+ {
+ "k": "Land",
+ "n": 2,
+ "share": 2.6
+ },
+ {
+ "k": "Hospitality",
+ "n": 2,
+ "share": 2.6
+ },
+ {
+ "k": "Medical/Life Science",
+ "n": 1,
+ "share": 1.3
+ },
+ {
+ "k": "Mixed-Use",
+ "n": 1,
+ "share": 1.3
+ }
+ ]
+ },
+ "headline": {
+ "median_words": 11,
+ "median_chars": 67,
+ "words_p10_p90": [
+ 7,
+ 15
+ ],
+ "leads_with_verb_share": 87.2,
+ "top_verbs": [
+ {
+ "verb": "buy",
+ "n": 12,
+ "share": 15.4
+ },
+ {
+ "verb": "land",
+ "n": 9,
+ "share": 11.5
+ },
+ {
+ "verb": "close",
+ "n": 8,
+ "share": 10.3
+ },
+ {
+ "verb": "spend",
+ "n": 7,
+ "share": 9
+ },
+ {
+ "verb": "acquire",
+ "n": 6,
+ "share": 7.7
+ },
+ {
+ "verb": "trade",
+ "n": 4,
+ "share": 5.1
+ },
+ {
+ "verb": "secure",
+ "n": 2,
+ "share": 2.6
+ },
+ {
+ "verb": "fetche",
+ "n": 2,
+ "share": 2.6
+ },
+ {
+ "verb": "sell",
+ "n": 2,
+ "share": 2.6
+ },
+ {
+ "verb": "lease",
+ "n": 2,
+ "share": 2.6
+ },
+ {
+ "verb": "refinanced",
+ "n": 2,
+ "share": 2.6
+ },
+ {
+ "verb": "wraps up",
+ "n": 2,
+ "share": 2.6
+ }
+ ],
+ "includes_dollar_share": 57.7,
+ "top_formulas_by_txn": {
+ "Sale": [
+ {
+ "formula": "{WHO} {AMOUNT} {ST} Apartment Buy",
+ "n": 1,
+ "share": 1.9
+ },
+ {
+ "formula": "{WHO} California Multifamily Communities in Deals Totaling {AMOUNT}",
+ "n": 1,
+ "share": 1.9
+ },
+ {
+ "formula": "{WHO} on 1.1 msf Industrial Facility in {CITY}",
+ "n": 1,
+ "share": 1.9
+ },
+ {
+ "formula": "{WHO} 5.9 msf Industrial Portfolio",
+ "n": 1,
+ "share": 1.9
+ },
+ {
+ "formula": "{WHO} {SIZE} Res Property in {CITY} , {ST}",
+ "n": 1,
+ "share": 1.9
+ }
+ ],
+ "Financing": [
+ {
+ "formula": "{WHO} {AMOUNT} in New Financing on 538k sf {CITY} Life Science Campus",
+ "n": 1,
+ "share": 12.5
+ },
+ {
+ "formula": "{WHO} {AMOUNT} Refi on Recently Completed {CITY} Area Multifamily Development",
+ "n": 1,
+ "share": 12.5
+ },
+ {
+ "formula": "{CITY} , {ST} Multifamily Community Refinanced with New Loan for {AMOUNT}",
+ "n": 1,
+ "share": 12.5
+ },
+ {
+ "formula": "{WHO} on Office High-Rise in {CITY} �s Financial District",
+ "n": 1,
+ "share": 12.5
+ },
+ {
+ "formula": "{CITY} Area Res Community Refinanced with {AMOUNT} Loan from Mesa West Capital",
+ "n": 1,
+ "share": 12.5
+ }
+ ],
+ "Lease": [
+ {
+ "formula": "{WHO} at 1.27 msf Facility in Visalia",
+ "n": 1,
+ "share": 12.5
+ },
+ {
+ "formula": "{WHO} 289k sf Bay Area Manufacturing Facility",
+ "n": 1,
+ "share": 12.5
+ },
+ {
+ "formula": "{WHO} for 650k sf {CITY} Office Tower Awarded to CBRE",
+ "n": 1,
+ "share": 12.5
+ },
+ {
+ "formula": "{CITY} Distribution Facility Leased by Auto Parts Distributor in {AMOUNT} Deal",
+ "n": 1,
+ "share": 12.5
+ },
+ {
+ "formula": "{WHO} 1.1 msf of Space at Luke Field in {CITY} , {ST}",
+ "n": 1,
+ "share": 12.5
+ }
+ ],
+ "Development": [
+ {
+ "formula": "{WHO} with {SIZE} Res Community in {CITY}",
+ "n": 1,
+ "share": 11.1
+ },
+ {
+ "formula": "{WHO} on New {SIZE} {CITY} Res Development",
+ "n": 1,
+ "share": 11.1
+ },
+ {
+ "formula": "{WHO} to Develop {SIZE} of Affordable Housing in {CITY}",
+ "n": 1,
+ "share": 11.1
+ },
+ {
+ "formula": "{WHO} on {CITY} High-Rise Res Development",
+ "n": 1,
+ "share": 11.1
+ },
+ {
+ "formula": "{CITY} Social Housing Developer Wraps Up {AMOUNT} Res Buy in Downtown {CITY}",
+ "n": 1,
+ "share": 11.1
+ }
+ ]
+ }
+ },
+ "completeness": {
+ "has_amount_share": 67.9,
+ "median_deal_size": 53000000,
+ "has_city_share": 96.2,
+ "has_state_share": 85.9,
+ "has_size_share": 62.8,
+ "median_story_words": 394
+ }
+}
\ No newline at end of file
diff --git a/data/news-posting-playbook.md b/data/news-posting-playbook.md
new file mode 100644
index 00000000..5d139e3f
--- /dev/null
+++ b/data/news-posting-playbook.md
@@ -0,0 +1,80 @@
+# How to Post a CRE News Event — evidence from the rentv.com archive
+
+_Auto-derived from 78 rentv.com articles (2026-06-01 → 2026-08-05). Generated 2026-08-05. Source of truth for news posting across all RE builds (rentv · crcp · homesonspec · usre)._
+
+## 1. Cadence — how often to post
+- **~1.73 posts per active day.**
+- Busiest weekday: **Tue**. Weekday spread: Sun 16 · Mon 15 · Tue 19 · Wed 14 · Thu 14 · Fri 0 · Sat 0.
+- Post on **weekdays**; CRE news is a business-hours cadence, not weekends.
+
+## 2. What to cover — category mix
+Deal type (lead with these, in this proportion):
+- **Sale** — 67.9%
+- **Development** — 11.5%
+- **Financing** — 10.3%
+- **Lease** — 10.3%
+
+Property type:
+- Multifamily — 32.1%
+- Office — 26.9%
+- Industrial — 23.1%
+- Retail — 10.3%
+- Land — 2.6%
+- Hospitality — 2.6%
+- Medical/Life Science — 1.3%
+- Mixed-Use — 1.3%
+
+## 3. The headline — the single most important element
+- **Length: aim 11 words (~67 chars).** Typical range 7–15 words.
+- **87.2% of headlines carry an explicit deal VERB.** Use one. Top verbs:
+ - buy (15.4%)
+ - land (11.5%)
+ - close (10.3%)
+ - spend (9%)
+ - acquire (7.7%)
+ - trade (5.1%)
+ - secure (2.6%)
+ - fetche (2.6%)
+- **57.7% put the \$ amount right in the headline.** If there's a price, lead with it.
+
+### Proven headline formulas by deal type
+**Sale**
+ - `{WHO} {AMOUNT} {ST} Apartment Buy` _(1.9%)_
+ - `{WHO} California Multifamily Communities in Deals Totaling {AMOUNT}` _(1.9%)_
+ - `{WHO} on 1.1 msf Industrial Facility in {CITY}` _(1.9%)_
+ - `{WHO} 5.9 msf Industrial Portfolio` _(1.9%)_
+ - `{WHO} {SIZE} Res Property in {CITY} , {ST}` _(1.9%)_
+
+**Financing**
+ - `{WHO} {AMOUNT} in New Financing on 538k sf {CITY} Life Science Campus` _(12.5%)_
+ - `{WHO} {AMOUNT} Refi on Recently Completed {CITY} Area Multifamily Development` _(12.5%)_
+ - `{CITY} , {ST} Multifamily Community Refinanced with New Loan for {AMOUNT}` _(12.5%)_
+ - `{WHO} on Office High-Rise in {CITY} �s Financial District` _(12.5%)_
+ - `{CITY} Area Res Community Refinanced with {AMOUNT} Loan from Mesa West Capital` _(12.5%)_
+
+**Lease**
+ - `{WHO} at 1.27 msf Facility in Visalia` _(12.5%)_
+ - `{WHO} 289k sf Bay Area Manufacturing Facility` _(12.5%)_
+ - `{WHO} for 650k sf {CITY} Office Tower Awarded to CBRE` _(12.5%)_
+ - `{CITY} Distribution Facility Leased by Auto Parts Distributor in {AMOUNT} Deal` _(12.5%)_
+ - `{WHO} 1.1 msf of Space at Luke Field in {CITY} , {ST}` _(12.5%)_
+
+**Development**
+ - `{WHO} with {SIZE} Res Community in {CITY}` _(11.1%)_
+ - `{WHO} on New {SIZE} {CITY} Res Development` _(11.1%)_
+ - `{WHO} to Develop {SIZE} of Affordable Housing in {CITY}` _(11.1%)_
+ - `{WHO} on {CITY} High-Rise Res Development` _(11.1%)_
+ - `{CITY} Social Housing Developer Wraps Up {AMOUNT} Res Buy in Downtown {CITY}` _(11.1%)_
+
+_Placeholders: {WHO}=the actor/company · {AMOUNT}=price · {CITY}/{ST}=location · {SIZE}=units/SF/acres._
+
+## 4. A complete post — the checklist
+Every rentv-grade CRE post should carry:
+- ✅ **Deal amount** — present in 67.9% of posts (median deal $53,000,000).
+- ✅ **City** (96.2%) and **state** (85.9%) — always geo-anchor the asset.
+- ✅ **Size** — units/SF/acres, in 62.8% of posts.
+- ✅ **Body** — median ~394 words; lead sentence states who/what/where/how-much, then the parties + details.
+- ✅ **Deal type + property type** tags (for filtering + SEO topical hubs).
+
+## 5. Rule of thumb
+> A CRE news post = **{WHO} {VERB} {LOCATION} {PROPERTY-TYPE} for {AMOUNT}** in the headline, 394-word body that answers who/what/where/how-much in sentence one, geo-anchored and typed. Post on weekdays, lead with the biggest-$ deals.
diff --git a/scripts/analyze-news-posts.mjs b/scripts/analyze-news-posts.mjs
new file mode 100644
index 00000000..9fddf0bb
--- /dev/null
+++ b/scripts/analyze-news-posts.mjs
@@ -0,0 +1,178 @@
+#!/usr/bin/env node
+// analyze-news-posts.mjs — mine the rentv.com article corpus for the "how to properly post a CRE
+// news event" playbook (TK-10247/10250). Reads data/articles-corpus.jsonl and derives the real,
+// evidence-based posting patterns rentv.com uses: cadence, category mix, headline formulas by deal
+// type, headline length, $/location/size placement, lead structure, and a "complete post" checklist.
+//
+// Output: data/news-posting-playbook.json (structured, machine-readable) + data/news-posting-
+// playbook.md (readable reference). Shared to the re-props skill so crcp/homesonspec/usre/rentv all
+// post news the same, evidence-backed way. Runs on a PARTIAL corpus too (re-run as the crawl grows).
+//
+// Usage: node scripts/analyze-news-posts.mjs
+import { createReadStream, existsSync, writeFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createInterface } from 'node:readline';
+import { homedir } from 'node:os';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const DATA = join(HERE, '..', 'data');
+const CORPUS = join(DATA, 'articles-corpus.jsonl');
+
+// ── verbs that carry the deal action in a CRE headline (drives the formula extraction) ──
+const VERB_RE = /\b(sells?|sold|acquires?|acquired|buys?|bought|purchases?|purchased|spends?|pays?|nets?|lands?|snaps up|picks up|trades?|closes?|refinances?|refinanced|obtains?|secures?|secured|lands?|leases?|leased|signs?|inks?|renews?|breaks? ground|develops?|delivers?|completes?|tops? out|unveils?|proposes?|plans?|launches?|opens?|wraps? up|fetches?)\b/i;
+const median = (a) => { if (!a.length) return null; const s = a.slice().sort((x, y) => x - y); const m = Math.floor(s.length / 2); return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
+const pct = (n, d) => d ? +(100 * n / d).toFixed(1) : 0;
+const top = (obj, k = 12) => Object.entries(obj).sort((a, b) => b[1] - a[1]).slice(0, k);
+
+// Abstract a headline into a FORMULA: entities → placeholders so recurring templates surface.
+function formula(a) {
+ let h = ' ' + a.title + ' ';
+ h = h.replace(/\$[\d,]+(?:\.\d+)?\s?(?:bil|billion|mil|million|k)?/gi, ' {AMOUNT} ');
+ h = h.replace(/\b[\d,]+(?:\.\d+)?[- ]?(?:unit|sf|square[ -]?f(?:ee|oo)t|acre)s?\b/gi, ' {SIZE} ');
+ if (a.city) h = h.replace(new RegExp('\\b' + a.city.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'gi'), ' {CITY} ');
+ if (a.state) h = h.replace(new RegExp('\\b' + a.state + '\\b', 'g'), ' {ST} ');
+ // collapse the leading proper-noun run (the actor/company) to {WHO}
+ h = h.replace(/^\s+([A-Z][\w&.'-]+(?:\s+(?:and|&|of|the)?\s*[A-Z][\w&.'-]+){0,4})\s+/, ' {WHO} ');
+ return h.replace(/\s+/g, ' ').trim();
+}
+function leadVerb(title) { const m = title.match(VERB_RE); return m ? m[1].toLowerCase().replace(/s$/, '') : null; }
+
+async function readCorpus() {
+ const rows = [];
+ if (!existsSync(CORPUS)) return rows;
+ await new Promise((res) => {
+ const rl = createInterface({ input: createReadStream(CORPUS), crlfDelay: Infinity });
+ rl.on('line', (l) => { if (!l.trim()) return; try { rows.push(JSON.parse(l)); } catch { /* skip */ } });
+ rl.on('close', res);
+ });
+ return rows;
+}
+
+function analyze(rows) {
+ const dated = rows.filter((r) => r.date).sort((a, b) => a.date.localeCompare(b.date));
+ const dates = dated.map((r) => r.date);
+ const span = dates.length ? { from: dates[0], to: dates[dates.length - 1] } : null;
+ // cadence
+ const byDow = [0, 0, 0, 0, 0, 0, 0], byMonth = {}, byDay = {};
+ for (const r of dated) { const d = new Date(r.date); byDow[d.getDay()]++; const ym = r.date.slice(0, 7); byMonth[ym] = (byMonth[ym] || 0) + 1; byDay[r.date] = (byDay[r.date] || 0) + 1; }
+ const activeDays = Object.keys(byDay).length;
+ const perActiveDay = activeDays ? +(dated.length / activeDays).toFixed(2) : 0;
+ // category mixes
+ const txnMix = {}, typeMix = {}, verbMix = {};
+ const titleWords = [], titleChars = [], storyWords = [];
+ let withAmount = 0, withCity = 0, withState = 0, withSize = 0, amountInTitle = 0;
+ const amounts = [];
+ const formulasByTxn = {};
+ for (const r of rows) {
+ txnMix[r.txn_type || '—'] = (txnMix[r.txn_type || '—'] || 0) + 1;
+ typeMix[r.property_type || '—'] = (typeMix[r.property_type || '—'] || 0) + 1;
+ const v = leadVerb(r.title || ''); if (v) verbMix[v] = (verbMix[v] || 0) + 1;
+ if (r.title_words) titleWords.push(r.title_words);
+ if (r.title_len) titleChars.push(r.title_len);
+ if (r.story_words) storyWords.push(r.story_words);
+ if (r.amount) { withAmount++; amounts.push(r.amount); }
+ if (r.city) withCity++;
+ if (r.state) withState++;
+ if (r.size_label) withSize++;
+ if (/\$/.test(r.title || '')) amountInTitle++;
+ const key = r.txn_type || '—';
+ (formulasByTxn[key] = formulasByTxn[key] || {});
+ const f = formula(r); formulasByTxn[key][f] = (formulasByTxn[key][f] || 0) + 1;
+ }
+ const N = rows.length;
+ const topFormulas = {};
+ for (const [txn, fs] of Object.entries(formulasByTxn)) topFormulas[txn] = top(fs, 5).map(([f, n]) => ({ formula: f, n, share: pct(n, Object.values(fs).reduce((a, b) => a + b, 0)) }));
+
+ return {
+ generated_at: new Date().toISOString(),
+ corpus: { articles: N, dated: dated.length, date_span: span },
+ cadence: {
+ per_active_day: perActiveDay,
+ by_weekday: { Sun: byDow[0], Mon: byDow[1], Tue: byDow[2], Wed: byDow[3], Thu: byDow[4], Fri: byDow[5], Sat: byDow[6] },
+ busiest_weekday: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][byDow.indexOf(Math.max(...byDow))],
+ recent_months: top(byMonth, 6).map(([m, n]) => ({ month: m, posts: n })),
+ },
+ category_mix: {
+ txn_type: top(txnMix).map(([k, n]) => ({ k, n, share: pct(n, N) })),
+ property_type: top(typeMix).map(([k, n]) => ({ k, n, share: pct(n, N) })),
+ },
+ headline: {
+ median_words: median(titleWords), median_chars: median(titleChars),
+ words_p10_p90: [percentile(titleWords, 10), percentile(titleWords, 90)],
+ leads_with_verb_share: pct(Object.values(verbMix).reduce((a, b) => a + b, 0), N),
+ top_verbs: top(verbMix, 12).map(([k, n]) => ({ verb: k, n, share: pct(n, N) })),
+ includes_dollar_share: pct(amountInTitle, N),
+ top_formulas_by_txn: topFormulas,
+ },
+ completeness: {
+ has_amount_share: pct(withAmount, N), median_deal_size: median(amounts),
+ has_city_share: pct(withCity, N), has_state_share: pct(withState, N), has_size_share: pct(withSize, N),
+ median_story_words: median(storyWords),
+ },
+ };
+}
+function percentile(a, p) { if (!a.length) return null; const s = a.slice().sort((x, y) => x - y); return s[Math.min(s.length - 1, Math.floor(p / 100 * s.length))]; }
+
+function toMarkdown(P) {
+ const c = P.category_mix, h = P.headline, k = P.completeness;
+ const row = (arr, f) => arr.map(f).join('\n');
+ return `# How to Post a CRE News Event — evidence from the rentv.com archive
+
+_Auto-derived from ${P.corpus.articles.toLocaleString()} rentv.com articles${P.corpus.date_span ? ` (${P.corpus.date_span.from} → ${P.corpus.date_span.to})` : ''}. Generated ${P.generated_at.slice(0, 10)}. Source of truth for news posting across all RE builds (rentv · crcp · homesonspec · usre)._
+
+## 1. Cadence — how often to post
+- **~${P.cadence.per_active_day} posts per active day.**
+- Busiest weekday: **${P.cadence.busiest_weekday}**. Weekday spread: ${Object.entries(P.cadence.by_weekday).map(([d, n]) => `${d} ${n}`).join(' · ')}.
+- Post on **weekdays**; CRE news is a business-hours cadence, not weekends.
+
+## 2. What to cover — category mix
+Deal type (lead with these, in this proportion):
+${row(c.txn_type, (x) => `- **${x.k}** — ${x.share}%`)}
+
+Property type:
+${row(c.property_type, (x) => `- ${x.k} — ${x.share}%`)}
+
+## 3. The headline — the single most important element
+- **Length: aim ${h.median_words} words (~${h.median_chars} chars).** Typical range ${h.words_p10_p90[0]}–${h.words_p10_p90[1]} words.
+- **${h.leads_with_verb_share}% of headlines carry an explicit deal VERB.** Use one. Top verbs:
+${row(h.top_verbs.slice(0, 8), (x) => ` - ${x.verb} (${x.share}%)`)}
+- **${h.includes_dollar_share}% put the \\$ amount right in the headline.** If there's a price, lead with it.
+
+### Proven headline formulas by deal type
+${Object.entries(h.top_formulas_by_txn).map(([txn, fs]) => `**${txn}**\n${fs.map((f) => ` - \`${f.formula}\` _(${f.share}%)_`).join('\n')}`).join('\n\n')}
+
+_Placeholders: {WHO}=the actor/company · {AMOUNT}=price · {CITY}/{ST}=location · {SIZE}=units/SF/acres._
+
+## 4. A complete post — the checklist
+Every rentv-grade CRE post should carry:
+- ✅ **Deal amount** — present in ${k.has_amount_share}% of posts (median deal ${k.median_deal_size ? '$' + Math.round(k.median_deal_size).toLocaleString() : 'n/a'}).
+- ✅ **City** (${k.has_city_share}%) and **state** (${k.has_state_share}%) — always geo-anchor the asset.
+- ✅ **Size** — units/SF/acres, in ${k.has_size_share}% of posts.
+- ✅ **Body** — median ~${k.median_story_words} words; lead sentence states who/what/where/how-much, then the parties + details.
+- ✅ **Deal type + property type** tags (for filtering + SEO topical hubs).
+
+## 5. Rule of thumb
+> A CRE news post = **{WHO} {VERB} {LOCATION} {PROPERTY-TYPE} for {AMOUNT}** in the headline, ${k.median_story_words}-word body that answers who/what/where/how-much in sentence one, geo-anchored and typed. Post on weekdays, lead with the biggest-$ deals.
+`;
+}
+
+async function main() {
+ const rows = await readCorpus();
+ if (!rows.length) { console.error('corpus empty — run pull-archive.mjs first'); process.exit(1); }
+ const P = analyze(rows);
+ writeFileSync(join(DATA, 'news-posting-playbook.json'), JSON.stringify(P, null, 1));
+ const md = toMarkdown(P);
+ writeFileSync(join(DATA, 'news-posting-playbook.md'), md);
+ // SHARE across all RE builds: sync the playbook into the re-props skill references so crcp,
+ // homesonspec, usre & rentv news work all reads one evidence-based source (Steve 2026-08-05).
+ const reProps = join(homedir(), '.claude', 'skills', 're-props', 'references');
+ if (existsSync(reProps)) {
+ writeFileSync(join(reProps, 'news-posting-playbook.md'), md);
+ writeFileSync(join(reProps, 'news-posting-playbook.json'), JSON.stringify(P, null, 1));
+ console.log(' → synced to re-props skill references (shared across RE builds)');
+ }
+ console.log(`playbook built from ${rows.length} articles → data/news-posting-playbook.{json,md}`);
+ console.log(` cadence ~${P.cadence.per_active_day}/active-day · busiest ${P.cadence.busiest_weekday} · headline ${P.headline.median_words}w · $ in title ${P.headline.includes_dollar_share}%`);
+}
+main().catch((e) => { console.error('analyze failed:', e.message); process.exit(1); });
diff --git a/scripts/lib/parse-article.mjs b/scripts/lib/parse-article.mjs
new file mode 100644
index 00000000..5ac36870
--- /dev/null
+++ b/scripts/lib/parse-article.mjs
@@ -0,0 +1,127 @@
+// parse-article.mjs — shared rentv.com article parser (TK-10247). Turns a raw article page
+// (/content/homepage/mainnews/news/{id}) into a structured record for the corpus + posting
+// analysis. Reuses the proven deal-parsing regexes from pull-deals.mjs and adds headline/date
+// extraction so the full ARCHIVE (not just the live news-listing window) can be ingested.
+//
+// rentv.com pages are iso-8859-1; the caller must decode (utf8 → latin1 fallback) before parsing.
+import { parseLocation } from './parse-location.mjs';
+
+// A real article page carries the headline in <td class="header">…<br>. The empty boilerplate
+// stub (byte-identical ~36395 bytes) has no such header → headline '' → treated as not-an-article.
+export function parseHeadline(html) {
+ const m = html.match(/<td[^>]*class="header"[^>]*>([\s\S]*?)<br/i);
+ if (!m) return '';
+ const h = clean(m[1]);
+ // guard against the generic site title leaking in as a "headline"
+ if (!h || /^RENTV\.com\b/i.test(h) || h.length < 8) return '';
+ return h;
+}
+
+// The article date (m/d/yy or m/d/yyyy) is stamped at the START of the body, right after the
+// header cell. Take the first such token AFTER the headline; normalize to ISO (yyyy-mm-dd).
+export function parseDate(html) {
+ const hi = html.search(/<td[^>]*class="header"[^>]*>/i);
+ const scope = hi >= 0 ? html.slice(hi, hi + 1200) : html;
+ const m = scope.match(/(\d{1,2})\/(\d{1,2})\/(\d{2,4})/);
+ if (!m) return { date: null, date_raw: null };
+ let [_, mo, d, y] = m;
+ y = y.length === 2 ? (+y >= 70 ? '19' + y : '20' + y) : y;
+ const iso = `${y}-${String(+mo).padStart(2, '0')}-${String(+d).padStart(2, '0')}`;
+ const dt = new Date(iso);
+ return { date: isNaN(dt) ? null : iso, date_raw: `${mo}/${d}/${m[3]}` };
+}
+
+export const clean = (t) => String(t).replace(/&/g, '&').replace(/'/g, "'").replace(/"/g, '"')
+ .replace(/ | /g, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
+
+// full readable body text (tags stripped, scripts/styles removed) — for length + structure analysis
+export function bodyText(html) {
+ return html.replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '')
+ .replace(/<[^>]+>/g, ' ').replace(/ | /g, ' ').replace(/\s+/g, ' ').trim();
+}
+
+// $51.8 mil / $1.2 bil / $985,000 → normalized dollars (from pull-deals.mjs)
+export function parseAmount(text) {
+ const m = text.match(/\$\s?([\d,]+(?:\.\d+)?)\s?(bil|billion|mil|million|k)?/i);
+ if (!m) return { amount: null, amount_label: null };
+ let n = parseFloat(m[1].replace(/,/g, ''));
+ const unit = (m[2] || '').toLowerCase();
+ if (/bil/.test(unit)) n *= 1e9; else if (/mil/.test(unit)) n *= 1e6; else if (unit === 'k') n *= 1e3;
+ const label = n >= 1e9 ? `$${(n / 1e9).toFixed(2)}B` : n >= 1e6 ? `$${(n / 1e6).toFixed(1)}M` : `$${n.toLocaleString()}`;
+ return { amount: Math.round(n), amount_label: label };
+}
+export function txnOf(t) {
+ return /refinanc|\brefi\b|obtains? (a )?(new )?loan|senior loan|recap|bridge loan/.test(t) ? 'Financing'
+ : /\bsold\b|sells|\bsale\b|acquir|buys|purchas|trades|closes on|pays|spends|snaps up|picks up|fetches|changes hands|nets \$|lands \$|works out to/.test(t) ? 'Sale'
+ : /leas|tenant|renew|signs? (a|new)|inks? a/.test(t) ? 'Lease'
+ : /break(s)? ground|develop|deliver|top(s|ped) out|complet|construction|to build|unveils?|plan(s|ned) to|underway|rises|proposes?/.test(t) ? 'Development'
+ : null;
+}
+export function typeOf(t) {
+ return /office|\bhq\b|high-?rise|tower/.test(t) ? 'Office'
+ : /industrial|warehouse|logistics|distribution|\bflex\b/.test(t) ? 'Industrial'
+ : /retail|shopping|mall|grocery|storefront|strip (center|mall)/.test(t) ? 'Retail'
+ : /hotel|hospitality|resort|motel/.test(t) ? 'Hospitality'
+ : /medical|life science|\blab\b|biotech/.test(t) ? 'Medical/Life Science'
+ : /mixed-?use/.test(t) ? 'Mixed-Use'
+ : /self-?storage/.test(t) ? 'Self-Storage'
+ : /multifamily|apartment|\bres\b|residential communit/.test(t) ? 'Multifamily'
+ : /\bland\b|\bsite\b|\bacres?\b/.test(t) ? 'Land'
+ : null;
+}
+export function classify(title, body) {
+ const tl = (title || '').toLowerCase(), bl = (body || '').toLowerCase();
+ const txn = txnOf(tl) || txnOf(bl) || 'Deal';
+ const type = typeOf(tl) || typeOf(bl) || (/\bunit(s)?\b/.test(bl) ? 'Multifamily' : 'Commercial');
+ return { txn, type };
+}
+export function parseSize(text) {
+ const units = text.match(/([\d,]+)[- ]unit/i);
+ const sf = text.match(/([\d,]+)\s?(?:sf|sq\.?\s?ft|square[ -]feet|square[ -]foot)/i);
+ const acres = text.match(/([\d,.]+)[- ]acre/i);
+ const parts = [];
+ if (units) parts.push(`${units[1]} units`);
+ if (sf) parts.push(`${sf[1]} SF`);
+ if (acres) parts.push(`${acres[1]} acres`);
+ return { size_label: parts.join(' · ') || null,
+ units: units ? +units[1].replace(/,/g, '') : null, sqft: sf ? +sf[1].replace(/,/g, '') : null };
+}
+export function summarize(title, body) {
+ let b = body;
+ const ti = b.indexOf(title);
+ if (ti >= 0) b = b.slice(ti + title.length);
+ b = b.replace(/^\s*\d{1,2}\/\d{1,2}\/\d{2,4}\s*/, '').trim();
+ const sentences = b.match(/[^.!?]+[.!?]+/g) || [b];
+ return sentences.slice(0, 2).join(' ').trim().slice(0, 320);
+}
+
+/**
+ * Parse a full article page into a structured corpus record. Returns null if the page is not a
+ * real article (no headline → boilerplate stub / soft-404).
+ */
+export function parseArticle(id, html) {
+ const title = parseHeadline(html);
+ if (!title) return null; // not a real article
+ const body = bodyText(html);
+ // the story text = body after the headline (drops the shared nav/sidebar boilerplate for length)
+ const hi = body.indexOf(title);
+ const story = hi >= 0 ? body.slice(hi + title.length).replace(/^\s*\d{1,2}\/\d{1,2}\/\d{2,4}\s*/, '').trim() : body;
+ const hay = title + ' ' + story;
+ const { amount, amount_label } = parseAmount(/\$/.test(title) ? title : hay);
+ const { txn, type } = classify(title, story);
+ const loc = parseLocation(title, story);
+ const size = parseSize(hay);
+ const { date, date_raw } = parseDate(html);
+ return {
+ id: String(id),
+ url: `https://www.rentv.com/content/homepage/mainnews/news/${id}`,
+ title, date, date_raw,
+ txn_type: txn, property_type: type,
+ city: loc.city, state: loc.state,
+ amount, amount_label,
+ size_label: size.size_label, units: size.units, sqft: size.sqft,
+ title_len: title.length, title_words: title.split(/\s+/).length,
+ story_len: story.length, story_words: story.split(/\s+/).filter(Boolean).length,
+ summary: summarize(title, story) || null,
+ };
+}
diff --git a/scripts/pull-archive.mjs b/scripts/pull-archive.mjs
new file mode 100644
index 00000000..af64ce16
--- /dev/null
+++ b/scripts/pull-archive.mjs
@@ -0,0 +1,97 @@
+#!/usr/bin/env node
+// pull-archive.mjs — backfill the FULL rentv.com article archive into the corpus (TK-10247,
+// Steve 2026-08-05: "load all rentv.com articles… to analyze how to properly post news events").
+//
+// rentv.com articles are /content/homepage/mainnews/news/{id} with sequential integer ids. IDs
+// below ~20000 302→cart.php (gone). From ~20000→newest they're live: real articles interleaved
+// with a byte-identical empty boilerplate stub. parse-article.mjs returns null for the stub, so
+// we keep only real articles. $0 plain-fetch, polite jittered delay, iso-8859-1 decode.
+//
+// RESUMABLE: appends to data/articles-corpus.jsonl (one article per line) and tracks a descending
+// cursor in data/archive-crawl-state.json. Re-run with --resume (default) to continue; already-
+// ingested ids are skipped. Runs for a while (14k+ ids) — launch in the background.
+//
+// Usage: node scripts/pull-archive.mjs [--max 34700] [--min 20000] [--delay 450] [--limit N] [--fresh]
+import { createReadStream, existsSync, appendFileSync, writeFileSync, readFileSync, renameSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { createInterface } from 'node:readline';
+import { parseArticle } from './lib/parse-article.mjs';
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const DATA = join(HERE, '..', 'data');
+const CORPUS = join(DATA, 'articles-corpus.jsonl');
+const STATE = join(DATA, 'archive-crawl-state.json');
+const arg = (k, d) => { const i = process.argv.indexOf(k); return i > -1 ? (process.argv[i + 1] ?? true) : d; };
+const MAX = +arg('--max', 34700);
+const MIN = +arg('--min', 20000);
+const DELAY = +arg('--delay', 450);
+const LIMIT = +arg('--limit', 0) || Infinity;
+const FRESH = process.argv.includes('--fresh');
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const jitter = () => DELAY + Math.floor(Math.random() * 350);
+
+async function fetchArticle(id) {
+ const url = `https://www.rentv.com/content/homepage/mainnews/news/${id}`;
+ const r = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 RENTV-archive' }, redirect: 'manual', signal: AbortSignal.timeout(20000) });
+ if (r.status !== 200) return { status: r.status, html: null }; // 302 = gone
+ const b = Buffer.from(await r.arrayBuffer());
+ let s = b.toString('utf8');
+ if ((s.match(/�/g) || []).length > 5) s = b.toString('latin1');
+ return { status: 200, html: s };
+}
+
+async function loadIngestedIds() {
+ const seen = new Set();
+ if (!existsSync(CORPUS)) return seen;
+ await new Promise((res) => {
+ const rl = createInterface({ input: createReadStream(CORPUS), crlfDelay: Infinity });
+ rl.on('line', (l) => { if (!l.trim()) return; try { const id = JSON.parse(l).id; if (id != null) seen.add(String(id)); } catch { /* skip */ } });
+ rl.on('close', res);
+ });
+ return seen;
+}
+const loadState = () => { try { return JSON.parse(readFileSync(STATE, 'utf8')); } catch { return null; } };
+const saveState = (s) => { const tmp = STATE + '.tmp'; writeFileSync(tmp, JSON.stringify(s, null, 1)); renameSync(tmp, STATE); };
+
+async function main() {
+ if (FRESH && existsSync(CORPUS)) writeFileSync(CORPUS, '');
+ const seen = FRESH ? new Set() : await loadIngestedIds();
+ const prev = FRESH ? null : loadState();
+ let cursor = prev && prev.cursor != null ? Math.min(prev.cursor, MAX) : MAX; // descending
+ const stat = prev && !FRESH ? prev
+ : { max: MAX, min: MIN, cursor, real: 0, empty: 0, gone: 0, errors: 0, started_at: new Date().toISOString() };
+ stat.max = Math.max(stat.max || MAX, MAX);
+
+ console.log(`archive backfill: ids ${cursor}→${MIN} · ${seen.size} already in corpus · delay ~${DELAY}ms · $0 local`);
+ let processed = 0, consecErr = 0;
+ const t0 = Date.now();
+ for (let id = cursor; id >= MIN; id--) {
+ stat.cursor = id;
+ if (seen.has(String(id))) continue;
+ if (processed >= LIMIT) break;
+ processed++;
+ let res;
+ try { res = await fetchArticle(id); consecErr = 0; }
+ catch (e) { stat.errors++; consecErr++; if (consecErr >= 12) { console.error(`\n12 consecutive fetch errors — network down? stopping (resumable at ${id}).`); break; } await sleep(1500); continue; }
+ if (res.status === 302 || res.status === 301) { stat.gone++; }
+ else if (res.status === 200 && res.html) {
+ const a = res.html ? parseArticle(id, res.html) : null;
+ if (a) { appendFileSync(CORPUS, JSON.stringify(a) + '\n'); seen.add(String(id)); stat.real++; }
+ else stat.empty++;
+ } else stat.empty++;
+ if (processed % 25 === 0) {
+ stat.updated_at = new Date().toISOString();
+ saveState(stat);
+ const rate = processed / ((Date.now() - t0) / 1000);
+ process.stdout.write(`\r at id ${id} real:${stat.real} empty:${stat.empty} gone:${stat.gone} (${rate.toFixed(1)}/s, ${processed} this run) `);
+ }
+ await sleep(jitter());
+ }
+ stat.updated_at = new Date().toISOString();
+ stat.done = stat.cursor <= MIN;
+ saveState(stat);
+ console.log(`\n${stat.done ? 'DONE' : 'PAUSED'}: ${stat.real} real articles in corpus · empty:${stat.empty} · gone:${stat.gone} · errors:${stat.errors}`);
+ if (!stat.done) console.log(` resume: node scripts/pull-archive.mjs (continues at id ${stat.cursor})`);
+}
+main().catch((e) => { console.error('archive crawl failed:', e.message); process.exit(1); });
← 8c7c2767 Audience: CRM contacts drill into their pr-intelligence pers
·
back to Rentv
·
Audience (Cody gate): top-level try/catch on /api/audience ( f53f85cb →