[object Object]

← 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

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(/&amp;/g, '&').replace(/&#39;/g, "'").replace(/&quot;/g, '"')
+  .replace(/&nbsp;|&#160;/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(/&nbsp;|&#160;/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 →