Rosewater and the Benefits Behind Our Favorite Flower \| Downs Style<\/title>/i,
+ );
+ assert.match(html, /og-comparison\.png/);
+ assert.match(html, /six toners and face mists/i);
+ assert.match(html, /Rose in Skincare/);
+ assert.match(html, /Our Favorite Flower/);
+ assert.match(html, /oasis of skincare/);
+ assert.match(html, /unruly weather/);
+ assert.equal((html.match(/class="opening-cluster"/g) ?? []).length, 2);
+ assert.match(html, /<\/p>Not without good cause/);
+ assert.match(html, /<\/p>
The fermented flower complex/);
+ assert.match(html, /Rose has always been one of our favorite skincare ingredients/);
+ assert.match(html, /So why does rosewater keep finding its way back/);
+ assert.match(html, /result belongs to the material studied/);
+ assert.match(html, /aria-label="Shop Mario Badescu Facial Spray/);
+ assert.match(html, /Mario Badescu Facial Spray/);
+ assert.match(html, /Whamisa Organic Flowers Toner Deep Rich/);
+ assert.match(html, /Whamisa does not even try to be Mario/);
+ assert.match(html, /this romance ends here/);
+ assert.match(html, /which sounds extremely glamorous/);
+ assert.match(html, /a whisper was clearly never the point/);
+ assert.match(html, /Santa Maria Novella Acqua di Rose/);
+ assert.match(html, /Chantecaille Pure Rosewater/);
+ assert.match(html, /Sisley-Paris Floral Toning Lotion/);
+ assert.match(html, /Fresh Rose Deep Hydration Facial Toner/);
+ assert.match(html, /bring the whole toner ritual roaring back/);
+ assert.match(html, /glycerin and hyaluronic acid/);
+ assert.match(html, /Affiliate disclosure/);
+ assert.match(html, /As an Amazon Associate I earn from qualifying purchases/);
+ assert.match(html, /Paid link — Downs Style may earn a commission/);
+ assert.match(html, /Each of the six goes straight/);
+ assert.equal((html.match(/ disclosurePosition);
+ }
+ assert.equal(
+ (
+ html.match(
+ /href="https:\/\/on\.ltk\.com\/\+IRLNZ3842CX6uNfhjQ9edg"/g,
+ ) ?? []
+ ).length,
+ 2,
+ );
+ assert.match(html, /Red 40 and Yellow 5/);
+ assert.match(html, /Charles(?:'|'|')s photo goes here/);
+ assert.match(html, /Which formula fits your routine\?/);
+ assert.doesNotMatch(html, /codex-preview|react-loading-skeleton/);
+ assert.doesNotMatch(html, /thoughtful packaging|fermented complex of rosebud/);
+ assert.doesNotMatch(html, /comparison-grid|formula-note/);
+ assert.doesNotMatch(html, /\$\d/);
+ assert.match(html, /href="\/archive"/);
+ assert.match(html, /href="\/voice"/);
+});
+
+test("server-renders the three-panel draft comparison", async () => {
+ const response = await render("/compare");
+ assert.equal(response.status, 200);
+
+ const html = await response.text();
+ assert.match(html, /Original draft/);
+ assert.match(html, /Human draft/);
+ assert.match(html, /All edits/);
+ assert.match(html, /First unfinished snapshot/);
+ assert.match(html, /Current live Downs Style copy/);
+ assert.match(html, /Why it changed/);
+ assert.match(html, /added white space is also an edit/);
+ assert.match(html, /Article title/);
+ assert.match(html, /Rose Toners and the Benefits Behind Our Favorite Scent/);
+ assert.match(html, /Applied the newer spoken title/);
+ assert.match(html, /favorite flower keeps the warmth Charles approved/);
+ assert.match(html, /slightly dramatic reactions/);
+ assert.match(html, /Why rosewater belongs here/);
+ assert.match(html, /getting excited and then checking himself/);
+ assert.match(html, /Treated the phone-call comment as criticism/);
+ assert.match(html, /removed the brand-site language/);
+ assert.match(html, /Removed the product-by-product recap/);
+ assert.match(html, /Whamsica/);
+ assert.match(html, /Whamisa/);
+ assert.match(html, /Organic Flowers Toner Deep Rich/);
+ assert.match(html, /Santa Maria Novella Acqua di Rose/);
+ assert.match(html, /current dye disclosure stays factual and calm/);
+ assert.match(html, //);
+ assert.match(html, //);
+ assert.equal((html.match(/class="comparison-row"/g) ?? []).length, 11);
+ assert.match(html, /id="fresh"/);
+ assert.match(html, /Fresh<\/h2>/);
+ assert.match(html, /committed rose bottle/);
+ assert.match(html, /Not present in Charles(?:'|'|')s original draft/);
+ assert.match(html, /Paid link — Downs Style may earn a commission/);
+ assert.match(html, /Each of the six goes straight/);
+ assert.match(html, /All 258 posts/);
+ assert.match(html, /Voice system/);
+});
+
+test("server-renders all 258 archive records without article bodies or tags", async () => {
+ const response = await render("/archive");
+ assert.equal(response.status, 200);
+
+ const html = await response.text();
+ assert.match(html, /The whole history/);
+ assert.match(html, /finally in one room/);
+ assert.match(html, /258 pieces across nine editorial worlds/);
+ assert.equal((html.match(/data-archive-entry/g) ?? []).length, 258);
+ assert.match(html, /Rose Water and the benefits behind our favorite flower/);
+ assert.match(html, /My Favorite Candles of the Year/);
+ assert.match(html, /Skincare/);
+ assert.match(html, /Masks/);
+ assert.match(html, /Look Book/);
+ assert.match(html, /Interior Design/);
+ assert.match(html, /Search the archive/);
+ assert.match(html, /Calendar span/);
+ assert.match(html, /og-archive\.png/);
+ assert.doesNotMatch(html, /candle junkie|luxury scents/);
+});
+
+test("server-renders the evidence-backed Charles voice system", async () => {
+ const response = await render("/voice");
+ assert.equal(response.status, 200);
+
+ const html = await response.text();
+ assert.match(html, /His voice was/);
+ assert.match(html, /already there/);
+ assert.match(html, /257-post causal baseline/);
+ assert.match(html, /76,597/);
+ assert.match(html, /alphabetic tokens in the historical baseline/);
+ assert.match(html, /45\.01/);
+ assert.match(html, /0\.98/);
+ assert.match(html, /I is the fingerprint/);
+ assert.match(html, /Thematic core/);
+ assert.match(html, /Natural Center/);
+ assert.match(html, /Channel profile/);
+ assert.match(html, /How to properly let a candle burn/);
+ assert.match(html, /Transcend Cosmetics becomes Transcend Essentials/);
+ assert.match(html, /The summer fabric sequence/);
+ assert.match(html, /compact myth check/);
+ assert.match(html, /og-archive\.png/);
+});
+
+test("archive data is a bounded seven-field public ledger", async () => {
+ const posts = JSON.parse(
+ await readFile(new URL("../data/posts.json", import.meta.url), "utf8"),
+ );
+ const expectedKeys = [
+ "publishedDate",
+ "year",
+ "category",
+ "title",
+ "url",
+ "author",
+ "wordCount",
+ ];
+
+ assert.equal(posts.length, 258);
+ assert.equal(new Set(posts.map((post) => post.url)).size, 258);
+ for (const post of posts) {
+ assert.deepEqual(Object.keys(post), expectedKeys);
+ assert.match(post.url, /^https:\/\/www\.downsstyle\.com\//);
+ assert.equal("body" in post, false);
+ assert.equal("tags" in post, false);
+ }
+});
+
+test("removes starter-only preview assets and dependencies", async () => {
+ const packageJson = await readFile(new URL("../package.json", import.meta.url), "utf8");
+ assert.doesNotMatch(packageJson, /react-loading-skeleton/);
+
+ await assert.rejects(access(new URL("app/_sites-preview", templateRoot)));
+});
diff --git a/docs/continuations/charles/rose-toners-share/tsconfig.json b/docs/continuations/charles/rose-toners-share/tsconfig.json
new file mode 100644
index 000000000..3a13f90a7
--- /dev/null
+++ b/docs/continuations/charles/rose-toners-share/tsconfig.json
@@ -0,0 +1,34 @@
+{
+ "compilerOptions": {
+ "target": "ES2017",
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "react-jsx",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ],
+ "paths": {
+ "@/*": ["./*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts",
+ "**/*.mts"
+ ],
+ "exclude": ["node_modules"]
+}
diff --git a/docs/continuations/charles/rose-toners-share/vite.config.ts b/docs/continuations/charles/rose-toners-share/vite.config.ts
new file mode 100644
index 000000000..ae08f9367
--- /dev/null
+++ b/docs/continuations/charles/rose-toners-share/vite.config.ts
@@ -0,0 +1,59 @@
+import vinext from "vinext";
+import { defineConfig } from "vite";
+import hostingConfig from "./.openai/hosting.json";
+import { sites } from "./build/sites-vite-plugin";
+
+const SITE_CREATOR_PLACEHOLDER_DATABASE_ID =
+ "00000000-0000-4000-8000-000000000000";
+
+const { d1, r2 } = hostingConfig;
+
+// macOS Seatbelt blocks FSEvents, so Codex previews need polling for HMR.
+const isCodexSeatbeltSandbox = process.env.CODEX_SANDBOX === "seatbelt";
+
+const localBindingConfig = {
+ main: "./worker/index.ts",
+ compatibility_flags: ["nodejs_compat"],
+ d1_databases: d1
+ ? [
+ {
+ binding: d1,
+ database_name: "site-creator-d1",
+ database_id: SITE_CREATOR_PLACEHOLDER_DATABASE_ID,
+ },
+ ]
+ : [],
+ r2_buckets: r2
+ ? [
+ {
+ binding: r2,
+ bucket_name: "site-creator-r2",
+ },
+ ]
+ : [],
+};
+
+export default defineConfig(async () => {
+ // Keep Wrangler and Miniflare state project-local. These are non-secret tool
+ // settings; application environment belongs in ignored `.env*` files.
+ process.env.WRANGLER_WRITE_LOGS ??= "false";
+ process.env.WRANGLER_LOG_PATH ??= ".wrangler/logs";
+ process.env.MINIFLARE_REGISTRY_PATH ??= ".wrangler/registry";
+
+ // Wrangler snapshots its log path while the Cloudflare plugin is imported.
+ const { cloudflare } = await import("@cloudflare/vite-plugin");
+
+ return {
+ server: isCodexSeatbeltSandbox
+ ? { watch: { useFsEvents: false, usePolling: true } }
+ : undefined,
+ plugins: [
+ vinext(),
+ sites(),
+ cloudflare({
+ viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] },
+ config: localBindingConfig,
+ }),
+ ],
+ };
+});
diff --git a/docs/continuations/charles/rose-toners-share/worker/index.ts b/docs/continuations/charles/rose-toners-share/worker/index.ts
new file mode 100644
index 000000000..b3cd13786
--- /dev/null
+++ b/docs/continuations/charles/rose-toners-share/worker/index.ts
@@ -0,0 +1,47 @@
+/** Cloudflare Worker entry point for the vinext-starter template. */
+import { handleImageOptimization, DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES } from "vinext/server/image-optimization";
+import handler from "vinext/server/app-router-entry";
+
+interface Env {
+ ASSETS: Fetcher;
+ DB: D1Database;
+ IMAGES: {
+ input(stream: ReadableStream): {
+ transform(options: Record): {
+ output(options: { format: string; quality: number }): Promise<{ response(): Response }>;
+ };
+ };
+ };
+}
+
+interface ExecutionContext {
+ waitUntil(promise: Promise): void;
+ passThroughOnException(): void;
+}
+
+// Image security config. SVG sources with .svg extension auto-skip the
+// optimization endpoint on the client side (served directly, no proxy).
+// To route SVGs through the optimizer (with security headers), set
+// dangerouslyAllowSVG: true in next.config.js and uncomment below:
+// const imageConfig: ImageConfig = { dangerouslyAllowSVG: true };
+
+const worker = {
+ async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise {
+ const url = new URL(request.url);
+
+ if (url.pathname === "/_vinext/image") {
+ const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES];
+ return handleImageOptimization(request, {
+ fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))),
+ transformImage: async (body, { width, format, quality }) => {
+ const result = await env.IMAGES.input(body).transform(width > 0 ? { width } : {}).output({ format, quality });
+ return result.response();
+ },
+ }, allowedWidths);
+ }
+
+ return handler.fetch(request, env, ctx);
+ },
+};
+
+export default worker;
diff --git a/scripts/analyze-downs-style-voice.py b/scripts/analyze-downs-style-voice.py
new file mode 100644
index 000000000..168447b9e
--- /dev/null
+++ b/scripts/analyze-downs-style-voice.py
@@ -0,0 +1,284 @@
+#!/usr/bin/env python3
+"""Derive reproducible, non-verbatim voice metrics from a private post corpus.
+
+The input is the temporary JSON emitted by ``audit-downs-style-archive.py``.
+The output deliberately contains aggregate counts only: no article body, sentence,
+or excerpt is retained in the tracked analysis artifact.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import re
+import statistics
+from collections import Counter
+from datetime import date
+from pathlib import Path
+from typing import Any
+
+
+WORD_PATTERN = re.compile(r"[A-Za-z]+(?:['’][A-Za-z]+)?")
+SENTENCE_BOUNDARY = re.compile(r"(?<=[.!?])\s+(?=[A-Z0-9\"“(])")
+
+PRONOUN_GROUPS = {
+ "first_person_singular": ("i", "me", "my", "mine"),
+ "first_person_plural": ("we", "us", "our", "ours"),
+ "second_person": ("you", "your", "yours"),
+}
+
+LEXICAL_MARKERS = (
+ "love",
+ "loved",
+ "favorite",
+ "obsessed",
+ "amazing",
+ "great",
+ "beautiful",
+ "cute",
+ "luxury",
+ "luxurious",
+ "glowing",
+ "healthy",
+ "natural",
+ "organic",
+ "personally",
+ "however",
+ "overall",
+)
+
+PHRASE_MARKERS = (
+ "i love",
+ "i loved",
+ "i like",
+ "i think",
+ "i feel",
+ "i recommend",
+ "i personally",
+ "i have to say",
+ "my favorite",
+ "one of my favorite",
+ "for me",
+ "of course",
+ "so i decided",
+ "had to try",
+ "fell in love",
+ "made my skin feel",
+ "left my skin feeling",
+ "overall i",
+ "thanks so much for reading",
+ "shop below",
+ "click to shop",
+)
+
+
+def normalize_word(word: str) -> str:
+ return word.lower().replace("’", "'")
+
+
+def words(text: str) -> list[str]:
+ return [normalize_word(word) for word in WORD_PATTERN.findall(text)]
+
+
+def safe_rate(count: int, total: int) -> float:
+ return round((count / total * 1000) if total else 0.0, 2)
+
+
+def era_for(published_date: str) -> str:
+ year = int(published_date[:4])
+ if year <= 2018:
+ return "2017-2018_foundation"
+ if year <= 2020:
+ return "2019-2020_reviews"
+ if year <= 2023:
+ return "2021-2023_expansion"
+ if year == 2024:
+ return "2024_editorial"
+ return "2025_plus"
+
+
+def summarize(posts: list[dict[str, Any]]) -> dict[str, Any]:
+ all_words = [word for post in posts for word in words(post["body"])]
+ word_counts = Counter(all_words)
+ word_total = len(all_words)
+ post_lengths = [len(words(post["body"])) for post in posts]
+
+ sentences: list[str] = []
+ paragraphs: list[str] = []
+ for post in posts:
+ compact = " ".join(post["body"].split())
+ sentences.extend(
+ sentence
+ for sentence in SENTENCE_BOUNDARY.split(compact)
+ if len(words(sentence)) >= 3
+ )
+ paragraphs.extend(
+ paragraph.strip()
+ for paragraph in post["body"].split("\n\n")
+ if paragraph.strip()
+ )
+
+ joined = "\n".join(post["body"] for post in posts)
+ normalized_text = joined.lower().replace("’", "'")
+ sentence_lengths = [len(words(sentence)) for sentence in sentences]
+ paragraph_lengths = [len(words(paragraph)) for paragraph in paragraphs]
+
+ pronouns = {
+ label: {
+ "count": sum(word_counts[word] for word in group),
+ "per_1000_words": safe_rate(
+ sum(word_counts[word] for word in group), word_total
+ ),
+ }
+ for label, group in PRONOUN_GROUPS.items()
+ }
+ lexical = {
+ marker: {
+ "count": word_counts[marker],
+ "per_1000_words": safe_rate(word_counts[marker], word_total),
+ }
+ for marker in LEXICAL_MARKERS
+ }
+ phrases = {
+ marker: {
+ "count": normalized_text.count(marker),
+ "per_1000_words": safe_rate(normalized_text.count(marker), word_total),
+ }
+ for marker in PHRASE_MARKERS
+ }
+
+ return {
+ "posts": len(posts),
+ "words": word_total,
+ "median_post_words": round(statistics.median(post_lengths), 2) if posts else 0,
+ "mean_sentence_words": round(statistics.mean(sentence_lengths), 2)
+ if sentence_lengths
+ else 0,
+ "median_paragraph_words": round(statistics.median(paragraph_lengths), 2)
+ if paragraph_lengths
+ else 0,
+ "pronouns": pronouns,
+ "punctuation_per_1000_words": {
+ "question_marks": safe_rate(joined.count("?"), word_total),
+ "exclamation_marks": safe_rate(joined.count("!"), word_total),
+ },
+ "structural_post_counts": {
+ "first_person_title": sum(
+ bool(re.search(r"\b(?:I|My)\b", post["title"])) for post in posts
+ ),
+ "question_title": sum("?" in post["title"] for post in posts),
+ "review_title": sum("review" in post["title"].lower() for post in posts),
+ "shop_cue": sum(
+ bool(re.search(r"\bshop\b", post["body"], re.IGNORECASE))
+ for post in posts
+ ),
+ "reader_thanks": sum(
+ "thanks" in post["body"].lower()
+ and "reading" in post["body"].lower()
+ for post in posts
+ ),
+ "numeric_rating": sum(
+ bool(re.search(r"\b\d+(?:\.\d+)?\s*/\s*10\b", post["body"]))
+ for post in posts
+ ),
+ },
+ "lexical_markers": lexical,
+ "phrase_markers": phrases,
+ }
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--corpus-json", type=Path, required=True)
+ parser.add_argument("--output", type=Path, required=True)
+ parser.add_argument(
+ "--baseline-cutoff",
+ type=date.fromisoformat,
+ default=date(2024, 12, 31),
+ help="Latest publication date included in the causal voice baseline",
+ )
+ return parser.parse_args()
+
+
+def main() -> int:
+ args = parse_args()
+ corpus_bytes = args.corpus_json.read_bytes()
+ posts = json.loads(corpus_bytes)
+ if not isinstance(posts, list) or not all(isinstance(post, dict) for post in posts):
+ raise SystemExit("corpus JSON must be a list of post objects")
+ required = {"published_date", "category", "title", "url", "body"}
+ for index, post in enumerate(posts):
+ missing = required - post.keys()
+ if missing:
+ raise SystemExit(f"post {index} is missing fields: {sorted(missing)}")
+
+ baseline = [
+ post
+ for post in posts
+ if date.fromisoformat(post["published_date"][:10]) <= args.baseline_cutoff
+ ]
+ excluded = [post for post in posts if post not in baseline]
+
+ by_year = {
+ year: summarize(
+ [post for post in posts if post["published_date"].startswith(year)]
+ )
+ for year in sorted({post["published_date"][:4] for post in posts})
+ }
+ by_category = {
+ category: summarize([post for post in posts if post["category"] == category])
+ for category in sorted({post["category"] for post in posts})
+ }
+ by_era = {
+ era: summarize(
+ [post for post in posts if era_for(post["published_date"]) == era]
+ )
+ for era in dict.fromkeys(era_for(post["published_date"]) for post in posts)
+ }
+
+ output = {
+ "schema_version": 1,
+ "source": {
+ "corpus_sha256": hashlib.sha256(corpus_bytes).hexdigest(),
+ "public_post_count": len(posts),
+ "first_publication_date": min(post["published_date"] for post in posts),
+ "last_publication_date": max(post["published_date"] for post in posts),
+ "contains_verbatim_article_text": False,
+ },
+ "baseline": {
+ "cutoff": args.baseline_cutoff.isoformat(),
+ "rationale": (
+ "Causal baseline excludes posts published after the historical corpus "
+ "that prompted this voice audit."
+ ),
+ "included_posts": len(baseline),
+ "excluded_posts": len(excluded),
+ "excluded_urls": [post["url"] for post in excluded],
+ "metrics": summarize(baseline),
+ },
+ "all_public_posts": summarize(posts),
+ "by_era": by_era,
+ "by_year": by_year,
+ "by_category": by_category,
+ "method_notes": [
+ "Tokens are alphabetic words with internal apostrophes.",
+ "Sentence and paragraph boundaries are heuristic because historic posts mix prose and shopping lists.",
+ "Rates are normalized per 1,000 words; phrase counts are literal and case-insensitive.",
+ "Metrics describe the corpus and are evidence for editorial judgment, not a claim of sole authorship.",
+ ],
+ }
+ if any("body" in key for key in output):
+ raise AssertionError("derived output must not expose article bodies")
+ serialized = json.dumps(output, indent=2, ensure_ascii=False, sort_keys=False) + "\n"
+ args.output.parent.mkdir(parents=True, exist_ok=True)
+ args.output.write_text(serialized, encoding="utf-8")
+ print(
+ f"analyzed {len(posts)} posts; baseline {len(baseline)}; "
+ f"excluded {len(excluded)}; wrote {args.output}"
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/audit-downs-style-archive.py b/scripts/audit-downs-style-archive.py
new file mode 100644
index 000000000..34d992491
--- /dev/null
+++ b/scripts/audit-downs-style-archive.py
@@ -0,0 +1,398 @@
+#!/usr/bin/env python3
+"""Inventory Downs Style's public blog archive from its sitemap and collection pages.
+
+The committed inventory contains metadata and content fingerprints only. Pass
+``--corpus-json`` to create a temporary full-text corpus for local voice analysis; raw
+article text should not be committed by this workflow.
+"""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import hashlib
+import json
+import re
+import time
+import urllib.error
+import urllib.request
+import xml.etree.ElementTree as ET
+from concurrent.futures import ThreadPoolExecutor, as_completed
+from dataclasses import asdict, dataclass
+from pathlib import Path
+from typing import Iterable
+from urllib.parse import urljoin, urlparse
+
+from bs4 import BeautifulSoup
+
+
+BASE_URL = "https://www.downsstyle.com"
+SITEMAP_URL = f"{BASE_URL}/sitemap.xml"
+USER_AGENT = "DownsStyleArchiveAudit/1.0 (+https://github.com/organvm/limen)"
+POST_PATH = re.compile(
+ r"^/(?P[^/]+)/(?P\d{4})/(?P\d{1,2})/"
+ r"(?P\d{1,2})/(?P[^/?#]+)/?$"
+)
+WORD = re.compile(r"\b[\w’'-]+\b", re.UNICODE)
+
+COLLECTION_LABELS = {
+ "gifts": "Gift Inspo",
+ "look-book": "Look Book",
+ "masks": "Masks",
+ "new-blog": "Candles",
+ "new-blog-1": "Interior Design",
+ "new-blog-2": "Eat",
+ "skincare": "Skincare",
+ "travel": "Travel",
+ "workoutsdiet": "Workouts/Diet",
+}
+
+
+@dataclass(frozen=True)
+class SitemapPost:
+ url: str
+ lastmod: str
+
+
+@dataclass
+class PostRecord:
+ published_date: str
+ category: str
+ collection: str
+ title: str
+ url: str
+ discovery_sources: str
+ sitemap_lastmod: str
+ http_status: int | None
+ final_url: str
+ author: str
+ tags: str
+ word_count: int
+ body_blocks: int
+ content_sha256: str
+ error: str
+
+
+def fetch(url: str, *, timeout: float, attempts: int = 3) -> tuple[int, str, str]:
+ """Return status, final URL, and decoded response text with bounded retries."""
+ request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
+ last_error: Exception | None = None
+ for attempt in range(attempts):
+ try:
+ with urllib.request.urlopen(request, timeout=timeout) as response:
+ charset = response.headers.get_content_charset() or "utf-8"
+ body = response.read().decode(charset, errors="replace")
+ return response.status, response.geturl(), body
+ except (urllib.error.URLError, TimeoutError) as exc:
+ last_error = exc
+ if attempt + 1 < attempts:
+ time.sleep(0.5 * (attempt + 1))
+ assert last_error is not None
+ raise last_error
+
+
+def canonical_post_url(url: str) -> str | None:
+ """Normalize a Downs Style dated-post URL or return None."""
+ parsed = urlparse(urljoin(BASE_URL, url))
+ if parsed.netloc.lower() not in {"downsstyle.com", "www.downsstyle.com"}:
+ return None
+ match = POST_PATH.match(parsed.path)
+ if not match:
+ return None
+ path = parsed.path.rstrip("/")
+ return f"{BASE_URL}{path}"
+
+
+def parse_sitemap(xml_text: str) -> list[SitemapPost]:
+ root = ET.fromstring(xml_text)
+ namespace = {"s": "http://www.sitemaps.org/schemas/sitemap/0.9"}
+ posts: list[SitemapPost] = []
+ for node in root.findall("s:url", namespace):
+ location = node.findtext("s:loc", default="", namespaces=namespace)
+ canonical = canonical_post_url(location)
+ if not canonical:
+ continue
+ lastmod = node.findtext("s:lastmod", default="", namespaces=namespace)
+ posts.append(SitemapPost(url=canonical, lastmod=lastmod))
+ return posts
+
+
+def discover_posts(html: str) -> set[str]:
+ soup = BeautifulSoup(html, "html.parser")
+ urls: set[str] = set()
+ for anchor in soup.find_all("a", href=True):
+ canonical = canonical_post_url(anchor["href"])
+ if canonical:
+ urls.add(canonical)
+ return urls
+
+
+def discover_collection_posts(collection: str, *, timeout: float) -> set[str]:
+ """Enumerate every public item exposed by a Squarespace collection."""
+ page_url = f"{BASE_URL}/{collection}?format=json"
+ visited_pages: set[str] = set()
+ urls: set[str] = set()
+ while page_url:
+ if page_url in visited_pages:
+ raise RuntimeError(f"pagination cycle detected for {collection}: {page_url}")
+ visited_pages.add(page_url)
+ _, _, response_text = fetch(page_url, timeout=timeout)
+ payload = json.loads(response_text)
+ for item in payload.get("items", []):
+ canonical = canonical_post_url(item.get("fullUrl", ""))
+ if canonical:
+ urls.add(canonical)
+
+ next_page = payload.get("pagination", {}).get("nextPageUrl")
+ if not next_page:
+ page_url = ""
+ continue
+ page_url = urljoin(BASE_URL, next_page)
+ separator = "&" if "?" in page_url else "?"
+ page_url = f"{page_url}{separator}format=json"
+ return urls
+
+
+def fallback_date_and_collection(url: str) -> tuple[str, str]:
+ match = POST_PATH.match(urlparse(url).path)
+ if not match:
+ return "", ""
+ values = match.groupdict()
+ date = f"{values['year']}-{int(values['month']):02d}-{int(values['day']):02d}"
+ return date, values["collection"]
+
+
+def clean_text(node: BeautifulSoup) -> str:
+ text = node.get_text(" ", strip=True)
+ return " ".join(text.split())
+
+
+def parse_post(
+ url: str,
+ *,
+ sources: Iterable[str],
+ lastmod: str,
+ timeout: float,
+) -> tuple[PostRecord, str]:
+ fallback_date, collection = fallback_date_and_collection(url)
+ category = COLLECTION_LABELS.get(collection, collection)
+ try:
+ status, final_url, html = fetch(url, timeout=timeout)
+ soup = BeautifulSoup(html, "html.parser")
+ article = soup.select_one("article.BlogItem")
+ title_node = soup.select_one("h1.BlogItem-title")
+ if title_node:
+ title = clean_text(title_node)
+ else:
+ meta_title = soup.find("meta", attrs={"property": "og:title"})
+ title = meta_title.get("content", "") if meta_title else ""
+ title = re.sub(r"\s+—\s+Downs Style$", "", title).strip()
+
+ date_node = soup.select_one("time.Blog-meta-item--date[datetime]")
+ published_date = date_node.get("datetime", fallback_date) if date_node else fallback_date
+ author_node = soup.select_one(".Blog-meta-item--author")
+ author = clean_text(author_node) if author_node else ""
+ tags = sorted(
+ {
+ clean_text(tag)
+ for tag in soup.select(".Blog-meta-item--tags .Blog-meta-item-tag")
+ if clean_text(tag)
+ },
+ key=str.casefold,
+ )
+
+ blocks: list[str] = []
+ if article:
+ for unwanted in article.select("script, style, noscript, .BlogItem-share"):
+ unwanted.decompose()
+ for block in article.select(".sqs-block-content"):
+ text = clean_text(block)
+ if text:
+ blocks.append(text)
+ if not blocks:
+ text = clean_text(article)
+ if text:
+ blocks.append(text)
+ body = "\n\n".join(blocks)
+ digest = hashlib.sha256(body.encode("utf-8")).hexdigest() if body else ""
+ record = PostRecord(
+ published_date=published_date,
+ category=category,
+ collection=collection,
+ title=title,
+ url=url,
+ discovery_sources=";".join(sorted(set(sources))),
+ sitemap_lastmod=lastmod,
+ http_status=status,
+ final_url=final_url,
+ author=author,
+ tags=";".join(tags),
+ word_count=len(WORD.findall(body)),
+ body_blocks=len(blocks),
+ content_sha256=digest,
+ error="" if article else "article element not found",
+ )
+ return record, body
+ except Exception as exc: # noqa: BLE001 - inventory must retain failed URLs
+ record = PostRecord(
+ published_date=fallback_date,
+ category=category,
+ collection=collection,
+ title="",
+ url=url,
+ discovery_sources=";".join(sorted(set(sources))),
+ sitemap_lastmod=lastmod,
+ http_status=None,
+ final_url="",
+ author="",
+ tags="",
+ word_count=0,
+ body_blocks=0,
+ content_sha256="",
+ error=f"{type(exc).__name__}: {exc}",
+ )
+ return record, ""
+
+
+def write_inventory(path: Path, records: list[PostRecord]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ fieldnames = list(asdict(records[0]).keys()) if records else list(PostRecord.__annotations__)
+ with path.open("w", newline="", encoding="utf-8") as handle:
+ writer = csv.DictWriter(handle, fieldnames=fieldnames)
+ writer.writeheader()
+ for record in records:
+ writer.writerow(asdict(record))
+
+
+def write_corpus(path: Path, records_and_bodies: list[tuple[PostRecord, str]]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ payload = [
+ {
+ "published_date": record.published_date,
+ "category": record.category,
+ "title": record.title,
+ "url": record.url,
+ "body": body,
+ }
+ for record, body in records_and_bodies
+ ]
+ path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
+
+
+def write_site_inventory(path: Path, records: list[PostRecord]) -> None:
+ """Write the bounded metadata used by the browsable archive site."""
+ payload = [
+ {
+ "publishedDate": record.published_date[:10],
+ "year": record.published_date[:4],
+ "category": record.category,
+ "title": record.title,
+ "url": record.url,
+ "author": record.author,
+ "wordCount": record.word_count,
+ }
+ for record in reversed(records)
+ ]
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(
+ json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
+ encoding="utf-8",
+ )
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--output", type=Path, required=True, help="Metadata CSV output path")
+ parser.add_argument(
+ "--corpus-json",
+ type=Path,
+ help="Optional private full-text JSON output for local analysis",
+ )
+ parser.add_argument(
+ "--site-json",
+ type=Path,
+ help="Optional bounded metadata JSON for the archive website",
+ )
+ parser.add_argument("--workers", type=int, default=4)
+ parser.add_argument("--timeout", type=float, default=30.0)
+ return parser.parse_args()
+
+
+def main() -> int:
+ args = parse_args()
+ if not 1 <= args.workers <= 8:
+ raise SystemExit("--workers must be between 1 and 8")
+
+ _, _, sitemap_xml = fetch(SITEMAP_URL, timeout=args.timeout)
+ sitemap_posts = parse_sitemap(sitemap_xml)
+ sources_by_url: dict[str, set[str]] = {post.url: {"sitemap"} for post in sitemap_posts}
+ lastmod_by_url = {post.url: post.lastmod for post in sitemap_posts}
+
+ collections = sorted({fallback_date_and_collection(post.url)[1] for post in sitemap_posts})
+ try:
+ _, _, homepage_html = fetch(BASE_URL, timeout=args.timeout)
+ for url in discover_posts(homepage_html):
+ sources_by_url.setdefault(url, set()).add("homepage")
+ except Exception as exc: # noqa: BLE001 - continue with other discovery rails
+ print(f"warning: homepage discovery failed: {exc}")
+
+ for collection in collections:
+ source = f"collection:{collection}"
+ try:
+ collection_urls = discover_collection_posts(
+ collection,
+ timeout=args.timeout,
+ )
+ except Exception as exc: # noqa: BLE001 - continue with sitemap coverage
+ print(f"warning: {source} discovery failed: {exc}")
+ continue
+ print(f"discovered {len(collection_urls)} posts in {source}")
+ for url in collection_urls:
+ sources_by_url.setdefault(url, set()).add(source)
+
+ def audit(url: str) -> tuple[PostRecord, str]:
+ return parse_post(
+ url,
+ sources=sources_by_url[url],
+ lastmod=lastmod_by_url.get(url, ""),
+ timeout=args.timeout,
+ )
+
+ audited: list[tuple[PostRecord, str]] = []
+ urls = sorted(sources_by_url)
+ with ThreadPoolExecutor(max_workers=args.workers) as executor:
+ futures = {executor.submit(audit, url): url for url in urls}
+ for index, future in enumerate(as_completed(futures), start=1):
+ audited.append(future.result())
+ if index % 25 == 0 or index == len(urls):
+ print(f"audited {index}/{len(urls)} posts")
+
+ audited.sort(
+ key=lambda item: (
+ item[0].published_date,
+ item[0].category.casefold(),
+ item[0].title.casefold(),
+ item[0].url,
+ )
+ )
+ records = [record for record, _ in audited]
+ write_inventory(args.output, records)
+ if args.corpus_json:
+ write_corpus(args.corpus_json, audited)
+ if args.site_json:
+ write_site_inventory(args.site_json, records)
+
+ failures = [record for record in records if record.error or record.http_status != 200]
+ sitemap_only = [
+ record for record in records if record.discovery_sources == "sitemap"
+ ]
+ non_sitemap = [record for record in records if not record.sitemap_lastmod]
+ print(f"inventory: {len(records)} posts")
+ print(f"sitemap-only: {len(sitemap_only)}")
+ print(f"discovered outside sitemap: {len(non_sitemap)}")
+ print(f"parse/fetch failures: {len(failures)}")
+ return 1 if failures else 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/verify-downs-style-archive.py b/scripts/verify-downs-style-archive.py
new file mode 100644
index 000000000..c7c898d04
--- /dev/null
+++ b/scripts/verify-downs-style-archive.py
@@ -0,0 +1,144 @@
+#!/usr/bin/env python3
+"""Verify the tracked Downs Style archive and its bounded presentation data."""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import json
+from collections import Counter
+from pathlib import Path
+from typing import Any
+
+
+EXPECTED_CATEGORIES = {
+ "Candles": 18,
+ "Eat": 12,
+ "Gift Inspo": 7,
+ "Interior Design": 8,
+ "Look Book": 36,
+ "Masks": 51,
+ "Skincare": 112,
+ "Travel": 7,
+ "Workouts/Diet": 7,
+}
+EXPECTED_YEARS = {
+ "2017": 16,
+ "2018": 78,
+ "2019": 22,
+ "2020": 54,
+ "2021": 22,
+ "2022": 5,
+ "2023": 36,
+ "2024": 24,
+ "2026": 1,
+}
+
+
+def require(condition: bool, message: str) -> None:
+ if not condition:
+ raise SystemExit(f"archive verification failed: {message}")
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--ledger",
+ type=Path,
+ default=Path("docs/continuations/charles/downs-style-post-ledger.csv"),
+ )
+ parser.add_argument(
+ "--metrics",
+ type=Path,
+ default=Path("docs/continuations/charles/downs-style-voice-metrics.json"),
+ )
+ parser.add_argument(
+ "--site-json",
+ type=Path,
+ default=Path(
+ "docs/continuations/charles/rose-toners-share/data/posts.json"
+ ),
+ )
+ return parser.parse_args()
+
+
+def load_json(path: Path) -> Any:
+ return json.loads(path.read_text(encoding="utf-8"))
+
+
+def main() -> int:
+ args = parse_args()
+ with args.ledger.open(newline="", encoding="utf-8") as handle:
+ records = list(csv.DictReader(handle))
+
+ require(len(records) == 258, f"expected 258 ledger rows, found {len(records)}")
+ urls = [record["url"] for record in records]
+ require(len(set(urls)) == 258, "ledger URLs are not unique")
+ require(
+ all(record["http_status"] == "200" for record in records),
+ "not every archived URL returned HTTP 200",
+ )
+ require(
+ all(not record["error"] for record in records),
+ "one or more posts have a parse error",
+ )
+ require(
+ {record["author"] for record in records} == {"Chas Downs"},
+ "author metadata is not uniformly Chas Downs",
+ )
+ require(
+ len({record["content_sha256"] for record in records}) == 258,
+ "content fingerprints are missing or duplicated",
+ )
+ require(
+ Counter(record["category"] for record in records) == EXPECTED_CATEGORIES,
+ "category counts changed",
+ )
+ require(
+ Counter(record["published_date"][:4] for record in records) == EXPECTED_YEARS,
+ "year counts changed",
+ )
+ require(records[0]["published_date"][:10] == "2017-12-04", "earliest date changed")
+ require(records[-1]["published_date"][:10] == "2026-08-02", "latest date changed")
+ require(
+ sum(not record["sitemap_lastmod"] for record in records) == 1,
+ "expected exactly one post discovered outside the sitemap",
+ )
+
+ metrics = load_json(args.metrics)
+ require(metrics.get("schema_version") == 1, "unsupported metrics schema")
+ require(metrics["source"]["public_post_count"] == 258, "metrics omit public posts")
+ require(
+ metrics["source"]["contains_verbatim_article_text"] is False,
+ "metrics claim to contain verbatim article text",
+ )
+ require(metrics["baseline"]["included_posts"] == 257, "baseline must contain 257 posts")
+ require(metrics["baseline"]["excluded_posts"] == 1, "baseline must exclude one newer post")
+ require(metrics["baseline"]["cutoff"] == "2024-12-31", "baseline cutoff changed")
+
+ site_posts = load_json(args.site_json)
+ require(isinstance(site_posts, list), "site archive must be a JSON list")
+ require(len(site_posts) == 258, "site archive must expose all 258 posts")
+ require(
+ {post.get("url") for post in site_posts} == set(urls),
+ "site and ledger URL sets differ",
+ )
+ prohibited = {"body", "content", "html", "tags"}
+ require(
+ all(not (prohibited & post.keys()) for post in site_posts),
+ "site data includes article bodies or noisy raw tags",
+ )
+ require(
+ all(post.get("author") == "Chas Downs" for post in site_posts),
+ "site author metadata changed",
+ )
+
+ print(
+ "verified 258 unique Downs Style posts, nine categories, "
+ "a 257-post causal voice baseline, and body-free site data"
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())