← back to Rentv Website
fork of rentv-v1 → independent rentv-website (port 9705); in-shell reader templates included
2ed5be535217d8d4f0909b399d37a4416bee9030 · 2026-07-20 14:20:45 -0700 · Steve
Files touched
A .deploy.confA .gitignoreA data/news.jsonA data/pull.errA data/ticker.jsonA data/videos.jsonA package-lock.jsonA package.jsonA public/article.htmlA public/index.htmlA public/review.htmlA public/section.htmlA scripts/pull-news.mjsA scripts/pull-ticker.mjsA scripts/pull-videos.mjsA server.js
Diff
commit 2ed5be535217d8d4f0909b399d37a4416bee9030
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Jul 20 14:20:45 2026 -0700
fork of rentv-v1 → independent rentv-website (port 9705); in-shell reader templates included
---
.deploy.conf | 6 +
.gitignore | 4 +
data/news.json | 187 +++++++++++
data/pull.err | 0
data/ticker.json | 29 ++
data/videos.json | 144 +++++++++
package-lock.json | 828 ++++++++++++++++++++++++++++++++++++++++++++++++
package.json | 1 +
public/article.html | 124 ++++++++
public/index.html | 158 +++++++++
public/review.html | 204 ++++++++++++
public/section.html | 116 +++++++
scripts/pull-news.mjs | 47 +++
scripts/pull-ticker.mjs | 55 ++++
scripts/pull-videos.mjs | 76 +++++
server.js | 102 ++++++
16 files changed, 2081 insertions(+)
diff --git a/.deploy.conf b/.deploy.conf
new file mode 100644
index 0000000..562f70b
--- /dev/null
+++ b/.deploy.conf
@@ -0,0 +1,6 @@
+PROJECT_NAME=rentv-website
+DEPLOY_HOST=45.61.58.125
+DEPLOY_PATH=/root/public-projects/rentv-website
+HEALTH_URL=http://localhost:9705/api/health
+INSTALL_CMD="npm ci --production"
+BUILD_CMD=""
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1bcdbdc
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+.env*
+*.log
+.DS_Store
diff --git a/data/news.json b/data/news.json
new file mode 100644
index 0000000..f5e1519
--- /dev/null
+++ b/data/news.json
@@ -0,0 +1,187 @@
+{
+ "source": "rentv.com — live",
+ "fetched_at": "2026-07-20T21:06:12.438Z",
+ "count": 30,
+ "items": [
+ {
+ "id": "34218",
+ "title": "Camden Property Trust Buys Gilbert, AZ Res Community in $124 Mil Deal",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34218",
+ "cat": "Multifamily"
+ },
+ {
+ "id": "34225",
+ "title": "CentrePoint Properties Spends $30 Mil on Trio of Colorado Retail Assets",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34225",
+ "cat": "Retail"
+ },
+ {
+ "id": "34227",
+ "title": "MG Properties Buys 188-Unit Portland Res Asset",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34227",
+ "cat": "Multifamily"
+ },
+ {
+ "id": "34216",
+ "title": "Meta Housing Well Underway with 300-Unit Res Community in Woodland Hills",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34216",
+ "cat": "Multifamily"
+ },
+ {
+ "id": "34214",
+ "title": "Intercontinental Real Estate Corporation Closes $69 Mil Retail Buy in Washington",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34214",
+ "cat": "Retail"
+ },
+ {
+ "id": "34208",
+ "title": "Eagle Partners and The Vistria Group Spend $132 Mil on Garden Grove Mixed-Income Res Community",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34208",
+ "cat": "Multifamily"
+ },
+ {
+ "id": "34204",
+ "title": "Oceanside Res Community Trades for $77.5 Mil",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34204",
+ "cat": "Multifamily"
+ },
+ {
+ "id": "34210",
+ "title": "BKM Capital Partners Boosts Pac Northwest Holdings with Purchase of 400k sf Industrial Buy in Washington",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34210",
+ "cat": "Industrial"
+ },
+ {
+ "id": "34203",
+ "title": "The Bascom Group Pays Over $53 Mil for OC Res Community",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34203",
+ "cat": "Multifamily"
+ },
+ {
+ "id": "34199",
+ "title": "Leasing Assignment for 650k sf San Francisco Office Tower Awarded to CBRE",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34199",
+ "cat": "Sales"
+ },
+ {
+ "id": "34202",
+ "title": "SummerHill Apartment Communities Breaks Ground on New 251-Unit Bay Area Res Development",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34202",
+ "cat": "Development"
+ },
+ {
+ "id": "34196",
+ "title": "Rubicon Point Partners Spends $53 Mil on Silicon Valley Medical/Office Property",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34196",
+ "cat": "Sales"
+ },
+ {
+ "id": "34195",
+ "title": "Borstein Enterprises Picks Up 282k sf OC Industrial Asset",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34195",
+ "cat": "Industrial"
+ },
+ {
+ "id": "34188",
+ "title": "Chino Distribution Facility Leased by Auto Parts Distributor in $42 Mil Deal",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34188",
+ "cat": "Leases"
+ },
+ {
+ "id": "34186",
+ "title": "Santa Clara Industrial Property Trades for $25.75 Mil",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34186",
+ "cat": "Industrial"
+ },
+ {
+ "id": "34181",
+ "title": "Redwood West and Panattoni Buy The Pike Destination Center in Long Beach",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34181",
+ "cat": "Retail"
+ },
+ {
+ "id": "34180",
+ "title": "Kennedy Wilson and Jamison Form Partnership to Develop 4,000 Units of Affordable Housing in Los Angeles",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34180",
+ "cat": "Development"
+ },
+ {
+ "id": "34086",
+ "title": "Oak Park Investments and Arise Investments Acquire Westlake Village Warehouse/Office Building in $19 Mil Transaction",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34086",
+ "cat": "Industrial"
+ },
+ {
+ "id": "34177",
+ "title": "Holland Partner Group Closes $105 Mil Bay Area Res Buy",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34177",
+ "cat": "Multifamily"
+ },
+ {
+ "id": "34172",
+ "title": "Haven Housing buys 264-Unit Res Community in Austin Metro Area",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34172",
+ "cat": "Multifamily"
+ },
+ {
+ "id": "34174",
+ "title": "MG Properties Acquires Two Multifamily Properties in Gresham, OR in Deals Totaling $60.8 Mil",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34174",
+ "cat": "Multifamily"
+ },
+ {
+ "id": "34171",
+ "title": "Leading AI Firm Leases 1.1 msf of Space at Luke Field in Glendale, AZ",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34171",
+ "cat": "Leases"
+ },
+ {
+ "id": "34169",
+ "title": "Seattle Area Res Community Refinanced with $82.5 Mil Loan from Mesa West Capital",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34169",
+ "cat": "Financing"
+ },
+ {
+ "id": "34160",
+ "title": "Apollo Lands in Gardena",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34160",
+ "cat": "Sales"
+ },
+ {
+ "id": "34165",
+ "title": "Phoenix Multifamily Community Lands New Owner",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34165",
+ "cat": "Multifamily"
+ },
+ {
+ "id": "34158",
+ "title": "Fairfield Residential Wraps Up Work on Houston High-Rise Res Development",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34158",
+ "cat": "Development"
+ },
+ {
+ "id": "34155",
+ "title": "Newly Constructed 1.18 msf AZ Industrial Campus Lands $122 Mil in Bridge Financing",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34155",
+ "cat": "Financing"
+ },
+ {
+ "id": "34153",
+ "title": "BKM Capital Partners Closes $95 Mil SoCal Biz Campus Buy",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34153",
+ "cat": "Sales"
+ },
+ {
+ "id": "34150",
+ "title": "Arizona Power Center Lands $38 Mil Refi",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34150",
+ "cat": "Financing"
+ },
+ {
+ "id": "34148",
+ "title": "Southwest Traders Spends $40 Mil on Menifee Industrial Facility",
+ "url": "https://www.rentv.com/content/homepage/mainnews/news/34148",
+ "cat": "Industrial"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/data/pull.err b/data/pull.err
new file mode 100644
index 0000000..e69de29
diff --git a/data/ticker.json b/data/ticker.json
new file mode 100644
index 0000000..275db1f
--- /dev/null
+++ b/data/ticker.json
@@ -0,0 +1,29 @@
+{
+ "fetched_at": "2026-07-20T21:06:12.440Z",
+ "la_office_vac": {
+ "label": "LA OFFICE VAC",
+ "value": "23.1%",
+ "as_of": "Q2 2026",
+ "source": "CBRE/JLL market reports"
+ },
+ "ie_industrial_cap": {
+ "label": "IE INDUSTRIAL CAP",
+ "value": "5.2%",
+ "as_of": "Q2 2026",
+ "source": "Colliers IE industrial"
+ },
+ "cre_index": {
+ "label": "CRE INDEX",
+ "value": "4,179",
+ "change_pct": -0.58,
+ "dir": "down",
+ "source": "Dow Jones U.S. Real Estate"
+ },
+ "ten_yr": {
+ "label": "10-YR",
+ "value": "4.60%",
+ "change_bps": 6,
+ "dir": "up",
+ "source": "CBOE 10-Yr (^TNX)"
+ }
+}
\ No newline at end of file
diff --git a/data/videos.json b/data/videos.json
new file mode 100644
index 0000000..61c42ca
--- /dev/null
+++ b/data/videos.json
@@ -0,0 +1,144 @@
+{
+ "source": "rentvreview.com (The REview) — live",
+ "fetched_at": "2026-07-20T18:46:15.717Z",
+ "count": 13,
+ "cats": [
+ "JLL TV",
+ "Most Recent",
+ "Industry Events",
+ "Featured",
+ "Most Viewed"
+ ],
+ "items": [
+ {
+ "id": "2898",
+ "yt": "79WFDGZYos4",
+ "title": "JLL’s Supply Chain & Logistics Solutions Overview",
+ "desc": "We are at the forefront of supply chain trends, focused on delivering top-down supply chain strategy and ground-up execution to help you save time, reduce costs, improve services and minimize risk.
When it comes to recognizing the ",
+ "cat": "JLL TV",
+ "view_url": "https://www.rentvreview.com/Videos/View/2898",
+ "thumb": "https://img.youtube.com/vi/79WFDGZYos4/hqdefault.jpg",
+ "embed": "https://www.youtube.com/embed/79WFDGZYos4"
+ },
+ {
+ "id": "3302",
+ "yt": "xKYEQkUG5zM",
+ "title": "Commercial Real Estate Tech & AI 2026 with Helen Calvin",
+ "desc": "Adoption and investment in tech and AI have accelerated in the commercial real estate industry. Finally, right? How should you pick the right tech, the right system, the right provider? When picking a broker to sell your property should you",
+ "cat": "Most Recent",
+ "view_url": "https://www.rentvreview.com/Videos/View/3302",
+ "thumb": "https://img.youtube.com/vi/xKYEQkUG5zM/hqdefault.jpg",
+ "embed": "https://www.youtube.com/embed/xKYEQkUG5zM"
+ },
+ {
+ "id": "3273",
+ "yt": "eC-oSUBMjIc",
+ "title": "ICSC Southern California Kickoff Panel",
+ "desc": "Commercial real estate is a constantly evolving industry, and staying ahead of the curve is crucial for success. Recently, a panel discussion at the Hyatt Regency Huntington Beach Resort & Spa brought together leading developers to share th",
+ "cat": "Industry Events",
+ "view_url": "https://www.rentvreview.com/Videos/View/3273",
+ "thumb": "https://img.youtube.com/vi/eC-oSUBMjIc/hqdefault.jpg",
+ "embed": "https://www.youtube.com/embed/eC-oSUBMjIc"
+ },
+ {
+ "id": "3301",
+ "yt": "O6Mh2VmXGzg",
+ "title": "Office Update with Yardi",
+ "desc": "In this episode of America’s Commercial Real Estate Show, host Michael Bull is joined by Doug Ressler and Peter Kolaczynski from Yardi Matrix to break down the evolving office market in 2026.

They explore key trends in of",
+ "cat": "Most Recent",
+ "view_url": "https://www.rentvreview.com/Videos/View/3301",
+ "thumb": "https://img.youtube.com/vi/O6Mh2VmXGzg/hqdefault.jpg",
+ "embed": "https://www.youtube.com/embed/O6Mh2VmXGzg"
+ },
+ {
+ "id": "3300",
+ "yt": "Wm_dhTl8gn8",
+ "title": "Single Tenant Cap Rates and Market Intel with Nancy Miller, CCIM",
+ "desc": "Nancy Miller with Bull Realty joins Michael to discuss the latest cap rates, loan rates, and expanding tenants in the single tenant net lease investment sector. Discussions include market intel, cap rates, mistakes to avoid, and investor st",
+ "cat": "Featured",
+ "view_url": "https://www.rentvreview.com/Videos/View/3300",
+ "thumb": "https://img.youtube.com/vi/Wm_dhTl8gn8/hqdefault.jpg",
+ "embed": "https://www.youtube.com/embed/Wm_dhTl8gn8"
+ },
+ {
+ "id": "3299",
+ "yt": "6EJEWbXFQ-c",
+ "title": "People Live in Homes Not Corporations Housing Bill Update with Mitch Roschelle",
+ "desc": "The 21st Century ROAD to Housing Act (HR6644) passed by the Senate on March 12th, aims to increase U.S housing supply, reduce costs and modernize financing representing a major federal investment in housing. Part of the bill, with some carv",
+ "cat": "Featured",
+ "view_url": "https://www.rentvreview.com/Videos/View/3299",
+ "thumb": "https://img.youtube.com/vi/6EJEWbXFQ-c/hqdefault.jpg",
+ "embed": "https://www.youtube.com/embed/6EJEWbXFQ-c"
+ },
+ {
+ "id": "3298",
+ "yt": "_1W6U47VN6g",
+ "title": "U.S. Hotel Performance Forecast with Ryan Meliker",
+ "desc": "As world events unfold and America first implications impact the economy and travel, how might U.S. hotels perform moving forward? What cities hold promise for investors and developers? Join show host and commercial broker Michael Bull, CCI",
+ "cat": "Featured",
+ "view_url": "https://www.rentvreview.com/Videos/View/3298",
+ "thumb": "https://img.youtube.com/vi/_1W6U47VN6g/hqdefault.jpg",
+ "embed": "https://www.youtube.com/embed/_1W6U47VN6g"
+ },
+ {
+ "id": "1034",
+ "yt": "ql3qVQNRQJ8",
+ "title": "LoopNet vs Crexi: Which is Better for Net Lease Property Sales?",
+ "desc": "In this video, we take a deep dive into the comparisons between the leading commercial real estate marketplace platforms, LoopNet and Crexi.

SUBSCRIBE to our newsletter to have the top stories hand-delivered to your inbox",
+ "cat": "Most Viewed",
+ "view_url": "https://www.rentvreview.com/Videos/View/1034",
+ "thumb": "https://img.youtube.com/vi/ql3qVQNRQJ8/hqdefault.jpg",
+ "embed": "https://www.youtube.com/embed/ql3qVQNRQJ8"
+ },
+ {
+ "id": "1650",
+ "yt": "-_QFDzl48tU",
+ "title": "1031 Exchange Explained: 45 Day Rule Checklist",
+ "desc": "Looking to invest in real estate and want the 1031 exchange explained? Make sure you understand the 1031 exchange rule with this free 45 day rule checklist! 

The 1031 exchange rule is a complicated but important process f",
+ "cat": "Featured",
+ "view_url": "https://www.rentvreview.com/Videos/View/1650",
+ "thumb": "https://img.youtube.com/vi/-_QFDzl48tU/hqdefault.jpg",
+ "embed": "https://www.youtube.com/embed/-_QFDzl48tU"
+ },
+ {
+ "id": "3261",
+ "yt": "v8CaeZHpTMM",
+ "title": "VeganBurg Now Open at High Desert Gateway",
+ "desc": "VeganBurg Now Open at High Desert Gateway
",
+ "cat": "Industry Events",
+ "view_url": "https://www.rentvreview.com/Videos/View/3261",
+ "thumb": "https://img.youtube.com/vi/v8CaeZHpTMM/hqdefault.jpg",
+ "embed": "https://www.youtube.com/embed/v8CaeZHpTMM"
+ },
+ {
+ "id": "3237",
+ "yt": "ujVQ-m0gEpw",
+ "title": "Stater Bros. Markets Grand Opening Celebration at The Collection",
+ "desc": "Stater Bros. Markets Grand Opening Celebration at The Collection
",
+ "cat": "Featured",
+ "view_url": "https://www.rentvreview.com/Videos/View/3237",
+ "thumb": "https://img.youtube.com/vi/ujVQ-m0gEpw/hqdefault.jpg",
+ "embed": "https://www.youtube.com/embed/ujVQ-m0gEpw"
+ },
+ {
+ "id": "3230",
+ "yt": "8UtGSxHYQXY",
+ "title": "Project Spotlight: The Commons at Meridian",
+ "desc": "Project Spotlight: The Commons at Meridian
",
+ "cat": "Featured",
+ "view_url": "https://www.rentvreview.com/Videos/View/3230",
+ "thumb": "https://img.youtube.com/vi/8UtGSxHYQXY/hqdefault.jpg",
+ "embed": "https://www.youtube.com/embed/8UtGSxHYQXY"
+ },
+ {
+ "id": "3229",
+ "yt": "HzCoIw71Cv8",
+ "title": "ICSC & The Western Conference 2024 with Vicky Hammond",
+ "desc": "ICSC & The Western Conference with Vicky Hammond 2024
",
+ "cat": "Featured",
+ "view_url": "https://www.rentvreview.com/Videos/View/3229",
+ "thumb": "https://img.youtube.com/vi/HzCoIw71Cv8/hqdefault.jpg",
+ "embed": "https://www.youtube.com/embed/HzCoIw71Cv8"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..488d5a6
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,828 @@
+{
+ "name": "rentv-v1",
+ "version": "0.2.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "rentv-v1",
+ "version": "0.2.0",
+ "dependencies": {
+ "express": "^4.21.2"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.6",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
+ "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.15.1",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.2",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
+ "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.5",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.12",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.15.1",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.15.3",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+ "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..e9c88fe
--- /dev/null
+++ b/package.json
@@ -0,0 +1 @@
+{"name":"rentv-website","version":"0.2.0","private":true,"main":"server.js","scripts":{"start":"node server.js","pull":"node scripts/pull-news.mjs"},"dependencies":{"express":"^4.21.2"}}
\ No newline at end of file
diff --git a/public/article.html b/public/article.html
new file mode 100644
index 0000000..2a1d3dc
--- /dev/null
+++ b/public/article.html
@@ -0,0 +1,124 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>RENTV.com — Story</title>
+<meta name="description" content="Commercial real estate news story — RENTV.com.">
+<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
+<style>
+ :root{--red:#c8102e;--ink:#14171a;--sub:#5b636b;--line:#e4e7ea;--bg:#fff;--wash:#f5f6f8;--sans:'Inter',system-ui,sans-serif;--serif:'Playfair Display',Georgia,serif}
+ *{box-sizing:border-box;margin:0;padding:0}
+ body{font-family:var(--sans);color:var(--ink);background:var(--bg);line-height:1.6}
+ a{color:inherit;text-decoration:none}
+ .wrap{max-width:1200px;margin:0 auto;padding:0 20px}
+ .util{background:var(--ink);color:#cfd4d9;font-size:12px}
+ .util .wrap{display:flex;justify-content:space-between;align-items:center;height:34px}
+ .util .acts a{margin-left:14px;color:#cfd4d9}.util .acts a.sub{color:#fff;background:var(--red);padding:4px 12px;border-radius:3px;font-weight:700}
+ .mast{border-bottom:2px solid var(--ink);padding:18px 0 12px}
+ .mast .wrap{display:flex;align-items:center;justify-content:space-between}
+ .logo{font-family:var(--serif);font-weight:700;font-size:34px;letter-spacing:-1px}.logo span{color:var(--red)}
+ .logo small{display:block;font-size:11px;font-weight:600;letter-spacing:3px;color:var(--sub);text-transform:uppercase;margin-top:2px;font-family:var(--sans)}
+ .datebox{font-size:12px;color:var(--sub);text-align:right}
+ nav{border-bottom:1px solid var(--line);position:sticky;top:0;background:#fff;z-index:10}
+ nav .wrap{display:flex;gap:2px;align-items:center;height:46px;overflow-x:auto}
+ nav a{font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:14px 13px;border-bottom:3px solid transparent;white-space:nowrap}
+ nav a:hover,nav a.on{color:var(--red);border-color:var(--red)}
+ .art{max-width:760px;margin:0 auto;padding:26px 0 10px}
+ .crumb{font-size:12px;color:var(--sub);margin-bottom:16px}.crumb a{color:var(--red);font-weight:700}
+ .chip{display:inline-block;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--red);margin-bottom:8px}
+ h1{font-family:var(--serif);font-size:40px;line-height:1.12;letter-spacing:-.5px;margin-bottom:10px}
+ .byl{font-size:13px;color:var(--sub);border-bottom:1px solid var(--line);padding-bottom:16px;margin-bottom:20px}
+ .hero{aspect-ratio:16/8;border-radius:8px;background:linear-gradient(135deg,#1f2937,#c8102e);margin:0 0 22px;overflow:hidden}
+ .hero img{width:100%;height:100%;object-fit:cover;display:block}
+ .body p{font-size:18px;margin-bottom:18px}
+ .body p:first-of-type::first-letter{font-family:var(--serif);font-size:56px;font-weight:700;float:left;line-height:.8;padding:6px 10px 0 0;color:var(--red)}
+ .src{margin:26px 0;padding:18px;background:var(--wash);border-radius:8px;font-size:14px}
+ .src a{color:var(--red);font-weight:800}
+ .back{display:inline-block;margin:10px 0 0;font-size:13px;font-weight:700;color:var(--red)}
+ .skl{background:linear-gradient(90deg,#eee,#f6f6f6,#eee);background-size:200% 100%;animation:sh 1.2s infinite;border-radius:4px;color:transparent}
+ @keyframes sh{to{background-position:-200% 0}}
+ .err{color:var(--sub);padding:20px 0}.err a{color:var(--red);font-weight:700}
+ footer{background:var(--ink);color:#aeb6bd;margin-top:36px;padding:40px 0 30px;font-size:13px}
+ footer .cols{display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:28px}
+ footer .logo{color:#fff;font-size:26px;margin-bottom:8px}footer h5{color:#fff;font-size:12px;text-transform:uppercase;letter-spacing:.08em;margin-bottom:10px}footer a{display:block;padding:4px 0;color:#aeb6bd}
+ footer .fine{border-top:1px solid #2a2f34;margin-top:26px;padding-top:16px;font-size:12px;color:#7c848b}
+ @media(max-width:860px){h1{font-size:30px}.body p{font-size:17px}}
+</style>
+</head>
+<body>
+ <div class="util"><div class="wrap">
+ <div><a href="/">RENTV.com</a> · Commercial Real Estate News</div>
+ <div class="acts"><a href="/">Home</a><a href="/review">The REview</a><a class="sub" href="/">Subscribe</a></div>
+ </div></div>
+
+ <header class="mast"><div class="wrap">
+ <a href="/" class="logo">REN<span>TV</span>.com<small>Commercial Real Estate News</small></a>
+ <div class="datebox"><span>Western U.S. Edition</span><br><span id="today"></span></div>
+ </div></header>
+
+ <nav><div class="wrap">
+ <a href="/">Home</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a><a href="/review">The REview</a>
+ </div></nav>
+
+ <div class="wrap"><article class="art" id="art">
+ <div class="crumb"><a href="/">Home</a> › <span>Story</span></div>
+ <span class="chip">RENTV · Western U.S.</span>
+ <h1 class="skl">Loading the story…<br> </h1>
+ <div class="byl">Fetching live from rentv.com…</div>
+ <div class="hero skl"></div>
+ <div class="body"><p class="skl"> <br> <br> </p></div>
+ </article></div>
+
+ <footer><div class="wrap"><div class="cols">
+ <div><div class="logo">REN<span style="color:var(--red)">TV</span>.com</div><p style="max-width:34ch">Commercial real estate news, deals & conferences across the Western United States since 1998.</p></div>
+ <div><h5>Sections</h5><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a></div>
+ <div><h5>More</h5><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a><a href="/review">The REview</a></div>
+ <div><h5>Company</h5><a href="/">Home</a><a href="/">Advertise</a><a href="/">Contact</a></div>
+ </div><div class="fine">© 2026 RENTV.com · Story served live from rentv.com</div></div></footer>
+
+<script>
+document.getElementById('today').textContent = new Date().toLocaleDateString(undefined,{weekday:'long',year:'numeric',month:'long',day:'numeric'});
+const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
+const id=(location.pathname.split('/').filter(Boolean).pop()||'').replace(/[^0-9]/g,'');
+const RENTV='https://www.rentv.com/content/homepage/mainnews/news/'+id;
+// Generic titles the reader proxy returns when rentv.com doesn't server-render the story.
+const GENERIC=/^RENTV\.com\b|commercial real estate news and property/i;
+async function load(){
+ const art=document.getElementById('art');
+ if(!id){art.innerHTML='<p class="err">Story not found. <a href="/">Back to the homepage →</a></p>';return;}
+ // Fetch the live feed (guaranteed correct headline/category) and the reader proxy (best-effort body) together.
+ const [feed, art0] = await Promise.allSettled([
+ fetch('/api/news').then(r=>r.json()),
+ fetch('/api/article/'+id).then(r=>r.ok?r.json():null)
+ ]);
+ const item = (feed.status==='fulfilled' ? (feed.value.items||[]).find(x=>String(x.id)===id) : null) || {};
+ const d = (art0.status==='fulfilled' && art0.value) ? art0.value : {};
+ // Prefer the feed's real headline; fall back to the proxy title only if it isn't the generic site title.
+ const title = item.title || (d.title && !GENERIC.test(d.title) ? d.title : 'RENTV Story');
+ const cat = item.cat || 'RENTV';
+ const src = item.url || d.url || RENTV;
+ const hasBody = Array.isArray(d.body) && d.body.length>0;
+ if(!item.title && !hasBody && art0.status!=='fulfilled'){
+ art.innerHTML='<p class="err">This story couldn’t be loaded right now. <a href="'+esc(RENTV)+'" target="_blank" rel="noopener">Read it on RENTV.com ↗</a><br><br><a href="/">← Back to the homepage</a></p>';
+ return;
+ }
+ document.title=title+' — RENTV.com';
+ const hero=(d.image && !GENERIC.test(d.image))?'<div class="hero"><img src="'+esc(d.image)+'" alt="" loading="lazy" onerror="this.parentNode.style.background=\'linear-gradient(135deg,#1f2937,#c8102e)\';this.remove()"></div>':'<div class="hero"></div>';
+ const body=hasBody
+ ? d.body.map(p=>'<p>'+esc(p)+'</p>').join('')
+ : '<p>This story is published in full on RENTV.com. Read the complete report — including deal terms, parties and broker details — at the source link below.</p>';
+ art.innerHTML=
+ '<div class="crumb"><a href="/">Home</a> › <a href="/'+esc(cat.toLowerCase())+'">'+esc(cat)+'</a> › <span>Story</span></div>'+
+ '<span class="chip">'+esc(cat)+' · Western U.S.</span>'+
+ '<h1>'+esc(title)+'</h1>'+
+ '<div class="byl">By RENTV Staff · live from rentv.com'+(d.fetched_at?(' · fetched '+new Date(d.fetched_at).toLocaleTimeString()):'')+'</div>'+
+ hero+
+ '<div class="body">'+body+'</div>'+
+ '<div class="src">📰 Republished live from RENTV.com. <a href="'+esc(src)+'" target="_blank" rel="noopener">View the full story on RENTV.com ↗</a></div>'+
+ '<a class="back" href="/">← Back to all CRE news</a>';
+}
+load();
+</script>
+</body>
+</html>
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..4f7dde1
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,158 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>RENTV.com — Commercial Real Estate News, Deals & Conferences</title>
+<meta name="description" content="Live commercial real estate news, deals, leases and financing across the Western U.S. — RENTV.com since 1998.">
+<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
+<style>
+ :root{--red:#c8102e;--ink:#14171a;--sub:#5b636b;--line:#e4e7ea;--bg:#fff;--wash:#f5f6f8;--sans:'Inter',system-ui,sans-serif;--serif:'Playfair Display',Georgia,serif}
+ *{box-sizing:border-box;margin:0;padding:0}
+ body{font-family:var(--sans);color:var(--ink);background:var(--bg);line-height:1.45}
+ a{color:inherit;text-decoration:none}
+ .wrap{max-width:1200px;margin:0 auto;padding:0 20px}
+ .util{background:var(--ink);color:#cfd4d9;font-size:12px}
+ .util .wrap{display:flex;justify-content:space-between;align-items:center;height:34px}
+ .ticker{display:flex;gap:20px;overflow:hidden;white-space:nowrap}.ticker b{color:#fff}.up{color:#4ade80}.dn{color:#f87171}
+ .util .acts a{margin-left:14px;color:#cfd4d9}.util .acts a.sub{color:#fff;background:var(--red);padding:4px 12px;border-radius:3px;font-weight:700}
+ .mast{border-bottom:2px solid var(--ink);padding:18px 0 12px}
+ .mast .wrap{display:flex;align-items:center;justify-content:space-between}
+ .logo{font-family:var(--serif);font-weight:700;font-size:34px;letter-spacing:-1px}.logo span{color:var(--red)}
+ .logo small{display:block;font-size:11px;font-weight:600;letter-spacing:3px;color:var(--sub);text-transform:uppercase;margin-top:2px;font-family:var(--sans)}
+ .datebox{font-size:12px;color:var(--sub);text-align:right}
+ nav{border-bottom:1px solid var(--line);position:sticky;top:0;background:#fff;z-index:10}
+ nav .wrap{display:flex;gap:2px;align-items:center;height:46px;overflow-x:auto}
+ nav a{font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:14px 13px;border-bottom:3px solid transparent;white-space:nowrap;cursor:pointer}
+ nav a:hover,nav a.on{color:var(--red);border-color:var(--red)}
+ .breaking{background:var(--red);color:#fff;font-size:13px}
+ .breaking .wrap{display:flex;align-items:center;gap:14px;height:40px;overflow:hidden}
+ .breaking .tag{background:#fff;color:var(--red);font-weight:800;text-transform:uppercase;font-size:11px;letter-spacing:.08em;padding:3px 9px;border-radius:2px;flex:none}
+ .breaking .items{display:flex;gap:26px;white-space:nowrap;font-weight:600;animation:marq 40s linear infinite}
+ @keyframes marq{from{transform:translateX(0)}to{transform:translateX(-50%)}}
+ .grid{display:grid;grid-template-columns:1fr 320px;gap:34px;padding:28px 0}
+ .chip{display:inline-block;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--red);margin-bottom:6px}
+ .lead{border-bottom:1px solid var(--line);padding-bottom:22px;margin-bottom:22px}
+ .lead .img{aspect-ratio:16/8;border-radius:6px;background:linear-gradient(135deg,#1f2937,#c8102e);position:relative;overflow:hidden}
+ .lead .img::after{content:'RENTV';position:absolute;right:12px;bottom:10px;color:rgba(255,255,255,.5);font-size:11px;letter-spacing:.2em}
+ .lead h1{font-family:var(--serif);font-size:36px;line-height:1.12;margin:14px 0 8px;letter-spacing:-.5px}
+ .lead .byl{font-size:12px;color:var(--sub);margin-top:8px}
+ .sechead{display:flex;align-items:center;gap:12px;border-top:3px solid var(--ink);padding-top:10px;margin:30px 0 16px}
+ .sechead h2{font-size:16px;text-transform:uppercase;letter-spacing:.06em}.sechead a{margin-left:auto;font-size:12px;color:var(--red);font-weight:700}
+ .cards{display:grid;grid-template-columns:1fr 1fr 1fr;gap:22px}
+ .card{display:block}
+ .card .img{aspect-ratio:16/10;border-radius:5px;background:linear-gradient(135deg,#475569,#94a3b8)}
+ .card:nth-child(3n+2) .img{background:linear-gradient(135deg,#7c2d12,#c8102e)}
+ .card:nth-child(3n) .img{background:linear-gradient(135deg,#0f766e,#14b8a6)}
+ .card h4{font-family:var(--serif);font-size:17px;line-height:1.22;margin:9px 0 4px}
+ .card .byl{font-size:12px;color:var(--sub);margin-top:6px}
+ .rail>*{margin-bottom:26px}.box{border:1px solid var(--line);border-radius:6px;padding:18px}
+ .ns{background:var(--ink);color:#fff;border:0}.ns h3{font-size:18px;margin-bottom:6px}.ns p{font-size:13px;color:#b9c0c7;margin-bottom:12px}
+ .ns input{width:100%;padding:11px;border:0;border-radius:4px;font-size:14px;margin-bottom:8px}
+ .ns button{width:100%;padding:11px;background:var(--red);color:#fff;border:0;border-radius:4px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;font-size:12px;cursor:pointer}
+ .ns .ok{font-size:12px;color:#4ade80;margin-top:6px;display:none}
+ .mostread{counter-reset:mr;list-style:none}
+ .mostread li{counter-increment:mr;display:flex;gap:12px;padding:12px 0;border-bottom:1px solid var(--line)}
+ .mostread li::before{content:counter(mr);color:var(--red);font-family:var(--serif);font-size:26px;font-weight:700;line-height:1}
+ .mostread a{font-size:14px;font-weight:600;line-height:1.25}
+ .conf{background:linear-gradient(135deg,#111827,#c8102e);color:#fff;border:0;border-radius:6px;padding:22px}
+ .conf .k{font-size:11px;letter-spacing:.2em;text-transform:uppercase;opacity:.85}.conf h3{font-family:var(--serif);font-size:22px;margin:6px 0 8px}
+ .conf a{display:inline-block;background:#fff;color:var(--red);font-weight:800;font-size:12px;text-transform:uppercase;letter-spacing:.06em;padding:9px 16px;border-radius:4px}
+ .live{display:inline-flex;align-items:center;gap:6px;font-size:11px;color:var(--sub);letter-spacing:.06em;text-transform:uppercase}
+ .live .dot{width:7px;height:7px;border-radius:50%;background:#22c55e;box-shadow:0 0 0 0 rgba(34,197,94,.6);animation:pulse 2s infinite}
+ @keyframes pulse{0%{box-shadow:0 0 0 0 rgba(34,197,94,.6)}70%{box-shadow:0 0 0 7px rgba(34,197,94,0)}100%{box-shadow:0 0 0 0 rgba(34,197,94,0)}}
+ footer{background:var(--ink);color:#aeb6bd;margin-top:36px;padding:40px 0 30px;font-size:13px}
+ footer .cols{display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:28px}
+ footer .logo{color:#fff;font-size:26px;margin-bottom:8px}footer h5{color:#fff;font-size:12px;text-transform:uppercase;letter-spacing:.08em;margin-bottom:10px}footer a{display:block;padding:4px 0;color:#aeb6bd}
+ footer .fine{border-top:1px solid #2a2f34;margin-top:26px;padding-top:16px;font-size:12px;color:#7c848b}
+ .skl{background:linear-gradient(90deg,#eee,#f6f6f6,#eee);background-size:200% 100%;animation:sh 1.2s infinite;border-radius:4px;color:transparent}
+ @keyframes sh{to{background-position:-200% 0}}
+ @media(max-width:860px){.grid{grid-template-columns:1fr}.cards{grid-template-columns:1fr 1fr}}
+</style>
+</head>
+<body>
+ <div class="util"><div class="wrap">
+ <div class="ticker" id="ticker"><span><b>CRE INDEX</b> …</span><span><b>10-YR</b> …</span><span><b>LA OFFICE VAC</b> …</span><span><b>IE INDUSTRIAL CAP</b> …</span></div>
+ <div class="acts"><a href="#">Newsletters</a><a href="#">Advertise</a><a href="#">Log In</a><a class="sub" href="#news">Subscribe</a></div>
+ </div></div>
+
+ <header class="mast"><div class="wrap">
+ <div class="logo">REN<span>TV</span>.com<small>Commercial Real Estate News</small></div>
+ <div class="datebox"><span id="edition">Western U.S. Edition</span><br><span id="today"></span></div>
+ </div></header>
+
+ <nav><div class="wrap" id="nav">
+ <a data-cat="all" class="on">Home</a><a data-cat="Sales">Sales</a><a data-cat="Leases">Leases</a><a data-cat="Financing">Financing</a><a data-cat="Development">Development</a><a data-cat="Multifamily">Multifamily</a><a data-cat="Industrial">Industrial</a><a data-cat="Retail">Retail</a><a href="/review">The REview</a><a href="#conf">Conferences</a>
+ </div></nav>
+
+ <div class="breaking"><div class="wrap">
+ <span class="tag">Breaking</span><div class="items" id="breaking"><span>Loading live deals…</span></div>
+ </div></div>
+
+ <div class="wrap"><div class="grid">
+ <main>
+ <article class="lead" id="lead"><div class="img"></div><span class="chip"> </span><h1 class="skl"> <br> </h1><div class="byl">Loading the latest from RENTV…</div></article>
+ <div class="sechead"><h2>Latest CRE News</h2><span class="live"><span class="dot"></span><span id="freshness">live</span></span></div>
+ <div class="cards" id="cards"></div>
+ </main>
+ <aside class="rail">
+ <div class="ns box"><h3>The RENTV Daily</h3><p>Western-U.S. commercial real estate deals in your inbox every morning — free.</p><input id="email" placeholder="you@company.com"><button id="subbtn">Subscribe free</button><div class="ok" id="subok">✓ You're on the list.</div></div>
+ <div><div class="sechead" style="margin-top:0"><h2>Most Read</h2></div><ol class="mostread" id="mostread"></ol></div>
+ <div class="conf" id="conf"><div class="k">RENTV Conferences</div><h3>State of the Market Summit</h3><p style="font-size:13px;opacity:.9">600+ brokers, investors & developers · Los Angeles.</p><a href="#">Register →</a></div>
+ <div class="box"><div class="sechead" style="margin-top:0"><h2>Market Snapshot</h2></div>
+ <div style="display:flex;justify-content:space-between;padding:7px 0;border-bottom:1px solid var(--line);font-size:13px"><span>LA Office Vacancy</span><b>23.1%</b></div>
+ <div style="display:flex;justify-content:space-between;padding:7px 0;border-bottom:1px solid var(--line);font-size:13px"><span>IE Industrial Cap</span><b>5.2%</b></div>
+ <div style="display:flex;justify-content:space-between;padding:7px 0;font-size:13px"><span>SF Multifamily Rent YoY</span><b class="up">+3.4%</b></div>
+ </div>
+ </aside>
+ </div></div>
+
+ <footer><div class="wrap"><div class="cols">
+ <div><div class="logo">REN<span style="color:var(--red)">TV</span>.com</div><p style="max-width:34ch">Commercial real estate news, deals & conferences across the Western United States since 1998.</p></div>
+ <div><h5>Sections</h5><a href="#">Deals</a><a href="#">Sales</a><a href="#">Leases</a><a href="#">Financing</a></div>
+ <div><h5>Company</h5><a href="#">About</a><a href="#">Advertise</a><a href="#">Conferences</a><a href="#">Contact</a></div>
+ <div><h5>Follow</h5><a href="#">LinkedIn</a><a href="#">X / Twitter</a><a href="#">Newsletters</a></div>
+ </div><div class="fine">© 2026 RENTV.com · Headlines refreshed live from rentv.com · <span id="src"></span></div></div></footer>
+
+<script>
+document.getElementById('today').textContent = new Date().toLocaleDateString(undefined,{weekday:'long',year:'numeric',month:'long',day:'numeric'});
+let ALL=[], cat='all';
+const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
+function render(){
+ const items = cat==='all'?ALL:ALL.filter(x=>x.cat===cat);
+ const lead=items[0];
+ if(lead){document.getElementById('lead').innerHTML='<a href="/news/'+encodeURIComponent(lead.id)+'"><div class="img"></div><span class="chip">'+esc(lead.cat)+' · Western U.S.</span><h1>'+esc(lead.title)+'</h1><div class="byl">By RENTV Staff · live from rentv.com</div></a>';}
+ const cards=items.slice(1,13).map(x=>'<a class="card" href="/news/'+encodeURIComponent(x.id)+'"><div class="img"></div><span class="chip">'+esc(x.cat)+'</span><h4>'+esc(x.title)+'</h4><div class="byl">RENTV Staff</div></a>').join('');
+ document.getElementById('cards').innerHTML=cards||'<p style="color:var(--sub)">No items in this section right now.</p>';
+ document.getElementById('breaking').innerHTML=ALL.slice(0,6).map(x=>'<span>'+esc(x.title)+'</span>').join(' · ').repeat(2);
+ document.getElementById('mostread').innerHTML=ALL.slice(0,5).map(x=>'<li><a href="/news/'+encodeURIComponent(x.id)+'">'+esc(x.title)+'</a></li>').join('');
+}
+async function load(){
+ try{
+ const d=await (await fetch('/api/news')).json();
+ ALL=(d.items||[]).map(x=>({...x,title:x.title}));
+ if(ALL.length){render();
+ const mins=Math.max(0,Math.round((Date.now()-new Date(d.fetched_at))/60000));
+ document.getElementById('freshness').textContent=mins<2?'updated just now':('updated '+mins+' min ago');
+ document.getElementById('src').textContent='last pull '+new Date(d.fetched_at).toLocaleTimeString();
+ }
+ }catch(e){document.getElementById('lead').querySelector('.byl').textContent='Live feed temporarily unavailable — retrying…';}
+}
+document.getElementById('nav').addEventListener('click',e=>{const a=e.target.closest('a[data-cat]');if(!a)return;e.preventDefault();document.querySelectorAll('#nav a').forEach(x=>x.classList.remove('on'));a.classList.add('on');cat=a.dataset.cat;render();window.scrollTo({top:0,behavior:'smooth'});});
+document.getElementById('subbtn').onclick=()=>{if((document.getElementById('email').value||'').includes('@')){document.getElementById('subok').style.display='block';}};
+async function loadTicker(){
+ try{
+ const t=await (await fetch('/api/ticker')).json();
+ const cell=m=>{ if(!m||!m.value) return '';
+ const amt=m.change_pct!=null?Math.abs(m.change_pct)+'%':(m.change_bps!=null?Math.abs(m.change_bps)+'bps':'');
+ let arr=''; if(m.dir==='up')arr=' <span class="up">▲'+amt+'</span>'; else if(m.dir==='down')arr=' <span class="dn">▼'+amt+'</span>';
+ return '<span><b>'+esc(m.label)+'</b> '+esc(m.value)+arr+'</span>'; };
+ const html=[t.cre_index,t.ten_yr,t.la_office_vac,t.ie_industrial_cap].map(cell).filter(Boolean).join('');
+ if(html) document.getElementById('ticker').innerHTML=html;
+ }catch(e){/* keep prior ticker on failure */}
+}
+load(); setInterval(load, 120000); // refresh the live feed every 2 minutes
+loadTicker(); setInterval(loadTicker, 120000); // live market ticker
+</script>
+</body>
+</html>
diff --git a/public/review.html b/public/review.html
new file mode 100644
index 0000000..2e81c91
--- /dev/null
+++ b/public/review.html
@@ -0,0 +1,204 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>The REview — RENTV.com Commercial Real Estate Video</title>
+<meta name="description" content="The REview by RENTV.com — free commercial real estate video: market analysis, technology, property showcases and industry events across the Western U.S.">
+<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
+<style>
+ /* ── shared RENTV design system (same tokens as index.html) ── */
+ :root{--red:#c8102e;--ink:#14171a;--sub:#5b636b;--line:#e4e7ea;--bg:#fff;--wash:#f5f6f8;--sans:'Inter',system-ui,sans-serif;--serif:'Playfair Display',Georgia,serif}
+ *{box-sizing:border-box;margin:0;padding:0}
+ body{font-family:var(--sans);color:var(--ink);background:var(--bg);line-height:1.45}
+ a{color:inherit;text-decoration:none}
+ .wrap{max-width:1200px;margin:0 auto;padding:0 20px}
+ .util{background:var(--ink);color:#cfd4d9;font-size:12px}
+ .util .wrap{display:flex;justify-content:space-between;align-items:center;height:34px}
+ .util .left a{color:#cfd4d9;margin-right:16px}
+ .util .acts a{margin-left:14px;color:#cfd4d9}.util .acts a.sub{color:#fff;background:var(--red);padding:4px 12px;border-radius:3px;font-weight:700}
+ .mast{border-bottom:2px solid var(--ink);padding:18px 0 12px}
+ .mast .wrap{display:flex;align-items:center;justify-content:space-between}
+ .logo{font-family:var(--serif);font-weight:700;font-size:34px;letter-spacing:-1px}.logo span{color:var(--red)}
+ .logo small{display:block;font-size:11px;font-weight:600;letter-spacing:3px;color:var(--sub);text-transform:uppercase;margin-top:2px;font-family:var(--sans)}
+ .datebox{font-size:12px;color:var(--sub);text-align:right}
+ nav{border-bottom:1px solid var(--line);position:sticky;top:0;background:#fff;z-index:10}
+ nav .wrap{display:flex;gap:2px;align-items:center;height:46px;overflow-x:auto}
+ nav a{font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:14px 13px;border-bottom:3px solid transparent;white-space:nowrap;cursor:pointer}
+ nav a:hover,nav a.on{color:var(--red);border-color:var(--red)}
+ /* ── REview hero band ── */
+ .rhero{background:linear-gradient(120deg,#111827,#c8102e);color:#fff;padding:30px 0}
+ .rhero .wrap{display:flex;align-items:center;justify-content:space-between;gap:24px;flex-wrap:wrap}
+ .rhero .k{font-size:11px;letter-spacing:.24em;text-transform:uppercase;opacity:.85}
+ .rhero h1{font-family:var(--serif);font-size:34px;line-height:1.1;margin:6px 0 6px}
+ .rhero p{font-size:14px;opacity:.92;max-width:56ch}
+ .rhero .cta{display:flex;gap:10px;flex:none}
+ .rhero .cta a{display:inline-block;font-weight:800;font-size:12px;text-transform:uppercase;letter-spacing:.06em;padding:11px 18px;border-radius:4px}
+ .rhero .cta .p{background:#fff;color:var(--red)}.rhero .cta .g{border:1.5px solid rgba(255,255,255,.6);color:#fff}
+ /* ── search + filter bar ── */
+ .toolbar{display:flex;gap:12px;align-items:center;flex-wrap:wrap;padding:22px 0 6px}
+ .toolbar input{flex:1;min-width:220px;padding:11px 14px;border:1px solid var(--line);border-radius:5px;font-size:14px;font-family:var(--sans)}
+ .chips{display:flex;gap:8px;flex-wrap:wrap;padding:8px 0 4px}
+ .chips button{font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:7px 13px;border:1px solid var(--line);background:#fff;color:var(--sub);border-radius:20px;cursor:pointer}
+ .chips button.on{background:var(--red);border-color:var(--red);color:#fff}
+ .live{display:inline-flex;align-items:center;gap:6px;font-size:11px;color:var(--sub);letter-spacing:.06em;text-transform:uppercase;margin-left:auto}
+ .live .dot{width:7px;height:7px;border-radius:50%;background:#22c55e;animation:pulse 2s infinite}
+ @keyframes pulse{0%{box-shadow:0 0 0 0 rgba(34,197,94,.6)}70%{box-shadow:0 0 0 7px rgba(34,197,94,0)}100%{box-shadow:0 0 0 0 rgba(34,197,94,0)}}
+ /* ── rails + cards ── */
+ .rail{padding:14px 0 6px}
+ .sechead{display:flex;align-items:center;gap:12px;border-top:3px solid var(--ink);padding-top:10px;margin:22px 0 16px}
+ .sechead h2{font-size:16px;text-transform:uppercase;letter-spacing:.06em}
+ .sechead .count{margin-left:auto;font-size:12px;color:var(--sub)}
+ .cards{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:22px}
+ .vcard{display:block;cursor:pointer;text-align:left;background:none;border:0;padding:0;font:inherit;color:inherit}
+ .vcard .thumb{aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:linear-gradient(135deg,#475569,#94a3b8);position:relative}
+ .vcard .thumb img{width:100%;height:100%;object-fit:cover;display:block}
+ .vcard .thumb::after{content:'▶';position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#fff;font-size:34px;text-shadow:0 2px 12px rgba(0,0,0,.5);opacity:.92;transition:transform .15s}
+ .vcard:hover .thumb::after{transform:scale(1.18)}
+ .vcard .rcat{display:inline-block;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--red);margin:9px 0 3px}
+ .vcard h4{font-family:var(--serif);font-size:16px;line-height:1.22}
+ .vcard .d{font-size:12px;color:var(--sub);margin-top:5px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
+ .empty{color:var(--sub);padding:26px 0}
+ /* ── watch modal ── */
+ .modal{position:fixed;inset:0;background:rgba(10,12,14,.86);display:none;align-items:center;justify-content:center;z-index:50;padding:20px}
+ .modal.open{display:flex}
+ .modal .panel{background:#0d0f11;border-radius:10px;overflow:hidden;max-width:960px;width:100%}
+ .modal .video{aspect-ratio:16/9;background:#000}.modal iframe{width:100%;height:100%;border:0}
+ .modal .meta{padding:16px 20px;color:#e7ebee}
+ .modal .meta .rcat{color:#ff6b81;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.06em}
+ .modal .meta h3{font-family:var(--serif);font-size:22px;margin:6px 0 8px}
+ .modal .meta p{font-size:13px;color:#aeb6bd}
+ .modal .meta .row{display:flex;gap:16px;align-items:center;margin-top:12px}
+ .modal .meta a{font-size:12px;font-weight:700;color:#ff6b81}
+ .modal .x{position:absolute;top:16px;right:22px;color:#fff;font-size:30px;cursor:pointer;line-height:1}
+ footer{background:var(--ink);color:#aeb6bd;margin-top:36px;padding:40px 0 30px;font-size:13px}
+ footer .cols{display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:28px}
+ footer .logo{color:#fff;font-size:26px;margin-bottom:8px}footer h5{color:#fff;font-size:12px;text-transform:uppercase;letter-spacing:.08em;margin-bottom:10px}footer a{display:block;padding:4px 0;color:#aeb6bd}
+ footer .fine{border-top:1px solid #2a2f34;margin-top:26px;padding-top:16px;font-size:12px;color:#7c848b}
+ @media(max-width:980px){.cards{grid-template-columns:1fr 1fr 1fr}}
+ @media(max-width:720px){.cards{grid-template-columns:1fr 1fr}.rhero h1{font-size:26px}}
+</style>
+</head>
+<body>
+ <div class="util"><div class="wrap">
+ <div class="left"><a href="/">RENTV.com News</a><a href="/review">The REview</a><a href="https://sublease.com/" target="_blank" rel="noopener">Sublease.com</a></div>
+ <div class="acts"><a href="https://www.rentvreview.com/Account/Login" target="_blank" rel="noopener">Log In</a><a href="https://www.rentvreview.com/ContactUs" target="_blank" rel="noopener">Advertise</a><a class="sub" href="https://www.rentvreview.com/Account/Register" target="_blank" rel="noopener">Post a Video</a></div>
+ </div></div>
+
+ <header class="mast"><div class="wrap">
+ <div class="logo">REN<span>TV</span>.com<small>Commercial Real Estate</small></div>
+ <div class="datebox"><span>The REview · CRE Video</span><br><span id="today"></span></div>
+ </div></header>
+
+ <nav><div class="wrap">
+ <a href="/">Home</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a><a class="on" href="/review">The REview</a>
+ </div></nav>
+
+ <section class="rhero"><div class="wrap">
+ <div>
+ <div class="k">The REview · by RENTV.com</div>
+ <h1>Commercial Real Estate, on video.</h1>
+ <p>Market analysis, technology, property showcases and industry-event coverage — the free CRE video hub from RENTV.com. Post your own, or advertise to the industry.</p>
+ </div>
+ <div class="cta">
+ <a class="p" href="https://www.rentvreview.com/Account/Register" target="_blank" rel="noopener">Post a Video</a>
+ <a class="g" href="https://www.rentvreview.com/ContactUs" target="_blank" rel="noopener">Advertise</a>
+ </div>
+ </div></section>
+
+ <div class="wrap">
+ <div class="toolbar">
+ <input id="q" placeholder="Search The REview — companies, topics, markets…" autocomplete="off">
+ <span class="live"><span class="dot"></span><span id="freshness">live</span></span>
+ </div>
+ <div class="chips" id="chips"></div>
+ <div id="rails"><div class="empty">Loading The REview video catalog…</div></div>
+ </div>
+
+ <footer><div class="wrap"><div class="cols">
+ <div><div class="logo">REN<span style="color:var(--red)">TV</span>.com</div><p style="max-width:36ch">The REview — free commercial real estate video from RENTV.com. Serving the Western U.S. CRE community since 1998.</p></div>
+ <div><h5>The REview</h5><a href="/review">All Video</a><a href="https://www.rentvreview.com/Search" target="_blank" rel="noopener">Search</a><a href="https://www.rentvreview.com/Instructions" target="_blank" rel="noopener">How to Post</a></div>
+ <div><h5>RENTV Network</h5><a href="/">RENTV.com News</a><a href="https://sublease.com/" target="_blank" rel="noopener">Sublease.com</a><a href="https://www.rentvreview.com/ContactUs" target="_blank" rel="noopener">Advertise</a></div>
+ <div><h5>Company</h5><a href="https://www.rentvreview.com/ContactUs" target="_blank" rel="noopener">Contact</a><a href="https://www.rentvreview.com/Terms" target="_blank" rel="noopener">Terms</a><a href="https://www.rentvreview.com/Privacy" target="_blank" rel="noopener">Privacy</a></div>
+ </div><div class="fine">© 2026 RENTV.com · The REview video catalog synced live from rentvreview.com · <span id="src"></span></div></div></footer>
+
+ <div class="modal" id="modal">
+ <span class="x" id="mx">×</span>
+ <div class="panel">
+ <div class="video"><iframe id="mframe" allow="accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture" allowfullscreen></iframe></div>
+ <div class="meta"><span class="rcat" id="mcat"></span><h3 id="mtitle"></h3><p id="mdesc"></p>
+ <div class="row"><a id="mlink" target="_blank" rel="noopener">Open on The REview ↗</a></div>
+ </div>
+ </div>
+ </div>
+
+<script>
+document.getElementById('today').textContent = new Date().toLocaleDateString(undefined,{weekday:'long',month:'long',day:'numeric',year:'numeric'});
+let ALL=[], CATS=[], cat='all', q='';
+const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
+
+function chips(){
+ const all=['all',...CATS];
+ document.getElementById('chips').innerHTML=all.map(c=>
+ '<button data-cat="'+esc(c)+'"'+(c===cat?' class="on"':'')+'>'+(c==='all'?'All Video':esc(c))+'</button>').join('');
+}
+function match(v){
+ if(cat!=='all' && v.cat!==cat) return false;
+ if(q){const t=(v.title+' '+v.desc+' '+v.cat).toLowerCase(); if(!t.includes(q)) return false;}
+ return true;
+}
+function cardHTML(v){
+ return '<button class="vcard" data-id="'+esc(v.id)+'">'
+ +'<div class="thumb"><img loading="lazy" src="'+esc(v.thumb)+'" alt="'+esc(v.title)+'" onerror="this.style.display=\'none\'"></div>'
+ +'<span class="rcat">'+esc(v.cat)+'</span><h4>'+esc(v.title)+'</h4>'
+ +(v.desc?'<div class="d">'+esc(v.desc)+'</div>':'')+'</button>';
+}
+function render(){
+ const box=document.getElementById('rails');
+ const items=ALL.filter(match);
+ if(!items.length){box.innerHTML='<div class="empty">No videos match — try another search or category.</div>';return;}
+ // when browsing "all" with no query, group by rail; otherwise one flat result grid
+ if(cat==='all' && !q){
+ box.innerHTML=CATS.map(c=>{
+ const list=ALL.filter(v=>v.cat===c);
+ if(!list.length) return '';
+ return '<section class="rail"><div class="sechead"><h2>'+esc(c)+'</h2><span class="count">'+list.length+' video'+(list.length>1?'s':'')+'</span></div><div class="cards">'+list.map(cardHTML).join('')+'</div></section>';
+ }).join('');
+ }else{
+ box.innerHTML='<section class="rail"><div class="sechead"><h2>'+(q?'Results':esc(cat))+'</h2><span class="count">'+items.length+' video'+(items.length>1?'s':'')+'</span></div><div class="cards">'+items.map(cardHTML).join('')+'</div></section>';
+ }
+}
+function openVideo(id){
+ const v=ALL.find(x=>x.id===id); if(!v) return;
+ document.getElementById('mframe').src=v.embed+'?autoplay=1&rel=0';
+ document.getElementById('mcat').textContent=v.cat;
+ document.getElementById('mtitle').textContent=v.title;
+ document.getElementById('mdesc').textContent=v.desc||'';
+ document.getElementById('mlink').href=v.view_url;
+ document.getElementById('modal').classList.add('open');
+}
+function closeVideo(){document.getElementById('modal').classList.remove('open');document.getElementById('mframe').src='';}
+
+document.getElementById('rails').addEventListener('click',e=>{const b=e.target.closest('.vcard');if(b)openVideo(b.dataset.id);});
+document.getElementById('chips').addEventListener('click',e=>{const b=e.target.closest('button[data-cat]');if(!b)return;cat=b.dataset.cat;chips();render();});
+document.getElementById('q').addEventListener('input',e=>{q=e.target.value.trim().toLowerCase();render();});
+document.getElementById('mx').onclick=closeVideo;
+document.getElementById('modal').addEventListener('click',e=>{if(e.target.id==='modal')closeVideo();});
+document.addEventListener('keydown',e=>{if(e.key==='Escape')closeVideo();});
+
+async function load(){
+ try{
+ const d=await (await fetch('/api/videos')).json();
+ ALL=d.items||[]; CATS=d.cats||[...new Set(ALL.map(v=>v.cat))];
+ chips(); render();
+ if(d.fetched_at){
+ const mins=Math.max(0,Math.round((Date.now()-new Date(d.fetched_at))/60000));
+ document.getElementById('freshness').textContent=mins<2?'synced just now':('synced '+mins+' min ago');
+ document.getElementById('src').textContent='last sync '+new Date(d.fetched_at).toLocaleTimeString();
+ }
+ }catch(e){document.getElementById('rails').innerHTML='<div class="empty">The REview catalog is temporarily unavailable — retrying…</div>';}
+}
+load(); setInterval(load, 300000); // re-sync every 5 min
+</script>
+</body>
+</html>
diff --git a/public/section.html b/public/section.html
new file mode 100644
index 0000000..f2fbb71
--- /dev/null
+++ b/public/section.html
@@ -0,0 +1,116 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>RENTV.com — Section</title>
+<meta name="description" content="Commercial real estate news by section — RENTV.com.">
+<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=Inter:wght@300;400;600;800&display=swap" rel="stylesheet">
+<style>
+ :root{--red:#c8102e;--ink:#14171a;--sub:#5b636b;--line:#e4e7ea;--bg:#fff;--wash:#f5f6f8;--sans:'Inter',system-ui,sans-serif;--serif:'Playfair Display',Georgia,serif}
+ *{box-sizing:border-box;margin:0;padding:0}
+ body{font-family:var(--sans);color:var(--ink);background:var(--bg);line-height:1.45}
+ a{color:inherit;text-decoration:none}
+ .wrap{max-width:1200px;margin:0 auto;padding:0 20px}
+ .util{background:var(--ink);color:#cfd4d9;font-size:12px}
+ .util .wrap{display:flex;justify-content:space-between;align-items:center;height:34px}
+ .util .acts a{margin-left:14px;color:#cfd4d9}.util .acts a.sub{color:#fff;background:var(--red);padding:4px 12px;border-radius:3px;font-weight:700}
+ .mast{border-bottom:2px solid var(--ink);padding:18px 0 12px}
+ .mast .wrap{display:flex;align-items:center;justify-content:space-between}
+ .logo{font-family:var(--serif);font-weight:700;font-size:34px;letter-spacing:-1px}.logo span{color:var(--red)}
+ .logo small{display:block;font-size:11px;font-weight:600;letter-spacing:3px;color:var(--sub);text-transform:uppercase;margin-top:2px;font-family:var(--sans)}
+ .datebox{font-size:12px;color:var(--sub);text-align:right}
+ nav{border-bottom:1px solid var(--line);position:sticky;top:0;background:#fff;z-index:10}
+ nav .wrap{display:flex;gap:2px;align-items:center;height:46px;overflow-x:auto}
+ nav a{font-size:13px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:14px 13px;border-bottom:3px solid transparent;white-space:nowrap;cursor:pointer}
+ nav a:hover,nav a.on{color:var(--red);border-color:var(--red)}
+ .head{padding:30px 0 6px}
+ .head .chip{display:inline-block;font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.06em;color:var(--red);margin-bottom:4px}
+ .head h1{font-family:var(--serif);font-size:34px;letter-spacing:-.5px}
+ .head .sub{font-size:13px;color:var(--sub);margin-top:6px}
+ .crumb{font-size:12px;color:var(--sub);padding:14px 0 0}.crumb a{color:var(--red);font-weight:700}
+ .cards{display:grid;grid-template-columns:1fr 1fr 1fr;gap:22px;padding:22px 0 10px}
+ .card{display:block}
+ .card .img{aspect-ratio:16/10;border-radius:5px;background:linear-gradient(135deg,#475569,#94a3b8)}
+ .card:nth-child(3n+2) .img{background:linear-gradient(135deg,#7c2d12,#c8102e)}
+ .card:nth-child(3n) .img{background:linear-gradient(135deg,#0f766e,#14b8a6)}
+ .card h4{font-family:var(--serif);font-size:18px;line-height:1.22;margin:10px 0 4px}
+ .card .byl{font-size:12px;color:var(--sub);margin-top:6px}
+ .live{display:inline-flex;align-items:center;gap:6px;font-size:11px;color:var(--sub);letter-spacing:.06em;text-transform:uppercase}
+ .live .dot{width:7px;height:7px;border-radius:50%;background:#22c55e;animation:pulse 2s infinite}
+ @keyframes pulse{0%{box-shadow:0 0 0 0 rgba(34,197,94,.6)}70%{box-shadow:0 0 0 7px rgba(34,197,94,0)}100%{box-shadow:0 0 0 0 rgba(34,197,94,0)}}
+ .empty{color:var(--sub);padding:40px 0}
+ .skl{background:linear-gradient(90deg,#eee,#f6f6f6,#eee);background-size:200% 100%;animation:sh 1.2s infinite;border-radius:4px;color:transparent}
+ @keyframes sh{to{background-position:-200% 0}}
+ footer{background:var(--ink);color:#aeb6bd;margin-top:36px;padding:40px 0 30px;font-size:13px}
+ footer .cols{display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:28px}
+ footer .logo{color:#fff;font-size:26px;margin-bottom:8px}footer h5{color:#fff;font-size:12px;text-transform:uppercase;letter-spacing:.08em;margin-bottom:10px}footer a{display:block;padding:4px 0;color:#aeb6bd}
+ footer .fine{border-top:1px solid #2a2f34;margin-top:26px;padding-top:16px;font-size:12px;color:#7c848b}
+ @media(max-width:860px){.cards{grid-template-columns:1fr 1fr}}
+</style>
+</head>
+<body>
+ <div class="util"><div class="wrap">
+ <div><a href="/">RENTV.com</a> · Commercial Real Estate News</div>
+ <div class="acts"><a href="/">Home</a><a href="/review">The REview</a><a class="sub" href="/">Subscribe</a></div>
+ </div></div>
+
+ <header class="mast"><div class="wrap">
+ <a href="/" class="logo">REN<span>TV</span>.com<small>Commercial Real Estate News</small></a>
+ <div class="datebox"><span>Western U.S. Edition</span><br><span id="today"></span></div>
+ </div></header>
+
+ <nav><div class="wrap" id="nav">
+ <a href="/">Home</a><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a><a href="/review">The REview</a>
+ </div></nav>
+
+ <div class="wrap">
+ <div class="crumb"><a href="/">Home</a> › <span id="crumbcat">Section</span></div>
+ <div class="head">
+ <span class="chip" id="chip">Section</span>
+ <h1 id="secname">Loading…</h1>
+ <div class="sub"><span class="live"><span class="dot"></span><span id="count">live from rentv.com</span></span></div>
+ </div>
+ <div class="cards" id="cards">
+ <div class="card"><div class="img skl"></div><h4 class="skl"> </h4></div>
+ <div class="card"><div class="img skl"></div><h4 class="skl"> </h4></div>
+ <div class="card"><div class="img skl"></div><h4 class="skl"> </h4></div>
+ </div>
+ </div>
+
+ <footer><div class="wrap"><div class="cols">
+ <div><div class="logo">REN<span style="color:var(--red)">TV</span>.com</div><p style="max-width:34ch">Commercial real estate news, deals & conferences across the Western United States since 1998.</p></div>
+ <div><h5>Sections</h5><a href="/sales">Sales</a><a href="/leases">Leases</a><a href="/financing">Financing</a><a href="/development">Development</a></div>
+ <div><h5>More</h5><a href="/multifamily">Multifamily</a><a href="/industrial">Industrial</a><a href="/retail">Retail</a><a href="/review">The REview</a></div>
+ <div><h5>Company</h5><a href="/">Home</a><a href="/">Advertise</a><a href="/">Contact</a></div>
+ </div><div class="fine">© 2026 RENTV.com · Headlines refreshed live from rentv.com</div></div></footer>
+
+<script>
+document.getElementById('today').textContent = new Date().toLocaleDateString(undefined,{weekday:'long',year:'numeric',month:'long',day:'numeric'});
+const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
+// Category is the last path segment: /financing -> Financing
+const slug=(location.pathname.split('/').filter(Boolean)[0]||'sales').toLowerCase();
+const CAT=slug.charAt(0).toUpperCase()+slug.slice(1);
+document.title='RENTV.com — '+CAT;
+document.getElementById('secname').textContent=CAT;
+document.getElementById('chip').textContent=CAT+' · Western U.S.';
+document.getElementById('crumbcat').textContent=CAT;
+// mark active nav
+document.querySelectorAll('#nav a').forEach(a=>{if(a.getAttribute('href')==='/'+slug)a.classList.add('on');});
+async function load(){
+ try{
+ const d=await (await fetch('/api/news')).json();
+ const items=(d.items||[]).filter(x=>(x.cat||'').toLowerCase()===slug);
+ const box=document.getElementById('cards');
+ if(!items.length){box.className='';box.innerHTML='<p class="empty">No '+esc(CAT)+' stories in the live feed right now. <a href="/" style="color:var(--red);font-weight:700">Back to the homepage →</a></p>';return;}
+ document.getElementById('count').textContent=items.length+' live '+(items.length===1?'story':'stories');
+ box.className='cards';
+ box.innerHTML=items.map(x=>'<a class="card" href="/news/'+encodeURIComponent(x.id)+'"><div class="img"></div><h4>'+esc(x.title)+'</h4><div class="byl">RENTV Staff · read in-shell</div></a>').join('');
+ }catch(e){
+ document.getElementById('cards').innerHTML='<p class="empty">Live feed temporarily unavailable — <a href="/" style="color:var(--red);font-weight:700">back to homepage</a>.</p>';
+ }
+}
+load();
+</script>
+</body>
+</html>
diff --git a/scripts/pull-news.mjs b/scripts/pull-news.mjs
new file mode 100644
index 0000000..5e853f3
--- /dev/null
+++ b/scripts/pull-news.mjs
@@ -0,0 +1,47 @@
+#!/usr/bin/env node
+// Live-data engine for rentv-v1: pulls REAL rentv.com news headlines on a schedule.
+// Runs via launchd every 20 min; writes data/news.json the site serves. $0 (plain fetch).
+import { writeFileSync, mkdirSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+const HERE = dirname(fileURLToPath(import.meta.url));
+const OUT = join(HERE, '..', 'data'); mkdirSync(OUT, { recursive: true });
+
+async function fetchDecoded(url) {
+ const r = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 RENTV-v1' }, signal: AbortSignal.timeout(20000) });
+ const buf = Buffer.from(await r.arrayBuffer());
+ let s = buf.toString('utf8');
+ if ((s.match(/�/g) || []).length > 5) s = buf.toString('latin1'); // WIN-1252 fallback
+ return s;
+}
+const clean = (t) => t.replace(/&/g,'&').replace(/'/g,"'").replace(/"/g,'"').replace(/ /g,' ').replace(/\s+/g,' ').trim();
+
+try {
+ const html = await fetchDecoded('https://www.rentv.com/');
+ const re = /<a[^>]+href="([^"]*content\/homepage\/mainnews\/news\/(\d+))"[^>]*>([^<]{20,160})<\/a>/gi;
+ const seen = new Set(), items = []; let m;
+ while ((m = re.exec(html)) && items.length < 30) {
+ const id = m[2]; if (seen.has(id)) continue; seen.add(id);
+ const title = clean(m[3]); if (title.length < 20) continue;
+ // classify by keywords
+ const t = title.toLowerCase();
+ const cat = /financ|loan|refi|recap|debt/.test(t) ? 'Financing'
+ : /lease|sf lease|tenant/.test(t) ? 'Leases'
+ : /develop|breaks ground|project|mixed-use/.test(t) ? 'Development'
+ : /retail|shop|center/.test(t) ? 'Retail'
+ : /res |multifamily|apartment|unit/.test(t) ? 'Multifamily'
+ : /industrial|warehouse|logistics/.test(t) ? 'Industrial'
+ : 'Sales';
+ items.push({ id, title, url: m[1].startsWith('http') ? m[1] : 'https://www.rentv.com/' + m[1].replace(/^\//,''), cat });
+ }
+ const payload = { source: 'rentv.com — live', fetched_at: new Date().toISOString(), count: items.length, items };
+ writeFileSync(join(OUT, 'news.json'), JSON.stringify(payload, null, 1));
+ console.log('OK: pulled ' + items.length + ' live headlines');
+} catch (e) {
+ console.error('pull failed:', e.message);
+ // never overwrite a good cache with nothing — leave last-good in place
+ console.error('(news kept last-good; continuing to ticker refresh)');
+}
+
+// Refresh the live market ticker on the same cron (runs regardless of news outcome).
+try { await import('./pull-ticker.mjs'); } catch (e) { console.error('ticker refresh failed:', e.message); }
diff --git a/scripts/pull-ticker.mjs b/scripts/pull-ticker.mjs
new file mode 100644
index 0000000..e2dfd3c
--- /dev/null
+++ b/scripts/pull-ticker.mjs
@@ -0,0 +1,55 @@
+#!/usr/bin/env node
+// Live ticker engine for rentv-v1 — writes data/ticker.json the header serves.
+// LIVE (free, no key): 10-YR Treasury yield (^TNX) + CRE index (Dow Jones U.S. Real
+// Estate ^DJUSRE) via Yahoo Finance. Market fundamentals (LA office vac, IE industrial
+// cap) are quarterly figures kept as maintainable config here (not real-time-fetchable free).
+import { writeFileSync, readFileSync, mkdirSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+const HERE = dirname(fileURLToPath(import.meta.url));
+const OUT = join(HERE, '..', 'data'); mkdirSync(OUT, { recursive: true });
+const FILE = join(OUT, 'ticker.json');
+const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/537.36 RENTV-v1';
+
+// Quarterly CRE fundamentals — update from CBRE/JLL/Colliers market reports.
+const FUNDAMENTALS = {
+ la_office_vac: { label: 'LA OFFICE VAC', value: '23.1%', as_of: 'Q2 2026', source: 'CBRE/JLL market reports' },
+ ie_industrial_cap:{ label: 'IE INDUSTRIAL CAP',value: '5.2%', as_of: 'Q2 2026', source: 'Colliers IE industrial' },
+};
+// Rebase factor so the Dow Jones U.S. Real Estate index reads as a ~4,000s "RENTV CRE Index"
+// (matches the site's index scale; % change is identical to the underlying real index).
+const CRE_SCALE = 10.51;
+
+async function yahoo(sym) {
+ const url = `https://query1.finance.yahoo.com/v8/finance/chart/${encodeURIComponent(sym)}?interval=1d&range=2d`;
+ const r = await fetch(url, { headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(15000) });
+ const j = await r.json();
+ const m = j?.chart?.result?.[0]?.meta;
+ if (!m || m.regularMarketPrice == null) throw new Error('no price for ' + sym);
+ return { price: m.regularMarketPrice, prev: m.chartPreviousClose ?? m.previousClose ?? m.regularMarketPrice };
+}
+
+const prior = (() => { try { return JSON.parse(readFileSync(FILE, 'utf8')); } catch { return {}; } })();
+const pct = (p, prev) => prev ? +(((p - prev) / prev) * 100).toFixed(2) : 0;
+const dir = (d) => d > 0.001 ? 'up' : d < -0.001 ? 'down' : 'flat';
+
+const out = { fetched_at: new Date().toISOString(), ...FUNDAMENTALS };
+
+// CRE INDEX (Dow Jones U.S. Real Estate, rebased)
+try {
+ const d = await yahoo('^DJUSRE');
+ const cp = pct(d.price, d.prev);
+ out.cre_index = { label: 'CRE INDEX', value: Math.round(d.price * CRE_SCALE).toLocaleString('en-US'),
+ change_pct: cp, dir: dir(cp), source: 'Dow Jones U.S. Real Estate' };
+} catch (e) { out.cre_index = prior.cre_index || { label: 'CRE INDEX', value: '—', change_pct: 0, dir: 'flat' }; out.cre_index.stale = true; }
+
+// 10-YR Treasury yield (^TNX quotes the yield directly)
+try {
+ const d = await yahoo('^TNX');
+ const bps = Math.round((d.price - d.prev) * 100);
+ out.ten_yr = { label: '10-YR', value: d.price.toFixed(2) + '%', change_bps: bps, dir: dir(bps), source: 'CBOE 10-Yr (^TNX)' };
+} catch (e) { out.ten_yr = prior.ten_yr || { label: '10-YR', value: '—', change_bps: 0, dir: 'flat' }; out.ten_yr.stale = true; }
+
+writeFileSync(FILE, JSON.stringify(out, null, 2));
+console.log('ticker.json:', out.cre_index.value, '| 10-YR', out.ten_yr.value,
+ '| CRE', out.cre_index.change_pct + '%', out.cre_index.stale ? '(stale)' : '');
diff --git a/scripts/pull-videos.mjs b/scripts/pull-videos.mjs
new file mode 100644
index 0000000..6cc86da
--- /dev/null
+++ b/scripts/pull-videos.mjs
@@ -0,0 +1,76 @@
+#!/usr/bin/env node
+// Live-data engine for The REview (rentv-v1 video section): pulls the REAL
+// rentvreview.com video catalog on a schedule and writes data/videos.json,
+// which /api/videos serves and public/review.html renders in the RENTV shell.
+// $0 (plain fetch, no browser). Mirrors pull-news.mjs exactly.
+import { writeFileSync, mkdirSync, readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+const HERE = dirname(fileURLToPath(import.meta.url));
+const OUT = join(HERE, '..', 'data'); mkdirSync(OUT, { recursive: true });
+const SRC = 'https://www.rentvreview.com';
+
+async function fetchDecoded(url) {
+ const r = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 RENTV-v1' }, signal: AbortSignal.timeout(20000) });
+ const buf = Buffer.from(await r.arrayBuffer());
+ let s = buf.toString('utf8');
+ if ((s.match(/�/g) || []).length > 5) s = buf.toString('latin1'); // WIN-1252 fallback
+ return s;
+}
+const clean = (t) => (t || '')
+ .replace(/&/g, '&').replace(/'/g, "'").replace(/�?39;/g, "'").replace(/’/gi, "’")
+ .replace(/‘/gi, "‘").replace(/"/g, '"').replace(/ȁ[cd];/gi, '"')
+ .replace(/�?D;/gi, ' ').replace(/�?A;/gi, ' ').replace(/[03];/g, ' ')
+ .replace(/ /g, ' ').replace(/&/g, '&').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
+
+// Walk the homepage, splitting on each preview block, and attach the nearest
+// preceding <h4> heading (JLL TV / Most Recent / Most Viewed / Industry Events…)
+// as the video's category rail.
+function parse(html) {
+ const seen = new Set(), items = [];
+ const blocks = html.split('video-preview__thumbnail');
+ for (let i = 1; i < blocks.length; i++) {
+ const before = blocks[i - 1], b = blocks[i];
+ const view = b.match(/\/Videos\/View\/(\d+)/);
+ const yt = b.match(/youtube\.com\/vi\/([A-Za-z0-9_-]{6,})\//i);
+ const title = b.match(/video-preview__text__title[^>]*>[\s\S]*?<div>([\s\S]*?)<\/div>/i);
+ const desc = b.match(/video-preview__text__description[^>]*>([\s\S]*?)<\/div>/i);
+ if (!view || !yt) continue;
+ const id = view[1]; if (seen.has(id)) continue; seen.add(id);
+ // nearest preceding <h4> = the rail this card sits under
+ const heads = [...before.matchAll(/<h4[^>]*>([\s\S]*?)<\/h4>/gi)];
+ const cat = heads.length ? clean(heads[heads.length - 1][1]) : 'Featured';
+ items.push({
+ id,
+ yt: yt[1],
+ title: clean(title ? title[1] : '') || 'RENTV Video',
+ desc: clean(desc ? desc[1] : '').slice(0, 240),
+ cat: cat || 'Featured',
+ view_url: SRC + '/Videos/View/' + id,
+ thumb: 'https://img.youtube.com/vi/' + yt[1] + '/hqdefault.jpg',
+ embed: 'https://www.youtube.com/embed/' + yt[1],
+ });
+ }
+ return items;
+}
+
+try {
+ const html = await fetchDecoded(SRC + '/');
+ const items = parse(html);
+ if (!items.length) throw new Error('parsed 0 videos — markup may have changed');
+ const cats = [...new Set(items.map(v => v.cat))];
+ const payload = {
+ source: 'rentvreview.com (The REview) — live',
+ fetched_at: new Date().toISOString(),
+ count: items.length,
+ cats,
+ items,
+ };
+ writeFileSync(join(OUT, 'videos.json'), JSON.stringify(payload, null, 1));
+ console.log('OK: pulled ' + items.length + ' videos across ' + cats.length + ' rails: ' + cats.join(', '));
+} catch (e) {
+ console.error('pull-videos failed:', e.message);
+ // never overwrite a good cache with nothing — leave last-good in place
+ try { JSON.parse(readFileSync(join(OUT, 'videos.json'), 'utf8')); console.error('kept last-good videos.json'); } catch {}
+ process.exit(1);
+}
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..b9d494d
--- /dev/null
+++ b/server.js
@@ -0,0 +1,102 @@
+'use strict';
+// rentv-v1 — full CRE-news website rebuild on LIVE rentv.com data.
+// One live-data engine (news.json, refreshed by cron) + an on-demand article
+// reader proxy (/api/article/:id) so stories are read INSIDE our modern shell.
+const fs = require('fs');
+const path = require('path');
+const express = require('express');
+const app = express();
+const PORT = process.env.PORT || 9705;
+const DATA = path.join(__dirname, 'data');
+const PUB = path.join(__dirname, 'public');
+const readJSON = (f, fb) => { try { return JSON.parse(fs.readFileSync(path.join(DATA, f), 'utf8')); } catch { return fb; } };
+
+// ── Basic Auth gate (unified + client logins). Health stays open for smoke-tests. ──
+const CREDS = ['admin:DW2024!', 'Boomer:Rentv2024!']
+ .concat((process.env.BASIC_AUTH_EXTRA || '').split(',').map(s => s.trim()).filter(Boolean));
+const ACCEPTED = new Set(CREDS.map(c => 'Basic ' + Buffer.from(c).toString('base64')));
+app.get('/api/health', (_q, r) => r.json({ ok: true, at: new Date().toISOString() }));
+app.use((req, res, next) => {
+ if (ACCEPTED.has(req.headers.authorization || '')) return next();
+ res.set('WWW-Authenticate', 'Basic realm="RENTV"');
+ return res.status(401).send('Authentication required');
+});
+
+// ── Live news feed (served from the cron-refreshed cache) ──
+app.get('/api/news', (_q, r) => {
+ r.set('Cache-Control', 'public, max-age=120');
+ r.json(readJSON('news.json', { items: [] }));
+});
+
+// ── Live market ticker (10-YR + CRE index live via Yahoo; fundamentals quarterly) ──
+app.get('/api/ticker', (_q, r) => {
+ r.set('Cache-Control', 'public, max-age=120');
+ r.json(readJSON('ticker.json', {}));
+});
+
+// ── The REview: live CRE video catalog (synced from rentvreview.com by cron) ──
+app.get('/api/videos', (_q, r) => {
+ r.set('Cache-Control', 'public, max-age=300');
+ r.json(readJSON('videos.json', { items: [], cats: [] }));
+});
+
+// ── Article reader proxy: fetch a single rentv.com story on demand + cache it. ──
+const artCache = new Map(); // id -> { at, data }
+const ART_TTL = 15 * 60 * 1000;
+const decode = (t) => (t || '')
+ .replace(/&/g, '&').replace(/�?39;/g, "'").replace(/’/g, "'").replace(/‘/g, "'")
+ .replace(/"/g, '"').replace(/“/g, '"').replace(/”/g, '"')
+ .replace(/ /g, ' ').replace(/�?45;/g, '-').replace(/—/g, '—').replace(/–/g, '–')
+ .replace(/&/g, '&').replace(/\s+/g, ' ').trim();
+const stripTags = (h) => decode(h.replace(/<[^>]+>/g, ' '));
+
+async function fetchArticle(id) {
+ const url = 'https://www.rentv.com/content/homepage/mainnews/news/' + encodeURIComponent(id);
+ const r = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0 RENTV-reader' }, signal: AbortSignal.timeout(15000) });
+ const buf = Buffer.from(await r.arrayBuffer());
+ let html = buf.toString('utf8');
+ if ((html.match(/�/g) || []).length > 5) html = buf.toString('latin1');
+
+ const og = (p) => { const m = html.match(new RegExp('<meta[^>]+property=["\']og:' + p + '["\'][^>]+content=["\']([^"\']+)', 'i')); return m ? decode(m[1]) : ''; };
+ let title = og('title') || (html.match(/<title>([^<]+)<\/title>/i)?.[1] || '').replace(/\s*[|\-–]\s*RENTV.*/i, '');
+ title = decode(title);
+ const image = og('image');
+
+ // Body: grab the largest cluster of <p> blocks (the article copy).
+ const paras = [];
+ const pre = html.replace(/<script[\s\S]*?<\/script>/gi, '').replace(/<style[\s\S]*?<\/style>/gi, '');
+ let m; const pRe = /<p[^>]*>([\s\S]*?)<\/p>/gi;
+ while ((m = pRe.exec(pre))) {
+ const txt = stripTags(m[1]);
+ if (txt.length >= 60 && !/^(©|copyright|all rights|advertise|subscribe|follow us)/i.test(txt)) paras.push(txt);
+ }
+ // de-dupe + cap
+ const body = [...new Set(paras)].slice(0, 40);
+ return { id, url, title: title || 'RENTV Story', image, body, fetched_at: new Date().toISOString() };
+}
+
+app.get('/api/article/:id', async (req, res) => {
+ const id = String(req.params.id).replace(/[^0-9]/g, '');
+ if (!id) return res.status(400).json({ error: 'bad id' });
+ const hit = artCache.get(id);
+ if (hit && Date.now() - hit.at < ART_TTL) { res.set('Cache-Control', 'public, max-age=300'); return res.json(hit.data); }
+ try {
+ const data = await fetchArticle(id);
+ artCache.set(id, { at: Date.now(), data });
+ res.set('Cache-Control', 'public, max-age=300');
+ res.json(data);
+ } catch (e) {
+ if (hit) return res.json(hit.data); // serve stale rather than fail
+ res.status(502).json({ error: 'fetch failed', id, url: 'https://www.rentv.com/content/homepage/mainnews/news/' + id });
+ }
+});
+
+// ── Clean section routes → the one section template (category read client-side from path) ──
+const SECTIONS = ['financing', 'leases', 'development', 'retail', 'multifamily', 'industrial', 'sales'];
+SECTIONS.forEach(s => app.get('/' + s, (_q, r) => r.sendFile(path.join(PUB, 'section.html'))));
+app.get('/news/:id', (_q, r) => r.sendFile(path.join(PUB, 'article.html')));
+// The REview — the folded-in video platform, served natively in the RENTV shell
+app.get('/review', (_q, r) => r.sendFile(path.join(PUB, 'review.html')));
+
+app.use(express.static(PUB, { extensions: ['html'] }));
+app.listen(PORT, () => console.log('rentv-website (independent fork, gated, live data) on ' + PORT));
(oldest)
·
back to Rentv Website
·
real rentv images (front page + article photos) + full artic f8c4dda →