diff --git a/index.html b/index.html
index 07810c4..4ab1ff0 100644
--- a/index.html
+++ b/index.html
@@ -15,15 +15,15 @@
.hero .inner{position:relative;z-index:2}
.hero h1{font-size:clamp(40px,7vw,76px);font-weight:700;letter-spacing:1px;margin:16px 0}
.hero h1 .em{font-style:italic;color:var(--gold)}
- .hero p{color:var(--muted);max-width:640px;margin:0 auto;font-size:16px}
+ .hero p{color:var(--muted);max-width:640px;margin:0 auto;font-size:var(--t-md)}
.hero .cta{display:flex;gap:14px;justify-content:center;flex-wrap:wrap;margin-top:32px}
.stats{display:flex;gap:40px;justify-content:center;flex-wrap:wrap;margin-top:40px}
.stats .n{font-family:'Playfair Display',serif;font-size:32px;color:var(--cream);display:block;line-height:1}
- .stats .l{font-size:10px;letter-spacing:2px;text-transform:uppercase;margin-top:6px;color:var(--muted)}
+ .stats .l{font-size:var(--t-xs);letter-spacing:2px;text-transform:uppercase;margin-top:6px;color:var(--muted)}
/* doors */
.doors{display:grid;grid-template-columns:repeat(3,1fr);gap:18px;margin:54px 0 10px}
@media(max-width:880px){.doors{grid-template-columns:1fr}}
- .door{position:relative;border:1px solid var(--line);border-radius:16px;overflow:hidden;min-height:300px;
+ .door{position:relative;border:1px solid var(--b-subtle);border-radius:16px;overflow:hidden;min-height:300px;
display:flex;flex-direction:column;justify-content:flex-end;padding:26px;transition:.25s;background:#0d0d0d;
box-shadow:0 0 0 1px rgba(212,165,116,.12),0 0 26px rgba(212,165,116,.08)}
.door .dimg{position:absolute;inset:0;background-size:cover;background-position:top center;opacity:.42;transition:.4s}
@@ -46,11 +46,11 @@
.srow:hover .srow-bg{opacity:.42;transform:scale(1.06)}
.srow::after{content:'';position:absolute;inset:0;z-index:1;pointer-events:none;background:linear-gradient(180deg,rgba(20,18,16,.46),rgba(18,18,18,.86) 60%,rgba(18,18,18,.95))}
.srow h4,.srow .sc,.srow .sm{position:relative;z-index:2}
- .srow h4{font-size:20px}
- .srow .sc{color:var(--muted);font-size:12px;margin:6px 0 10px;min-height:48px}
+ .srow h4{font-size:var(--t-lg)}
+ .srow .sc{color:var(--muted);font-size:var(--t-sm);margin:6px 0 10px;min-height:48px}
.srow .sm{color:var(--blue);font-size:11px;letter-spacing:1px}
.featrow{display:flex;align-items:baseline;justify-content:space-between;margin:46px 0 6px}
- .featrow a{color:var(--gold);font-size:12px;letter-spacing:1px;text-transform:uppercase}
+ .featrow a{color:var(--gold);font-size:var(--t-sm);letter-spacing:1px;text-transform:uppercase}
diff --git a/quiz.html b/quiz.html
index e3bc6d1..0e65f9a 100644
--- a/quiz.html
+++ b/quiz.html
@@ -37,7 +37,7 @@
padding: 16px 42px;
background: var(--gold);
border: none;
- border-radius: 10px;
+ border-radius: var(--r-md);
color: #111;
font-family: 'Montserrat', sans-serif;
font-size: 13px;
@@ -51,7 +51,7 @@
.intro .note {
margin-top: 20px;
color: var(--muted);
- font-size: 12px;
+ font-size: var(--t-sm);
letter-spacing: 1px;
}
@@ -77,7 +77,7 @@
.qprogress-track {
flex: 1;
height: 3px;
- background: var(--panel2);
+ background: var(--s3);
border-radius: 3px;
overflow: hidden;
}
@@ -98,8 +98,8 @@
}
.qcard {
- background: var(--panel);
- border: 1px solid var(--line);
+ background: var(--s2);
+ border: 1px solid var(--b-subtle);
border-radius: 16px;
padding: 36px;
}
@@ -205,6 +205,10 @@
.qcard { padding: 22px 18px; }
.qprogress-label { min-width: auto; }
}
+
+.match-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px;margin-bottom:28px}
+.match-divider{grid-column:1/-1;display:flex;align-items:center;gap:12px;margin:4px 0;color:var(--muted);font-size:11px;letter-spacing:1.8px;text-transform:uppercase}
+.match-divider::before,.match-divider::after{content:'';flex:1;height:1px;background:var(--line)}
@@ -367,40 +371,58 @@
// pick winner — highest-scoring family with at least one live char
const win = ranked.find(f => liveByFam(f).length > 0) || ranked[0];
- // pick up to 3 matches — diversified by body_code
+ // pick up to 4 matches from the top family — diversified by body_code first
const pool = liveByFam(win);
const seenBody = new Set();
const matches = [];
for (const c of pool) {
if (!seenBody.has(c.body_code)) { matches.push(c); seenBody.add(c.body_code); }
- if (matches.length === 3) break;
+ if (matches.length === 4) break;
}
- // if still < 3, fill from same family without body-code diversity
for (const c of pool) {
- if (matches.length >= 3) break;
+ if (matches.length >= 4) break;
if (!matches.includes(c)) matches.push(c);
}
- // final fallback: any live character
+
+ // fill remaining slots from second-ranked family before any cross-family fallback
+ const runner = ranked.find(f => f !== win && liveByFam(f).length > 0);
+ const fillFrom = runner ? liveByFam(runner) : [];
+ let fromSecond = 0;
+ for (const c of fillFrom) {
+ if (matches.length >= 4) break;
+ if (!matches.includes(c)) { matches.push(c); fromSecond++; }
+ }
+
+ // final fallback: any remaining live character
if (matches.length < 3) {
const anyLive = m.characters.filter(c => c.status === 'live');
for (const c of anyLive) {
- if (matches.length >= 3) break;
- if (!matches.includes(c)) matches.push(c);
+ if (matches.length >= 4) break;
+ if (!matches.includes(c)) { matches.push(c); fromSecond++; }
}
}
+ const splitAt = matches.length - fromSecond;
+
// update progress bar to 100%
document.getElementById('pbar').style.width = '100%';
hide('qscreen');
- showResult(win, matches);
+ showResult(win, runner, matches, splitAt);
}
- function showResult(win, matches) {
+ function showResult(win, runner, matches, splitAt) {
const famColor = ZX.famColor(win);
const desc = FAM_DESC[win] || '';
const rscreen = document.getElementById('rscreen');
+ const cardItems = matches.map((c, i) => {
+ const divider = (i === splitAt && i > 0 && runner)
+ ? `
- ${matches.map((c, i) => `
${ZX.charCard(c)}
`).join('')}
+
+ ${cardItems}
`;
diff --git a/scripts/analyze_independent_groupings.py b/scripts/analyze_independent_groupings.py
index 4f08f9f..a9efbc2 100644
--- a/scripts/analyze_independent_groupings.py
+++ b/scripts/analyze_independent_groupings.py
@@ -11,9 +11,15 @@
import sqlite3
from pathlib import Path
-import matplotlib.pyplot as plt
-import pandas as pd
-import seaborn as sns
+try:
+ import matplotlib.pyplot as plt
+ import pandas as pd
+ import seaborn as sns
+except ImportError as exc:
+ raise SystemExit(
+ "This script requires matplotlib, pandas, and seaborn. "
+ "Install with: pip install matplotlib pandas seaborn"
+ ) from exc
ROOT = Path(__file__).parent.parent
DB_PATH = ROOT / "db" / "independent_competitor.sqlite"
@@ -180,10 +186,10 @@ def write_pdr_brief(
lines.append("## Visual Matrix Artifacts")
lines.append("")
- lines.append(f"- Line architecture heatmap: {OUT_LINE_PNG.relative_to(ROOT)}")
- lines.append(f"- Price-band heatmap: {OUT_PRICE_PNG.relative_to(ROOT)}")
- lines.append(f"- Full matrix CSV: {OUT_CSV.relative_to(ROOT)}")
- lines.append(f"- Narrative matrix report: {OUT_MD.relative_to(ROOT)}")
+ lines.append(f"- Line architecture heatmap: {OUT_LINE_PNG.relative_to(ROOT).as_posix()}")
+ lines.append(f"- Price-band heatmap: {OUT_PRICE_PNG.relative_to(ROOT).as_posix()}")
+ lines.append(f"- Full matrix CSV: {OUT_CSV.relative_to(ROOT).as_posix()}")
+ lines.append(f"- Narrative matrix report: {OUT_MD.relative_to(ROOT).as_posix()}")
OUT_PDR_BRIEF.parent.mkdir(parents=True, exist_ok=True)
OUT_PDR_BRIEF.write_text("\n".join(lines) + "\n", encoding="utf-8")
@@ -414,8 +420,8 @@ def main() -> None:
print(
"Wrote "
- f"{OUT_CSV.relative_to(ROOT)}, {OUT_MD.relative_to(ROOT)}, {OUT_JSON.relative_to(ROOT)}, "
- f"{OUT_LINE_PNG.relative_to(ROOT)}, {OUT_PRICE_PNG.relative_to(ROOT)}, and {OUT_PDR_BRIEF.relative_to(ROOT)}"
+ f"{OUT_CSV.relative_to(ROOT).as_posix()}, {OUT_MD.relative_to(ROOT).as_posix()}, {OUT_JSON.relative_to(ROOT).as_posix()}, "
+ f"{OUT_LINE_PNG.relative_to(ROOT).as_posix()}, {OUT_PRICE_PNG.relative_to(ROOT).as_posix()}, and {OUT_PDR_BRIEF.relative_to(ROOT).as_posix()}"
)
diff --git a/scripts/build_competitor_catalog_taxonomy.py b/scripts/build_competitor_catalog_taxonomy.py
index 0756279..6893e39 100644
--- a/scripts/build_competitor_catalog_taxonomy.py
+++ b/scripts/build_competitor_catalog_taxonomy.py
@@ -436,14 +436,20 @@ def scrape_supplier(config: SupplierConfig, session: requests.Session, families:
if title.startswith("‣"):
continue
- block = measurement_text_from_card(anchor)
+ try:
+ block = measurement_text_from_card(anchor)
+ except Exception as exc:
+ print(f"[scrape_supplier] skip {url}: {exc}")
+ continue
if not block:
# Avoid deep per-product fetches in supplier mode; rely on inline card specs.
continue
height = parse_number(r"Height:\s*([\d.]+)\s*cm", block) or parse_number(r"Body height[^\d]*([\d.]+)\s*cm", block)
+ if height is not None and height < 100:
+ height = None # guard against partial regex matches (e.g. "16.0" instead of "160")
weight = parse_number(r"Weight:\s*([\d.]+)\s*kg", block) or parse_number(r"weights?\s*(?:ca\.)?\s*([\d.]+)\s*kg", block)
- bust = parse_number(r"Breasts?:\s*([\d.]+)\s*cm", block) or parse_number(r"Bust/Chest\s*([\d.]+)\s*cm", block)
+ bust = parse_number(r"Breasts?:\s*([\d.]+)\s*cm", block) or parse_number(r"(?:Bust|Chest):\s*([\d.]+)\s*cm", block)
waist = parse_number(r"Waist:\s*([\d.]+)\s*cm", block)
hips = parse_number(r"Hips?:\s*([\d.]+)\s*cm", block)
underbust = parse_number(r"under bust\)?[:\s]*([\d.]+)\s*cm", block)
@@ -526,7 +532,11 @@ def scrape_irontech(session: requests.Session, families: list[dict]) -> list[dic
rows = []
seen_codes: set[str] = set()
for url in irontech_product_links(session):
- html = fetch_text(url, session)
+ try:
+ html = fetch_text(url, session)
+ except Exception as exc:
+ print(f"[scrape_irontech] skip {url}: {exc}")
+ continue
soup = BeautifulSoup(html, "html.parser")
specs = parse_irontech_specs(soup)
if not specs:
@@ -546,6 +556,8 @@ def scrape_irontech(session: requests.Session, families: list[dict]) -> list[dic
cup = cup_match.group(1).upper() if cup_match else None
height = parse_number(r"([\d.]+)\s*cm", specs.get("height", ""))
+ if height is not None and height < 100:
+ height = None
bust = parse_number(r"([\d.]+)\s*cm", specs.get("breastline", ""))
underbust = parse_number(r"([\d.]+)\s*cm", specs.get("under breastline", ""))
waist = parse_number(r"([\d.]+)\s*cm", specs.get("waistline", ""))
@@ -625,7 +637,11 @@ def scrape_tayu(session: requests.Session, families: list[dict]) -> list[dict]:
representative_urls.setdefault(key, url)
for url in representative_urls.values():
- html = fetch_text(url, session)
+ try:
+ html = fetch_text(url, session)
+ except Exception as exc:
+ print(f"[scrape_tayu] skip {url}: {exc}")
+ continue
soup = BeautifulSoup(html, "html.parser")
text = normalize_space(soup.get_text(" ", strip=True))
@@ -637,6 +653,8 @@ def scrape_tayu(session: requests.Session, families: list[dict]) -> list[dict]:
continue
height = parse_number(r"Height:\s*([\d.]+)\s*cm", text)
+ if height is not None and height < 100:
+ height = None
bust = parse_number(r"Bust:\s*([\d.]+)\s*cm", text)
waist = parse_number(r"Waist:\s*([\d.]+)\s*cm", text)
hips = parse_number(r"Hips:\s*([\d.]+)\s*cm", text)
@@ -1311,7 +1329,8 @@ def main() -> None:
rows.extend(scrape_siliconwives(sw_config, session, families))
realdoll_inventory = capture_realdoll_inventory(session)
- unavailable = list(UNAVAILABLE_COMPETITORS)
+ scraped_brands = {r["brand"] for r in rows}
+ unavailable = [u for u in UNAVAILABLE_COMPETITORS if u["brand"] not in scraped_brands]
if realdoll_inventory:
unavailable.insert(
0,
diff --git a/scripts/build_independent_db.py b/scripts/build_independent_db.py
index c0be6cf..1b9ec0a 100644
--- a/scripts/build_independent_db.py
+++ b/scripts/build_independent_db.py
@@ -1,6 +1,6 @@
"""
Build independent competitor database from web-scraped catalog data.
-Sources: SiliconWives (Shopify API, 1731 products), FantasyWives (60 products)
+Sources: SiliconWives (Shopify API), FantasyWives, BestRealDoll
Output: db/independent_competitor.sqlite + db/independent_competitor_report.json
"""
diff --git a/scripts/inspect_scrape.py b/scripts/inspect_scrape.py
index d855924..497a9aa 100644
--- a/scripts/inspect_scrape.py
+++ b/scripts/inspect_scrape.py
@@ -1,6 +1,8 @@
import json, statistics
+from pathlib import Path
-with open('db/competitor_web_scrape.json', encoding='utf-8') as f:
+ROOT = Path(__file__).resolve().parent.parent
+with open(ROOT / 'db' / 'competitor_web_scrape.json', encoding='utf-8') as f:
d = json.load(f)
brands = {}
diff --git a/scripts/print_report.py b/scripts/print_report.py
index b160e31..ca79101 100644
--- a/scripts/print_report.py
+++ b/scripts/print_report.py
@@ -1,5 +1,8 @@
import json
-with open('db/independent_competitor_report.json', encoding='utf-8') as f:
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+with open(ROOT / 'db' / 'independent_competitor_report.json', encoding='utf-8') as f:
r = json.load(f)
print('Sources:')
diff --git a/scripts/scrape_competitors.js b/scripts/scrape_competitors.js
index 6bc7140..1f58013 100644
--- a/scripts/scrape_competitors.js
+++ b/scripts/scrape_competitors.js
@@ -107,20 +107,6 @@ async function fetchShopifyCatalog(baseUrl) {
return all;
}
-// ── Playwright: product page deep-dive for measurements ──────────────────────
-async function deepMeasurements(page, productUrl, baseUrl) {
- try {
- await page.goto(productUrl.startsWith('http') ? productUrl : baseUrl + productUrl, {
- waitUntil: 'domcontentloaded', timeout: 20000,
- });
- await page.waitForTimeout(1500);
- const bodyHtml = await page.evaluate(() => document.body.innerHTML);
- return parseMeasurements(bodyHtml);
- } catch {
- return {};
- }
-}
-
// ── Playwright: full catalog scrape fallback ──────────────────────────────────
async function playwrightCatalog(browser, target) {
const ctx = await browser.newContext({ userAgent: UA, locale: 'en-US' });
@@ -144,7 +130,7 @@ async function playwrightCatalog(browser, target) {
const selectors = ['.product-card', '.product-item', '.grid__item', 'li.grid-item', '.product', '[class*="product-card"]', 'article.product'];
for (const sel of selectors) {
const els = document.querySelectorAll(sel);
- if (els.length > 3) {
+ if (els.length > 0) {
return Array.from(els).slice(0, 300).map(el => ({
title: el.querySelector('[class*="title"],[class*="name"],h2,h3,h4')?.textContent?.trim() || '',
price: el.querySelector('[class*="price"],.price,.amount')?.textContent?.trim() || '',
@@ -200,7 +186,9 @@ async function enrichMeasurements(browser, baseUrl, products, sampleSize = 20) {
for (const product of toSample) {
const handle = product.handle;
if (!handle) continue;
- const productUrl = handle.startsWith('http') ? handle : `${baseUrl}/products/${handle}`;
+ const productUrl = handle.startsWith('http') ? handle
+ : handle.startsWith('/products/') ? `${baseUrl}${handle}`
+ : `${baseUrl}/products/${handle}`;
try {
await page.goto(productUrl, { waitUntil: 'domcontentloaded', timeout: 20000 });
await page.waitForTimeout(1000);
diff --git a/series.html b/series.html
index 8dbd34c..811af23 100644
--- a/series.html
+++ b/series.html
@@ -12,7 +12,7 @@
.hero{padding:34px 32px 20px;text-align:center;background:radial-gradient(ellipse at top,#1c1c1c,#121212 70%)}
.hero .eyebrow{margin-bottom:9px;font-size:11px}
.hero h1{font-size:clamp(26px,4.4vw,44px);font-weight:700;letter-spacing:.5px}
- .hero p{color:var(--muted);max-width:600px;margin:9px auto 0;font-size:14px}
+ .hero p{color:var(--muted);max-width:600px;margin:9px auto 0;font-size:var(--t-base)}
.stats{display:flex;gap:28px;justify-content:center;flex-wrap:wrap;margin-top:18px;color:var(--muted)}
.stats .n{font-family:'Playfair Display',serif;font-size:23px;color:var(--cream);display:block;line-height:1}
.stats .l{font-size:9px;letter-spacing:2px;text-transform:uppercase;margin-top:5px}
@@ -20,29 +20,29 @@
/* ---- consolidated sticky controls bar (series switcher + view toggle) ---- */
.controls-bar{position:sticky;top:58px;z-index:40;display:flex;align-items:center;
justify-content:space-between;gap:14px 18px;flex-wrap:wrap;padding:11px 24px;
- background:rgba(18,18,18,.94);backdrop-filter:blur(10px);border-bottom:1px solid var(--line)}
+ background:rgba(18,18,18,.94);backdrop-filter:blur(10px);border-bottom:1px solid var(--b-subtle)}
.series-tabs{display:flex;gap:7px;flex-wrap:wrap;align-items:center}
- .tab{padding:6px 16px;border:1px solid var(--line);border-radius:30px;background:transparent;
- color:var(--muted);font-size:12px;letter-spacing:.5px;font-family:inherit;transition:.2s;cursor:pointer;display:inline-block}
+ .tab{padding:6px 16px;border:1px solid var(--b-subtle);border-radius:var(--r-pill);background:transparent;
+ color:var(--muted);font-size:var(--t-sm);letter-spacing:.5px;font-family:inherit;transition:.2s;cursor:pointer;display:inline-block}
.tab:hover{color:var(--text);border-color:var(--text)}
.tab.active{background:var(--cream);color:#111;border-color:var(--cream);font-weight:600}
/* ---- view-mode toggle (segmented pill) ---- */
- .view-toggle{display:inline-flex;border:1px solid var(--line);border-radius:30px;overflow:hidden;
- background:var(--panel2);flex-shrink:0}
+ .view-toggle{display:inline-flex;border:1px solid var(--b-subtle);border-radius:var(--r-pill);overflow:hidden;
+ background:var(--s3);flex-shrink:0}
.view-toggle button{padding:6px 18px;border:none;background:transparent;
color:var(--muted);font-size:11px;letter-spacing:1.2px;text-transform:uppercase;
font-family:inherit;cursor:pointer;transition:.2s;white-space:nowrap}
- .view-toggle button:first-child{border-right:1px solid var(--line)}
+ .view-toggle button:first-child{border-right:1px solid var(--b-subtle)}
.view-toggle button.active{background:var(--cream);color:#111;font-weight:600}
.view-toggle button:not(.active):hover{color:var(--text)}
@media(max-width:600px){.controls-bar{justify-content:center}}
/* ---- body-architecture mode ---- */
- .body-group{border-top:1px solid var(--line);padding:32px 0 8px}
+ .body-group{border-top:1px solid var(--b-subtle);padding:32px 0 8px}
.body-group-head{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;margin-bottom:20px}
.body-group-head h3{font-size:22px}
- .body-group-head .meta{color:var(--muted);font-size:12px}
+ .body-group-head .meta{color:var(--muted);font-size:var(--t-sm)}
/* ---- characters mode filter bar ---- */
.char-filterbar{
@@ -51,9 +51,9 @@
}
.chip-row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.pill{
- padding:6px 16px;border:1px solid var(--line);border-radius:30px;
+ padding:6px 16px;border:1px solid var(--b-subtle);border-radius:var(--r-pill);
background:transparent;color:var(--muted);
- cursor:pointer;font-size:12px;letter-spacing:.5px;
+ cursor:pointer;font-size:var(--t-sm);letter-spacing:.5px;
font-family:inherit;transition:.2s;white-space:nowrap;
}
.pill:hover{color:var(--text);border-color:var(--muted)}
@@ -61,7 +61,7 @@
.filter-sep{width:1px;height:20px;background:var(--line);margin:0 4px;flex-shrink:0;align-self:center}
.toggle-wrap{display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none}
.toggle-wrap input[type=checkbox]{accent-color:var(--gold);width:15px;height:15px;cursor:pointer}
- .toggle-label{font-size:12px;color:var(--muted);letter-spacing:.5px;transition:.2s}
+ .toggle-label{font-size:var(--t-sm);color:var(--muted);letter-spacing:.5px;transition:.2s}
.toggle-wrap:hover .toggle-label{color:var(--text)}
/* ---- count bar ---- */