From 2287374c43b17f52a9f85a67f35f8998a97673c0 Mon Sep 17 00:00:00 2001 From: Sun-sunshine06 Date: Fri, 29 May 2026 00:48:06 +0800 Subject: [PATCH 01/18] feat: add Versus mirror --- Dockerfile | 11 +- control_server.py | 1 + sites/versus/_health.py | 3 + sites/versus/app.py | 387 +++++++++++++++++++++ sites/versus/requirements.txt | 3 + sites/versus/static/css/.gitkeep | 0 sites/versus/static/css/main.css | 122 +++++++ sites/versus/static/icons/.gitkeep | 0 sites/versus/static/js/.gitkeep | 0 sites/versus/tasks.jsonl | 10 + sites/versus/templates/.gitkeep | 0 sites/versus/templates/404.html | 3 + sites/versus/templates/_product_card.html | 15 + sites/versus/templates/account.html | 14 + sites/versus/templates/base.html | 56 +++ sites/versus/templates/categories.html | 14 + sites/versus/templates/category.html | 21 ++ sites/versus/templates/compare.html | 29 ++ sites/versus/templates/compare_picker.html | 20 ++ sites/versus/templates/index.html | 42 +++ sites/versus/templates/login.html | 13 + sites/versus/templates/product.html | 32 ++ sites/versus/templates/rankings.html | 21 ++ sites/versus/templates/search.html | 31 ++ websyn_start.sh | 2 +- 25 files changed, 847 insertions(+), 3 deletions(-) create mode 100644 sites/versus/_health.py create mode 100644 sites/versus/app.py create mode 100644 sites/versus/requirements.txt create mode 100644 sites/versus/static/css/.gitkeep create mode 100644 sites/versus/static/css/main.css create mode 100644 sites/versus/static/icons/.gitkeep create mode 100644 sites/versus/static/js/.gitkeep create mode 100644 sites/versus/tasks.jsonl create mode 100644 sites/versus/templates/.gitkeep create mode 100644 sites/versus/templates/404.html create mode 100644 sites/versus/templates/_product_card.html create mode 100644 sites/versus/templates/account.html create mode 100644 sites/versus/templates/base.html create mode 100644 sites/versus/templates/categories.html create mode 100644 sites/versus/templates/category.html create mode 100644 sites/versus/templates/compare.html create mode 100644 sites/versus/templates/compare_picker.html create mode 100644 sites/versus/templates/index.html create mode 100644 sites/versus/templates/login.html create mode 100644 sites/versus/templates/product.html create mode 100644 sites/versus/templates/rankings.html create mode 100644 sites/versus/templates/search.html diff --git a/Dockerfile b/Dockerfile index 86c17615..00380fd6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # WebHarbor — slim, self-contained image. -# 24 Flask mirror sites + control plane on :8101. +# 25 Flask mirror sites + control plane on :8101. FROM python:3.12-slim-bookworm @@ -50,6 +50,13 @@ RUN python3 /opt/check_asset_inventory.py /opt/WebSyn/walmart_careers && \ RUN cd /opt/WebSyn/walmart_careers && rm -rf instance instance_seed && \ PYTHONHASHSEED=0 python seed_data.py && rm -rf instance +# Versus: data is fully code-generated by app.py. +RUN cd /opt/WebSyn/versus && \ + rm -rf instance instance_seed && \ + mkdir -p instance_seed && \ + python3 -c "from app import app" && \ + cp instance/versus.db instance_seed/versus.db + COPY websyn_start.sh /opt/websyn_start.sh COPY control_server.py /opt/control_server.py COPY site_runner.py /opt/site_runner.py @@ -72,6 +79,6 @@ os.makedirs('instance_seed', exist_ok=True); \ shutil.copy2('instance/rotten_tomatoes.db', 'instance_seed/rotten_tomatoes.db'); \ print('Rotten Tomatoes seed DB generated at build time.')" && rm -rf /opt/WebSyn/rotten_tomatoes/instance -EXPOSE 8101 40000-40023 +EXPOSE 8101 40000-40024 CMD ["/opt/websyn_start.sh"] diff --git a/control_server.py b/control_server.py index 7df0d9ee..a54c5c0d 100644 --- a/control_server.py +++ b/control_server.py @@ -27,6 +27,7 @@ 'github', 'google_flights', 'google_map', 'google_search', 'huggingface', 'wolfram_alpha', 'cambridge_dictionary', 'coursera', 'espn', 'merriam_webster', 'ikea', 'phys_org', 'target', 'ted', 'osu', 'rotten_tomatoes', 'compass', 'walmart_careers', + 'versus', ] BASE_PORT = 40000 WEBSYN_DIR = '/opt/WebSyn' diff --git a/sites/versus/_health.py b/sites/versus/_health.py new file mode 100644 index 00000000..a140107e --- /dev/null +++ b/sites/versus/_health.py @@ -0,0 +1,3 @@ +"""Per-site health probe (optional, called by control_server).""" +def health(): + return {"ok": True, "site": "versus"} diff --git a/sites/versus/app.py b/sites/versus/app.py new file mode 100644 index 00000000..f6e23a50 --- /dev/null +++ b/sites/versus/app.py @@ -0,0 +1,387 @@ +"""Versus mirror — product comparison and ranking workflows.""" +from __future__ import annotations + +import os +import re +from functools import wraps + +from flask import ( + Flask, + flash, + redirect, + render_template, + request, + session, + url_for, +) +from flask_sqlalchemy import SQLAlchemy +from werkzeug.security import check_password_hash, generate_password_hash + + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +app = Flask(__name__, instance_path=os.path.join(BASE_DIR, "instance")) +app.config["SECRET_KEY"] = "webharbor-versus-dev-key" +app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{os.path.join(BASE_DIR, 'instance', 'versus.db')}" +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False +db = SQLAlchemy(app) + +STOP_WORDS = {"the", "a", "an", "and", "or", "of", "for", "to", "in", "on", "with", "vs", "versus"} + + +class User(db.Model): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + email = db.Column(db.String(160), unique=True, nullable=False) + display_name = db.Column(db.String(120), nullable=False) + password_hash = db.Column(db.String(255), nullable=False) + + +class Category(db.Model): + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(80), unique=True, nullable=False) + name = db.Column(db.String(120), nullable=False) + tagline = db.Column(db.String(180), nullable=False) + spec_1 = db.Column(db.String(80), nullable=False) + spec_2 = db.Column(db.String(80), nullable=False) + spec_3 = db.Column(db.String(80), nullable=False) + unit_1 = db.Column(db.String(24), default="") + unit_2 = db.Column(db.String(24), default="") + unit_3 = db.Column(db.String(24), default="") + + +class Product(db.Model): + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(120), unique=True, nullable=False) + name = db.Column(db.String(160), nullable=False) + brand = db.Column(db.String(80), nullable=False) + category_id = db.Column(db.Integer, db.ForeignKey("category.id"), nullable=False) + score = db.Column(db.Integer, nullable=False) + price = db.Column(db.Integer, nullable=False) + release_year = db.Column(db.Integer, nullable=False) + spec_1_value = db.Column(db.Float, nullable=False) + spec_2_value = db.Column(db.Float, nullable=False) + spec_3_value = db.Column(db.Float, nullable=False) + battery_hours = db.Column(db.Float, default=0) + weight_grams = db.Column(db.Float, default=0) + pros = db.Column(db.Text, nullable=False) + cons = db.Column(db.Text, nullable=False) + summary = db.Column(db.Text, nullable=False) + category = db.relationship("Category") + + @property + def search_blob(self) -> str: + return ( + f"{self.name} {self.brand} {self.category.name} {self.summary} " + f"{self.pros} {self.cons} {self.category.spec_1} {self.spec_1_value} " + f"{self.category.spec_2} {self.spec_2_value} {self.category.spec_3} {self.spec_3_value} " + f"battery {self.battery_hours} hours weight {self.weight_grams} grams " + f"price {self.price} score {self.score}" + ) + + +class SavedComparison(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + left_id = db.Column(db.Integer, db.ForeignKey("product.id"), nullable=False) + right_id = db.Column(db.Integer, db.ForeignKey("product.id"), nullable=False) + note = db.Column(db.String(240), default="") + left = db.relationship("Product", foreign_keys=[left_id]) + right = db.relationship("Product", foreign_keys=[right_id]) + + +def current_user() -> User | None: + user_id = session.get("user_id") + return db.session.get(User, user_id) if user_id else None + + +@app.context_processor +def inject_common(): + return {"current_user": current_user(), "categories": Category.query.order_by(Category.name).all()} + + +def login_required(view): + @wraps(view) + def wrapped(*args, **kwargs): + if not current_user(): + flash("Sign in to save comparisons.", "info") + return redirect(url_for("login", next=request.path)) + return view(*args, **kwargs) + + return wrapped + + +def tokenize(query: str) -> list[str]: + return [ + token + for token in re.split(r"\W+", query.lower()) + if len(token) > 1 and token not in STOP_WORDS + ] + + +def scored_search(query: str, rows, fields: list[str]): + parts = tokenize(query) + if not parts: + return list(rows) + scored = [] + for row in rows: + text = " ".join(str(getattr(row, field, "") or "") for field in fields).lower() + score = sum(1 for part in parts if part in text) + if score: + scored.append((score, row)) + scored.sort(key=lambda item: (-item[0], getattr(item[1], "score", 0) * -1, getattr(item[1], "name", ""))) + return [row for _, row in scored] + + +def product_by_slug(slug: str) -> Product: + return Product.query.filter_by(slug=slug).first_or_404() + + +def winner(left: Product, right: Product) -> Product: + return left if left.score >= right.score else right + + +@app.route("/") +def index(): + top = Product.query.order_by(Product.score.desc()).limit(8).all() + popular_pairs = [ + ("iphone-15-pro", "samsung-galaxy-s24-ultra"), + ("sony-wh-1000xm5", "bose-quietcomfort-ultra"), + ("canon-eos-r6-mark-ii", "sony-a7-iv"), + ("rtx-4080-super", "radeon-rx-7900-xtx"), + ] + pairs = [(product_by_slug(a), product_by_slug(b)) for a, b in popular_pairs] + return render_template("index.html", top=top, pairs=pairs) + + +@app.route("/categories") +def category_index(): + counts = { + cat.id: Product.query.filter_by(category_id=cat.id).count() + for cat in Category.query.all() + } + return render_template("categories.html", counts=counts) + + +@app.route("/category/") +def category_detail(slug): + category = Category.query.filter_by(slug=slug).first_or_404() + brand = request.args.get("brand", "") + max_price = request.args.get("max_price", type=int) + min_score = request.args.get("min_score", type=int) + rows = Product.query.filter_by(category_id=category.id).order_by(Product.score.desc()).all() + if brand: + rows = [item for item in rows if item.brand == brand] + if max_price: + rows = [item for item in rows if item.price <= max_price] + if min_score: + rows = [item for item in rows if item.score >= min_score] + brands = [row[0] for row in db.session.query(Product.brand).filter_by(category_id=category.id).distinct().order_by(Product.brand)] + return render_template("category.html", category=category, products=rows, brands=brands, brand=brand, max_price=max_price, min_score=min_score) + + +@app.route("/item/") +def product_detail(slug): + product = product_by_slug(slug) + related = ( + Product.query.filter(Product.category_id == product.category_id, Product.slug != product.slug) + .order_by(Product.score.desc()) + .limit(5) + .all() + ) + return render_template("product.html", product=product, related=related) + + +@app.route("/compare") +def compare_picker(): + left_slug = request.args.get("left", "") + right_slug = request.args.get("right", "") + if left_slug and right_slug: + return redirect(url_for("compare_detail", left=left_slug, right=right_slug)) + products = Product.query.order_by(Product.category_id, Product.score.desc()).all() + return render_template("compare_picker.html", products=products, left_slug=left_slug, right_slug=right_slug) + + +@app.route("/compare/-vs-") +def compare_detail(left, right): + left_product = product_by_slug(left) + right_product = product_by_slug(right) + if left_product.category_id != right_product.category_id: + flash("Those products are in different categories; compare signals are still shown side by side.", "info") + return render_template("compare.html", left=left_product, right=right_product, winner=winner(left_product, right_product)) + + +@app.route("/compare/-vs-/save", methods=["POST"]) +@login_required +def save_comparison(left, right): + left_product = product_by_slug(left) + right_product = product_by_slug(right) + user = current_user() + existing = SavedComparison.query.filter_by(user_id=user.id, left_id=left_product.id, right_id=right_product.id).first() + if not existing: + db.session.add(SavedComparison(user_id=user.id, left_id=left_product.id, right_id=right_product.id, note=request.form.get("note", ""))) + db.session.commit() + flash("Comparison saved.", "success") + return redirect(url_for("account")) + + +@app.route("/rankings") +def rankings(): + category_slug = request.args.get("category", "") + rows = Product.query.order_by(Product.score.desc()).all() + if category_slug: + category = Category.query.filter_by(slug=category_slug).first_or_404() + rows = [row for row in rows if row.category_id == category.id] + return render_template("rankings.html", products=rows, category_slug=category_slug) + + +@app.route("/search") +def search(): + query = request.args.get("q", "").strip() + products = scored_search(query, Product.query.all(), ["search_blob"])[:12] if query else [] + cats = scored_search(query, Category.query.all(), ["name", "tagline"]) if query else [] + return render_template("search.html", query=query, products=products, cats=cats) + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + email = request.form.get("email", "").lower().strip() + password = request.form.get("password", "") + user = User.query.filter_by(email=email).first() + if user and check_password_hash(user.password_hash, password): + session["user_id"] = user.id + flash(f"Welcome back, {user.display_name}.", "success") + return redirect(request.args.get("next") or url_for("account")) + flash("Email or password did not match.", "error") + return render_template("login.html") + + +@app.route("/logout") +def logout(): + session.clear() + flash("Signed out.", "info") + return redirect(url_for("index")) + + +@app.route("/account") +@login_required +def account(): + saved = SavedComparison.query.filter_by(user_id=current_user().id).all() + return render_template("account.html", saved=saved) + + +@app.route("/product-art/.svg") +def product_art(slug): + product = product_by_slug(slug) + hue = abs(hash(product.slug)) % 360 + initials = "".join(part[0] for part in product.brand.split()[:2]).upper() + svg = f""" + + + + + +{initials} +{product.score} Versus Score +""" + return app.response_class(svg, mimetype="image/svg+xml") + + +@app.route("/_health") +def health(): + return {"ok": True, "site": "versus"} + + +def seed_database(): + if Category.query.count() > 0: + return + categories = [ + ("smartphones", "Smartphones", "Compare cameras, screens, battery life, and performance.", "Camera score", "Battery", "Display", "pt", "h", "in"), + ("headphones", "Headphones", "Compare noise cancelling, battery, weight, and travel features.", "ANC score", "Battery", "Weight", "pt", "h", "g"), + ("cameras", "Cameras", "Compare sensor resolution, stabilization, burst speed, and video features.", "Megapixels", "Burst", "Weight", "MP", "fps", "g"), + ("graphics-cards", "Graphics Cards", "Compare gaming performance, VRAM, power draw, and value.", "VRAM", "Power", "Benchmark", "GB", "W", "pt"), + ("smartwatches", "Smartwatches", "Compare fitness sensors, battery, display, and ecosystem support.", "Fitness score", "Battery", "Weight", "pt", "h", "g"), + ] + category_map = {} + for slug, name, tagline, spec_1, spec_2, spec_3, unit_1, unit_2, unit_3 in categories: + cat = Category(slug=slug, name=name, tagline=tagline, spec_1=spec_1, spec_2=spec_2, spec_3=spec_3, unit_1=unit_1, unit_2=unit_2, unit_3=unit_3) + db.session.add(cat) + db.session.flush() + category_map[slug] = cat + + products = [ + ("iphone-15-pro", "iPhone 15 Pro", "Apple", "smartphones", 93, 999, 2023, 92, 23, 6.1, 23, 187, "Excellent video, fast chip, titanium frame", "Expensive, slower wired charging"), + ("samsung-galaxy-s24-ultra", "Samsung Galaxy S24 Ultra", "Samsung", "smartphones", 95, 1299, 2024, 96, 28, 6.8, 28, 232, "Long zoom, bright display, S Pen", "Large and heavy"), + ("google-pixel-8-pro", "Google Pixel 8 Pro", "Google", "smartphones", 91, 999, 2023, 94, 26, 6.7, 26, 213, "Computational camera, clean Android", "Charging speed trails rivals"), + ("oneplus-12", "OnePlus 12", "OnePlus", "smartphones", 89, 799, 2024, 88, 31, 6.8, 31, 220, "Fast charging, strong value", "Camera tuning less consistent"), + ("sony-wh-1000xm5", "Sony WH-1000XM5", "Sony", "headphones", 94, 399, 2022, 96, 30, 250, 30, 250, "Top-tier ANC, light design, app EQ", "Does not fold compactly"), + ("bose-quietcomfort-ultra", "Bose QuietComfort Ultra", "Bose", "headphones", 93, 429, 2023, 95, 24, 253, 24, 253, "Excellent comfort, immersive audio", "Premium price"), + ("apple-airpods-max", "AirPods Max", "Apple", "headphones", 88, 549, 2020, 90, 20, 385, 20, 385, "Spatial audio, premium build", "Heavy, case is awkward"), + ("sennheiser-momentum-4", "Sennheiser Momentum 4", "Sennheiser", "headphones", 90, 349, 2022, 86, 60, 293, 60, 293, "Huge battery life, balanced sound", "ANC trails Sony and Bose"), + ("canon-eos-r6-mark-ii", "Canon EOS R6 Mark II", "Canon", "cameras", 92, 2499, 2022, 24, 40, 670, 0, 670, "Fast autofocus, strong video tools", "Resolution lower than rivals"), + ("sony-a7-iv", "Sony A7 IV", "Sony", "cameras", 91, 2498, 2021, 33, 10, 658, 0, 658, "Great hybrid camera, lens ecosystem", "Rolling shutter in some modes"), + ("nikon-z8", "Nikon Z8", "Nikon", "cameras", 96, 3999, 2023, 45.7, 20, 910, 0, 910, "Pro body performance, excellent stills", "Large and expensive"), + ("fujifilm-x-t5", "Fujifilm X-T5", "Fujifilm", "cameras", 88, 1699, 2022, 40, 15, 557, 0, 557, "Compact body, high resolution APS-C", "Video AF behind full-frame leaders"), + ("rtx-4080-super", "GeForce RTX 4080 Super", "NVIDIA", "graphics-cards", 94, 999, 2024, 16, 320, 18400, 0, 0, "Excellent 4K ray tracing, DLSS 3", "Still expensive"), + ("radeon-rx-7900-xtx", "Radeon RX 7900 XTX", "AMD", "graphics-cards", 91, 949, 2022, 24, 355, 16800, 0, 0, "Large VRAM, strong raster performance", "Ray tracing behind NVIDIA"), + ("rtx-4070-super", "GeForce RTX 4070 Super", "NVIDIA", "graphics-cards", 88, 599, 2024, 12, 220, 12300, 0, 0, "Efficient, strong 1440p card", "12GB VRAM limit for some workloads"), + ("radeon-rx-7800-xt", "Radeon RX 7800 XT", "AMD", "graphics-cards", 86, 499, 2023, 16, 263, 10800, 0, 0, "Good value and VRAM", "Upscaling ecosystem weaker"), + ("apple-watch-series-9", "Apple Watch Series 9", "Apple", "smartwatches", 92, 399, 2023, 94, 18, 42, 18, 42, "Best iPhone integration, bright display", "Battery lasts about a day"), + ("garmin-venu-3", "Garmin Venu 3", "Garmin", "smartwatches", 90, 449, 2023, 92, 336, 47, 336, 47, "Long battery, health metrics", "Smaller app ecosystem"), + ("samsung-galaxy-watch-6", "Samsung Galaxy Watch 6", "Samsung", "smartwatches", 87, 299, 2023, 88, 40, 33, 40, 33, "Good Android integration, slim design", "Battery is moderate"), + ("fitbit-sense-2", "Fitbit Sense 2", "Fitbit", "smartwatches", 82, 249, 2022, 84, 144, 37, 144, 37, "Simple health tracking, light body", "Limited third-party apps"), + ] + for slug, name, brand, category_slug, score, price, year, spec1, spec2, spec3, battery, weight, pros, cons in products: + db.session.add(Product( + slug=slug, + name=name, + brand=brand, + category_id=category_map[category_slug].id, + score=score, + price=price, + release_year=year, + spec_1_value=spec1, + spec_2_value=spec2, + spec_3_value=spec3, + battery_hours=battery, + weight_grams=weight, + pros=pros, + cons=cons, + summary=f"{name} is a {category_map[category_slug].name.lower()} contender with a Versus score of {score}, released in {year}, and priced around ${price}.", + )) + db.session.commit() + + +def seed_benchmark_users(): + if User.query.filter_by(email="alice.j@test.com").first(): + return + users = [ + ("alice_j", "alice.j@test.com", "Alice Johnson"), + ("bob_c", "bob.c@test.com", "Bob Chen"), + ("carol_d", "carol.d@test.com", "Carol Davis"), + ("david_k", "david.k@test.com", "David Kim"), + ] + for username, email, display_name in users: + db.session.add(User(username=username, email=email, display_name=display_name, password_hash=generate_password_hash("TestPass123!"))) + db.session.commit() + alice = User.query.filter_by(email="alice.j@test.com").first() + for left_slug, right_slug, note in [ + ("iphone-15-pro", "samsung-galaxy-s24-ultra", "Phone upgrade shortlist"), + ("sony-wh-1000xm5", "bose-quietcomfort-ultra", "Travel headphones"), + ("rtx-4080-super", "radeon-rx-7900-xtx", "4K build"), + ]: + db.session.add(SavedComparison(user_id=alice.id, left_id=product_by_slug(left_slug).id, right_id=product_by_slug(right_slug).id, note=note)) + db.session.commit() + + +with app.app_context(): + os.makedirs(app.instance_path, exist_ok=True) + db.create_all() + seed_database() + seed_benchmark_users() + + +if __name__ == "__main__": + port = int(os.environ.get("PORT", 5000)) + app.run(host="0.0.0.0", port=port, debug=False) diff --git a/sites/versus/requirements.txt b/sites/versus/requirements.txt new file mode 100644 index 00000000..1519f159 --- /dev/null +++ b/sites/versus/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.1.0 +Flask-SQLAlchemy==3.1.1 +Werkzeug==3.1.3 diff --git a/sites/versus/static/css/.gitkeep b/sites/versus/static/css/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/versus/static/css/main.css b/sites/versus/static/css/main.css new file mode 100644 index 00000000..306582bd --- /dev/null +++ b/sites/versus/static/css/main.css @@ -0,0 +1,122 @@ +:root { + --blue: #2563eb; + --cyan: #06b6d4; + --green: #16a34a; + --red: #ef4444; + --ink: #111827; + --muted: #64748b; + --line: #dbe3ef; + --panel: #ffffff; + --bg: #f4f7fb; +} +* { box-sizing: border-box; } +body { margin: 0; font-family: Arial, Helvetica, sans-serif; color: var(--ink); background: var(--bg); line-height: 1.5; } +a { color: inherit; text-decoration: none; } +button, input, select { font: inherit; } +.site-header { + display: grid; + grid-template-columns: auto 1fr minmax(220px, 340px) auto; + gap: 16px; + align-items: center; + padding: 14px 28px; + background: var(--panel); + border-bottom: 1px solid var(--line); + position: sticky; + top: 0; + z-index: 10; +} +.brand { display: flex; align-items: center; gap: 10px; font-weight: 900; font-size: 22px; } +.brand span { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 50%; color: white; background: linear-gradient(135deg, var(--blue), var(--cyan)); } +.top-nav, .account-links, .category-pills { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; } +.top-nav a, .account-links a { font-weight: 700; font-size: 14px; color: #22304a; } +.header-search, .hero-search { display: flex; gap: 8px; } +input, select { width: 100%; min-height: 42px; border: 1px solid var(--line); border-radius: 8px; padding: 9px 11px; background: white; } +button, .button { border: 0; border-radius: 8px; padding: 10px 14px; background: var(--blue); color: white; font-weight: 800; cursor: pointer; display: inline-block; } +.primary { background: var(--green); } +main { min-height: 72vh; } +.hero { + display: grid; + grid-template-columns: minmax(0, 1fr) 360px; + gap: 28px; + padding: 62px 42px; + background: linear-gradient(135deg, #0f172a 0%, #1d4ed8 54%, #06b6d4 100%); + color: white; +} +.hero h1, .page-heading h1, .detail-hero h1 { margin: 0 0 12px; font-size: clamp(38px, 6vw, 68px); line-height: 1; } +.hero p { max-width: 720px; color: #e0f2fe; font-size: 18px; } +.hero-search { max-width: 680px; } +.hero-compare { + align-self: end; + display: grid; + gap: 10px; + padding: 24px; + border-radius: 10px; + background: rgba(255,255,255,.12); + border: 1px solid rgba(255,255,255,.28); +} +.hero-compare strong { font-size: 24px; } +.hero-compare em, .pair-card em, .versus-hero > span { color: var(--red); font-style: normal; font-weight: 900; text-transform: uppercase; } +.hero-compare a { color: white; font-weight: 800; text-decoration: underline; } +.eyebrow { color: var(--cyan); text-transform: uppercase; font-size: 12px; font-weight: 900; letter-spacing: .08em; } +.section, .page-heading, .detail-hero, .metric-grid, .pro-con, .compare-table-wrap, .winner-band, .versus-hero, .ranking-list, .category-grid, .filter-bar, .compare-picker, .auth-panel { + max-width: 1180px; + margin: 0 auto; + padding: 32px 24px; +} +.section-head { display: flex; justify-content: space-between; align-items: end; margin-bottom: 16px; gap: 16px; } +.card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 18px; } +.product-card, .category-card, .pair-card, .ranking-row, .auth-panel form, .compare-picker, .winner-band, .metric-grid > div, .pro-con article { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 10px; +} +.product-card { overflow: hidden; } +.product-art { display: block; aspect-ratio: 3 / 2; background: #eaf2ff; } +.product-art img, .detail-hero img, .versus-hero img { width: 100%; height: 100%; object-fit: cover; display: block; } +.product-body { padding: 16px; } +.product-body h3 { margin: 6px 0; } +.product-body p { color: var(--muted); min-height: 70px; } +.stat-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 12px 0 0; } +dt { color: var(--muted); font-size: 12px; } +dd { margin: 0; font-weight: 900; } +.pair-grid, .category-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px; } +.category-grid.inset { padding: 0; } +.pair-card, .category-card { display: grid; gap: 8px; padding: 18px; } +.pair-card span { font-weight: 900; } +.category-card strong { font-size: 22px; } +.category-card span, .category-card small { color: var(--muted); } +.filter-bar { display: grid; grid-template-columns: minmax(220px, 1fr) repeat(2, 160px) auto; gap: 12px; padding-top: 0; } +.detail-hero { display: grid; grid-template-columns: minmax(280px, 430px) minmax(0, 1fr); gap: 28px; align-items: center; } +.detail-hero img, .versus-hero img { border-radius: 10px; border: 1px solid var(--line); background: white; } +.score-pill { display: inline-block; padding: 10px 14px; border-radius: 999px; background: #dcfce7; color: #166534; font-weight: 900; margin: 8px 0 16px; } +.metric-grid, .pro-con { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 14px; } +.metric-grid > div, .pro-con article { padding: 18px; } +.metric-grid span { color: var(--muted); display: block; } +.metric-grid strong { font-size: 22px; } +.versus-hero { display: grid; grid-template-columns: 1fr auto 1fr; gap: 18px; align-items: center; text-align: center; } +.versus-hero > span { font-size: 28px; } +.versus-hero h1 { font-size: clamp(24px, 3vw, 38px); } +.winner-band { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-top: 0; } +.compare-table { width: 100%; border-collapse: collapse; background: white; border: 1px solid var(--line); } +.compare-table th, .compare-table td { border: 1px solid var(--line); padding: 13px; text-align: left; min-width: 180px; } +.compare-table th { background: #eff6ff; } +.ranking-list { display: grid; gap: 10px; } +.ranking-row { display: grid; grid-template-columns: 52px 1fr auto 60px; gap: 12px; align-items: center; padding: 14px; } +.rank, .score { font-weight: 900; color: var(--blue); } +.ranking-row small { color: var(--muted); } +.category-pills { max-width: 1180px; margin: 0 auto; padding: 0 24px; } +.category-pills a { padding: 8px 12px; border-radius: 999px; border: 1px solid var(--line); background: white; } +.category-pills .active { color: white; background: var(--blue); } +.compare-picker, .auth-panel form { max-width: 760px; display: grid; gap: 14px; } +.flash-wrap { max-width: 1180px; margin: 12px auto 0; padding: 0 24px; } +.flash { padding: 12px 14px; border-radius: 8px; background: #eff6ff; border: 1px solid #bfdbfe; } +.flash.success { background: #ecfdf5; border-color: #86efac; } +.flash.error { background: #fef2f2; border-color: #fecaca; } +.empty-state { color: var(--muted); } +.site-footer { display: flex; justify-content: space-between; gap: 24px; padding: 28px; background: #0f172a; color: white; } +.site-footer nav { display: flex; gap: 16px; flex-wrap: wrap; } +@media (max-width: 900px) { + .site-header, .hero, .detail-hero, .versus-hero { grid-template-columns: 1fr; } + .filter-bar, .ranking-row { grid-template-columns: 1fr; } + .winner-band, .site-footer { flex-direction: column; align-items: stretch; } +} diff --git a/sites/versus/static/icons/.gitkeep b/sites/versus/static/icons/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/versus/static/js/.gitkeep b/sites/versus/static/js/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/versus/tasks.jsonl b/sites/versus/tasks.jsonl new file mode 100644 index 00000000..75dadb5d --- /dev/null +++ b/sites/versus/tasks.jsonl @@ -0,0 +1,10 @@ +{"web_name":"Versus","id":"Versus--0","ques":"Compare the iPhone 15 Pro and Samsung Galaxy S24 Ultra, then identify which product has the higher Versus Score.","web":"http://localhost:40015/","upstream_url":"https://versus.com/"} +{"web_name":"Versus","id":"Versus--1","ques":"Find headphones with at least 50 hours of battery life and open the matching product detail page.","web":"http://localhost:40015/","upstream_url":"https://versus.com/"} +{"web_name":"Versus","id":"Versus--2","ques":"Open the graphics cards ranking and identify the highest ranked product in that category.","web":"http://localhost:40015/","upstream_url":"https://versus.com/"} +{"web_name":"Versus","id":"Versus--3","ques":"Use search to find a camera with 40 megapixels or more, then open its product page.","web":"http://localhost:40015/","upstream_url":"https://versus.com/"} +{"web_name":"Versus","id":"Versus--4","ques":"Compare Sony WH-1000XM5 and Bose QuietComfort Ultra and determine which has longer battery life.","web":"http://localhost:40015/","upstream_url":"https://versus.com/"} +{"web_name":"Versus","id":"Versus--5","ques":"Sign in as alice.j@test.com with password TestPass123! and list one saved comparison.","web":"http://localhost:40015/","upstream_url":"https://versus.com/"} +{"web_name":"Versus","id":"Versus--6","ques":"Filter smartphones by a maximum price of 1000 and identify the highest scoring result shown.","web":"http://localhost:40015/","upstream_url":"https://versus.com/"} +{"web_name":"Versus","id":"Versus--7","ques":"Save the comparison between RTX 4080 Super and Radeon RX 7900 XTX to Alice's account.","web":"http://localhost:40015/","upstream_url":"https://versus.com/"} +{"web_name":"Versus","id":"Versus--8","ques":"Find the Smartwatches category and open the Garmin Venu 3 product detail page.","web":"http://localhost:40015/","upstream_url":"https://versus.com/"} +{"web_name":"Versus","id":"Versus--9","ques":"Build a comparison between Canon EOS R6 Mark II and Sony A7 IV from the compare picker.","web":"http://localhost:40015/","upstream_url":"https://versus.com/"} diff --git a/sites/versus/templates/.gitkeep b/sites/versus/templates/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/versus/templates/404.html b/sites/versus/templates/404.html new file mode 100644 index 00000000..47390f8b --- /dev/null +++ b/sites/versus/templates/404.html @@ -0,0 +1,3 @@ +{% extends "base.html" %} +{% block title %}Not Found{% endblock %} +{% block content %}

Page not found

Try search or rankings.

{% endblock %} diff --git a/sites/versus/templates/_product_card.html b/sites/versus/templates/_product_card.html new file mode 100644 index 00000000..a49bfb9c --- /dev/null +++ b/sites/versus/templates/_product_card.html @@ -0,0 +1,15 @@ +
+ + {{ product.name }} + +
+
{{ product.category.name }}
+

{{ product.name }}

+

{{ product.summary }}

+
+
Score
{{ product.score }}
+
Price
${{ product.price }}
+
Year
{{ product.release_year }}
+
+
+
diff --git a/sites/versus/templates/account.html b/sites/versus/templates/account.html new file mode 100644 index 00000000..8c3d1753 --- /dev/null +++ b/sites/versus/templates/account.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}Saved Comparisons{% endblock %} +{% block content %} +
Account

Saved Comparisons

+
+ {% for item in saved %} + + {{ item.left.name }}vs{{ item.right.name }}{{ item.note }} + + {% else %} +

No saved comparisons yet.

+ {% endfor %} +
+{% endblock %} diff --git a/sites/versus/templates/base.html b/sites/versus/templates/base.html new file mode 100644 index 00000000..d55c51bc --- /dev/null +++ b/sites/versus/templates/base.html @@ -0,0 +1,56 @@ + + + + + + {% block title %}Versus{% endblock %} + + + + + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
{% block content %}{% endblock %}
+ + + + diff --git a/sites/versus/templates/categories.html b/sites/versus/templates/categories.html new file mode 100644 index 00000000..73c9e3aa --- /dev/null +++ b/sites/versus/templates/categories.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}Categories{% endblock %} +{% block content %} +
Browse by vertical

Categories

+
+ {% for category in categories %} + + {{ category.name }} + {{ category.tagline }} + {{ counts[category.id] }} products + + {% endfor %} +
+{% endblock %} diff --git a/sites/versus/templates/category.html b/sites/versus/templates/category.html new file mode 100644 index 00000000..ef2a84b6 --- /dev/null +++ b/sites/versus/templates/category.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block title %}{{ category.name }}{% endblock %} +{% block content %} +
+
Category

{{ category.name }}

{{ category.tagline }}

+
+
+ + + + +
+
+ {% for product in products %} + {% include "_product_card.html" %} + {% endfor %} +
+{% endblock %} diff --git a/sites/versus/templates/compare.html b/sites/versus/templates/compare.html new file mode 100644 index 00000000..1f25baf9 --- /dev/null +++ b/sites/versus/templates/compare.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}{{ left.name }} vs {{ right.name }}{% endblock %} +{% block content %} +
+
{{ left.name }}

{{ left.name }}

+ vs +
{{ right.name }}

{{ right.name }}

+
+
+ {{ winner.name }} wins with a {{ winner.score }} Versus Score. +
+ +
+
+
+ + + + + + + + + + + +
Signal{{ left.name }}{{ right.name }}
Score{{ left.score }}{{ right.score }}
Price${{ left.price }}${{ right.price }}
{{ left.category.spec_1 }}{{ left.spec_1_value|round(1) }}{{ left.category.unit_1 }}{{ right.spec_1_value|round(1) }}{{ right.category.unit_1 }}
{{ left.category.spec_2 }}{{ left.spec_2_value|round(1) }}{{ left.category.unit_2 }}{{ right.spec_2_value|round(1) }}{{ right.category.unit_2 }}
{{ left.category.spec_3 }}{{ left.spec_3_value|round(1) }}{{ left.category.unit_3 }}{{ right.spec_3_value|round(1) }}{{ right.category.unit_3 }}
Pros{{ left.pros }}{{ right.pros }}
Cons{{ left.cons }}{{ right.cons }}
+
+{% endblock %} diff --git a/sites/versus/templates/compare_picker.html b/sites/versus/templates/compare_picker.html new file mode 100644 index 00000000..ef17db28 --- /dev/null +++ b/sites/versus/templates/compare_picker.html @@ -0,0 +1,20 @@ +{% extends "base.html" %} +{% block title %}Compare Products{% endblock %} +{% block content %} +
Build comparison

Compare Products

+
+ + + +
+{% endblock %} diff --git a/sites/versus/templates/index.html b/sites/versus/templates/index.html new file mode 100644 index 00000000..1235b5fb --- /dev/null +++ b/sites/versus/templates/index.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} +{% block title %}Versus{% endblock %} +{% block content %} +
+
+
Specs, scores, and side-by-side decisions
+

Versus

+

Compare phones, headphones, cameras, graphics cards, and smartwatches using ranked specifications and clear winner signals.

+ +
+
+ Popular comparison + Galaxy S24 Ultra + vs + iPhone 15 Pro + Open comparison +
+
+ +
+

Popular Comparisons

Build your own
+
+ {% for left, right in pairs %} + + {{ left.name }}vs{{ right.name }} + + {% endfor %} +
+
+ +
+

Top Rated Products

Full ranking
+
+ {% for product in top %} + {% include "_product_card.html" %} + {% endfor %} +
+
+{% endblock %} diff --git a/sites/versus/templates/login.html b/sites/versus/templates/login.html new file mode 100644 index 00000000..9b4854d9 --- /dev/null +++ b/sites/versus/templates/login.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% block title %}Sign in{% endblock %} +{% block content %} +
+
+

Sign in

+ + + +

Benchmark users use password TestPass123!.

+
+
+{% endblock %} diff --git a/sites/versus/templates/product.html b/sites/versus/templates/product.html new file mode 100644 index 00000000..3a189c34 --- /dev/null +++ b/sites/versus/templates/product.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block title %}{{ product.name }}{% endblock %} +{% block content %} +
+ {{ product.name }} +
+
{{ product.category.name }}
+

{{ product.name }}

+

{{ product.summary }}

+
{{ product.score }} Versus Score
+ Compare this product +
+
+
+
{{ product.category.spec_1 }}{{ product.spec_1_value|round(1) }}{{ product.category.unit_1 }}
+
{{ product.category.spec_2 }}{{ product.spec_2_value|round(1) }}{{ product.category.unit_2 }}
+
{{ product.category.spec_3 }}{{ product.spec_3_value|round(1) }}{{ product.category.unit_3 }}
+
Price${{ product.price }}
+
+
+

Pros

{{ product.pros }}

+

Cons

{{ product.cons }}

+
+
+

Compare with

+
+ {% for item in related %} + {{ product.name }}vs{{ item.name }} + {% endfor %} +
+
+{% endblock %} diff --git a/sites/versus/templates/rankings.html b/sites/versus/templates/rankings.html new file mode 100644 index 00000000..0ea61426 --- /dev/null +++ b/sites/versus/templates/rankings.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} +{% block title %}Rankings{% endblock %} +{% block content %} +
Ranked by Versus Score

Rankings

+
+ All + {% for category in categories %} + {{ category.name }} + {% endfor %} +
+
+ {% for product in products %} + + {{ loop.index }} + {{ product.name }} + {{ product.category.name }} · ${{ product.price }} + {{ product.score }} + + {% endfor %} +
+{% endblock %} diff --git a/sites/versus/templates/search.html b/sites/versus/templates/search.html new file mode 100644 index 00000000..9d25d99c --- /dev/null +++ b/sites/versus/templates/search.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% block title %}Search{% endblock %} +{% block content %} +
Search

{{ query or "Search Versus" }}

+
+ + +
+{% if query %} +
+

Products

+
+ {% for product in products %} + {% include "_product_card.html" %} + {% else %} +

No product matches.

+ {% endfor %} +
+
+
+

Categories

+
+ {% for category in cats %} + {{ category.name }}{{ category.tagline }} + {% else %} +

No category matches.

+ {% endfor %} +
+
+{% endif %} +{% endblock %} diff --git a/websyn_start.sh b/websyn_start.sh index 9539b4f9..cf28ea81 100644 --- a/websyn_start.sh +++ b/websyn_start.sh @@ -5,7 +5,7 @@ set -e SITES=(allrecipes amazon apple arxiv bbc_news booking github google_flights google_map google_search huggingface wolfram_alpha - cambridge_dictionary coursera espn merriam_webster ikea phys_org target ted osu rotten_tomatoes compass walmart_careers) + cambridge_dictionary coursera espn merriam_webster ikea phys_org target ted osu rotten_tomatoes compass walmart_careers versus) BASE_PORT=40000 PID_DIR=/tmp/websyn_pids mkdir -p "$PID_DIR" From c6b8c8ab3727f347dd7f3a6017c6e0f0c3d75f58 Mon Sep 17 00:00:00 2001 From: JackJin <1037461232@qq.com> Date: Sun, 13 Sep 2026 12:35:21 +0800 Subject: [PATCH 02/18] fix(versus): make the generated seed byte-reproducible sites/versus builds its seed at image build time rather than shipping an instance_seed asset, which is fine and matches what osu and rotten_tomatoes already do on main. But seed_benchmark_users() called generate_password_hash() for each account, and werkzeug draws a fresh scrypt salt on every call, so two builds of the same commit produced different seed bytes: 96d9d1dc... and 568bfb9d... The logical content was identical both times (5 categories, 20 products, 4 users, 3 saved comparisons hash the same with password_hash excluded); only the salts moved. The in-image reset contract still held, because the seed is copied from that same generation, so this never surfaced as a failing md5 check -- what it broke is pinning the seed hash in review evidence or in a verifier. PYTHONHASHSEED=0, which osu uses for its build-time seed, does not help here. Freeze the hash the way osu freezes BENCHMARK_PASSWORD_HASH instead. Two builds now agree: 231149c4... twice. --- sites/versus/app.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/sites/versus/app.py b/sites/versus/app.py index f6e23a50..c1815561 100644 --- a/sites/versus/app.py +++ b/sites/versus/app.py @@ -15,7 +15,8 @@ url_for, ) from flask_sqlalchemy import SQLAlchemy -from werkzeug.security import check_password_hash, generate_password_hash +from flask_wtf.csrf import CSRFProtect +from werkzeug.security import check_password_hash BASE_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -25,9 +26,20 @@ app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{os.path.join(BASE_DIR, 'instance', 'versus.db')}" app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False db = SQLAlchemy(app) +csrf = CSRFProtect(app) STOP_WORDS = {"the", "a", "an", "and", "or", "of", "for", "to", "in", "on", "with", "vs", "versus"} +# Benchmark accounts all share the password "TestPass123!". The hash is frozen +# rather than recomputed at seed time because werkzeug draws a fresh scrypt salt +# on every call, which made instance_seed/versus.db differ byte-for-byte between +# two builds of the same commit and left its hash unpinnable. +BENCHMARK_PASSWORD = "TestPass123!" +BENCHMARK_PASSWORD_HASH = ( + "scrypt:32768:8:1$L0zp47QSxuocH7od$a67d3cb38348337beea69448cc092b28dde062db907e" + "f1b48ca024c4b3de11f31ae5ef044671d7d20de786d5be7ce8609b0481642c35d87fb696d6be9a2956dc" +) + class User(db.Model): id = db.Column(db.Integer, primary_key=True) @@ -363,7 +375,7 @@ def seed_benchmark_users(): ("david_k", "david.k@test.com", "David Kim"), ] for username, email, display_name in users: - db.session.add(User(username=username, email=email, display_name=display_name, password_hash=generate_password_hash("TestPass123!"))) + db.session.add(User(username=username, email=email, display_name=display_name, password_hash=BENCHMARK_PASSWORD_HASH)) db.session.commit() alice = User.query.filter_by(email="alice.j@test.com").first() for left_slug, right_slug, note in [ From 1fdf7920c14e17bfb740e1ab7c516bdca7f799fe Mon Sep 17 00:00:00 2001 From: JackJin <1037461232@qq.com> Date: Sun, 13 Sep 2026 12:35:25 +0800 Subject: [PATCH 03/18] fix(versus): protect state-changing posts with CSRF 23 of the 25 sites on main install CSRFProtect; versus did not, and a POST to /compare/-vs-/save carrying Origin and Referer of another site was accepted and wrote the row. Install CSRFProtect, add the token to the two posting forms, and declare Flask-WTF in the site's requirements (the image already pins 1.2.2). Also stop pre-filling the benchmark email and password into the login inputs. The credentials stay in the task text, which is this repo's convention, but a page that ships them in value attributes lets an agent sign in without having read anything. --- sites/versus/requirements.txt | 1 + sites/versus/templates/compare.html | 1 + sites/versus/templates/login.html | 5 +++-- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/sites/versus/requirements.txt b/sites/versus/requirements.txt index 1519f159..a366965d 100644 --- a/sites/versus/requirements.txt +++ b/sites/versus/requirements.txt @@ -1,3 +1,4 @@ Flask==3.1.0 Flask-SQLAlchemy==3.1.1 +Flask-WTF==1.2.2 Werkzeug==3.1.3 diff --git a/sites/versus/templates/compare.html b/sites/versus/templates/compare.html index 1f25baf9..7ef3a3f0 100644 --- a/sites/versus/templates/compare.html +++ b/sites/versus/templates/compare.html @@ -9,6 +9,7 @@
{{ winner.name }} wins with a {{ winner.score }} Versus Score.
+
diff --git a/sites/versus/templates/login.html b/sites/versus/templates/login.html index 9b4854d9..313896a3 100644 --- a/sites/versus/templates/login.html +++ b/sites/versus/templates/login.html @@ -3,9 +3,10 @@ {% block content %}
+

Sign in

- - + +

Benchmark users use password TestPass123!.

From 4c223ebcb3a6c8e6c6d9426839e972db3caf59c7 Mon Sep 17 00:00:00 2001 From: JackJin <1037461232@qq.com> Date: Sun, 13 Sep 2026 12:36:12 +0800 Subject: [PATCH 04/18] fix(versus): add a favicon The missing /favicon.ico was the only console error on every page load. --- sites/versus/static/icons/favicon.svg | 5 +++++ sites/versus/templates/base.html | 1 + 2 files changed, 6 insertions(+) create mode 100644 sites/versus/static/icons/favicon.svg diff --git a/sites/versus/static/icons/favicon.svg b/sites/versus/static/icons/favicon.svg new file mode 100644 index 00000000..4e4d5daf --- /dev/null +++ b/sites/versus/static/icons/favicon.svg @@ -0,0 +1,5 @@ + + + VS + diff --git a/sites/versus/templates/base.html b/sites/versus/templates/base.html index d55c51bc..f8a154ae 100644 --- a/sites/versus/templates/base.html +++ b/sites/versus/templates/base.html @@ -4,6 +4,7 @@ {% block title %}Versus{% endblock %} + From 53372b58266998eda694d3f22d9b593169d27e4e Mon Sep 17 00:00:00 2001 From: JackJin <1037461232@qq.com> Date: Sun, 13 Sep 2026 12:36:14 +0800 Subject: [PATCH 05/18] review(versus): rewrite the task set and add the grading contract Reviewer-side work per CONTRIBUTING: the contributor supplied 10 task definitions and no verifiers. This adds a deterministic verifier per task and a judge_rubric, and reworks the questions that could not be graded honestly. Answer leakage. Versus prints Score, Price and Year on every product card, and /rankings lists all 20 products ordered with score, price and category and needs no filter. Three of the original tasks were therefore answerable without opening anything: "which of these two scores higher" (--0), "highest ranked graphics card" (--2), and "highest scoring smartphone under $1000" (--6). The fix is not to hide the ranking -- it is a real page of the site -- but to ask for a fact the list does not carry. Every spec value (camera score, ANC score, megapixels, burst, VRAM, power, benchmark, battery hours, weight, display) is rendered only on detail and comparison pages, so each rewritten question now ends in one of those. A no-op stateful task. The only task that changed state asked Alice to save the RTX 4080 Super vs RX 7900 XTX comparison, which seed_benchmark_users() already saves for her, and save_comparison() de-duplicates. The after state was identical whether or not the agent acted. It now targets a pair she does not have, and a second save task uses Bob, who starts with none. An ambiguous answer. "A camera with 40 megapixels or more" had two valid answers (Nikon Z8 at 45.7 and Fujifilm X-T5 at 40.0). It now asks for the highest count, which is unique. Ground truth is derived from the passed initial_db rather than frozen into the verifier, so the expected answer moves with the seed and a stale verifier fails loudly instead of grading against a dead value. Where a question implies a superlative, unique_extreme() returns None on a tie and the verifier fails closed rather than picking one. Malformed or missing input produces a structured FAIL, never a traceback. Stateful tasks read the after_db and also assert the pair was absent before, so a task that regresses into a no-op is caught by the verifier itself. 17 tasks, 17 verifiers, 17 rubrics. --- sites/versus/tasks.jsonl | 27 ++- sites/versus/verify/verify_0.py | 40 ++++ sites/versus/verify/verify_1.py | 40 ++++ sites/versus/verify/verify_10.py | 36 +++ sites/versus/verify/verify_11.py | 36 +++ sites/versus/verify/verify_12.py | 40 ++++ sites/versus/verify/verify_13.py | 39 ++++ sites/versus/verify/verify_14.py | 36 +++ sites/versus/verify/verify_15.py | 36 +++ sites/versus/verify/verify_16.py | 40 ++++ sites/versus/verify/verify_2.py | 38 ++++ sites/versus/verify/verify_3.py | 38 ++++ sites/versus/verify/verify_4.py | 40 ++++ sites/versus/verify/verify_5.py | 53 +++++ sites/versus/verify/verify_6.py | 40 ++++ sites/versus/verify/verify_7.py | 39 ++++ sites/versus/verify/verify_8.py | 36 +++ sites/versus/verify/verify_9.py | 43 ++++ sites/versus/verify/verify_lib.py | 365 ++++++++++++++++++++++++++++++ 19 files changed, 1052 insertions(+), 10 deletions(-) create mode 100644 sites/versus/verify/verify_0.py create mode 100644 sites/versus/verify/verify_1.py create mode 100644 sites/versus/verify/verify_10.py create mode 100644 sites/versus/verify/verify_11.py create mode 100644 sites/versus/verify/verify_12.py create mode 100644 sites/versus/verify/verify_13.py create mode 100644 sites/versus/verify/verify_14.py create mode 100644 sites/versus/verify/verify_15.py create mode 100644 sites/versus/verify/verify_16.py create mode 100644 sites/versus/verify/verify_2.py create mode 100644 sites/versus/verify/verify_3.py create mode 100644 sites/versus/verify/verify_4.py create mode 100644 sites/versus/verify/verify_5.py create mode 100644 sites/versus/verify/verify_6.py create mode 100644 sites/versus/verify/verify_7.py create mode 100644 sites/versus/verify/verify_8.py create mode 100644 sites/versus/verify/verify_9.py create mode 100644 sites/versus/verify/verify_lib.py diff --git a/sites/versus/tasks.jsonl b/sites/versus/tasks.jsonl index 75dadb5d..7f048b7f 100644 --- a/sites/versus/tasks.jsonl +++ b/sites/versus/tasks.jsonl @@ -1,10 +1,17 @@ -{"web_name":"Versus","id":"Versus--0","ques":"Compare the iPhone 15 Pro and Samsung Galaxy S24 Ultra, then identify which product has the higher Versus Score.","web":"http://localhost:40015/","upstream_url":"https://versus.com/"} -{"web_name":"Versus","id":"Versus--1","ques":"Find headphones with at least 50 hours of battery life and open the matching product detail page.","web":"http://localhost:40015/","upstream_url":"https://versus.com/"} -{"web_name":"Versus","id":"Versus--2","ques":"Open the graphics cards ranking and identify the highest ranked product in that category.","web":"http://localhost:40015/","upstream_url":"https://versus.com/"} -{"web_name":"Versus","id":"Versus--3","ques":"Use search to find a camera with 40 megapixels or more, then open its product page.","web":"http://localhost:40015/","upstream_url":"https://versus.com/"} -{"web_name":"Versus","id":"Versus--4","ques":"Compare Sony WH-1000XM5 and Bose QuietComfort Ultra and determine which has longer battery life.","web":"http://localhost:40015/","upstream_url":"https://versus.com/"} -{"web_name":"Versus","id":"Versus--5","ques":"Sign in as alice.j@test.com with password TestPass123! and list one saved comparison.","web":"http://localhost:40015/","upstream_url":"https://versus.com/"} -{"web_name":"Versus","id":"Versus--6","ques":"Filter smartphones by a maximum price of 1000 and identify the highest scoring result shown.","web":"http://localhost:40015/","upstream_url":"https://versus.com/"} -{"web_name":"Versus","id":"Versus--7","ques":"Save the comparison between RTX 4080 Super and Radeon RX 7900 XTX to Alice's account.","web":"http://localhost:40015/","upstream_url":"https://versus.com/"} -{"web_name":"Versus","id":"Versus--8","ques":"Find the Smartwatches category and open the Garmin Venu 3 product detail page.","web":"http://localhost:40015/","upstream_url":"https://versus.com/"} -{"web_name":"Versus","id":"Versus--9","ques":"Build a comparison between Canon EOS R6 Mark II and Sony A7 IV from the compare picker.","web":"http://localhost:40015/","upstream_url":"https://versus.com/"} +{"web_name": "Versus", "id": "Versus--0", "ques": "Compare the iPhone 15 Pro and the Samsung Galaxy S24 Ultra, then report the camera score of whichever product has the higher Versus Score.", "web": "http://localhost:40024/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_0.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the side-by-side comparison of these two products, or both product detail pages.\n2. The final answer MUST name the product with the higher Versus Score AND give that product's camera score as shown on the site.\n3. The camera score is not printed on list or card views, so an answer produced without opening a comparison or detail page is a FAIL.\n4. An empty answer, or one that gives only the Versus Score without the camera score, is a FAIL."} +{"web_name": "Versus", "id": "Versus--1", "ques": "Find the headphones with at least 50 hours of battery life, then report that product's ANC score.", "web": "http://localhost:40024/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_1.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the detail or comparison page of the qualifying headphones.\n2. The final answer MUST name that product AND give its ANC score as shown on the site.\n3. Battery life and ANC score appear only on detail and comparison pages, so an answer with no such page visit is a FAIL.\n4. An empty answer, or naming a product that does not meet the battery threshold, is a FAIL."} +{"web_name": "Versus", "id": "Versus--2", "ques": "Open the graphics cards ranking, then report the power draw in watts of the highest ranked card in that category.", "web": "http://localhost:40024/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_2.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the rankings view and the detail or comparison page of the top-ranked graphics card.\n2. The final answer MUST name that card AND give its power draw in watts as shown on the site.\n3. Power draw is not printed in the ranking list, so an answer with no detail or comparison page visit is a FAIL.\n4. An empty answer, or one that reports only the Versus Score, is a FAIL."} +{"web_name": "Versus", "id": "Versus--3", "ques": "Use search to find the camera with the highest megapixel count, then report its burst speed in frames per second.", "web": "http://localhost:40024/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_3.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have used the site search and opened the detail or comparison page of the winning camera.\n2. The final answer MUST name that camera AND give its burst speed in frames per second as shown on the site.\n3. Megapixels and burst speed appear only on detail and comparison pages, so an answer with no such visit is a FAIL.\n4. An empty answer, or naming a camera that is not the highest-megapixel one, is a FAIL."} +{"web_name": "Versus", "id": "Versus--4", "ques": "Compare the Sony WH-1000XM5 and the Bose QuietComfort Ultra, then report which one has the longer battery life and how many hours it lasts.", "web": "http://localhost:40024/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_4.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the comparison of these two products, or both detail pages.\n2. The final answer MUST name the longer-lasting product AND give its battery life in hours as shown on the site.\n3. Battery hours are not printed on cards, so an answer with no comparison or detail page visit is a FAIL.\n4. An empty answer, or naming the product without the hours, is a FAIL."} +{"web_name": "Versus", "id": "Versus--5", "ques": "Sign in as alice.j@test.com with password TestPass123! and report one comparison that is already saved to that account.", "web": "http://localhost:40024/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_5.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have signed in and opened the account page.\n2. The final answer MUST name both products of one comparison saved to that account.\n3. Naming a comparison that is not on the account, or reporting a comparison the agent created during the run instead of one already saved, is a FAIL.\n4. An empty answer is a FAIL."} +{"web_name": "Versus", "id": "Versus--6", "ques": "Among smartphones priced at $1000 or less, find the highest scoring one and report its display size in inches.", "web": "http://localhost:40024/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_6.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the winning product's detail or comparison page.\n2. The final answer MUST name that product AND give its display size in inches as shown on the site.\n3. Any route that narrows smartphones by price is acceptable: the price filter, the category listing, the rankings view or search.\n4. Display size is not printed on cards or in the ranking list, so an answer with no detail or comparison page visit is a FAIL.\n5. An empty answer, or naming a smartphone priced above the limit, is a FAIL."} +{"web_name": "Versus", "id": "Versus--7", "ques": "Sign in as alice.j@test.com with password TestPass123! and save the comparison between the Nikon Z8 and the Canon EOS R6 Mark II to that account.", "web": "http://localhost:40024/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_7.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have signed in and opened the comparison of these two products.\n2. The comparison MUST actually be saved to that account: the account's saved comparisons after the run must include this pair, which it did not contain before.\n3. A self-reported success with no change to the account is a FAIL.\n4. Saving a different pair, or saving to a different account, is a FAIL."} +{"web_name": "Versus", "id": "Versus--8", "ques": "Open the Smartwatches category and report the battery life in hours of the Garmin Venu 3.", "web": "http://localhost:40024/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_8.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have reached the product through the smartwatches category listing and opened its detail or comparison page.\n2. The final answer MUST give that product's battery life in hours as shown on the site.\n3. Battery hours are not printed on cards, so an answer with no detail or comparison page visit is a FAIL.\n4. An empty answer is a FAIL."} +{"web_name": "Versus", "id": "Versus--9", "ques": "Use the compare picker to build a comparison between the Canon EOS R6 Mark II and the Sony A7 IV, then report which product the site declares the winner.", "web": "http://localhost:40024/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_9.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have used the compare picker and landed on the comparison page for these two products.\n2. The final answer MUST name the product the site itself declares the winner.\n3. Naming the other product, or answering without reaching the comparison page, is a FAIL.\n4. An empty answer is a FAIL."} +{"web_name": "Versus", "id": "Versus--10", "ques": "Among graphics cards, find the one with the most VRAM and report its Versus Score.", "web": "http://localhost:40024/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_10.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the detail or comparison pages needed to read VRAM for the graphics cards.\n2. The final answer MUST name the card with the most VRAM AND give its Versus Score.\n3. VRAM is not printed on cards or in the ranking list, so an answer with no detail or comparison page visit is a FAIL.\n4. An empty answer, or naming the highest-scoring card instead of the highest-VRAM one, is a FAIL."} +{"web_name": "Versus", "id": "Versus--11", "ques": "Find the heaviest camera on the site and report its price.", "web": "http://localhost:40024/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_11.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the detail or comparison pages needed to read camera weights.\n2. The final answer MUST name the heaviest camera AND give its price.\n3. Weight is not printed on cards, so an answer with no detail or comparison page visit is a FAIL.\n4. An empty answer, or naming a camera that is not the heaviest, is a FAIL."} +{"web_name": "Versus", "id": "Versus--12", "ques": "Compare the OnePlus 12 and the Google Pixel 8 Pro, then report the battery life in hours of whichever has the longer battery.", "web": "http://localhost:40024/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_12.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the comparison of these two products, or both detail pages.\n2. The final answer MUST name the longer-lasting product AND give its battery life in hours as shown on the site.\n3. Battery hours are not printed on cards, so an answer with no comparison or detail page visit is a FAIL.\n4. An empty answer is a FAIL."} +{"web_name": "Versus", "id": "Versus--13", "ques": "Sign in as bob.c@test.com with password TestPass123! and save the comparison between the Apple Watch Series 9 and the Garmin Venu 3 to that account.", "web": "http://localhost:40024/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_13.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have signed in as that user and opened the comparison of these two products.\n2. The comparison MUST actually be saved to that account: the account's saved comparisons after the run must include this pair, which it did not contain before.\n3. A self-reported success with no change to the account is a FAIL.\n4. Saving to a different account, or saving a different pair, is a FAIL."} +{"web_name": "Versus", "id": "Versus--14", "ques": "Among smartwatches, find the one with the longest battery life and report its weight in grams.", "web": "http://localhost:40024/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_14.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the detail or comparison pages needed to read smartwatch battery life.\n2. The final answer MUST name the longest-lasting smartwatch AND give its weight in grams.\n3. Battery life and weight are not printed on cards, so an answer with no detail or comparison page visit is a FAIL.\n4. An empty answer, or naming the highest-scoring smartwatch instead of the longest-lasting one, is a FAIL."} +{"web_name": "Versus", "id": "Versus--15", "ques": "Among headphones, find the one with the lowest ANC score and report its price.", "web": "http://localhost:40024/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_15.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the detail or comparison pages needed to read ANC scores.\n2. The final answer MUST name the headphones with the lowest ANC score AND give their price.\n3. ANC score is not printed on cards, so an answer with no detail or comparison page visit is a FAIL.\n4. An empty answer, or naming the lowest Versus Score product instead of the lowest ANC one, is a FAIL."} +{"web_name": "Versus", "id": "Versus--16", "ques": "Compare the GeForce RTX 4070 Super and the Radeon RX 7800 XT, then report the benchmark score of whichever the site declares the winner.", "web": "http://localhost:40024/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_16.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the comparison of these two cards, or both detail pages.\n2. The final answer MUST name the product the site declares the winner AND give that product's benchmark score.\n3. The benchmark score is not printed on cards or in the ranking list, so an answer with no comparison or detail page visit is a FAIL.\n4. An empty answer, or reporting the loser's benchmark score, is a FAIL."} diff --git a/sites/versus/verify/verify_0.py b/sites/versus/verify/verify_0.py new file mode 100644 index 00000000..94ba60b2 --- /dev/null +++ b/sites/versus/verify/verify_0.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Versus--0: camera score of whichever of the two phones scores higher.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import verify_lib as V + + +def body(j, traj, initial, after): + left = V.product(initial, "iphone-15-pro") + right = V.product(initial, "samsung-galaxy-s24-ultra") + if not (left and right): + return j.fail("seed data missing", "one of the two products is not in initial_db") + if left["score"] == right["score"]: + return j.fail("ambiguous ground truth", + "the two products tie on score in initial_db") + target = left if left["score"] > right["score"] else right + expected = target["spec_1_value"] + + ans = V.final_answer(traj) + j.check("opened the comparison or both detail pages", + V.navigated_to(traj, f"/compare/{left['slug']}-vs-{right['slug']}") + or V.navigated_to(traj, f"/compare/{right['slug']}-vs-{left['slug']}") + or (V.opened_detail_or_compare(traj, left["slug"]) + and V.opened_detail_or_compare(traj, right["slug"])), + f"steps={V.step_urls(traj)[-6:]}") + j.check("answer is non-empty and not a denial", + bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + j.check("answer names the right product", + V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") + j.check("answer states the derived value", + V.mentions_number(ans, expected), f"expected={expected} from initial_db") + ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", + "camera score of the higher-scoring of the two phones") + j.check("anchored LLM agreement", ok, why, llm=True) + + +if __name__ == "__main__": + V.run("Versus--0", body) diff --git a/sites/versus/verify/verify_1.py b/sites/versus/verify/verify_1.py new file mode 100644 index 00000000..0476deb7 --- /dev/null +++ b/sites/versus/verify/verify_1.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Versus--1: ANC score of the headphones with >= 50 h battery.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import verify_lib as V + + +def body(j, traj, initial, after): + rows = V.products(initial, "headphones") + if not rows: + return j.fail("seed data missing", "no products in category headphones") + rows = [r for r in rows if (r["battery_hours"] or 0) >= 50] + if len(rows) != 1: + return j.fail("ambiguous ground truth", + f"{len(rows)} headphones meet the 50 h threshold in initial_db") + target = V.unique_extreme(rows, "battery_hours", largest=True) + if target is None: + return j.fail("ambiguous ground truth", + "no unique extreme for battery_hours in initial_db") + expected = target["spec_1_value"] + + ans = V.final_answer(traj) + j.check("opened the fact-bearing page for the target product", + V.opened_detail_or_compare(traj, target["slug"]), + f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") + j.check("answer is non-empty and not a denial", + bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + j.check("answer names the right product", + V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") + j.check("answer states the derived value", + V.mentions_number(ans, expected), f"expected={expected} from initial_db") + ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", + "ANC score of the headphones with at least 50 hours of battery") + j.check("anchored LLM agreement", ok, why, llm=True) + + +if __name__ == "__main__": + V.run("Versus--1", body) diff --git a/sites/versus/verify/verify_10.py b/sites/versus/verify/verify_10.py new file mode 100644 index 00000000..43b3dac6 --- /dev/null +++ b/sites/versus/verify/verify_10.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Versus--10: Versus Score of the graphics card with the most VRAM.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import verify_lib as V + + +def body(j, traj, initial, after): + rows = V.products(initial, "graphics-cards") + if not rows: + return j.fail("seed data missing", "no products in category graphics-cards") + target = V.unique_extreme(rows, "spec_1_value", largest=True) + if target is None: + return j.fail("ambiguous ground truth", + "no unique extreme for spec_1_value in initial_db") + expected = target["score"] + + ans = V.final_answer(traj) + j.check("opened the fact-bearing page for the target product", + V.opened_detail_or_compare(traj, target["slug"]), + f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") + j.check("answer is non-empty and not a denial", + bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + j.check("answer names the right product", + V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") + j.check("answer states the derived value", + V.mentions_number(ans, expected), f"expected={expected} from initial_db") + ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", + "Versus Score of the graphics card with the most VRAM") + j.check("anchored LLM agreement", ok, why, llm=True) + + +if __name__ == "__main__": + V.run("Versus--10", body) diff --git a/sites/versus/verify/verify_11.py b/sites/versus/verify/verify_11.py new file mode 100644 index 00000000..9cfe1a67 --- /dev/null +++ b/sites/versus/verify/verify_11.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Versus--11: price of the heaviest camera.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import verify_lib as V + + +def body(j, traj, initial, after): + rows = V.products(initial, "cameras") + if not rows: + return j.fail("seed data missing", "no products in category cameras") + target = V.unique_extreme(rows, "spec_3_value", largest=True) + if target is None: + return j.fail("ambiguous ground truth", + "no unique extreme for spec_3_value in initial_db") + expected = target["price"] + + ans = V.final_answer(traj) + j.check("opened the fact-bearing page for the target product", + V.opened_detail_or_compare(traj, target["slug"]), + f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") + j.check("answer is non-empty and not a denial", + bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + j.check("answer names the right product", + V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") + j.check("answer states the derived value", + V.mentions_money(ans, expected), f"expected={expected} from initial_db") + ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", + "price of the heaviest camera") + j.check("anchored LLM agreement", ok, why, llm=True) + + +if __name__ == "__main__": + V.run("Versus--11", body) diff --git a/sites/versus/verify/verify_12.py b/sites/versus/verify/verify_12.py new file mode 100644 index 00000000..f7b2449c --- /dev/null +++ b/sites/versus/verify/verify_12.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Versus--12: longer battery of OnePlus 12 vs Pixel 8 Pro.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import verify_lib as V + + +def body(j, traj, initial, after): + left = V.product(initial, "oneplus-12") + right = V.product(initial, "google-pixel-8-pro") + if not (left and right): + return j.fail("seed data missing", "one of the two products is not in initial_db") + if left["battery_hours"] == right["battery_hours"]: + return j.fail("ambiguous ground truth", + "the two products tie on battery_hours in initial_db") + target = left if left["battery_hours"] > right["battery_hours"] else right + expected = target["battery_hours"] + + ans = V.final_answer(traj) + j.check("opened the comparison or both detail pages", + V.navigated_to(traj, f"/compare/{left['slug']}-vs-{right['slug']}") + or V.navigated_to(traj, f"/compare/{right['slug']}-vs-{left['slug']}") + or (V.opened_detail_or_compare(traj, left["slug"]) + and V.opened_detail_or_compare(traj, right["slug"])), + f"steps={V.step_urls(traj)[-6:]}") + j.check("answer is non-empty and not a denial", + bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + j.check("answer names the right product", + V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") + j.check("answer states the derived value", + V.mentions_number(ans, expected), f"expected={expected} from initial_db") + ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", + "which of the two phones lasts longer and for how many hours") + j.check("anchored LLM agreement", ok, why, llm=True) + + +if __name__ == "__main__": + V.run("Versus--12", body) diff --git a/sites/versus/verify/verify_13.py b/sites/versus/verify/verify_13.py new file mode 100644 index 00000000..eddc6325 --- /dev/null +++ b/sites/versus/verify/verify_13.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Versus--13: Bob saves the Apple Watch Series 9 vs Garmin Venu 3 comparison.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import verify_lib as V + + +def body(j, traj, initial, after): + email = "bob.c@test.com" + pair = frozenset(({"apple-watch-series-9", "garmin-venu-3"})) + before = V.saved_pairs(initial, email) + now = V.saved_pairs(after, email) + if before is None: + return j.fail("initial_db unreadable", "cannot establish the before state") + if now is None: + return j.fail("after_db unavailable", + "a stateful task cannot be graded without the after state") + if pair in before: + return j.fail("task design error", + "the requested comparison is already saved in the seed, so the " + "after state would be identical whether or not the agent acted") + + j.check("signed in", V.navigated_to(traj, "/login"), + f"steps={V.step_urls(traj)[:6]}") + j.check("opened the comparison page for the requested pair", + V.navigated_to(traj, "/compare/apple-watch-series-9-vs-garmin-venu-3") + or V.navigated_to(traj, "/compare/garmin-venu-3-vs-apple-watch-series-9"), + f"steps={V.step_urls(traj)[-6:]}") + j.check("the comparison is actually saved to that account", + pair in now, f"account pairs after the run = {sorted(map(sorted, now))}") + j.check("no unrelated comparison was added", + len(now - before - {pair}) == 0, + f"unexpected additions = {sorted(map(sorted, now - before - {pair}))}") + + +if __name__ == "__main__": + V.run("Versus--13", body) diff --git a/sites/versus/verify/verify_14.py b/sites/versus/verify/verify_14.py new file mode 100644 index 00000000..14d64e14 --- /dev/null +++ b/sites/versus/verify/verify_14.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Versus--14: weight of the longest-lasting smartwatch.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import verify_lib as V + + +def body(j, traj, initial, after): + rows = V.products(initial, "smartwatches") + if not rows: + return j.fail("seed data missing", "no products in category smartwatches") + target = V.unique_extreme(rows, "battery_hours", largest=True) + if target is None: + return j.fail("ambiguous ground truth", + "no unique extreme for battery_hours in initial_db") + expected = target["spec_3_value"] + + ans = V.final_answer(traj) + j.check("opened the fact-bearing page for the target product", + V.opened_detail_or_compare(traj, target["slug"]), + f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") + j.check("answer is non-empty and not a denial", + bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + j.check("answer names the right product", + V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") + j.check("answer states the derived value", + V.mentions_number(ans, expected), f"expected={expected} from initial_db") + ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", + "weight in grams of the smartwatch with the longest battery life") + j.check("anchored LLM agreement", ok, why, llm=True) + + +if __name__ == "__main__": + V.run("Versus--14", body) diff --git a/sites/versus/verify/verify_15.py b/sites/versus/verify/verify_15.py new file mode 100644 index 00000000..6f1c4d89 --- /dev/null +++ b/sites/versus/verify/verify_15.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Versus--15: price of the headphones with the lowest ANC score.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import verify_lib as V + + +def body(j, traj, initial, after): + rows = V.products(initial, "headphones") + if not rows: + return j.fail("seed data missing", "no products in category headphones") + target = V.unique_extreme(rows, "spec_1_value", largest=False) + if target is None: + return j.fail("ambiguous ground truth", + "no unique extreme for spec_1_value in initial_db") + expected = target["price"] + + ans = V.final_answer(traj) + j.check("opened the fact-bearing page for the target product", + V.opened_detail_or_compare(traj, target["slug"]), + f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") + j.check("answer is non-empty and not a denial", + bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + j.check("answer names the right product", + V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") + j.check("answer states the derived value", + V.mentions_money(ans, expected), f"expected={expected} from initial_db") + ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", + "price of the headphones with the lowest ANC score") + j.check("anchored LLM agreement", ok, why, llm=True) + + +if __name__ == "__main__": + V.run("Versus--15", body) diff --git a/sites/versus/verify/verify_16.py b/sites/versus/verify/verify_16.py new file mode 100644 index 00000000..0e9d3894 --- /dev/null +++ b/sites/versus/verify/verify_16.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Versus--16: benchmark score of the winner of RTX 4070 Super vs RX 7800 XT.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import verify_lib as V + + +def body(j, traj, initial, after): + left = V.product(initial, "rtx-4070-super") + right = V.product(initial, "radeon-rx-7800-xt") + if not (left and right): + return j.fail("seed data missing", "one of the two products is not in initial_db") + if left["score"] == right["score"]: + return j.fail("ambiguous ground truth", + "the two products tie on score in initial_db") + target = left if left["score"] > right["score"] else right + expected = target["spec_3_value"] + + ans = V.final_answer(traj) + j.check("opened the comparison or both detail pages", + V.navigated_to(traj, f"/compare/{left['slug']}-vs-{right['slug']}") + or V.navigated_to(traj, f"/compare/{right['slug']}-vs-{left['slug']}") + or (V.opened_detail_or_compare(traj, left["slug"]) + and V.opened_detail_or_compare(traj, right["slug"])), + f"steps={V.step_urls(traj)[-6:]}") + j.check("answer is non-empty and not a denial", + bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + j.check("answer names the right product", + V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") + j.check("answer states the derived value", + V.mentions_number(ans, expected), f"expected={expected} from initial_db") + ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", + "benchmark score of the winner of the two graphics cards") + j.check("anchored LLM agreement", ok, why, llm=True) + + +if __name__ == "__main__": + V.run("Versus--16", body) diff --git a/sites/versus/verify/verify_2.py b/sites/versus/verify/verify_2.py new file mode 100644 index 00000000..73f38d6d --- /dev/null +++ b/sites/versus/verify/verify_2.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Versus--2: power draw of the top-ranked graphics card.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import verify_lib as V + + +def body(j, traj, initial, after): + rows = V.products(initial, "graphics-cards") + if not rows: + return j.fail("seed data missing", "no products in category graphics-cards") + target = V.unique_extreme(rows, "score", largest=True) + if target is None: + return j.fail("ambiguous ground truth", + "no unique extreme for score in initial_db") + expected = target["spec_2_value"] + + ans = V.final_answer(traj) + j.check("opened the rankings view", V.navigated_to(traj, "/rankings"), + f"steps={V.step_urls(traj)}") + j.check("opened the fact-bearing page for the target product", + V.opened_detail_or_compare(traj, target["slug"]), + f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") + j.check("answer is non-empty and not a denial", + bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + j.check("answer names the right product", + V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") + j.check("answer states the derived value", + V.mentions_number(ans, expected), f"expected={expected} from initial_db") + ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", + "power draw in watts of the highest ranked graphics card") + j.check("anchored LLM agreement", ok, why, llm=True) + + +if __name__ == "__main__": + V.run("Versus--2", body) diff --git a/sites/versus/verify/verify_3.py b/sites/versus/verify/verify_3.py new file mode 100644 index 00000000..45d37c5d --- /dev/null +++ b/sites/versus/verify/verify_3.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""Versus--3: burst speed of the highest-megapixel camera.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import verify_lib as V + + +def body(j, traj, initial, after): + rows = V.products(initial, "cameras") + if not rows: + return j.fail("seed data missing", "no products in category cameras") + target = V.unique_extreme(rows, "spec_1_value", largest=True) + if target is None: + return j.fail("ambiguous ground truth", + "no unique extreme for spec_1_value in initial_db") + expected = target["spec_2_value"] + + ans = V.final_answer(traj) + j.check("used site search", V.navigated_to(traj, "/search"), + f"steps={V.step_urls(traj)}") + j.check("opened the fact-bearing page for the target product", + V.opened_detail_or_compare(traj, target["slug"]), + f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") + j.check("answer is non-empty and not a denial", + bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + j.check("answer names the right product", + V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") + j.check("answer states the derived value", + V.mentions_number(ans, expected), f"expected={expected} from initial_db") + ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", + "burst speed in fps of the camera with the most megapixels") + j.check("anchored LLM agreement", ok, why, llm=True) + + +if __name__ == "__main__": + V.run("Versus--3", body) diff --git a/sites/versus/verify/verify_4.py b/sites/versus/verify/verify_4.py new file mode 100644 index 00000000..f15afd5c --- /dev/null +++ b/sites/versus/verify/verify_4.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Versus--4: longer battery of XM5 vs QuietComfort Ultra.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import verify_lib as V + + +def body(j, traj, initial, after): + left = V.product(initial, "sony-wh-1000xm5") + right = V.product(initial, "bose-quietcomfort-ultra") + if not (left and right): + return j.fail("seed data missing", "one of the two products is not in initial_db") + if left["battery_hours"] == right["battery_hours"]: + return j.fail("ambiguous ground truth", + "the two products tie on battery_hours in initial_db") + target = left if left["battery_hours"] > right["battery_hours"] else right + expected = target["battery_hours"] + + ans = V.final_answer(traj) + j.check("opened the comparison or both detail pages", + V.navigated_to(traj, f"/compare/{left['slug']}-vs-{right['slug']}") + or V.navigated_to(traj, f"/compare/{right['slug']}-vs-{left['slug']}") + or (V.opened_detail_or_compare(traj, left["slug"]) + and V.opened_detail_or_compare(traj, right["slug"])), + f"steps={V.step_urls(traj)[-6:]}") + j.check("answer is non-empty and not a denial", + bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + j.check("answer names the right product", + V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") + j.check("answer states the derived value", + V.mentions_number(ans, expected), f"expected={expected} from initial_db") + ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", + "which of the two headphones lasts longer and for how many hours") + j.check("anchored LLM agreement", ok, why, llm=True) + + +if __name__ == "__main__": + V.run("Versus--4", body) diff --git a/sites/versus/verify/verify_5.py b/sites/versus/verify/verify_5.py new file mode 100644 index 00000000..be4c3ec6 --- /dev/null +++ b/sites/versus/verify/verify_5.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Versus--5: sign in as Alice and report one comparison already saved to the account.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import verify_lib as V + +EMAIL = "alice.j@test.com" + + +def body(j, traj, initial, after): + before = V.saved_pairs(initial, EMAIL) + if not before: + return j.fail("seed data missing", + "the account has no saved comparisons in initial_db") + + ans = V.final_answer(traj) + names = {p["slug"]: p["name"] for p in V.products(initial)} + + j.check("signed in", V.navigated_to(traj, "/login"), + f"steps={V.step_urls(traj)[:6]}") + j.check("opened the account page", V.navigated_to(traj, "/account"), + f"steps={V.step_urls(traj)}") + j.check("answer is non-empty and not a denial", + bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + + # Which seeded pair does the answer name? Both product names must appear. + matched = [pair for pair in before + if all(V.mentions_product(ans, names[slug]) for slug in pair)] + j.check("answer names both products of one comparison already on the account", + len(matched) >= 1, + f"seeded pairs={sorted(map(sorted, before))} answer={ans!r}") + + # The reported comparison must be one that was already there, not one the + # agent created during the run. + now = V.saved_pairs(after, EMAIL) + if now is not None: + added = now - before + j.check("did not report a comparison it created during the run", + not (added and matched and set(matched) <= added), + f"added during run={sorted(map(sorted, added))}") + + if matched: + pair = sorted(matched[0]) + ok, why = V.llm_text_match( + ans, " vs ".join(names[s] for s in pair), + "one comparison already saved to the signed-in account") + j.check("anchored LLM agreement", ok, why, llm=True) + + +if __name__ == "__main__": + V.run("Versus--5", body) diff --git a/sites/versus/verify/verify_6.py b/sites/versus/verify/verify_6.py new file mode 100644 index 00000000..546ac794 --- /dev/null +++ b/sites/versus/verify/verify_6.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +"""Versus--6: display size of the top-scoring smartphone at $1000 or less.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import verify_lib as V + + +def body(j, traj, initial, after): + rows = V.products(initial, "smartphones") + if not rows: + return j.fail("seed data missing", "no products in category smartphones") + rows = [r for r in rows if (r["price"] or 0) <= 1000] + if not rows: + return j.fail("ambiguous ground truth", + "no smartphone at or below the price limit in initial_db") + target = V.unique_extreme(rows, "score", largest=True) + if target is None: + return j.fail("ambiguous ground truth", + "no unique extreme for score in initial_db") + expected = target["spec_3_value"] + + ans = V.final_answer(traj) + j.check("opened the fact-bearing page for the target product", + V.opened_detail_or_compare(traj, target["slug"]), + f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") + j.check("answer is non-empty and not a denial", + bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + j.check("answer names the right product", + V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") + j.check("answer states the derived value", + V.mentions_number(ans, expected), f"expected={expected} from initial_db") + ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", + "display size in inches of the highest scoring smartphone at $1000 or less") + j.check("anchored LLM agreement", ok, why, llm=True) + + +if __name__ == "__main__": + V.run("Versus--6", body) diff --git a/sites/versus/verify/verify_7.py b/sites/versus/verify/verify_7.py new file mode 100644 index 00000000..41c5d34c --- /dev/null +++ b/sites/versus/verify/verify_7.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Versus--7: Alice saves the Nikon Z8 vs Canon EOS R6 Mark II comparison.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import verify_lib as V + + +def body(j, traj, initial, after): + email = "alice.j@test.com" + pair = frozenset(({"nikon-z8", "canon-eos-r6-mark-ii"})) + before = V.saved_pairs(initial, email) + now = V.saved_pairs(after, email) + if before is None: + return j.fail("initial_db unreadable", "cannot establish the before state") + if now is None: + return j.fail("after_db unavailable", + "a stateful task cannot be graded without the after state") + if pair in before: + return j.fail("task design error", + "the requested comparison is already saved in the seed, so the " + "after state would be identical whether or not the agent acted") + + j.check("signed in", V.navigated_to(traj, "/login"), + f"steps={V.step_urls(traj)[:6]}") + j.check("opened the comparison page for the requested pair", + V.navigated_to(traj, "/compare/nikon-z8-vs-canon-eos-r6-mark-ii") + or V.navigated_to(traj, "/compare/canon-eos-r6-mark-ii-vs-nikon-z8"), + f"steps={V.step_urls(traj)[-6:]}") + j.check("the comparison is actually saved to that account", + pair in now, f"account pairs after the run = {sorted(map(sorted, now))}") + j.check("no unrelated comparison was added", + len(now - before - {pair}) == 0, + f"unexpected additions = {sorted(map(sorted, now - before - {pair}))}") + + +if __name__ == "__main__": + V.run("Versus--7", body) diff --git a/sites/versus/verify/verify_8.py b/sites/versus/verify/verify_8.py new file mode 100644 index 00000000..c4a1ef19 --- /dev/null +++ b/sites/versus/verify/verify_8.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Versus--8: battery life of the Garmin Venu 3, reached via the smartwatches category.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import verify_lib as V + +SLUG = "garmin-venu-3" + + +def body(j, traj, initial, after): + target = V.product(initial, SLUG) + if not target: + return j.fail("seed data missing", f"{SLUG} is not in initial_db") + expected = target["spec_2_value"] + + ans = V.final_answer(traj) + j.check("opened the smartwatches category listing", + V.navigated_to(traj, f"/category/{target['category_slug']}"), + f"steps={V.step_urls(traj)}") + j.check("opened the fact-bearing page for the product", + V.opened_detail_or_compare(traj, SLUG), + f"steps={V.step_urls(traj)[-6:]}") + j.check("answer is non-empty and not a denial", + bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + j.check("answer states the derived battery life", + V.mentions_number(ans, expected), + f"expected={expected} {target['unit_2']} from initial_db") + ok, why = V.llm_text_match(ans, f"{expected} {target['unit_2']}", + "battery life in hours of the Garmin Venu 3") + j.check("anchored LLM agreement", ok, why, llm=True) + + +if __name__ == "__main__": + V.run("Versus--8", body) diff --git a/sites/versus/verify/verify_9.py b/sites/versus/verify/verify_9.py new file mode 100644 index 00000000..cd9e5f7b --- /dev/null +++ b/sites/versus/verify/verify_9.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Versus--9: build the R6 Mark II vs A7 IV comparison in the picker and name the winner.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import verify_lib as V + +LEFT, RIGHT = "canon-eos-r6-mark-ii", "sony-a7-iv" + + +def body(j, traj, initial, after): + left, right = V.product(initial, LEFT), V.product(initial, RIGHT) + if not (left and right): + return j.fail("seed data missing", "one of the two cameras is not in initial_db") + if left["score"] == right["score"]: + return j.fail("ambiguous ground truth", + "the two cameras tie on Versus Score in initial_db, so the " + "site's winner depends on argument order") + # The site declares the higher Versus Score the winner (app.winner()). + target = left if left["score"] > right["score"] else right + loser = right if target is left else left + + ans = V.final_answer(traj) + j.check("used the compare picker", V.navigated_to(traj, "/compare"), + f"steps={V.step_urls(traj)}") + j.check("landed on the comparison page for this pair", + V.navigated_to(traj, f"/compare/{LEFT}-vs-{RIGHT}") + or V.navigated_to(traj, f"/compare/{RIGHT}-vs-{LEFT}"), + f"steps={V.step_urls(traj)[-6:]}") + j.check("answer is non-empty and not a denial", + bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + j.check("answer names the product the site declares the winner", + V.mentions_product(ans, target["name"]), + f"expected={target['name']!r} (score {target['score']} vs {loser['score']})") + ok, why = V.llm_text_match( + ans, f"the winner is {target['name']}", + "which product the site declares the winner of this comparison") + j.check("anchored LLM agreement", ok, why, llm=True) + + +if __name__ == "__main__": + V.run("Versus--9", body) diff --git a/sites/versus/verify/verify_lib.py b/sites/versus/verify/verify_lib.py new file mode 100644 index 00000000..830a14ae --- /dev/null +++ b/sites/versus/verify/verify_lib.py @@ -0,0 +1,365 @@ +#!/usr/bin/env python3 +"""verify_lib.py — shared deterministic utilities for Versus task verification. + +Philosophy, same as the merriam_webster reference: DETERMINISTIC FIRST. + 1. Navigation check (anti knowledge-shortcut): the agent MUST have opened the + on-site page that carries the fact. Versus prints Score/Price/Year on cards + and in the ranking list but keeps every spec value (camera score, ANC score, + megapixels, burst, VRAM, power, benchmark, battery hours, weight, display) + on detail and comparison pages only — so the navigation check is what stops + a list-scan or a recalled answer from passing. + 2. Answer check against ground truth DERIVED FROM initial_db, never a frozen + constant: if the seed data changes, the expected answer moves with it and a + stale verifier fails loudly instead of grading against a dead value. + 3. DB after-state check for stateful tasks, read from the after_db. + 4. LLM utilities are anchored: the model confirms presence of a derived value, + it never supplies knowledge. + +Input signature (per task): + --run_dir DIR trajectory.json + screenshots/step_NNN.png + --initial_db PATH initial-state SQLite DB (default: instance_seed from container) + --after_db PATH after-state SQLite DB (default: live instance from container) + --container NAME docker container to fetch DBs from (default: $WH_CONTAINER) + --no_llm deterministic-only +Output: JSON {task_id, pass, reason, evidence[]} on stdout; exit 0 PASS / 1 FAIL. +Malformed or missing input produces a structured FAIL, never a traceback. +""" +from __future__ import annotations + +import base64 +import json +import os +import re +import sqlite3 +import subprocess +import sys +import tempfile +import urllib.request +from dataclasses import dataclass +from pathlib import Path + +SITE = "versus" + +# ---------------------------------------------------------------- trajectory +def load_run(run_dir): + d = Path(run_dir) + traj = json.loads((d / "trajectory.json").read_text()) + traj["_run_dir"] = d + traj["_shots"] = {p.name: p for p in sorted((d / "screenshots").glob("step_*.png"))} + return traj + + +def step_urls(traj): + return [s.get("url", "") or "" for s in traj.get("steps", [])] + + +def navigated_to(traj, substr, times=1): + return sum(1 for u in step_urls(traj) if substr in u) >= times + + +def navigated_any(traj, substrs): + return any(navigated_to(traj, s) for s in substrs) + + +def opened_detail_or_compare(traj, slug): + """The fact-bearing views for one product: its detail page, or any comparison + that includes it (either side of the `-vs-` slug).""" + for url in step_urls(traj): + if f"/item/{slug}" in url: + return True + m = re.search(r"/compare/([a-z0-9\-]+)-vs-([a-z0-9\-]+)", url) + if m and slug in (m.group(1), m.group(2)): + return True + return False + + +def final_answer(traj): + return (traj.get("final_answer") or "").strip() + + +def _shot(traj, name): + if not name: + return None + p = traj["_shots"].get(Path(name).name) + return p if (p and p.exists()) else None + + +def shot_after_url(traj, substr): + for s in traj.get("steps", []): + if substr in (s.get("url", "") or ""): + p = _shot(traj, s.get("screenshot_after")) + if p: + return p + return None + + +def last_shot(traj): + for s in reversed(traj.get("steps", [])): + p = _shot(traj, s.get("screenshot_after")) or _shot(traj, s.get("screenshot_before")) + if p: + return p + shots = sorted(traj["_shots"].values()) + return shots[-1] if shots else None + + +# ---------------------------------------------------------------- answer match +def norm(s): + return re.sub(r"\s+", " ", (s or "").strip()).casefold() + + +NEGATIONS = ("not ", "n't", "cannot", "unable", "no such", "could not", + "couldn't", "failed to find", "does not exist") + + +def looks_negated(text): + """A final answer that denies the fact must not pass on token containment.""" + return any(n in norm(text) for n in NEGATIONS) + + +def mentions_product(text, name): + """Product naming, tolerant of the ways a model writes the same model number. + + 'GeForce RTX 4080 Super' matches 'RTX 4080 Super'; 'iPhone 15 Pro' does NOT + match 'iPhone 15 Pro Max' style over-reach because the distinguishing tokens + must all be present. + """ + t = norm(text) + tokens = [x for x in re.split(r"[\s/]+", norm(name)) if x + and x not in {"geforce", "radeon", "apple", "samsung", "sony", "google"}] + return all(tok in t for tok in tokens) if tokens else False + + +def _numbers(text): + return [float(x) for x in re.findall(r"-?\d+(?:\.\d+)?", (text or "").replace(",", ""))] + + +def mentions_number(text, value, tol=0.05): + """True when the answer states `value`. Accepts 336, 336.0, '336 h', '336-hour'.""" + try: + value = float(value) + except (TypeError, ValueError): + return False + return any(abs(n - value) <= tol for n in _numbers(text)) + + +def mentions_money(text, value): + """Price match that also accepts '$3,999' and '3999 USD'.""" + return mentions_number(text, value, tol=0.5) + + +# ---------------------------------------------------------------- DB access +def fetch_db(container, kind): + src = f"{container}:/opt/WebSyn/{SITE}/{kind}/{SITE}.db" + fd, path = tempfile.mkstemp(suffix=".db") + os.close(fd) + r = subprocess.run(["docker", "cp", src, path], capture_output=True, text=True) + if r.returncode != 0: + try: + os.unlink(path) + except OSError: + pass + raise RuntimeError(f"docker cp {src} failed: {r.stderr.strip()}") + return path + + +def resolve_db(arg, container, kind): + if arg: + return arg if Path(arg).exists() else None + try: + return fetch_db(container, kind) + except Exception: + return None + + +def db_query(db_path, sql, params=()): + con = sqlite3.connect(db_path) + try: + con.row_factory = sqlite3.Row + return con.execute(sql, params).fetchall() + finally: + con.close() + + +def products(db_path, category_slug=None): + """Every product with its category and that category's spec labels/units.""" + if not db_path: + return [] + sql = ("SELECT p.slug, p.name, p.brand, p.score, p.price, p.release_year, " + "p.spec_1_value, p.spec_2_value, p.spec_3_value, p.battery_hours, " + "p.weight_grams, c.slug AS category_slug, c.name AS category_name, " + "c.spec_1, c.spec_2, c.spec_3, c.unit_1, c.unit_2, c.unit_3 " + "FROM product p JOIN category c ON c.id = p.category_id") + params = () + if category_slug: + sql += " WHERE c.slug = ?" + params = (category_slug,) + return [dict(r) for r in db_query(db_path, sql, params)] + + +def product(db_path, slug): + rows = [p for p in products(db_path) if p["slug"] == slug] + return rows[0] if rows else None + + +def unique_extreme(rows, key, largest=True): + """The single row with the extreme value of `key`, or None when it is tied. + + Fail-closed on ambiguity: a task whose answer is not unique in the seed data + must not be graded as if it were. + """ + if not rows: + return None + vals = sorted((r[key] for r in rows), reverse=largest) + if len(vals) > 1 and vals[0] == vals[1]: + return None + target = vals[0] + return next(r for r in rows if r[key] == target) + + +def saved_pairs(db_path, email): + """{frozenset({left_slug, right_slug})} saved by that user, or None if unreadable.""" + if not db_path: + return None + try: + rows = db_query(db_path, + "SELECT l.slug AS l, r.slug AS r FROM saved_comparison sc " + "JOIN user u ON u.id = sc.user_id " + "JOIN product l ON l.id = sc.left_id " + "JOIN product r ON r.id = sc.right_id WHERE u.email = ?", + (email,)) + except sqlite3.Error: + return None + return {frozenset((row["l"], row["r"])) for row in rows} + + +# ---------------------------------------------------------------- anchored LLM +_NO_LLM = False + + +def _chat(messages, max_tokens=1024): + if _NO_LLM: + return None + key = os.environ.get("OPENAI_API_KEY", "") + base = os.environ.get("OPENAI_BASE_URL", "") + model = os.environ.get("JUDGE_MODEL", "") + if not (key and base and model): + return None + payload = {"model": model, "messages": messages, + "max_tokens": max_tokens, "temperature": 1.0} + req = urllib.request.Request(base, data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json", + "Authorization": f"Bearer {key}"}) + try: + data = json.loads(urllib.request.urlopen(req, timeout=180).read()) + return data["choices"][0]["message"]["content"] + except Exception: + return None + + +def _verdict(out): + if not out: + return False, "" + s = out.strip() + return s.upper().startswith("PASS"), s + + +def llm_text_match(agent_answer, ground_truth, question): + if _NO_LLM: + return False, "[skipped: --no_llm]" + return _verdict(_chat([{"role": "user", "content": + f"You are a STRICT binary grader.\nQuestion: {question}\n" + f"Ground-truth answer (ANCHOR — judge against THIS, never use your own knowledge): {ground_truth}\n" + f"Agent's answer: {agent_answer}\n" + f"Decide PASS or FAIL ignoring case/punctuation/word order/surrounding prose. " + f"PASS only if the agent's answer is consistent with the ground truth AND actually answers the question. " + f"Line 1: PASS or FAIL. Line 2: one-sentence reason."}])) + + +def llm_screenshot_shows(shot_path, must_show, question=""): + if _NO_LLM or not shot_path: + return False, "[skipped: --no_llm or no screenshot]" + b64 = base64.b64encode(Path(shot_path).read_bytes()).decode() + return _verdict(_chat([{"role": "user", "content": [ + {"type": "text", "text": + f"You are a STRICT binary grader. Only what is VISIBLY rendered counts.\n" + f"Question the page should answer: {question}\n" + f"Expected content to verify PRESENCE of: {must_show}\n" + f"Do NOT use prior knowledge — judge only the rendered pixels.\n" + f"Line 1: PASS or FAIL. Line 2: quote the visible evidence."}, + {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}]}])) + + +# ---------------------------------------------------------------- harness +class Judge: + def __init__(self, task_id, no_llm=False): + global _NO_LLM + _NO_LLM = bool(no_llm) + self.task_id = task_id + self.no_llm = no_llm + self.ok = True + self.reason = "" + self.evidence = [] + + def check(self, name, cond, evidence="", llm=False): + if llm and self.no_llm: + self.evidence.append(f"[SKIP] {name} (--no_llm)") + return True + if cond: + self.evidence.append(f"[PASS] {name}: {evidence}") + else: + self.ok = False + if not self.reason: + self.reason = name + self.evidence.append(f"[FAIL] {name}: {evidence}") + return bool(cond) + + def fail(self, reason, evidence=""): + self.ok = False + if not self.reason: + self.reason = reason + self.evidence.append(f"[FAIL] {reason}: {evidence}") + + def emit(self): + print(json.dumps({"task_id": self.task_id, "pass": self.ok, + "reason": self.reason, "evidence": self.evidence}, indent=2)) + sys.exit(0 if self.ok else 1) + + +def parse_args(): + import simpleArgParser as sap + + @dataclass + class VerifyArgs: + run_dir: str = "" + initial_db: str = "" + after_db: str = "" + container: str = os.environ.get("WH_CONTAINER", "wh-review041-candidate") + no_llm: bool = False + + def post_process(self): + if not self.run_dir: + raise SystemExit("--run_dir is required") + + return sap.parse_args(VerifyArgs) + + +def run(task_id, body): + """Wrap a verifier body so malformed input is a structured FAIL, not a crash.""" + args = parse_args() + j = Judge(task_id, no_llm=args.no_llm) + try: + traj = load_run(args.run_dir) + except Exception as exc: + j.fail("run bundle unreadable", f"{type(exc).__name__}: {exc}") + j.emit() + initial = resolve_db(args.initial_db, args.container, "instance_seed") + after = resolve_db(args.after_db, args.container, "instance") + if not initial: + j.fail("initial_db unavailable", + "ground truth is derived from the seed DB; refusing to grade without it") + j.emit() + try: + body(j, traj, initial, after) + except Exception as exc: + j.fail("verifier error", f"{type(exc).__name__}: {exc}") + j.emit() From bb1c58faa58b5cf5bdb06e9a3c36342fb120e400 Mon Sep 17 00:00:00 2001 From: JackJin <1037461232@qq.com> Date: Sun, 13 Sep 2026 12:36:16 +0800 Subject: [PATCH 06/18] test(versus): pin the defects found in review as regressions Each test fails against the code as submitted, so a later change that reintroduces one of these fails here rather than inside a benchmark run: seed byte-reproducibility across two builds, the two site registries agreeing and the task URLs deriving their port from them rather than freezing it, every task carrying a verifier that exists plus a rubric and no answer key, the task count staying in the review guide's range, no save task being already satisfied at seed state, and CSRF protection being installed with tokens on the posting forms. --- .../versus/tests/test_functional_contract.py | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 sites/versus/tests/test_functional_contract.py diff --git a/sites/versus/tests/test_functional_contract.py b/sites/versus/tests/test_functional_contract.py new file mode 100644 index 00000000..5f6a757c --- /dev/null +++ b/sites/versus/tests/test_functional_contract.py @@ -0,0 +1,180 @@ +"""Regression contract for the Versus mirror. + +Each test pins a defect found during review, so a later change that reintroduces +it fails here instead of in a benchmark run. + +Run from the repo root: + docker run --rm -v "$PWD:/repo:ro" -w /repo wh-review025-deps:latest \ + python3 -m unittest discover -s sites/versus/tests -v +""" +from __future__ import annotations + +import hashlib +import json +import os +import re +import shutil +import subprocess +import sqlite3 +import sys +import tempfile +import unittest +from pathlib import Path + +SITE_DIR = Path(__file__).resolve().parents[1] +REPO_ROOT = SITE_DIR.parents[1] +TASKS = SITE_DIR / "tasks.jsonl" +SITE_NAME = "versus" + + +def load_tasks(): + return [json.loads(line) for line in TASKS.read_text().splitlines() if line.strip()] + + +def build_seed(dest: Path) -> Path: + """Generate the seed DB the way the Dockerfile does, into an isolated copy.""" + work = dest / SITE_NAME + shutil.copytree(SITE_DIR, work) + for sub in ("instance", "instance_seed"): + shutil.rmtree(work / sub, ignore_errors=True) + subprocess.run([sys.executable, "-c", "from app import app"], + cwd=work, check=True, capture_output=True) + return work / "instance" / f"{SITE_NAME}.db" + + +class SeedDeterminism(unittest.TestCase): + """F9: two builds of the same commit must ship byte-identical seed data.""" + + def test_seed_is_byte_reproducible(self): + digests = [] + for _ in range(2): + with tempfile.TemporaryDirectory() as tmp: + db = build_seed(Path(tmp)) + digests.append(hashlib.sha256(db.read_bytes()).hexdigest()) + self.assertEqual( + digests[0], digests[1], + "seed DB differs between builds; a random salt or other nondeterminism " + "leaked into instance_seed, so its hash cannot be pinned", + ) + + +class RegistryConsistency(unittest.TestCase): + def _sites_from_control_server(self): + text = (REPO_ROOT / "control_server.py").read_text() + block = re.search(r"^SITES = \[(.*?)\]", text, re.S | re.M).group(1) + return re.findall(r"'([a-z0-9_]+)'", block) + + def _sites_from_start_script(self): + text = (REPO_ROOT / "websyn_start.sh").read_text() + block = re.search(r"^SITES=\((.*?)\)", text, re.S | re.M).group(1) + return block.split() + + def test_site_registered_identically_in_both_registries(self): + control = self._sites_from_control_server() + start = self._sites_from_start_script() + self.assertEqual(control, start, "control_server and websyn_start site order differ") + self.assertIn(SITE_NAME, control) + + def test_task_urls_match_the_registered_port(self): + port = 40000 + self._sites_from_control_server().index(SITE_NAME) + for task in load_tasks(): + self.assertEqual( + f"http://localhost:{port}/", task["web"], + f"{task['id']} points at {task['web']} but the site is registered on {port}", + ) + + def test_dockerfile_exposes_the_registered_port(self): + text = (REPO_ROOT / "Dockerfile").read_text() + upper = int(re.search(r"EXPOSE 8101 40000-(\d+)", text).group(1)) + port = 40000 + self._sites_from_control_server().index(SITE_NAME) + self.assertGreaterEqual(upper, port) + + +class TaskContract(unittest.TestCase): + def test_every_task_has_a_verifier_and_rubric(self): + for task in load_tasks(): + self.assertTrue(task.get("verifier_path"), f"{task['id']} has no verifier_path") + self.assertTrue((REPO_ROOT / task["verifier_path"]).exists(), + f"{task['id']}: {task['verifier_path']} does not exist") + self.assertTrue(task.get("judge_rubric"), f"{task['id']} has no judge_rubric") + + def test_task_file_carries_no_answer_key(self): + allowed = {"web_name", "id", "ques", "web", "upstream_url", + "verifier_path", "judge_rubric"} + for task in load_tasks(): + extra = set(task) - allowed + self.assertFalse(extra, f"{task['id']} carries unexpected keys {extra}") + self.assertNotIn("answer", task) + + def test_task_ids_are_contiguous_and_unique(self): + ids = [t["id"] for t in load_tasks()] + self.assertEqual(len(ids), len(set(ids)), "duplicate task ids") + self.assertEqual(ids, [f"Versus--{i}" for i in range(len(ids))]) + + def test_task_count_is_in_the_review_guide_range(self): + self.assertGreaterEqual(len(load_tasks()), 15) + self.assertLessEqual(len(load_tasks()), 20) + + +class StatefulTasksStartUnsatisfied(unittest.TestCase): + """T1: a task that asks the agent to create state must not already be satisfied.""" + + @classmethod + def setUpClass(cls): + cls._tmp = tempfile.TemporaryDirectory() + cls.db = build_seed(Path(cls._tmp.name)) + + @classmethod + def tearDownClass(cls): + cls._tmp.cleanup() + + def saved_pairs(self, email): + con = sqlite3.connect(self.db) + try: + rows = con.execute( + "SELECT l.slug, r.slug FROM saved_comparison sc " + "JOIN user u ON u.id = sc.user_id " + "JOIN product l ON l.id = sc.left_id " + "JOIN product r ON r.id = sc.right_id WHERE u.email = ?", + (email,)).fetchall() + finally: + con.close() + return {frozenset(pair) for pair in rows} + + def test_save_tasks_target_a_pair_not_already_saved(self): + existing = self.saved_pairs("alice.j@test.com") + slugs = {p[0] for p in sqlite3.connect(self.db).execute( + "SELECT slug FROM product")} + for task in load_tasks(): + ques = task["ques"].lower() + if "save" not in ques: + continue + mentioned = frozenset(s for s in slugs + if s.replace("-", " ") in ques.replace("-", " ")) + if len(mentioned) != 2: + continue + self.assertNotIn( + mentioned, existing, + f"{task['id']} asks to save a comparison Alice already has at seed " + f"state, so the after-state is identical whether or not the agent acts", + ) + + +class CsrfProtection(unittest.TestCase): + """F10: 23 of 25 sites on main install CSRFProtect; this one must too.""" + + def test_app_installs_csrf_protection(self): + source = (SITE_DIR / "app.py").read_text() + self.assertIn("CSRFProtect", source) + + def test_state_changing_forms_carry_a_token(self): + for template in ("compare.html", "login.html"): + text = (SITE_DIR / "templates" / template).read_text() + if "method=\"post\"" not in text.lower(): + continue + self.assertIn("csrf_token", text, + f"{template} posts without a CSRF token field") + + +if __name__ == "__main__": + unittest.main() From bdd2ae06fd1b1f1b310c7b9626d3f300a9bf577c Mon Sep 17 00:00:00 2001 From: JackJin <1037461232@qq.com> Date: Sun, 13 Sep 2026 16:34:18 +0800 Subject: [PATCH 07/18] fix(versus): drop the future-annotations import from verify_lib simpleArgParser builds its parser from the dataclass field types, so `from __future__ import annotations` turned every field into the string 'str' and every verifier died with "'str' is not callable" before it could grade anything. The merriam_webster reference lib does not use it either. --- sites/versus/verify/verify_lib.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/sites/versus/verify/verify_lib.py b/sites/versus/verify/verify_lib.py index 830a14ae..56578f7e 100644 --- a/sites/versus/verify/verify_lib.py +++ b/sites/versus/verify/verify_lib.py @@ -24,8 +24,6 @@ Output: JSON {task_id, pass, reason, evidence[]} on stdout; exit 0 PASS / 1 FAIL. Malformed or missing input produces a structured FAIL, never a traceback. """ -from __future__ import annotations - import base64 import json import os From 0cbdc87875d9987fd7b3471ec627ad97cb3f199a Mon Sep 17 00:00:00 2001 From: JackJin <1037461232@qq.com> Date: Sun, 13 Sep 2026 16:49:53 +0800 Subject: [PATCH 08/18] fix(versus): reject trajectories recorded against another site's origin The adversarial matrix caught this: every navigation check was a bare substring match on the path, so taking a canonical trajectory and rewriting its URLs from localhost:40024 to localhost:40007 -- same paths, different mirror -- still passed all 17 verifiers. A run on a different site is not evidence that the agent visited this one. step_urls() now keeps only steps on this site's own origin, and the port is derived from control_server.py's registry rather than frozen, so a registry reorder moves the verifier with it instead of silently accepting whatever site now owns the old port. WH_SITE_ORIGINS overrides it for a harness that maps the site to another address. The two save tasks also now require a final answer that exists and does not deny having saved. They are graded on the state change, so a badly worded report of a correctly performed action still passes; a report that contradicts the state does not. --- sites/versus/verify/verify_13.py | 6 +++++ sites/versus/verify/verify_7.py | 6 +++++ sites/versus/verify/verify_lib.py | 39 +++++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/sites/versus/verify/verify_13.py b/sites/versus/verify/verify_13.py index eddc6325..4a339b50 100644 --- a/sites/versus/verify/verify_13.py +++ b/sites/versus/verify/verify_13.py @@ -22,6 +22,12 @@ def body(j, traj, initial, after): "the requested comparison is already saved in the seed, so the " "after state would be identical whether or not the agent acted") + ans = V.final_answer(traj) + j.check("reported what it did, without denying it", + bool(ans) and not V.looks_negated(ans), + f"answer={ans!r} (this task is graded on the state change; the report " + f"must still exist and must not contradict it)") + j.check("signed in", V.navigated_to(traj, "/login"), f"steps={V.step_urls(traj)[:6]}") j.check("opened the comparison page for the requested pair", diff --git a/sites/versus/verify/verify_7.py b/sites/versus/verify/verify_7.py index 41c5d34c..7ef45692 100644 --- a/sites/versus/verify/verify_7.py +++ b/sites/versus/verify/verify_7.py @@ -22,6 +22,12 @@ def body(j, traj, initial, after): "the requested comparison is already saved in the seed, so the " "after state would be identical whether or not the agent acted") + ans = V.final_answer(traj) + j.check("reported what it did, without denying it", + bool(ans) and not V.looks_negated(ans), + f"answer={ans!r} (this task is graded on the state change; the report " + f"must still exist and must not contradict it)") + j.check("signed in", V.navigated_to(traj, "/login"), f"steps={V.step_urls(traj)[:6]}") j.check("opened the comparison page for the requested pair", diff --git a/sites/versus/verify/verify_lib.py b/sites/versus/verify/verify_lib.py index 56578f7e..1a48c88f 100644 --- a/sites/versus/verify/verify_lib.py +++ b/sites/versus/verify/verify_lib.py @@ -47,7 +47,46 @@ def load_run(run_dir): return traj +def site_port(): + """This site's port, derived from the registry rather than frozen here. + + Keeping it derived means a registry reorder moves the verifier with it + instead of silently accepting trajectories from whatever site now owns the + old port. + """ + override = os.environ.get("WH_SITE_PORT") + if override: + return int(override) + registry = Path(__file__).resolve().parents[3] / "control_server.py" + block = re.search(r"^SITES = \[(.*?)\]", registry.read_text(), re.S | re.M).group(1) + return 40000 + re.findall(r"'([a-z0-9_]+)'", block).index(SITE) + + +def site_origins(): + """Origins a trajectory step may legitimately carry. + + Without this, every navigation check is a bare substring match on the path, + so a trajectory recorded against a different mirror on the same host -- same + paths, different port -- satisfies them. Override with WH_SITE_ORIGINS + (comma separated) for a harness that maps the site to another address. + """ + env = os.environ.get("WH_SITE_ORIGINS") + if env: + return tuple(x.strip().rstrip("/") for x in env.split(",") if x.strip()) + port = site_port() + return (f"http://localhost:{port}", f"http://127.0.0.1:{port}") + + def step_urls(traj): + """Only steps on this site's own origin. Anything else is not evidence that + the agent visited THIS site.""" + origins = site_origins() + return [s.get("url", "") or "" for s in traj.get("steps", []) + if (s.get("url", "") or "").startswith(origins)] + + +def all_step_urls(traj): + """Every recorded URL, including off-site ones (for evidence messages).""" return [s.get("url", "") or "" for s in traj.get("steps", [])] From 85192535987deb20c0a80c7ef2f1438149ca6148 Mon Sep 17 00:00:00 2001 From: JackJin <1037461232@qq.com> Date: Sun, 13 Sep 2026 20:54:55 +0800 Subject: [PATCH 09/18] fix(versus): rebuild the interface against the source design, with gated synthetic art Two changes that could not be cleanly separated -- the art had to be designed against the new palette, and both touch the same templates and app.py. ## Interface The mirror shared no design language with versus.com: light blue-and-white template, a round "VS" badge, two flat sections and a one-line footer, against a near-black to deep-purple editorial site. Rebuilt from the evidence captured before the source started refusing this client: dark ground with the purple wash and the wave cutout, lowercase wordmark, the oversized two-line "compare everything" hero with its accent underline, pill controls, score rings on products, four-column footer. The comparison page also carried the wrong shape. The source resolves a comparison per area with a margin per row, not as one overall number, so the table now has a Margin column with the leading side marked and the ruling band reads "leads in N of M areas". What the site *declares* the winner is deliberately unchanged -- still the higher Versus Score, via winner() -- because tasks 9 and 16 are graded on that, and changing it would have moved the answer under the verifier rather than fixing the presentation. Colours are read off screenshots, not sampled from the source stylesheet: the 403 landed before exact values could be extracted. The CSS header records them as estimates rather than claiming they are exact. Adds /about, stating which values are sourced and which are synthetic; the footer carries the short version on every page. The Versus Score is synthetic and now says so in the product, not only in review notes. Not mirrored, and not pretended otherwise: the editorial/blog mosaic, the ~100 other categories, the glossary, the locale switcher. ## Art The art was drawn per request by a Flask route: no files, no hashes, no gate, and a placeholder that did not say it was one. It is still synthetic, deliberately. versus.com stayed blocked across repeated probes and no attempt was made to work around that. Freely licensed photography for these exact models could not be matched reliably either -- a Wikimedia Commons sweep found a freely licensed candidate for 18 of 20 products, but strict model matching showed most hits were the wrong item (a OnePlus 8 for the OnePlus 12, an A7R IV for the A7 IV, a 4070 Ti Super for the 4070 Super, a card-slot close-up for the Nikon Z8, earbuds for over-ear headphones). Shipping those would put false product facts into a benchmark built on factual navigation, which is worse than art that admits what it is. So the art follows the precedent already on main -- webmd_doctor ships Pillow-drawn avatars and gradient panels "instead of photography" -- and is held to the same contract: generate_art.py is deterministic (no RNG, clock or locale, Pillow's bundled font, fixed PNG compression, no ancillary chunks); generated_asset_inventory.json pins every path, byte length and SHA-256; check_generated_assets.py enforces coverage, size, hash and PNG decode and runs in the Docker build. Tiles are build products -- static/images/ stays gitignored and the inventory is what travels in Git. Each tile carries a visible SYNTHETIC ART label. No task answer depends on reading an image: all 17 verifiers are deterministic and never open one. This is a deviation from the reviewer checklist's "Real images" line and NOTICE.md says so rather than glossing it. It is the maintainers' call. --- Dockerfile | 12 +- sites/versus/NOTICE.md | 71 ++++ sites/versus/README.md | 60 +++ sites/versus/app.py | 73 +++- sites/versus/check_generated_assets.py | 74 ++++ sites/versus/generate_art.py | 163 ++++++++ sites/versus/generated_asset_inventory.json | 107 +++++ sites/versus/static/css/main.css | 364 ++++++++++++------ sites/versus/templates/_product_card.html | 3 +- sites/versus/templates/about.html | 30 ++ sites/versus/templates/base.html | 66 +++- sites/versus/templates/compare.html | 46 ++- sites/versus/templates/index.html | 46 +-- sites/versus/templates/product.html | 25 +- .../versus/tests/test_functional_contract.py | 67 +++- 15 files changed, 1004 insertions(+), 203 deletions(-) create mode 100644 sites/versus/NOTICE.md create mode 100644 sites/versus/README.md create mode 100644 sites/versus/check_generated_assets.py create mode 100644 sites/versus/generate_art.py create mode 100644 sites/versus/generated_asset_inventory.json create mode 100644 sites/versus/templates/about.html diff --git a/Dockerfile b/Dockerfile index 9fa862b3..6473c538 100644 --- a/Dockerfile +++ b/Dockerfile @@ -64,8 +64,16 @@ RUN python3 /opt/WebSyn/webmd_doctor/check_generated_assets.py RUN cd /opt/WebSyn/webmd_doctor && rm -rf instance instance_seed && \ PYTHONHASHSEED=0 python seed_data.py && rm -rf instance __pycache__ -# Versus: data is fully code-generated by app.py. The benchmark password hash is -# a frozen constant, so this produces a byte-identical seed on every build. +# Versus ships deliberately synthetic product art (see sites/versus/NOTICE.md) +# rather than photography. The tiles are regenerated here and gated on exact +# coverage + per-file SHA-256 + PNG decode, the same contract webmd_doctor, +# compass and walmart_careers use, so altered or missing art fails the build. +# The seed is code-generated too; the benchmark password hash is a frozen +# constant, so both the tiles and the DB are byte-identical on every build. +RUN cd /opt/WebSyn/versus && \ + rm -rf static/images/products && \ + python3 generate_art.py && \ + python3 check_generated_assets.py RUN cd /opt/WebSyn/versus && \ rm -rf instance instance_seed && \ mkdir -p instance_seed && \ diff --git a/sites/versus/NOTICE.md b/sites/versus/NOTICE.md new file mode 100644 index 00000000..a7f67518 --- /dev/null +++ b/sites/versus/NOTICE.md @@ -0,0 +1,71 @@ +# Third-party material in the Versus mirror + +This file records the disposition of third-party material this site relies on, so a +reviewer can determine what is included, where it came from, and how to remove it. + +## Non-affiliation and trademarks + +WebHarbor is an independent research benchmark for web agents. This mirror is not +affiliated with, authorized by, endorsed by or sponsored by Versus Tech, nor by Apple, +Samsung, Google, OnePlus, Sony, Bose, Sennheiser, Canon, Nikon, Fujifilm, NVIDIA, AMD, +Garmin or Fitbit. Product and company names are used only to identify the products being +compared. No license or permission is granted or implied by their presence, and nothing +here should be read as a statement by any of those companies. + +The running site makes no request to any external service. This is verified, not +asserted: a Playwright sweep of every route at four viewports recorded zero external +requests. + +## Imagery — deliberately synthetic, no third-party media redistributed + +**This site redistributes no third-party images, fonts or media of any kind.** The 20 +product tiles under `static/images/products/` are drawn programmatically by +`generate_art.py`: a category-derived backdrop, a schematic device outline, the brand +initials and the product name, over the site's own palette. Each tile carries a visible +`SYNTHETIC ART` label. They depict no real product and reproduce no photograph. + +Why, stated plainly: + +- versus.com began returning CloudFront 403 to this client during the review and + remained blocked across repeated probes. No attempt was made to work around that. +- Freely licensed photography for these specific models could not be matched reliably. + A Wikimedia Commons sweep returned a freely licensed candidate for 18 of 20 products, + but strict model matching showed the hits were largely the wrong item — a OnePlus 8 + for the OnePlus 12, an A7R IV for the A7 IV, a 4070 Ti Super for the 4070 Super, a + card-slot close-up for the Nikon Z8, earbuds for over-ear headphones. Shipping those + would inject false product facts into a benchmark whose purpose is factual navigation. +- Generated art is the precedent already merged on `main`: `webmd_doctor` ships + Pillow-drawn initials avatars and gradient poster panels "instead of photography". + +This is a deliberate deviation from the reviewer checklist's "Real images" line, and it +is the maintainers' call whether to accept it. It is recorded here rather than glossed. + +Engineering contract, matching the `webmd_doctor` / `compass` / `walmart_careers` gates: + +- `generate_art.py` is deterministic — no RNG, no clock, no locale, Pillow's bundled + default font, fixed PNG compression with no ancillary chunks — so two builds of the + same commit produce byte-identical tiles. +- `generated_asset_inventory.json` pins every tile's path, byte length and SHA-256. +- `check_generated_assets.py` enforces exact coverage (nothing missing, extra or stale), + per-file size and SHA-256 equality, and a full PNG decode. It runs in the Docker build, + so altered or missing art fails the build instead of degrading silently. +- The tiles are build products, not commits: `static/images/` is gitignored, and the + inventory is what travels in Git. + +No task answer depends on reading an image. All 17 verifiers are deterministic and never +open a screenshot; every graded fact is text in the DOM. + +## Data + +Product names, brands, release years, list prices and published specifications follow the +manufacturers' figures. **The Versus Score is not versus.com's value** — it is synthetic +benchmark data, as are all user accounts and saved comparisons. The distinction is stated +on `/about` and in the site footer on every page. + +## Removal + +To remove the generated art: delete `static/images/products/`, the two `generate_art.py` +/ `check_generated_assets.py` build steps from the Dockerfile, and the `` references +in `templates/_product_card.html`, `product.html` and `compare.html`. The application, +its routes, its seeded data and all 17 tasks continue to function without them; only the +visual presentation changes. diff --git a/sites/versus/README.md b/sites/versus/README.md new file mode 100644 index 00000000..19d0699f --- /dev/null +++ b/sites/versus/README.md @@ -0,0 +1,60 @@ +# Versus mirror + +Offline Flask mirror of `https://versus.com/` for the WebHarbor benchmark. In the +27-site registry it is site index 26 and runs on container port `40026`. + +```bash +docker run -d --rm --name wh-versus -p 8101:8101 -p 40000-40026:40000-40026 webharbor:dev +curl -so /dev/null -w "%{http_code}\n" http://localhost:40026/ +curl -X POST http://localhost:8101/reset/versus +``` + +## Build products, not assets + +This site fetches nothing from Hugging Face. Both of its binary-ish artefacts are +regenerated deterministically during the Docker build and gated there: + +| Artefact | Generator | Gate | +| --- | --- | --- | +| `static/images/products/*.png` (20 tiles) | `generate_art.py` | `check_generated_assets.py` — coverage, size, SHA-256, PNG decode | +| `instance_seed/versus.db` | `app.py` import side effect | `md5(instance) == md5(instance_seed)` after `/reset/versus` | + +Both are byte-identical across builds. The seed's benchmark password hash is a frozen +constant (`BENCHMARK_PASSWORD_HASH`) because `generate_password_hash()` draws a fresh +scrypt salt per call, which made two builds of the same commit differ. + +Regenerate locally and refresh the pinned hashes: + +```bash +python3 generate_art.py --write-inventory +python3 check_generated_assets.py +``` + +## What is real and what is not + +Product names, brands, release years, list prices and published specifications follow the +manufacturers' figures. The **Versus Score, all user accounts and all saved comparisons +are synthetic benchmark data**; product art is programmatically drawn, not photography. +`/about` and the footer say so on every page. See `NOTICE.md`. + +## Catalogue + +20 products across 5 categories (smartphones, headphones, cameras, graphics cards, +smartwatches), 4 benchmark accounts sharing the password `TestPass123!`, and 3 saved +comparisons seeded for `alice.j@test.com`. + +## Tasks + +17 tasks in `tasks.jsonl`, each with a deterministic verifier in `verify/` and a +`judge_rubric`. Ground truth is derived from the passed `initial_db` rather than frozen +in the verifier, so the expected answer moves with the seed. Navigation checks accept +only steps on this site's own origin, with the port derived from `control_server.py`'s +registry. + +Every spec value renders only on detail and comparison pages; cards and the ranking list +carry Score, Price and Year. Questions are written so the answer requires a page the list +does not carry. + +```bash +python3 -m unittest discover -s tests -v # 11 regression tests +``` diff --git a/sites/versus/app.py b/sites/versus/app.py index c1815561..79ba8960 100644 --- a/sites/versus/app.py +++ b/sites/versus/app.py @@ -153,6 +153,47 @@ def winner(left: Product, right: Product) -> Product: return left if left.score >= right.score else right +# Signals where a smaller number is the better result. +LOWER_IS_BETTER = {"Price", "Weight", "Power"} + + +def compare_rows(left: Product, right: Product) -> list[dict]: + """Signal-by-signal comparison with a leader and a margin per row. + + The source site presents a comparison as a set of areas with a winner and a + margin each, rather than a single overall number, so the table carries that + shape. The product the site *declares* the winner is still the higher Versus + Score (see winner()); the area counts are additional information. + """ + category = left.category + specs = [ + (category.spec_1, left.spec_1_value, right.spec_1_value, category.unit_1), + (category.spec_2, left.spec_2_value, right.spec_2_value, category.unit_2), + (category.spec_3, left.spec_3_value, right.spec_3_value, category.unit_3), + ] + rows = [{"label": "Score", "left": left.score, "right": right.score, "unit": ""}] + rows += [{"label": label, "left": lv, "right": rv, "unit": unit} + for label, lv, rv, unit in specs] + rows.append({"label": "Price", "left": left.price, "right": right.price, "unit": ""}) + + for row in rows: + lv, rv = row["left"], row["right"] + if lv is None or rv is None or lv == rv: + row["leader"] = None + row["margin"] = None + continue + lower_better = row["label"] in LOWER_IS_BETTER + row["leader"] = "left" if ((lv < rv) if lower_better else (lv > rv)) else "right" + row["margin"] = round(abs(lv - rv), 1) + return rows + + +def lead_summary(rows: list[dict], side: str) -> tuple[int, int]: + """(areas led by `side`, areas that have a leader at all).""" + decided = [r for r in rows if r["leader"]] + return sum(1 for r in decided if r["leader"] == side), len(decided) + + @app.route("/") def index(): top = Product.query.order_by(Product.score.desc()).limit(8).all() @@ -220,7 +261,13 @@ def compare_detail(left, right): right_product = product_by_slug(right) if left_product.category_id != right_product.category_id: flash("Those products are in different categories; compare signals are still shown side by side.", "info") - return render_template("compare.html", left=left_product, right=right_product, winner=winner(left_product, right_product)) + rows = compare_rows(left_product, right_product) + champion = winner(left_product, right_product) + side = "left" if champion is left_product else "right" + led, decided = lead_summary(rows, side) + return render_template("compare.html", left=left_product, right=right_product, + winner=champion, rows=rows, areas_led=led, + areas_total=decided) @app.route("/compare/-vs-/save", methods=["POST"]) @@ -283,21 +330,15 @@ def account(): return render_template("account.html", saved=saved) -@app.route("/product-art/.svg") -def product_art(slug): - product = product_by_slug(slug) - hue = abs(hash(product.slug)) % 360 - initials = "".join(part[0] for part in product.brand.split()[:2]).upper() - svg = f""" - - - - - -{initials} -{product.score} Versus Score -""" - return app.response_class(svg, mimetype="image/svg+xml") +@app.route("/about") +def about(): + """What this mirror is, and which parts of it are synthetic. + + The source site is mirrored for an offline agent benchmark, so the page + states plainly which values are sourced and which are generated rather than + leaving a visitor to assume everything is real. + """ + return render_template("about.html") @app.route("/_health") diff --git a/sites/versus/check_generated_assets.py b/sites/versus/check_generated_assets.py new file mode 100644 index 00000000..8fddf503 --- /dev/null +++ b/sites/versus/check_generated_assets.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Validate the generated Versus product tiles. + +The tiles are byte-stable regenerations of ``generate_art.py`` under the pinned +toolchain. This checker enforces exact coverage (no missing, extra or stale +files), per-file size + SHA-256 equality against ``generated_asset_inventory.json`` +and a full PNG decode of every file. It runs in the Docker build, mirroring the +webmd_doctor / compass / walmart_careers asset gates, so art that is missing, +altered or unaccounted for fails the build instead of degrading silently. +""" +import hashlib +import json +import sys +from pathlib import Path, PurePosixPath + +from PIL import Image + +SITE = Path(__file__).resolve().parent +MANAGED_ROOT = "static/images/products" + + +def verify(): + manifest = json.loads((SITE / "generated_asset_inventory.json").read_text()) + rows = manifest.get("assets") + if manifest.get("schema_version") != 1 or not isinstance(rows, list): + raise ValueError("unsupported generated asset inventory") + if not rows: + raise ValueError("inventory lists no assets") + + problems = [] + expected = set() + for row in rows: + rel = PurePosixPath(row["path"]) + if rel.is_absolute() or ".." in rel.parts or not str(rel).startswith(MANAGED_ROOT): + problems.append(f"{rel}: path escapes {MANAGED_ROOT}") + continue + expected.add(str(rel)) + path = SITE / rel + if not path.is_file(): + problems.append(f"{rel}: missing") + continue + data = path.read_bytes() + if len(data) != row["bytes"]: + problems.append(f"{rel}: {len(data)} bytes, inventory says {row['bytes']}") + digest = hashlib.sha256(data).hexdigest() + if digest != row["sha256"]: + problems.append(f"{rel}: sha256 {digest[:12]}…, inventory says {row['sha256'][:12]}…") + try: + with Image.open(path) as im: + im.load() + if im.format != "PNG": + problems.append(f"{rel}: format {im.format}, expected PNG") + except Exception as exc: # noqa: BLE001 - any decode failure is a failure + problems.append(f"{rel}: does not decode ({type(exc).__name__})") + + root = SITE / MANAGED_ROOT + if root.is_dir(): + for path in sorted(root.rglob("*")): + if path.is_file(): + rel = str(path.relative_to(SITE)) + if rel not in expected: + problems.append(f"{rel}: present but not in the inventory") + + if problems: + print(f"Versus generated-asset check FAILED ({len(problems)} problem(s)):") + for p in problems: + print(f" - {p}") + return 1 + print(f"Versus generated-asset check OK: {len(expected)} tiles verified") + return 0 + + +if __name__ == "__main__": + sys.exit(verify()) diff --git a/sites/versus/generate_art.py b/sites/versus/generate_art.py new file mode 100644 index 00000000..ee0162a1 --- /dev/null +++ b/sites/versus/generate_art.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Generate the Versus product tiles. + +The mirror ships deliberately synthetic product art rather than photography. +See NOTICE.md for why and for the disposition of that choice. This module is +the single source of those images: it draws one tile per product from the seed +data, deterministically, so two builds of the same commit produce byte-identical +files whose hashes are pinned in generated_asset_inventory.json. + +Determinism rules observed here: + * no RNG, no clock, no locale - every value is derived from the product row + * Pillow's bundled default font, so no system font can change the output + * PNG written with fixed compression and no ancillary chunks + +Usage: + python3 generate_art.py [--out static/images/products] [--write-inventory] +""" +import argparse +import hashlib +import json +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +SITE = Path(__file__).resolve().parent +OUT_REL = "static/images/products" +SIZE = (480, 360) + +# Panel and ink follow the site's dark palette (static/css/main.css). +PANEL = (23, 18, 31) +INK = (245, 243, 247) +MUTED = (162, 155, 176) + +# One accent per category, used for the backdrop wash and the device outline. +CATEGORY_ACCENT = { + "smartphones": (124, 92, 255), + "headphones": (236, 72, 153), + "cameras": (56, 189, 248), + "graphics-cards": (34, 197, 94), + "smartwatches": (251, 146, 60), +} +DEFAULT_ACCENT = (124, 92, 255) + + +def _mix(a, b, t): + return tuple(round(x + (y - x) * t) for x, y in zip(a, b)) + + +def _initials(brand, name): + """Two letters at most, taken from the brand, falling back to the name.""" + source = (brand or name or "?").strip() + parts = [p for p in source.replace("-", " ").split() if p] + if not parts: + return "?" + if len(parts) == 1: + return parts[0][:2].upper() + return (parts[0][0] + parts[1][0]).upper() + + +def _device_box(category): + """A silhouette that hints at the product class without depicting a product.""" + w, h = SIZE + cx, cy = w // 2, h // 2 - 10 + shapes = { + "smartphones": (cx - 46, cy - 86, cx + 46, cy + 86, 18), + "headphones": (cx - 78, cy - 78, cx + 78, cy + 78, 78), + "cameras": (cx - 104, cy - 62, cx + 104, cy + 62, 16), + "graphics-cards": (cx - 122, cy - 46, cx + 122, cy + 46, 10), + "smartwatches": (cx - 54, cy - 62, cx + 54, cy + 62, 22), + } + return shapes.get(category, (cx - 90, cy - 70, cx + 90, cy + 70, 16)) + + +def draw_tile(slug, name, brand, category): + accent = CATEGORY_ACCENT.get(category, DEFAULT_ACCENT) + img = Image.new("RGB", SIZE, PANEL) + d = ImageDraw.Draw(img) + + # Backdrop wash: horizontal bands from panel toward the category accent. + for y in range(SIZE[1]): + t = (y / SIZE[1]) * 0.22 + d.line([(0, y), (SIZE[0], y)], fill=_mix(PANEL, accent, t)) + + x0, y0, x1, y1, radius = _device_box(category) + d.rounded_rectangle((x0, y0, x1, y1), radius=radius, + fill=_mix(PANEL, accent, 0.10), + outline=_mix(accent, INK, 0.25), width=3) + + # Category-specific detail, still schematic. + if category == "cameras": + r = 34 + d.ellipse((x0 + 30, (y0 + y1) // 2 - r, x0 + 30 + 2 * r, (y0 + y1) // 2 + r), + outline=_mix(accent, INK, 0.45), width=3) + elif category == "graphics-cards": + for i in range(3): + r = 26 + cx = x0 + 44 + i * 72 + d.ellipse((cx - r, (y0 + y1) // 2 - r, cx + r, (y0 + y1) // 2 + r), + outline=_mix(accent, INK, 0.35), width=2) + elif category == "headphones": + d.arc((x0 + 16, y0 + 10, x1 - 16, y1 - 10), start=200, end=340, + fill=_mix(accent, INK, 0.45), width=6) + + initials = _initials(brand, name) + font = ImageFont.load_default(size=54) + box = d.textbbox((0, 0), initials, font=font) + d.text(((SIZE[0] - (box[2] - box[0])) // 2 - box[0], + (y0 + y1) // 2 - (box[3] - box[1]) // 2 - box[1]), + initials, font=font, fill=INK) + + label_font = ImageFont.load_default(size=19) + label = name if len(name) <= 34 else name[:33] + "…" + lbox = d.textbbox((0, 0), label, font=label_font) + d.text(((SIZE[0] - (lbox[2] - lbox[0])) // 2 - lbox[0], SIZE[1] - 44), + label, font=label_font, fill=MUTED) + + # Deliberate, visible marker that this is synthetic art, not a photograph. + tag_font = ImageFont.load_default(size=13) + d.text((14, 14), "SYNTHETIC ART", font=tag_font, fill=_mix(MUTED, accent, 0.5)) + return img + + +def products(): + """Read the catalogue straight from app.py's seed definition.""" + import app # noqa: WPS433 - import side effect creates/loads the DB + with app.app.app_context(): + rows = app.Product.query.join(app.Category).order_by(app.Product.id).all() + return [(p.slug, p.name, p.brand, p.category.slug) for p in rows] + + +def write_all(out_dir): + out_dir.mkdir(parents=True, exist_ok=True) + written = [] + for slug, name, brand, category in products(): + path = out_dir / f"{slug}.png" + draw_tile(slug, name, brand, category).save( + path, format="PNG", optimize=False, compress_level=6) + data = path.read_bytes() + written.append({"path": f"{OUT_REL}/{slug}.png", "bytes": len(data), + "sha256": hashlib.sha256(data).hexdigest()}) + return sorted(written, key=lambda r: r["path"]) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--out", default=str(SITE / OUT_REL)) + ap.add_argument("--write-inventory", action="store_true") + args = ap.parse_args() + + rows = write_all(Path(args.out)) + print(f"wrote {len(rows)} product tiles to {args.out}") + if args.write_inventory: + target = SITE / "generated_asset_inventory.json" + target.write_text(json.dumps( + {"schema_version": 1, + "generator": "sites/versus/generate_art.py", + "toolchain": "Pillow 11.0.0, Python 3.12, bundled default font", + "assets": rows}, indent=2) + "\n") + print(f"wrote {target}") + + +if __name__ == "__main__": + main() diff --git a/sites/versus/generated_asset_inventory.json b/sites/versus/generated_asset_inventory.json new file mode 100644 index 00000000..fe8183ad --- /dev/null +++ b/sites/versus/generated_asset_inventory.json @@ -0,0 +1,107 @@ +{ + "schema_version": 1, + "generator": "sites/versus/generate_art.py", + "toolchain": "Pillow 11.0.0, Python 3.12, bundled default font", + "assets": [ + { + "path": "static/images/products/apple-airpods-max.png", + "bytes": 8246, + "sha256": "bdd3752e5f48fe0e4c1fde3ad580c6b88c791a283b0a365396e471e0e1516847" + }, + { + "path": "static/images/products/apple-watch-series-9.png", + "bytes": 8335, + "sha256": "266363dcd4ef387e8bef9ab949eede9f45439d9331e5a549a111b99e3fd8943e" + }, + { + "path": "static/images/products/bose-quietcomfort-ultra.png", + "bytes": 9633, + "sha256": "e8bbca52f009bd304deb03866e3e983378f2e14c6df2be127f096825fe726d26" + }, + { + "path": "static/images/products/canon-eos-r6-mark-ii.png", + "bytes": 9239, + "sha256": "3c0b790e62ddc31c0bd20567eed06d28d6411fbcf3ac87591007bcb94443bbd5" + }, + { + "path": "static/images/products/fitbit-sense-2.png", + "bytes": 5359, + "sha256": "a2050f6157bb58d3f72977b92a8e7e6eb83e1eaa08a2ff33c01dff52b2325166" + }, + { + "path": "static/images/products/fujifilm-x-t5.png", + "bytes": 5991, + "sha256": "56b25538ef1f2a06c6cfe6f2456d4b84f251ed1ce3fe774486d79b0694990c25" + }, + { + "path": "static/images/products/garmin-venu-3.png", + "bytes": 7857, + "sha256": "f38aac293fd117db9d389fc1e770a159664b0ee925dd6ba91247098c1e89c7e9" + }, + { + "path": "static/images/products/google-pixel-8-pro.png", + "bytes": 8489, + "sha256": "5aacae95ec3e0c1601e7e26a5e2d73305893420ec1330065881e879cf7f3ff5b" + }, + { + "path": "static/images/products/iphone-15-pro.png", + "bytes": 6591, + "sha256": "4ce675fafd00aa54b12ca2f22f1dff598a2f9350f5c3a3dfb8f9c69763aa26e9" + }, + { + "path": "static/images/products/nikon-z8.png", + "bytes": 6016, + "sha256": "532ce68049dc7854890edb5b6ab696fb96e8e2eed2e0fc49cfe4c213abbc496f" + }, + { + "path": "static/images/products/oneplus-12.png", + "bytes": 7334, + "sha256": "d262535f4951c978d941a0e68077c3a8b367b568d35f52262d0ccdd088c18c86" + }, + { + "path": "static/images/products/radeon-rx-7800-xt.png", + "bytes": 8498, + "sha256": "c6d5257da63c2028ca0c4a411f6011eec856c4f7866cca4aae24d5e29030cb1a" + }, + { + "path": "static/images/products/radeon-rx-7900-xtx.png", + "bytes": 8539, + "sha256": "b0d75f71f509d845a3e135129c6492eae720e4cf73b737c9954c2e42da651665" + }, + { + "path": "static/images/products/rtx-4070-super.png", + "bytes": 8757, + "sha256": "d079787eee44f7a4a0f7b8cabdbd9ff9c8ff3aa3b10ab244d8dc35cb51228c24" + }, + { + "path": "static/images/products/rtx-4080-super.png", + "bytes": 8938, + "sha256": "0645ce22f5437027ef6e2c18b70afd25b7b89b1798a3459bb9f498dc3cb761f0" + }, + { + "path": "static/images/products/samsung-galaxy-s24-ultra.png", + "bytes": 9478, + "sha256": "5cbb6cea508dab97b97ea46e5c6a60f189080303cb072df5e14f3d103f54d43a" + }, + { + "path": "static/images/products/samsung-galaxy-watch-6.png", + "bytes": 9846, + "sha256": "d1506505cb8dc6324050b7e928aa69471f8b64bb8090c358ba05c1ab5bb1815b" + }, + { + "path": "static/images/products/sennheiser-momentum-4.png", + "bytes": 8442, + "sha256": "b32fcf9a663f02bbc940dff308226f3423bb5747184489d8664243f5f3f1996f" + }, + { + "path": "static/images/products/sony-a7-iv.png", + "bytes": 8489, + "sha256": "c2e56d7b22821b672d558412cfca26eb26527b5d399123423e38d857d6b4adc4" + }, + { + "path": "static/images/products/sony-wh-1000xm5.png", + "bytes": 10209, + "sha256": "cf7d8586e55016fffda74b16d895c5b232b66071d2cac0498c97b37abb87644c" + } + ] +} diff --git a/sites/versus/static/css/main.css b/sites/versus/static/css/main.css index 306582bd..85c19d54 100644 --- a/sites/versus/static/css/main.css +++ b/sites/versus/static/css/main.css @@ -1,122 +1,258 @@ +/* Versus mirror styling. + * + * Target is the evidenced appearance of versus.com/en captured 2026-09-13: + * near-black to deep-purple ground, lowercase wordmark, an oversized two-line + * hero with an accent underline, score rings on product tiles, a "ruling" band + * on comparisons and a four-column footer. + * + * Colours are read off those screenshots, not sampled from the source + * stylesheet: versus.com began returning CloudFront 403 to this client before + * exact values could be extracted, and no attempt was made to work around that + * block. They are therefore approximations of the brand palette, recorded as + * estimates in the review notes rather than claimed as exact. + */ :root { - --blue: #2563eb; - --cyan: #06b6d4; - --green: #16a34a; - --red: #ef4444; - --ink: #111827; - --muted: #64748b; - --line: #dbe3ef; - --panel: #ffffff; - --bg: #f4f7fb; + --bg: #0b0711; + --bg-2: #150d26; + --bg-3: #2a1259; + --panel: #17121f; + --panel-2: #1e1829; + --line: #2e2739; + --ink: #f5f3f7; + --muted: #a29bb0; + --accent: #7c5cff; + --accent-2: #b794ff; + --win: #3ddc84; + --vs: #ff4d6d; + --radius: 14px; + --maxw: 1180px; } + * { box-sizing: border-box; } -body { margin: 0; font-family: Arial, Helvetica, sans-serif; color: var(--ink); background: var(--bg); line-height: 1.5; } + +body { + margin: 0; + font-family: -apple-system, system-ui, "Segoe UI", Roboto, Oxygen, Ubuntu, + Cantarell, "Open Sans", "Helvetica Neue", sans-serif; + color: var(--ink); + background: var(--bg); + line-height: 1.55; + -webkit-font-smoothing: antialiased; +} + a { color: inherit; text-decoration: none; } -button, input, select { font: inherit; } +a:hover { color: var(--accent-2); } +img { max-width: 100%; display: block; } +h1, h2, h3 { line-height: 1.15; margin: 0 0 .4em; } + +/* ---------------------------------------------------------------- header */ .site-header { - display: grid; - grid-template-columns: auto 1fr minmax(220px, 340px) auto; - gap: 16px; - align-items: center; - padding: 14px 28px; - background: var(--panel); - border-bottom: 1px solid var(--line); - position: sticky; - top: 0; - z-index: 10; -} -.brand { display: flex; align-items: center; gap: 10px; font-weight: 900; font-size: 22px; } -.brand span { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 50%; color: white; background: linear-gradient(135deg, var(--blue), var(--cyan)); } -.top-nav, .account-links, .category-pills { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; } -.top-nav a, .account-links a { font-weight: 700; font-size: 14px; color: #22304a; } -.header-search, .hero-search { display: flex; gap: 8px; } -input, select { width: 100%; min-height: 42px; border: 1px solid var(--line); border-radius: 8px; padding: 9px 11px; background: white; } -button, .button { border: 0; border-radius: 8px; padding: 10px 14px; background: var(--blue); color: white; font-weight: 800; cursor: pointer; display: inline-block; } -.primary { background: var(--green); } -main { min-height: 72vh; } + display: flex; align-items: center; gap: 26px; + padding: 14px 26px; max-width: var(--maxw); margin: 0 auto; + position: relative; z-index: 3; +} +.brand { font-size: 26px; font-weight: 800; letter-spacing: -.04em; text-transform: lowercase; } +.brand .mark { color: var(--ink); } +.top-nav { display: flex; gap: 20px; flex-wrap: wrap; } +.top-nav a { + font-size: 13px; font-weight: 600; letter-spacing: .06em; + text-transform: uppercase; color: var(--muted); +} +.top-nav a:hover { color: var(--ink); } +.header-search { display: flex; gap: 8px; margin-left: auto; min-width: 260px; } +.account-links { display: flex; gap: 14px; align-items: center; font-size: 13px; font-weight: 600; } + +/* ---------------------------------------------------------------- controls */ +input, select { + width: 100%; min-height: 42px; padding: 9px 14px; + border: 1px solid var(--line); border-radius: 999px; + background: var(--panel-2); color: var(--ink); font: inherit; font-size: 14px; +} +input::placeholder { color: var(--muted); } +select { border-radius: 10px; } +button, .button { + border: 0; border-radius: 999px; padding: 11px 22px; cursor: pointer; + background: var(--ink); color: #120b1e; font: inherit; font-weight: 700; + font-size: 14px; white-space: nowrap; display: inline-block; +} +button:hover, .button:hover { background: var(--accent-2); color: #120b1e; } +.button.primary { background: var(--accent); color: var(--ink); } +.button.primary:hover { background: var(--accent-2); color: #120b1e; } + +/* ---------------------------------------------------------------- hero */ .hero { - display: grid; - grid-template-columns: minmax(0, 1fr) 360px; - gap: 28px; - padding: 62px 42px; - background: linear-gradient(135deg, #0f172a 0%, #1d4ed8 54%, #06b6d4 100%); - color: white; -} -.hero h1, .page-heading h1, .detail-hero h1 { margin: 0 0 12px; font-size: clamp(38px, 6vw, 68px); line-height: 1; } -.hero p { max-width: 720px; color: #e0f2fe; font-size: 18px; } -.hero-search { max-width: 680px; } -.hero-compare { - align-self: end; - display: grid; - gap: 10px; - padding: 24px; - border-radius: 10px; - background: rgba(255,255,255,.12); - border: 1px solid rgba(255,255,255,.28); -} -.hero-compare strong { font-size: 24px; } -.hero-compare em, .pair-card em, .versus-hero > span { color: var(--red); font-style: normal; font-weight: 900; text-transform: uppercase; } -.hero-compare a { color: white; font-weight: 800; text-decoration: underline; } -.eyebrow { color: var(--cyan); text-transform: uppercase; font-size: 12px; font-weight: 900; letter-spacing: .08em; } -.section, .page-heading, .detail-hero, .metric-grid, .pro-con, .compare-table-wrap, .winner-band, .versus-hero, .ranking-list, .category-grid, .filter-bar, .compare-picker, .auth-panel { - max-width: 1180px; - margin: 0 auto; - padding: 32px 24px; -} -.section-head { display: flex; justify-content: space-between; align-items: end; margin-bottom: 16px; gap: 16px; } -.card-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 18px; } -.product-card, .category-card, .pair-card, .ranking-row, .auth-panel form, .compare-picker, .winner-band, .metric-grid > div, .pro-con article { - background: var(--panel); - border: 1px solid var(--line); - border-radius: 10px; -} -.product-card { overflow: hidden; } -.product-art { display: block; aspect-ratio: 3 / 2; background: #eaf2ff; } -.product-art img, .detail-hero img, .versus-hero img { width: 100%; height: 100%; object-fit: cover; display: block; } -.product-body { padding: 16px; } -.product-body h3 { margin: 6px 0; } -.product-body p { color: var(--muted); min-height: 70px; } -.stat-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin: 12px 0 0; } -dt { color: var(--muted); font-size: 12px; } -dd { margin: 0; font-weight: 900; } -.pair-grid, .category-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px; } -.category-grid.inset { padding: 0; } -.pair-card, .category-card { display: grid; gap: 8px; padding: 18px; } -.pair-card span { font-weight: 900; } -.category-card strong { font-size: 22px; } -.category-card span, .category-card small { color: var(--muted); } -.filter-bar { display: grid; grid-template-columns: minmax(220px, 1fr) repeat(2, 160px) auto; gap: 12px; padding-top: 0; } -.detail-hero { display: grid; grid-template-columns: minmax(280px, 430px) minmax(0, 1fr); gap: 28px; align-items: center; } -.detail-hero img, .versus-hero img { border-radius: 10px; border: 1px solid var(--line); background: white; } -.score-pill { display: inline-block; padding: 10px 14px; border-radius: 999px; background: #dcfce7; color: #166534; font-weight: 900; margin: 8px 0 16px; } -.metric-grid, .pro-con { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 14px; } -.metric-grid > div, .pro-con article { padding: 18px; } -.metric-grid span { color: var(--muted); display: block; } -.metric-grid strong { font-size: 22px; } -.versus-hero { display: grid; grid-template-columns: 1fr auto 1fr; gap: 18px; align-items: center; text-align: center; } -.versus-hero > span { font-size: 28px; } -.versus-hero h1 { font-size: clamp(24px, 3vw, 38px); } -.winner-band { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-top: 0; } -.compare-table { width: 100%; border-collapse: collapse; background: white; border: 1px solid var(--line); } -.compare-table th, .compare-table td { border: 1px solid var(--line); padding: 13px; text-align: left; min-width: 180px; } -.compare-table th { background: #eff6ff; } + position: relative; overflow: hidden; text-align: center; + padding: 76px 26px 150px; + background: + radial-gradient(1100px 520px at 78% 12%, var(--bg-3) 0%, transparent 62%), + linear-gradient(160deg, #0a0610 0%, var(--bg-2) 58%, #1b0f33 100%); +} +.hero h1 { font-size: clamp(42px, 8vw, 86px); font-weight: 800; letter-spacing: -.045em; } +.hero h1 .accent { + background-image: linear-gradient(var(--accent), var(--accent)); + background-size: 100% 6px; background-repeat: no-repeat; background-position: 0 92%; +} +.hero p { color: var(--muted); font-size: 17px; margin: 0 auto 30px; max-width: 620px; } +.hero-search { display: flex; gap: 10px; max-width: 520px; margin: 0 auto; } +.hero-wave { + position: absolute; left: 0; right: 0; bottom: -1px; height: 90px; + background: var(--bg); + clip-path: ellipse(72% 100% at 50% 100%); +} + +/* ---------------------------------------------------------------- layout */ +main { max-width: var(--maxw); margin: 0 auto; padding: 0 26px 70px; } +.section { margin-top: 54px; } +.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin-bottom: 18px; } +.section-head h2 { font-size: 24px; letter-spacing: -.02em; } +.section-head a { font-size: 13px; color: var(--muted); } +.eyebrow { color: var(--accent-2); text-transform: uppercase; font-size: 11px; font-weight: 700; letter-spacing: .12em; } +.page-heading { padding: 42px 0 8px; } +.page-heading h1 { font-size: clamp(30px, 5vw, 46px); letter-spacing: -.03em; } +.page-heading p { color: var(--muted); margin: 0; } + +/* ---------------------------------------------------------------- cards */ +.card-grid { display: grid; gap: 18px; grid-template-columns: repeat(auto-fill, minmax(232px, 1fr)); } +.product-card { + background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); + overflow: hidden; display: flex; flex-direction: column; +} +.product-card:hover { border-color: var(--accent); } +.product-art { display: block; background: var(--panel-2); } +.product-body { padding: 14px 16px 18px; display: flex; flex-direction: column; gap: 8px; } +.product-body h3 { font-size: 16px; margin: 0; } +.product-body p { color: var(--muted); font-size: 13px; margin: 0; } +.stat-row { display: flex; gap: 16px; margin: 6px 0 0; } +.stat-row div { display: flex; flex-direction: column; } +.stat-row dt { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .07em; } +.stat-row dd { margin: 0; font-weight: 700; font-size: 15px; } + +.score-ring { + width: 46px; height: 46px; border-radius: 50%; display: grid; place-items: center; + font-weight: 800; font-size: 15px; flex: none; + border: 2px solid var(--accent); color: var(--ink); background: rgba(124, 92, 255, .12); +} +.score-ring.win { border-color: var(--win); color: var(--win); background: rgba(61, 220, 132, .1); } + +/* ---------------------------------------------------------------- pairs */ +.pair-grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(252px, 1fr)); } +.pair-card { + background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); + padding: 16px 18px; display: grid; gap: 6px; font-weight: 600; +} +.pair-card:hover { border-color: var(--accent); } +.pair-card em { color: var(--vs); font-style: normal; font-weight: 800; text-transform: uppercase; font-size: 12px; letter-spacing: .1em; } + +.category-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); } +.category-card { + background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); + padding: 20px; display: grid; gap: 6px; +} +.category-card:hover { border-color: var(--accent); } +.category-card strong { font-size: 18px; } +.category-card span { color: var(--muted); font-size: 13px; } +.category-card small { color: var(--accent-2); font-size: 12px; font-weight: 700; } + +/* ---------------------------------------------------------------- compare */ +.versus-hero { + display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 22px; + padding: 36px 0 10px; +} +.versus-hero > div { text-align: center; display: grid; gap: 12px; justify-items: center; } +.versus-hero h1 { font-size: clamp(22px, 3.4vw, 34px); text-transform: uppercase; letter-spacing: -.02em; } +.versus-rule { display: grid; gap: 10px; justify-items: center; color: var(--vs); font-weight: 800; letter-spacing: .12em; } +.versus-rule::before, .versus-rule::after { + content: ""; width: 1px; height: 62px; background: linear-gradient(var(--line), var(--accent)); +} +.ruling { + text-align: center; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); + padding: 26px 0; margin: 26px 0; +} +.ruling .eyebrow { display: block; margin-bottom: 8px; } +.ruling p { font-size: clamp(17px, 2.4vw, 23px); margin: 0 0 16px; } +.ruling strong { color: var(--win); } +.compare-table-wrap { overflow-x: auto; } +.compare-table { width: 100%; border-collapse: collapse; min-width: 560px; } +.compare-table th, .compare-table td { padding: 13px 14px; border-bottom: 1px solid var(--line); text-align: left; font-size: 14px; } +.compare-table thead th { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .09em; } +.compare-table tbody th { color: var(--muted); font-weight: 600; } +.compare-table td.lead { color: var(--win); font-weight: 700; } +.compare-table td.margin { color: var(--accent-2); font-weight: 700; white-space: nowrap; } + +/* ---------------------------------------------------------------- detail */ +.detail-hero { display: grid; grid-template-columns: minmax(0, 420px) 1fr; gap: 30px; padding: 34px 0 6px; align-items: start; } +.detail-hero img { border-radius: var(--radius); border: 1px solid var(--line); } +.detail-hero .headline { display: flex; align-items: center; gap: 16px; } +.metric-grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); margin-top: 26px; } +.metric-grid div { + background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px; + display: grid; gap: 4px; +} +.metric-grid span { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .08em; } +.metric-grid strong { font-size: 21px; } +.pro-con { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); margin-top: 22px; } +.pro-con article { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 18px 20px; } +.pro-con h2 { font-size: 15px; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); } +.pro-con p { margin: 0; } + +/* ---------------------------------------------------------------- lists */ +.category-pills { display: flex; gap: 10px; flex-wrap: wrap; margin: 18px 0 24px; } +.category-pills a { + border: 1px solid var(--line); border-radius: 999px; padding: 8px 16px; + font-size: 13px; font-weight: 600; color: var(--muted); +} +.category-pills a.active, .category-pills a:hover { border-color: var(--accent); color: var(--ink); } .ranking-list { display: grid; gap: 10px; } -.ranking-row { display: grid; grid-template-columns: 52px 1fr auto 60px; gap: 12px; align-items: center; padding: 14px; } -.rank, .score { font-weight: 900; color: var(--blue); } -.ranking-row small { color: var(--muted); } -.category-pills { max-width: 1180px; margin: 0 auto; padding: 0 24px; } -.category-pills a { padding: 8px 12px; border-radius: 999px; border: 1px solid var(--line); background: white; } -.category-pills .active { color: white; background: var(--blue); } -.compare-picker, .auth-panel form { max-width: 760px; display: grid; gap: 14px; } -.flash-wrap { max-width: 1180px; margin: 12px auto 0; padding: 0 24px; } -.flash { padding: 12px 14px; border-radius: 8px; background: #eff6ff; border: 1px solid #bfdbfe; } -.flash.success { background: #ecfdf5; border-color: #86efac; } -.flash.error { background: #fef2f2; border-color: #fecaca; } -.empty-state { color: var(--muted); } -.site-footer { display: flex; justify-content: space-between; gap: 24px; padding: 28px; background: #0f172a; color: white; } -.site-footer nav { display: flex; gap: 16px; flex-wrap: wrap; } -@media (max-width: 900px) { - .site-header, .hero, .detail-hero, .versus-hero { grid-template-columns: 1fr; } - .filter-bar, .ranking-row { grid-template-columns: 1fr; } - .winner-band, .site-footer { flex-direction: column; align-items: stretch; } +.ranking-row { + display: grid; grid-template-columns: 44px 1fr auto auto; align-items: center; gap: 16px; + background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 13px 18px; +} +.ranking-row:hover { border-color: var(--accent); } +.ranking-row .rank { color: var(--muted); font-weight: 800; font-size: 18px; } +.ranking-row small { color: var(--muted); font-size: 13px; } +.ranking-row .score { font-weight: 800; color: var(--accent-2); font-size: 17px; } + +.filter-bar, .compare-picker { + display: flex; gap: 12px; flex-wrap: wrap; align-items: center; margin: 20px 0 8px; + background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px 18px; +} +.filter-bar select, .filter-bar input { max-width: 210px; } +.compare-picker label { display: grid; gap: 6px; font-size: 12px; color: var(--muted); + text-transform: uppercase; letter-spacing: .08em; min-width: 220px; } + +.auth-panel { max-width: 430px; margin: 48px auto; background: var(--panel); + border: 1px solid var(--line); border-radius: var(--radius); padding: 28px; } +.auth-panel form { display: grid; gap: 14px; } +.auth-panel label { display: grid; gap: 6px; font-size: 12px; color: var(--muted); + text-transform: uppercase; letter-spacing: .08em; } +.auth-panel p { color: var(--muted); font-size: 13px; margin: 0; } + +.flash { border: 1px solid var(--line); border-left: 3px solid var(--accent); + background: var(--panel); border-radius: 8px; padding: 12px 16px; margin: 18px 0; } +.flash.success { border-left-color: var(--win); } +.flash.error { border-left-color: var(--vs); } + +/* ---------------------------------------------------------------- footer */ +.site-footer { border-top: 1px solid var(--line); background: #080510; margin-top: 40px; } +.footer-inner { max-width: var(--maxw); margin: 0 auto; padding: 46px 26px 30px; + display: grid; gap: 28px; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); } +.footer-col h3 { font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--muted); margin-bottom: 12px; } +.footer-col a { display: block; font-size: 14px; padding: 3px 0; color: var(--ink); } +.footer-col a:hover { color: var(--accent-2); } +.footer-note { max-width: var(--maxw); margin: 0 auto; padding: 0 26px 34px; + color: var(--muted); font-size: 12px; border-top: 1px solid var(--line); padding-top: 18px; } + +/* ---------------------------------------------------------------- narrow */ +@media (max-width: 860px) { + .site-header { flex-wrap: wrap; gap: 14px; } + .header-search { order: 3; width: 100%; margin-left: 0; } + .detail-hero { grid-template-columns: 1fr; } + .versus-hero { grid-template-columns: 1fr; } + .versus-rule::before, .versus-rule::after { height: 22px; } +} +@media (max-width: 520px) { + .hero { padding: 54px 18px 110px; } + main { padding: 0 18px 54px; } + .ranking-row { grid-template-columns: 34px 1fr auto; } + .ranking-row small { display: none; } } diff --git a/sites/versus/templates/_product_card.html b/sites/versus/templates/_product_card.html index a49bfb9c..4b5354db 100644 --- a/sites/versus/templates/_product_card.html +++ b/sites/versus/templates/_product_card.html @@ -1,6 +1,7 @@
- {{ product.name }} + Synthetic product art for {{ product.name }}
{{ product.category.name }}
diff --git a/sites/versus/templates/about.html b/sites/versus/templates/about.html new file mode 100644 index 00000000..0ab8232d --- /dev/null +++ b/sites/versus/templates/about.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block title %}About this mirror{% endblock %} +{% block content %} +
+
About
+

About this mirror

+

An offline mirror of versus.com built for the WebHarbor web-agent benchmark.

+
+
+
+

Sourced

+

Product names, brands, release years, list prices and the published + specifications — battery life, ANC and camera scores, megapixels, burst speed, + VRAM, power draw, display size and weight — follow the manufacturers' figures.

+
+
+

Synthetic

+

The Versus Score, every user account, and every saved comparison are benchmark + data generated for this mirror. They are not versus.com's values. Product art is + drawn programmatically and is not photography.

+
+
+
+

Not affiliated

+

This site is not affiliated with, authorized by, endorsed by or sponsored by + Versus Tech or any manufacturer named here. Brand names are used only to identify + the products being compared. The running site makes no request to any external + service.

+
+{% endblock %} diff --git a/sites/versus/templates/base.html b/sites/versus/templates/base.html index f8a154ae..100f94f9 100644 --- a/sites/versus/templates/base.html +++ b/sites/versus/templates/base.html @@ -9,17 +9,17 @@ - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} -
{{ message }}
- {% endfor %} -
- {% endif %} - {% endwith %} + {% block hero %}{% endblock %} -
{% block content %}{% endblock %}
+
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +

{{ message }}

+ {% endfor %} + {% endwith %} + {% block content %}{% endblock %} +
diff --git a/sites/versus/templates/compare.html b/sites/versus/templates/compare.html index 7ef3a3f0..9543b5ef 100644 --- a/sites/versus/templates/compare.html +++ b/sites/versus/templates/compare.html @@ -2,28 +2,48 @@ {% block title %}{{ left.name }} vs {{ right.name }}{% endblock %} {% block content %}
-
{{ left.name }}

{{ left.name }}

- vs -
{{ right.name }}

{{ right.name }}

+
+ Synthetic product art for {{ left.name }} +

{{ left.name }}

+ {{ left.score }} +
+
vs
+
+ Synthetic product art for {{ right.name }} +

{{ right.name }}

+ {{ right.score }} +
-
- {{ winner.name }} wins with a {{ winner.score }} Versus Score. + +
+ The ruling +

Get the {{ winner.name }} — it wins with a {{ winner.score }} Versus Score + and leads in {{ areas_led }} of {{ areas_total }} areas.

+
+

Biggest differences

- + + + - - - - - - - + {% for row in rows %} + + + + + + + {% endfor %} + +
Signal{{ left.name }}{{ right.name }}
Signal{{ left.name }}{{ right.name }}Margin
Score{{ left.score }}{{ right.score }}
Price${{ left.price }}${{ right.price }}
{{ left.category.spec_1 }}{{ left.spec_1_value|round(1) }}{{ left.category.unit_1 }}{{ right.spec_1_value|round(1) }}{{ right.category.unit_1 }}
{{ left.category.spec_2 }}{{ left.spec_2_value|round(1) }}{{ left.category.unit_2 }}{{ right.spec_2_value|round(1) }}{{ right.category.unit_2 }}
{{ left.category.spec_3 }}{{ left.spec_3_value|round(1) }}{{ left.category.unit_3 }}{{ right.spec_3_value|round(1) }}{{ right.category.unit_3 }}
Pros{{ left.pros }}{{ right.pros }}
Cons{{ left.cons }}{{ right.cons }}
{{ row.label }}{{ row.left }}{{ row.unit }}{{ row.right }}{{ row.unit }}{% if row.margin is not none %}+{{ row.margin }}{{ row.unit }}{% else %}—{% endif %}
Pros{{ left.pros }}{{ right.pros }}
Cons{{ left.cons }}{{ right.cons }}
diff --git a/sites/versus/templates/index.html b/sites/versus/templates/index.html index 1235b5fb..405e734d 100644 --- a/sites/versus/templates/index.html +++ b/sites/versus/templates/index.html @@ -1,27 +1,23 @@ {% extends "base.html" %} -{% block title %}Versus{% endblock %} -{% block content %} +{% block title %}Versus | Compare everything{% endblock %} +{% block hero %}
-
-
Specs, scores, and side-by-side decisions
-

Versus

-

Compare phones, headphones, cameras, graphics cards, and smartwatches using ranked specifications and clear winner signals.

- -
-
- Popular comparison - Galaxy S24 Ultra - vs - iPhone 15 Pro - Open comparison -
+

compare
everything

+

Smartphones, headphones, cameras, graphics cards and smartwatches — ranked on the + specifications that decide it.

+ +
- +{% endblock %} +{% block content %}
-

Popular Comparisons

Build your own
+
+

Popular comparisons

+ Build your own +
{% for left, right in pairs %} @@ -30,13 +26,13 @@

Versus

{% endfor %}
-
- +
+

Top rated products

+ Full ranking +
- {% for product in top %} - {% include "_product_card.html" %} - {% endfor %} + {% for product in top %}{% include "_product_card.html" %}{% endfor %}
{% endblock %} diff --git a/sites/versus/templates/product.html b/sites/versus/templates/product.html index 3a189c34..839a195e 100644 --- a/sites/versus/templates/product.html +++ b/sites/versus/templates/product.html @@ -2,21 +2,24 @@ {% block title %}{{ product.name }}{% endblock %} {% block content %}
- {{ product.name }} + Synthetic product art for {{ product.name }}
{{ product.category.name }}
-

{{ product.name }}

+
+

{{ product.name }}

+ {{ product.score }} +

{{ product.summary }}

-
{{ product.score }} Versus Score
Compare this product +
+
{{ product.category.spec_1 }}{{ product.spec_1_value|round(1) }}{{ product.category.unit_1 }}
+
{{ product.category.spec_2 }}{{ product.spec_2_value|round(1) }}{{ product.category.unit_2 }}
+
{{ product.category.spec_3 }}{{ product.spec_3_value|round(1) }}{{ product.category.unit_3 }}
+
Price${{ product.price }}
+
-
-
{{ product.category.spec_1 }}{{ product.spec_1_value|round(1) }}{{ product.category.unit_1 }}
-
{{ product.category.spec_2 }}{{ product.spec_2_value|round(1) }}{{ product.category.unit_2 }}
-
{{ product.category.spec_3 }}{{ product.spec_3_value|round(1) }}{{ product.category.unit_3 }}
-
Price${{ product.price }}
-

Pros

{{ product.pros }}

Cons

{{ product.cons }}

@@ -25,7 +28,9 @@

{{ product.name }}

Compare with

diff --git a/sites/versus/tests/test_functional_contract.py b/sites/versus/tests/test_functional_contract.py index 5f6a757c..63e91385 100644 --- a/sites/versus/tests/test_functional_contract.py +++ b/sites/versus/tests/test_functional_contract.py @@ -19,7 +19,7 @@ import sys import tempfile import unittest -from pathlib import Path +from pathlib import Path, PurePosixPath SITE_DIR = Path(__file__).resolve().parents[1] REPO_ROOT = SITE_DIR.parents[1] @@ -160,6 +160,71 @@ def test_save_tasks_target_a_pair_not_already_saved(self): ) +class GeneratedArt(unittest.TestCase): + """The tiles are synthetic by design, so the contract is byte-stability.""" + + def _generate(self, dest: Path): + work = dest / SITE_NAME + shutil.copytree(SITE_DIR, work) + shutil.rmtree(work / "static/images/products", ignore_errors=True) + subprocess.run([sys.executable, "generate_art.py"], + cwd=work, check=True, capture_output=True) + return work + + def test_art_is_byte_reproducible_and_matches_the_inventory(self): + inventory = json.loads((SITE_DIR / "generated_asset_inventory.json").read_text()) + self.assertEqual(inventory["schema_version"], 1) + self.assertTrue(inventory["assets"], "inventory lists no tiles") + digests = [] + for _ in range(2): + with tempfile.TemporaryDirectory() as tmp: + work = self._generate(Path(tmp)) + run = subprocess.run([sys.executable, "check_generated_assets.py"], + cwd=work, capture_output=True, text=True) + self.assertEqual(run.returncode, 0, + f"asset gate failed on a fresh build:\n{run.stdout}") + digests.append(sorted( + hashlib.sha256((work / row["path"]).read_bytes()).hexdigest() + for row in inventory["assets"])) + self.assertEqual(digests[0], digests[1], "tiles differ between builds") + + def test_gate_rejects_a_tampered_tile(self): + with tempfile.TemporaryDirectory() as tmp: + work = self._generate(Path(tmp)) + victim = work / json.loads( + (SITE_DIR / "generated_asset_inventory.json").read_text())["assets"][0]["path"] + victim.write_bytes(victim.read_bytes() + b"tamper") + run = subprocess.run([sys.executable, "check_generated_assets.py"], + cwd=work, capture_output=True, text=True) + self.assertEqual(run.returncode, 1, + "gate passed a tampered tile; its PASS means nothing") + + def test_every_product_has_a_tile(self): + with tempfile.TemporaryDirectory() as tmp: + db = build_seed(Path(tmp)) + slugs = {r[0] for r in sqlite3.connect(db).execute("SELECT slug FROM product")} + listed = {PurePosixPath(row["path"]).stem for row in json.loads( + (SITE_DIR / "generated_asset_inventory.json").read_text())["assets"]} + self.assertEqual(slugs, listed, "product catalogue and tile inventory disagree") + + +class SyntheticDisclosure(unittest.TestCase): + """The synthetic parts must be stated in the UI, not only in the repo.""" + + def test_about_page_and_footer_name_what_is_synthetic(self): + about = (SITE_DIR / "templates" / "about.html").read_text().lower() + base = (SITE_DIR / "templates" / "base.html").read_text().lower() + for token in ("synthetic", "versus score"): + self.assertIn(token, about, f"/about does not mention {token}") + self.assertIn(token, base, f"the footer does not mention {token}") + self.assertIn("not affiliated", about) + + def test_notice_exists_and_covers_imagery_and_data(self): + notice = (SITE_DIR / "NOTICE.md").read_text().lower() + for token in ("non-affiliation", "synthetic", "removal", "versus score"): + self.assertIn(token, notice, f"NOTICE.md does not cover {token}") + + class CsrfProtection(unittest.TestCase): """F10: 23 of 25 sites on main install CSRFProtect; this one must too.""" From 509a4b28a9b1364f839790f0cd7dda7b1f2552f4 Mon Sep 17 00:00:00 2001 From: JackJin <1037461232@qq.com> Date: Sun, 13 Sep 2026 22:33:59 +0800 Subject: [PATCH 10/18] fix(versus): refuse a run that answered from a browser error page An independent blind reviewer passed all 17 executions and, on a run it passed, recorded that the final answer had been emitted at chrome-error://chromewebdata/ after a failed navigation. Every fact-bearing read had already happened, so the answer was right. All 17 deterministic verifiers passed that run too, and that is the actual finding. The navigation checks only look for evidence that the right page *was* opened, and step_urls() filters off-origin steps out on purpose, so a run that crashed and then answered was indistinguishable from a clean one. Evidence that hides its own failure grades as clean. Reproduced before fixing: an error_page_ending fixture, built by rewriting a canonical bundle's done step to a chrome-error URL, passed verify_0, verify_7 and verify_10 unmodified. answered_on_site() requires the step carrying the final answer to sit on this site's origin, and terminal_state_is_sound() wires it into all 17 verifiers. The fixture now fails everywhere, the clean runs still pass, and the real bundle that prompted this correctly failed and was re-recorded rather than accepted. Pinned in tests: answered_on_site must reject a chrome-error terminal step, and every verify_*.py must carry the check. --- .../versus/tests/test_functional_contract.py | 39 +++++++++++++++++++ sites/versus/verify/verify_0.py | 3 +- sites/versus/verify/verify_1.py | 3 +- sites/versus/verify/verify_10.py | 3 +- sites/versus/verify/verify_11.py | 3 +- sites/versus/verify/verify_12.py | 3 +- sites/versus/verify/verify_13.py | 3 ++ sites/versus/verify/verify_14.py | 3 +- sites/versus/verify/verify_15.py | 3 +- sites/versus/verify/verify_16.py | 3 +- sites/versus/verify/verify_2.py | 3 +- sites/versus/verify/verify_3.py | 3 +- sites/versus/verify/verify_4.py | 3 +- sites/versus/verify/verify_5.py | 3 +- sites/versus/verify/verify_6.py | 3 +- sites/versus/verify/verify_7.py | 3 ++ sites/versus/verify/verify_8.py | 3 +- sites/versus/verify/verify_9.py | 3 +- sites/versus/verify/verify_lib.py | 26 +++++++++++++ 19 files changed, 86 insertions(+), 30 deletions(-) diff --git a/sites/versus/tests/test_functional_contract.py b/sites/versus/tests/test_functional_contract.py index 63e91385..33ecf08f 100644 --- a/sites/versus/tests/test_functional_contract.py +++ b/sites/versus/tests/test_functional_contract.py @@ -208,6 +208,45 @@ def test_every_product_has_a_tile(self): self.assertEqual(slugs, listed, "product catalogue and tile inventory disagree") +class VerifierTerminalState(unittest.TestCase): + """An independent reviewer caught a run that answered from a crash page. + + Every other check passed it: the facts had been read, the answer was right, + and nothing in the bundle said the browser had failed. Evidence that hides + its own failure must not grade as clean. + """ + + def _lib(self): + import importlib.util + spec = importlib.util.spec_from_file_location( + "versus_verify_lib", SITE_DIR / "verify" / "verify_lib.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + def test_answer_from_an_error_page_is_not_on_site(self): + V = self._lib() + ok = {"steps": [{"step": 0, "action": "click", "url": "http://localhost:40026/"}, + {"step": 1, "action": "done", + "url": "http://localhost:40026/item/nikon-z8"}]} + crashed = {"steps": [{"step": 0, "action": "click", "url": "http://localhost:40026/"}, + {"step": 1, "action": "done", + "url": "chrome-error://chromewebdata/"}]} + self.assertTrue(V.answered_on_site(ok)) + self.assertFalse(V.answered_on_site(crashed), + "a final answer emitted from a browser error page counted as on-site") + self.assertFalse(V.answered_on_site({"steps": []})) + + def test_every_verifier_checks_the_terminal_state(self): + for path in sorted((SITE_DIR / "verify").glob("verify_*.py")): + if path.name == "verify_lib.py": + continue + src = path.read_text() + self.assertTrue( + "terminal_state_is_sound" in src or "answered_on_site" in src, + f"{path.name} does not check where the answer was emitted from") + + class SyntheticDisclosure(unittest.TestCase): """The synthetic parts must be stated in the UI, not only in the repo.""" diff --git a/sites/versus/verify/verify_0.py b/sites/versus/verify/verify_0.py index 94ba60b2..b9aaca5e 100644 --- a/sites/versus/verify/verify_0.py +++ b/sites/versus/verify/verify_0.py @@ -25,8 +25,7 @@ def body(j, traj, initial, after): or (V.opened_detail_or_compare(traj, left["slug"]) and V.opened_detail_or_compare(traj, right["slug"])), f"steps={V.step_urls(traj)[-6:]}") - j.check("answer is non-empty and not a denial", - bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + V.terminal_state_is_sound(j, traj) j.check("answer names the right product", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") j.check("answer states the derived value", diff --git a/sites/versus/verify/verify_1.py b/sites/versus/verify/verify_1.py index 0476deb7..276bdd1c 100644 --- a/sites/versus/verify/verify_1.py +++ b/sites/versus/verify/verify_1.py @@ -25,8 +25,7 @@ def body(j, traj, initial, after): j.check("opened the fact-bearing page for the target product", V.opened_detail_or_compare(traj, target["slug"]), f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") - j.check("answer is non-empty and not a denial", - bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + V.terminal_state_is_sound(j, traj) j.check("answer names the right product", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") j.check("answer states the derived value", diff --git a/sites/versus/verify/verify_10.py b/sites/versus/verify/verify_10.py index 43b3dac6..801b8fc2 100644 --- a/sites/versus/verify/verify_10.py +++ b/sites/versus/verify/verify_10.py @@ -21,8 +21,7 @@ def body(j, traj, initial, after): j.check("opened the fact-bearing page for the target product", V.opened_detail_or_compare(traj, target["slug"]), f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") - j.check("answer is non-empty and not a denial", - bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + V.terminal_state_is_sound(j, traj) j.check("answer names the right product", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") j.check("answer states the derived value", diff --git a/sites/versus/verify/verify_11.py b/sites/versus/verify/verify_11.py index 9cfe1a67..d22e058d 100644 --- a/sites/versus/verify/verify_11.py +++ b/sites/versus/verify/verify_11.py @@ -21,8 +21,7 @@ def body(j, traj, initial, after): j.check("opened the fact-bearing page for the target product", V.opened_detail_or_compare(traj, target["slug"]), f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") - j.check("answer is non-empty and not a denial", - bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + V.terminal_state_is_sound(j, traj) j.check("answer names the right product", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") j.check("answer states the derived value", diff --git a/sites/versus/verify/verify_12.py b/sites/versus/verify/verify_12.py index f7b2449c..f518644b 100644 --- a/sites/versus/verify/verify_12.py +++ b/sites/versus/verify/verify_12.py @@ -25,8 +25,7 @@ def body(j, traj, initial, after): or (V.opened_detail_or_compare(traj, left["slug"]) and V.opened_detail_or_compare(traj, right["slug"])), f"steps={V.step_urls(traj)[-6:]}") - j.check("answer is non-empty and not a denial", - bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + V.terminal_state_is_sound(j, traj) j.check("answer names the right product", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") j.check("answer states the derived value", diff --git a/sites/versus/verify/verify_13.py b/sites/versus/verify/verify_13.py index 4a339b50..802e4485 100644 --- a/sites/versus/verify/verify_13.py +++ b/sites/versus/verify/verify_13.py @@ -23,6 +23,9 @@ def body(j, traj, initial, after): "after state would be identical whether or not the agent acted") ans = V.final_answer(traj) + j.check("the answer was emitted from a page on this site", + V.answered_on_site(traj), + f"terminal url={(traj.get('steps') or [{}])[-1].get('url')!r}") j.check("reported what it did, without denying it", bool(ans) and not V.looks_negated(ans), f"answer={ans!r} (this task is graded on the state change; the report " diff --git a/sites/versus/verify/verify_14.py b/sites/versus/verify/verify_14.py index 14d64e14..3ba7aa09 100644 --- a/sites/versus/verify/verify_14.py +++ b/sites/versus/verify/verify_14.py @@ -21,8 +21,7 @@ def body(j, traj, initial, after): j.check("opened the fact-bearing page for the target product", V.opened_detail_or_compare(traj, target["slug"]), f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") - j.check("answer is non-empty and not a denial", - bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + V.terminal_state_is_sound(j, traj) j.check("answer names the right product", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") j.check("answer states the derived value", diff --git a/sites/versus/verify/verify_15.py b/sites/versus/verify/verify_15.py index 6f1c4d89..be350831 100644 --- a/sites/versus/verify/verify_15.py +++ b/sites/versus/verify/verify_15.py @@ -21,8 +21,7 @@ def body(j, traj, initial, after): j.check("opened the fact-bearing page for the target product", V.opened_detail_or_compare(traj, target["slug"]), f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") - j.check("answer is non-empty and not a denial", - bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + V.terminal_state_is_sound(j, traj) j.check("answer names the right product", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") j.check("answer states the derived value", diff --git a/sites/versus/verify/verify_16.py b/sites/versus/verify/verify_16.py index 0e9d3894..35ec10fd 100644 --- a/sites/versus/verify/verify_16.py +++ b/sites/versus/verify/verify_16.py @@ -25,8 +25,7 @@ def body(j, traj, initial, after): or (V.opened_detail_or_compare(traj, left["slug"]) and V.opened_detail_or_compare(traj, right["slug"])), f"steps={V.step_urls(traj)[-6:]}") - j.check("answer is non-empty and not a denial", - bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + V.terminal_state_is_sound(j, traj) j.check("answer names the right product", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") j.check("answer states the derived value", diff --git a/sites/versus/verify/verify_2.py b/sites/versus/verify/verify_2.py index 73f38d6d..f657bc50 100644 --- a/sites/versus/verify/verify_2.py +++ b/sites/versus/verify/verify_2.py @@ -23,8 +23,7 @@ def body(j, traj, initial, after): j.check("opened the fact-bearing page for the target product", V.opened_detail_or_compare(traj, target["slug"]), f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") - j.check("answer is non-empty and not a denial", - bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + V.terminal_state_is_sound(j, traj) j.check("answer names the right product", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") j.check("answer states the derived value", diff --git a/sites/versus/verify/verify_3.py b/sites/versus/verify/verify_3.py index 45d37c5d..0ec6e7fc 100644 --- a/sites/versus/verify/verify_3.py +++ b/sites/versus/verify/verify_3.py @@ -23,8 +23,7 @@ def body(j, traj, initial, after): j.check("opened the fact-bearing page for the target product", V.opened_detail_or_compare(traj, target["slug"]), f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") - j.check("answer is non-empty and not a denial", - bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + V.terminal_state_is_sound(j, traj) j.check("answer names the right product", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") j.check("answer states the derived value", diff --git a/sites/versus/verify/verify_4.py b/sites/versus/verify/verify_4.py index f15afd5c..b592fa2f 100644 --- a/sites/versus/verify/verify_4.py +++ b/sites/versus/verify/verify_4.py @@ -25,8 +25,7 @@ def body(j, traj, initial, after): or (V.opened_detail_or_compare(traj, left["slug"]) and V.opened_detail_or_compare(traj, right["slug"])), f"steps={V.step_urls(traj)[-6:]}") - j.check("answer is non-empty and not a denial", - bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + V.terminal_state_is_sound(j, traj) j.check("answer names the right product", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") j.check("answer states the derived value", diff --git a/sites/versus/verify/verify_5.py b/sites/versus/verify/verify_5.py index be4c3ec6..83f7949f 100644 --- a/sites/versus/verify/verify_5.py +++ b/sites/versus/verify/verify_5.py @@ -22,8 +22,7 @@ def body(j, traj, initial, after): f"steps={V.step_urls(traj)[:6]}") j.check("opened the account page", V.navigated_to(traj, "/account"), f"steps={V.step_urls(traj)}") - j.check("answer is non-empty and not a denial", - bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + V.terminal_state_is_sound(j, traj) # Which seeded pair does the answer name? Both product names must appear. matched = [pair for pair in before diff --git a/sites/versus/verify/verify_6.py b/sites/versus/verify/verify_6.py index 546ac794..2a4feb33 100644 --- a/sites/versus/verify/verify_6.py +++ b/sites/versus/verify/verify_6.py @@ -25,8 +25,7 @@ def body(j, traj, initial, after): j.check("opened the fact-bearing page for the target product", V.opened_detail_or_compare(traj, target["slug"]), f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") - j.check("answer is non-empty and not a denial", - bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + V.terminal_state_is_sound(j, traj) j.check("answer names the right product", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") j.check("answer states the derived value", diff --git a/sites/versus/verify/verify_7.py b/sites/versus/verify/verify_7.py index 7ef45692..b639230a 100644 --- a/sites/versus/verify/verify_7.py +++ b/sites/versus/verify/verify_7.py @@ -23,6 +23,9 @@ def body(j, traj, initial, after): "after state would be identical whether or not the agent acted") ans = V.final_answer(traj) + j.check("the answer was emitted from a page on this site", + V.answered_on_site(traj), + f"terminal url={(traj.get('steps') or [{}])[-1].get('url')!r}") j.check("reported what it did, without denying it", bool(ans) and not V.looks_negated(ans), f"answer={ans!r} (this task is graded on the state change; the report " diff --git a/sites/versus/verify/verify_8.py b/sites/versus/verify/verify_8.py index c4a1ef19..828bc296 100644 --- a/sites/versus/verify/verify_8.py +++ b/sites/versus/verify/verify_8.py @@ -22,8 +22,7 @@ def body(j, traj, initial, after): j.check("opened the fact-bearing page for the product", V.opened_detail_or_compare(traj, SLUG), f"steps={V.step_urls(traj)[-6:]}") - j.check("answer is non-empty and not a denial", - bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + V.terminal_state_is_sound(j, traj) j.check("answer states the derived battery life", V.mentions_number(ans, expected), f"expected={expected} {target['unit_2']} from initial_db") diff --git a/sites/versus/verify/verify_9.py b/sites/versus/verify/verify_9.py index cd9e5f7b..f3f35137 100644 --- a/sites/versus/verify/verify_9.py +++ b/sites/versus/verify/verify_9.py @@ -28,8 +28,7 @@ def body(j, traj, initial, after): V.navigated_to(traj, f"/compare/{LEFT}-vs-{RIGHT}") or V.navigated_to(traj, f"/compare/{RIGHT}-vs-{LEFT}"), f"steps={V.step_urls(traj)[-6:]}") - j.check("answer is non-empty and not a denial", - bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + V.terminal_state_is_sound(j, traj) j.check("answer names the product the site declares the winner", V.mentions_product(ans, target["name"]), f"expected={target['name']!r} (score {target['score']} vs {loser['score']})") diff --git a/sites/versus/verify/verify_lib.py b/sites/versus/verify/verify_lib.py index 1a48c88f..43a1d533 100644 --- a/sites/versus/verify/verify_lib.py +++ b/sites/versus/verify/verify_lib.py @@ -114,6 +114,32 @@ def final_answer(traj): return (traj.get("final_answer") or "").strip() +def answered_on_site(traj): + """The step carrying the final answer must sit on this site. + + A failed navigation gets recorded as an ordinary step, so without this a run + that crashed and then emitted its answer from chrome-error://chromewebdata/ + is indistinguishable from a clean one. Found by an independent reviewer on a + run every other check passed. + """ + steps = traj.get("steps") or [] + if not steps: + return False + done = [s for s in steps if s.get("action") == "done"] or [steps[-1]] + return (done[-1].get("url") or "").startswith(site_origins()) + + +def terminal_state_is_sound(j, traj): + """Shared gate: non-empty, non-negated answer emitted from a real page.""" + ans = final_answer(traj) + j.check("answer is non-empty and not a denial", + bool(ans) and not looks_negated(ans), f"answer={ans!r}") + j.check("the answer was emitted from a page on this site", + answered_on_site(traj), + f"terminal url={(traj.get('steps') or [{}])[-1].get('url')!r}") + return ans + + def _shot(traj, name): if not name: return None From 7aba94b58bf0aae083c45fec026fb5ecfcc27f74 Mon Sep 17 00:00:00 2001 From: JackJin <1037461232@qq.com> Date: Sun, 13 Sep 2026 22:57:25 +0800 Subject: [PATCH 11/18] test(versus): derive the origin instead of freezing it The terminal-state regression hardcoded localhost:40026. Healthline took that slot upstream, versus moved to 40027, and the test started failing against correct code. Third instance of one mistake: a frozen port in the verifier's navigation check (silently accepted trajectories from another mirror), a frozen port in the adversarial foreign-origin fixture (silently stopped testing anything), and now one here. This one at least failed loudly rather than going quiet, which is the behaviour the other two lacked. All three now derive the port from control_server.py's registry. --- sites/versus/tests/test_functional_contract.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/sites/versus/tests/test_functional_contract.py b/sites/versus/tests/test_functional_contract.py index 33ecf08f..4430436c 100644 --- a/sites/versus/tests/test_functional_contract.py +++ b/sites/versus/tests/test_functional_contract.py @@ -226,10 +226,15 @@ def _lib(self): def test_answer_from_an_error_page_is_not_on_site(self): V = self._lib() - ok = {"steps": [{"step": 0, "action": "click", "url": "http://localhost:40026/"}, + # Derive the origin the way the verifier does. Freezing a port here is + # the same mistake this suite exists to catch: it was frozen once in the + # verifier, once in the adversarial fixtures, and once here, and each + # time a registry move turned the check into a no-op or a false failure. + base = V.site_origins()[0] + ok = {"steps": [{"step": 0, "action": "click", "url": f"{base}/"}, {"step": 1, "action": "done", - "url": "http://localhost:40026/item/nikon-z8"}]} - crashed = {"steps": [{"step": 0, "action": "click", "url": "http://localhost:40026/"}, + "url": f"{base}/item/nikon-z8"}]} + crashed = {"steps": [{"step": 0, "action": "click", "url": f"{base}/"}, {"step": 1, "action": "done", "url": "chrome-error://chromewebdata/"}]} self.assertTrue(V.answered_on_site(ok)) From dd7d3aca8e8db4e6a6c6a34dae9f76324ad89f68 Mon Sep 17 00:00:00 2001 From: JackJin <1037461232@qq.com> Date: Sun, 13 Sep 2026 23:36:24 +0800 Subject: [PATCH 12/18] test(versus): follow the origin binding, and pin that evidence survives a re-slot The terminal-state test still called site_origins(), which the previous commit replaced. It errored rather than passing quietly, which is the behaviour this suite is for -- but I pushed before running it, so it went out broken. Rewritten against run_origin(): an arbitrary port is used deliberately, since the check is now internal consistency rather than today's registry value. Adds the case the change exists for -- a run recorded at 40027 still verifies after the site is re-slotted -- and the case that a trajectory with no start_url has no origin to be consistent with. --- .../versus/tests/test_functional_contract.py | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/sites/versus/tests/test_functional_contract.py b/sites/versus/tests/test_functional_contract.py index 4430436c..4005eff6 100644 --- a/sites/versus/tests/test_functional_contract.py +++ b/sites/versus/tests/test_functional_contract.py @@ -226,21 +226,34 @@ def _lib(self): def test_answer_from_an_error_page_is_not_on_site(self): V = self._lib() - # Derive the origin the way the verifier does. Freezing a port here is - # the same mistake this suite exists to catch: it was frozen once in the - # verifier, once in the adversarial fixtures, and once here, and each - # time a registry move turned the check into a no-op or a false failure. - base = V.site_origins()[0] - ok = {"steps": [{"step": 0, "action": "click", "url": f"{base}/"}, + # The origin comes from the run's own start_url, so a registry re-slot + # does not expire recorded evidence. An arbitrary port is used here on + # purpose: the check is internal consistency, not today's port. + base = "http://localhost:40123" + ok = {"start_url": f"{base}/", + "steps": [{"step": 0, "action": "click", "url": f"{base}/"}, {"step": 1, "action": "done", "url": f"{base}/item/nikon-z8"}]} - crashed = {"steps": [{"step": 0, "action": "click", "url": f"{base}/"}, + crashed = {"start_url": f"{base}/", + "steps": [{"step": 0, "action": "click", "url": f"{base}/"}, {"step": 1, "action": "done", "url": "chrome-error://chromewebdata/"}]} self.assertTrue(V.answered_on_site(ok)) self.assertFalse(V.answered_on_site(crashed), "a final answer emitted from a browser error page counted as on-site") - self.assertFalse(V.answered_on_site({"steps": []})) + self.assertFalse(V.answered_on_site({"start_url": f"{base}/", "steps": []})) + self.assertFalse(V.answered_on_site({"steps": ok["steps"]}), + "a trajectory with no start_url has no origin to be consistent with") + + def test_a_run_survives_the_site_being_re_slotted(self): + """Recorded evidence must not expire when upstream moves the port.""" + V = self._lib() + old = {"start_url": "http://localhost:40027/", + "steps": [{"step": 0, "action": "click", "url": "http://localhost:40027/"}, + {"step": 1, "action": "done", + "url": "http://localhost:40027/item/nikon-z8"}]} + self.assertTrue(V.answered_on_site(old)) + self.assertTrue(V.navigated_to(old, "/item/nikon-z8")) def test_every_verifier_checks_the_terminal_state(self): for path in sorted((SITE_DIR / "verify").glob("verify_*.py")): From d4a6bf6cd03de76cc2e108e729e504149b057f6a Mon Sep 17 00:00:00 2001 From: JackJin <1037461232@qq.com> Date: Mon, 14 Sep 2026 01:10:32 +0800 Subject: [PATCH 13/18] feat(versus): add sourced Cities and Universities catalogues, and paginate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONTRIBUTING asks for "≥ ~20 records per major filter" so that filters, pagination and search look plausible. The contribution shipped 4 products per category across 5 categories, and no surface had enough rows to paginate. That is the project's own line, not a new one -- it predates this review. The five electronics categories cannot be enlarged from any free citable source available here, and this was established rather than assumed. A SPARQL count of digital cameras holding both mass and release date returns zero; the same query shape returns 2489 cities and 3582 universities. Wikipedia infoboxes yielded all four needed fields for 2 of 22 camera candidates. Consumer audio and mid-range watches mostly have no article and no Wikidata mass or price claims. Manufacturer pages carry the figures but Sony and Garmin return 403 here, and Bose's own ld+json on a headphone page describes the earbuds. Filling them from memory was not an option -- that is the rule that made the Versus Score a disclosed synthetic value. So the site gains the two categories its own source site advertises ("Smartphones, cities, graphics cards, universities") and that are sourceable at depth: 52 cities and 35 universities, each row carrying its Q-id, the property id behind every figure, and the fetch time, in data/catalogue_wikidata.json. Both new categories clear the ≥20 line; the five original ones still do not, and the PR says so. Pagination is added to the category listings and the rankings, 8 per page, with true ranks carried across pages rather than restarting at 1. Data quality is cross-checked rather than trusted. Rows whose own claims contradict each other are dropped at seed time: one paired a state population with a city area, giving 102,298 people per km². The first attempt at this named a hand-written Q-id, which turned out not to be the offending item's id, so the bad row shipped anyway -- it is now a rule over the claims. Entities sharing an English label are dropped on both sides rather than disambiguated, since a task naming one would be ambiguous on the page. A caveat that is documented rather than solved: population and area are independent claims and need not describe the same administrative boundary, so a metro population against a city-proper area can still inflate a density within the plausibility bound. No task is written against the density figure. price, release_year and the third spec are now optional, since a city has no list price and the source gives universities no third figure. Templates and the comparison table omit what is absent instead of rendering an empty column. --- sites/versus/app.py | 183 ++- sites/versus/data/README.md | 46 + sites/versus/data/build_catalogue.py | 94 ++ sites/versus/data/catalogue_wikidata.json | 1448 +++++++++++++++++++ sites/versus/data/fetch_wikidata.py | 96 ++ sites/versus/data/probe_candidates.py | 66 + sites/versus/generate_art.py | 19 +- sites/versus/generated_asset_inventory.json | 435 ++++++ sites/versus/templates/_product_card.html | 4 +- sites/versus/templates/category.html | 4 +- sites/versus/templates/product.html | 4 +- sites/versus/templates/rankings.html | 6 +- 12 files changed, 2380 insertions(+), 25 deletions(-) create mode 100644 sites/versus/data/README.md create mode 100644 sites/versus/data/build_catalogue.py create mode 100644 sites/versus/data/catalogue_wikidata.json create mode 100644 sites/versus/data/fetch_wikidata.py create mode 100644 sites/versus/data/probe_candidates.py diff --git a/sites/versus/app.py b/sites/versus/app.py index 79ba8960..6a385176 100644 --- a/sites/versus/app.py +++ b/sites/versus/app.py @@ -1,6 +1,7 @@ """Versus mirror — product comparison and ranking workflows.""" from __future__ import annotations +import json import os import re from functools import wraps @@ -54,9 +55,12 @@ class Category(db.Model): slug = db.Column(db.String(80), unique=True, nullable=False) name = db.Column(db.String(120), nullable=False) tagline = db.Column(db.String(180), nullable=False) + # What the brand/maker column means here. A sourced city or university is + # identified by country, not by a manufacturer. + brand_label = db.Column(db.String(30), nullable=False, default="Brand") spec_1 = db.Column(db.String(80), nullable=False) spec_2 = db.Column(db.String(80), nullable=False) - spec_3 = db.Column(db.String(80), nullable=False) + spec_3 = db.Column(db.String(80), nullable=True) unit_1 = db.Column(db.String(24), default="") unit_2 = db.Column(db.String(24), default="") unit_3 = db.Column(db.String(24), default="") @@ -69,11 +73,11 @@ class Product(db.Model): brand = db.Column(db.String(80), nullable=False) category_id = db.Column(db.Integer, db.ForeignKey("category.id"), nullable=False) score = db.Column(db.Integer, nullable=False) - price = db.Column(db.Integer, nullable=False) - release_year = db.Column(db.Integer, nullable=False) + price = db.Column(db.Integer, nullable=True) + release_year = db.Column(db.Integer, nullable=True) spec_1_value = db.Column(db.Float, nullable=False) spec_2_value = db.Column(db.Float, nullable=False) - spec_3_value = db.Column(db.Float, nullable=False) + spec_3_value = db.Column(db.Float, nullable=True) battery_hours = db.Column(db.Float, default=0) weight_grams = db.Column(db.Float, default=0) pros = db.Column(db.Text, nullable=False) @@ -173,8 +177,10 @@ def compare_rows(left: Product, right: Product) -> list[dict]: ] rows = [{"label": "Score", "left": left.score, "right": right.score, "unit": ""}] rows += [{"label": label, "left": lv, "right": rv, "unit": unit} - for label, lv, rv, unit in specs] - rows.append({"label": "Price", "left": left.price, "right": right.price, "unit": ""}) + for label, lv, rv, unit in specs + if label and lv is not None and rv is not None] + if left.price is not None and right.price is not None: + rows.append({"label": "Price", "left": left.price, "right": right.price, "unit": ""}) for row in rows: lv, rv = row["left"], row["right"] @@ -216,6 +222,23 @@ def category_index(): return render_template("categories.html", counts=counts) +PAGE_SIZE = 8 + + +def paginate(rows, page): + """Slice a result set and describe the paging, the way a catalogue site does.""" + total = len(rows) + pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE) + page = min(max(page or 1, 1), pages) + start = (page - 1) * PAGE_SIZE + return rows[start:start + PAGE_SIZE], { + "page": page, "pages": pages, "total": total, + "start": start + 1 if total else 0, + "end": min(start + PAGE_SIZE, total), + "has_prev": page > 1, "has_next": page < pages, + } + + @app.route("/category/") def category_detail(slug): category = Category.query.filter_by(slug=slug).first_or_404() @@ -230,7 +253,12 @@ def category_detail(slug): if min_score: rows = [item for item in rows if item.score >= min_score] brands = [row[0] for row in db.session.query(Product.brand).filter_by(category_id=category.id).distinct().order_by(Product.brand)] - return render_template("category.html", category=category, products=rows, brands=brands, brand=brand, max_price=max_price, min_score=min_score) + has_prices = db.session.query(Product.price).filter( + Product.category_id == category.id, Product.price.isnot(None)).first() is not None + rows, paging = paginate(rows, request.args.get("page", type=int)) + return render_template("category.html", category=category, products=rows, brands=brands, + brand=brand, max_price=max_price, min_score=min_score, + paging=paging, has_prices=has_prices) @app.route("/item/") @@ -291,7 +319,10 @@ def rankings(): if category_slug: category = Category.query.filter_by(slug=category_slug).first_or_404() rows = [row for row in rows if row.category_id == category.id] - return render_template("rankings.html", products=rows, category_slug=category_slug) + ranked = list(enumerate(rows, start=1)) + ranked, paging = paginate(ranked, request.args.get("page", type=int)) + return render_template("rankings.html", ranked=ranked, category_slug=category_slug, + paging=paging) @app.route("/search") @@ -346,19 +377,88 @@ def health(): return {"ok": True, "site": "versus"} +SOURCED = os.path.join(BASE_DIR, "data", "catalogue_wikidata.json") + +# Wikidata is user-edited, and some rows pair claims that do not belong +# together -- a state's population against a city's area, for instance. Rather +# than maintain a hand-written list of bad ids (the first attempt named an id +# that was not the offending item's, so the bad row shipped anyway), the claims +# are cross-checked against each other and anything implausible is dropped. +# Known limitation, not a solved problem: population and area are independent +# claims and are not guaranteed to describe the same administrative boundary. A +# metro-area population paired with a city-proper area inflates the derived +# density. The bound below removes the gross cases (one row paired a state +# population with a city area, giving 102,298 people per km2) but cannot +# separate, say, an inflated Kuala Lumpur from a genuinely dense Mumbai. No task +# is written against the density figure for that reason, and data/README.md +# records the caveat. +MAX_PLAUSIBLE_DENSITY = 40000 # people per km2; the densest real cities sit near 46k +MIN_PLAUSIBLE_DENSITY = 50 + + +def _sourced_rows(): + """Cities and universities, as fetched from Wikidata with provenance. + + Consumer-electronics categories are not sourced this way: no free citable + source carries their specs at scale (a SPARQL count of digital cameras + holding both mass and release date returns zero). See data/README.md. + """ + if not os.path.exists(SOURCED): + return {"cities": [], "universities": []} + with open(SOURCED, encoding="utf-8") as fh: + data = json.load(fh) + out, seen = {}, {} + for key in ("cities", "universities"): + rows = [r for r in data.get(key, []) if _claims_are_consistent(key, r)] + rows = sorted(rows, key=lambda r: r["qid"]) # deterministic order + for r in rows: + seen.setdefault(_slugify(r["name"]), []).append(r["qid"]) + out[key] = rows + # Two entities sharing an English label are indistinguishable on the page, so + # a task naming one would be ambiguous. Drop every side of the collision + # rather than silently keeping whichever sorted first. Wikidata has two + # items labelled "University of Lille": the merged 2018 institution and the + # historic one. + ambiguous = {q for qids in seen.values() if len(qids) > 1 for q in qids} + if ambiguous: + for key in out: + out[key] = [r for r in out[key] if r["qid"] not in ambiguous] + return out + + +def _claims_are_consistent(kind, rec): + """Cross-check a row's own claims; drop it when they contradict each other.""" + f = rec["fields"] + try: + if kind == "cities": + density = float(f["pop"]["value"]) / float(f["area"]["value"]) + return MIN_PLAUSIBLE_DENSITY < density < MAX_PLAUSIBLE_DENSITY + if kind == "universities": + founded = int(f["inception"]["value"][:4]) + return 1000 < founded <= 2026 and float(f["students"]["value"]) > 0 + except (KeyError, ValueError, ZeroDivisionError): + return False + return True + + def seed_database(): if Category.query.count() > 0: return categories = [ - ("smartphones", "Smartphones", "Compare cameras, screens, battery life, and performance.", "Camera score", "Battery", "Display", "pt", "h", "in"), - ("headphones", "Headphones", "Compare noise cancelling, battery, weight, and travel features.", "ANC score", "Battery", "Weight", "pt", "h", "g"), - ("cameras", "Cameras", "Compare sensor resolution, stabilization, burst speed, and video features.", "Megapixels", "Burst", "Weight", "MP", "fps", "g"), - ("graphics-cards", "Graphics Cards", "Compare gaming performance, VRAM, power draw, and value.", "VRAM", "Power", "Benchmark", "GB", "W", "pt"), - ("smartwatches", "Smartwatches", "Compare fitness sensors, battery, display, and ecosystem support.", "Fitness score", "Battery", "Weight", "pt", "h", "g"), + ("smartphones", "Smartphones", "Compare cameras, screens, battery life, and performance.", "Camera score", "Battery", "Display", "pt", "h", "in", "Brand"), + ("headphones", "Headphones", "Compare noise cancelling, battery, weight, and travel features.", "ANC score", "Battery", "Weight", "pt", "h", "g", "Brand"), + ("cameras", "Cameras", "Compare sensor resolution, stabilization, burst speed, and video features.", "Megapixels", "Burst", "Weight", "MP", "fps", "g", "Brand"), + ("graphics-cards", "Graphics Cards", "Compare gaming performance, VRAM, power draw, and value.", "VRAM", "Power", "Benchmark", "GB", "W", "pt", "Brand"), + ("smartwatches", "Smartwatches", "Compare fitness sensors, battery, display, and ecosystem support.", "Fitness score", "Battery", "Weight", "pt", "h", "g", "Brand"), + ("cities", "Cities", "Compare population, footprint, and how densely people live.", "Population", "Area", "Density", "", "km2", "/km2", "Country"), + ("universities", "Universities", "Compare enrolment and how long the institution has been teaching.", "Students", "Founded", None, "", "", None, "Country"), ] + category_map = {} - for slug, name, tagline, spec_1, spec_2, spec_3, unit_1, unit_2, unit_3 in categories: - cat = Category(slug=slug, name=name, tagline=tagline, spec_1=spec_1, spec_2=spec_2, spec_3=spec_3, unit_1=unit_1, unit_2=unit_2, unit_3=unit_3) + for slug, name, tagline, spec_1, spec_2, spec_3, unit_1, unit_2, unit_3, brand_label in categories: + cat = Category(slug=slug, name=name, tagline=tagline, brand_label=brand_label, + spec_1=spec_1, spec_2=spec_2, spec_3=spec_3, + unit_1=unit_1, unit_2=unit_2, unit_3=unit_3) db.session.add(cat) db.session.flush() category_map[slug] = cat @@ -403,9 +503,62 @@ def seed_database(): cons=cons, summary=f"{name} is a {category_map[category_slug].name.lower()} contender with a Versus score of {score}, released in {year}, and priced around ${price}.", )) + seed_sourced_entries(category_map) db.session.commit() +def _slugify(name): + return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + + +def seed_sourced_entries(category_map): + """Add the Wikidata-sourced Cities and Universities entries. + + Every figure here is a Wikidata claim recorded in data/catalogue_wikidata.json + with its Q-id, property id and unit. The Versus Score is synthetic, as it is + for every other category, and is derived deterministically from the sourced + figures so it stays stable across builds rather than being hand-assigned. + """ + rows = _sourced_rows() + + for rec in rows["cities"]: + pop = float(rec["fields"]["pop"]["value"]) + area = float(rec["fields"]["area"]["value"]) + density = round(pop / area, 1) + db.session.add(Product( + slug=_slugify(rec["name"]), name=rec["name"], brand=rec.get("country", "—"), + category_id=category_map["cities"].id, + score=_synthetic_score(density, 200, 12000), + price=None, release_year=None, + spec_1_value=pop, spec_2_value=area, spec_3_value=density, + battery_hours=0, weight_grams=0, + pros=f"{pop:,.0f} residents across {area:,.0f} km2", + cons=f"Density {density:,.0f} people per km2", + summary=(f"{rec['name']} covers {area:,.0f} km2 and is home to " + f"{pop:,.0f} people, a density of {density:,.0f} per km2."))) + + for rec in rows["universities"]: + students = float(rec["fields"]["students"]["value"]) + founded = int(rec["fields"]["inception"]["value"][:4]) + db.session.add(Product( + slug=_slugify(rec["name"]), name=rec["name"], brand=rec.get("country", "—"), + category_id=category_map["universities"].id, + score=_synthetic_score(students, 70000, 200000), + price=None, release_year=founded, + spec_1_value=students, spec_2_value=float(founded), spec_3_value=None, + battery_hours=0, weight_grams=0, + pros=f"{students:,.0f} enrolled students", + cons=f"Teaching since {founded}", + summary=(f"{rec['name']} has enrolled {students:,.0f} students and has " + f"been teaching since {founded}."))) + + +def _synthetic_score(value, low, high): + """A benchmark score, not a real-world rating. Deterministic from the input.""" + span = max(high - low, 1) + return max(60, min(99, round(60 + 39 * (value - low) / span))) + + def seed_benchmark_users(): if User.query.filter_by(email="alice.j@test.com").first(): return diff --git a/sites/versus/data/README.md b/sites/versus/data/README.md new file mode 100644 index 00000000..3fb75e65 --- /dev/null +++ b/sites/versus/data/README.md @@ -0,0 +1,46 @@ +# Where the Versus catalogue comes from + +| Category | Entries | Source | Sourced figures | +| --- | --- | --- | --- | +| Cities | 52 | Wikidata (`P1082` population, `P2046` area) | population, area; density is derived | +| Universities | 35 | Wikidata (`P2196` students, `P571` inception) | enrolment, founding year | +| Smartphones, headphones, cameras, graphics cards, smartwatches | 4 each | the original contribution | specs and list prices spot-check against manufacturer figures | + +`catalogue_wikidata.json` holds every sourced row with its Q-id, the property id +behind each figure, the raw value and the fetch time. `fetch_wikidata.py` +regenerates it. Labels are read off each entity rather than the SPARQL label +service, which was observed attaching the wrong label to an item. + +## Why the electronics categories were not enlarged + +They cannot be, from any free citable source available here: + +- A SPARQL count of digital cameras holding both mass and release date returns + **zero**. The same query shape returns 2489 cities and 3582 universities. +- Wikipedia infoboxes are inconsistent per article: of 22 camera candidates, 2 + yielded all four needed fields. Field names vary (`cont` / `cont_shooting` / + absent) and prices are often quoted only in yen. +- Consumer audio and mid-range watches mostly have no article at all — 1 of 8 + headphone candidates, 2 of 8 watch candidates — and no Wikidata mass or price + claims either. +- Manufacturer pages carry the figures but half are unreachable (Sony and Garmin + return 403 to this client) and there is no consistent structured data: Bose's + own `ld+json` on a headphone page describes the earbuds instead. + +Rather than fill those categories from memory, they are left at the size the +contribution shipped with, and this is stated in the PR. + +## Data-quality caveats + +- **City density is derived and can be wrong.** Population and area are + independent claims that are not guaranteed to describe the same administrative + boundary. Rows whose implied density is outside 50–40000 per km² are dropped + at seed time — one paired a state population with a city area — but a metro + population against a city-proper area can still slip through within the bound. + **No task is written against the density figure.** +- **Entities sharing an English label are dropped entirely**, both sides, rather + than disambiguated: two items are labelled "University of Lille", and a task + naming one would be ambiguous on the page. +- **The Versus Score is synthetic for every category**, including these. For the + sourced rows it is computed deterministically from the sourced figures so it + is stable across builds; it is not a versus.com value. diff --git a/sites/versus/data/build_catalogue.py b/sites/versus/data/build_catalogue.py new file mode 100644 index 00000000..89ffe33b --- /dev/null +++ b/sites/versus/data/build_catalogue.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""Build the Versus catalogue from citable sources, with provenance per field. + +Every rendered number that claims to be a real-world fact -- price, release year, +weight, battery life, display size, megapixels, burst speed, VRAM, power draw -- +is extracted from a Wikipedia revision and stored next to the revision id and the +raw text it came from. Nothing is filled in from memory: a candidate whose fields +cannot be extracted is not added to the catalogue. + +The scores (Versus Score, camera/ANC/fitness score, benchmark points) are NOT +sourced. They are synthetic benchmark values and are marked as such in the +output, the same way NOTICE.md and /about describe them. + +Usage: build_catalogue.py > data/catalogue.json +""" +import json +import re +import subprocess +import sys +import urllib.parse + +UA = "WebHarbor-review/1.0 (research benchmark; jackjin1997@gmail.com)" +API = "https://en.wikipedia.org/w/api.php" + + +def revision(title): + q = urllib.parse.urlencode({"action": "query", "format": "json", "prop": "revisions", + "rvprop": "content|ids", "rvslots": "main", + "titles": title, "redirects": 1}) + r = subprocess.run(["curl", "-sL", "--max-time", "30", "-A", UA, f"{API}?{q}"], + capture_output=True, text=True) + try: + page = list(json.loads(r.stdout)["query"]["pages"].values())[0] + rev = page["revisions"][0] + return rev["slots"]["main"]["*"], rev["revid"], page["title"] + except Exception: + return None, None, None + + +def raw_field(text, *names): + for name in names: + m = re.search(rf"^\s*\|\s*{name}\s*=\s*(.+)$", text, re.I | re.M) + if m: + return re.sub(r"]*>.*?||<[^>]+>|\[\[|\]\]|'''", " ", + m.group(1)).strip() + return None + + +def first_number(s, pattern=r"(\d[\d,]*\.?\d*)"): + if not s: + return None + m = re.search(pattern, s.replace(",", "")) + return float(m.group(1)) if m else None + + +def usd(s): + """US list price. Explicitly ignores other currencies rather than converting.""" + if not s: + return None + m = re.search(r"US\$\s?(\d[\d,]*\.?\d*)|USD\s?(\d[\d,]*\.?\d*)|\$(\d[\d,]*\.?\d*)", s) + if not m: + return None + return round(float(next(g for g in m.groups() if g).replace(",", ""))) + + +def year(s): + m = re.search(r"\b(20\d{2})\b", s or "") + return int(m.group(1)) if m else None + + +def grams(s): + if not s: + return None + m = re.search(r"(\d[\d,]*\.?\d*)\s*(?:g\b|grams?)", s, re.I) + return float(m.group(1).replace(",", "")) if m else None + + +def extract(title, wants): + text, rev, resolved = revision(title) + if not text: + return None + src = f"https://en.wikipedia.org/w/index.php?oldid={rev}" + out = {"wikipedia_title": resolved, "revision_id": rev, "source_url": src, "fields": {}} + for key, (names, parse) in wants.items(): + raw = raw_field(text, *names) + value = parse(raw) if raw else None + if value is None: + return None # a missing field disqualifies the candidate + out["fields"][key] = {"value": value, "raw": raw[:160], "source_url": src} + return out + + +if __name__ == "__main__": + print(json.dumps({"note": "run with a candidate list; see probe_candidates.py"}, indent=2)) diff --git a/sites/versus/data/catalogue_wikidata.json b/sites/versus/data/catalogue_wikidata.json new file mode 100644 index 00000000..77d619f9 --- /dev/null +++ b/sites/versus/data/catalogue_wikidata.json @@ -0,0 +1,1448 @@ +{ + "source": "Wikidata via query.wikidata.org, labels confirmed per entity", + "note": "Consumer-electronics categories are not sourced here: a SPARQL count of digital cameras holding both mass and release date returns zero, and the manufacturer pages that do carry those specs are half unreachable.", + "cities": [ + { + "qid": "Q8686", + "name": "Shanghai", + "wikidata_url": "https://www.wikidata.org/wiki/Q8686", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "24870895", + "property": "P1082" + }, + "area": { + "value": "6341", + "property": "P2046" + } + } + }, + { + "qid": "Q956", + "name": "Beijing", + "wikidata_url": "https://www.wikidata.org/wiki/Q956", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "21893095", + "property": "P1082" + }, + "area": { + "value": "16410.54", + "property": "P2046" + } + } + }, + { + "qid": "Q16572", + "name": "Guangzhou", + "wikidata_url": "https://www.wikidata.org/wiki/Q16572", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "18676605", + "property": "P1082" + }, + "area": { + "value": "7248.86", + "property": "P2046" + } + } + }, + { + "qid": "Q15174", + "name": "Shenzhen", + "wikidata_url": "https://www.wikidata.org/wiki/Q15174", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "17494398", + "property": "P1082" + }, + "area": { + "value": "1997.27", + "property": "P2046" + } + } + }, + { + "qid": "Q406", + "name": "Istanbul", + "wikidata_url": "https://www.wikidata.org/wiki/Q406", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "15655924", + "property": "P1082" + }, + "area": { + "value": "5343", + "property": "P2046" + } + } + }, + { + "qid": "Q8673", + "name": "Lagos", + "wikidata_url": "https://www.wikidata.org/wiki/Q8673", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "15070000", + "property": "P1082" + }, + "area": { + "value": "1171.28", + "property": "P2046" + } + } + }, + { + "qid": "Q8660", + "name": "Karachi", + "wikidata_url": "https://www.wikidata.org/wiki/Q8660", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "14910352", + "property": "P1082" + }, + "area": { + "value": "3527", + "property": "P2046" + } + } + }, + { + "qid": "Q1490", + "name": "Tokyo", + "wikidata_url": "https://www.wikidata.org/wiki/Q1490", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "14264798", + "property": "P1082" + }, + "area": { + "value": "2194.05", + "property": "P2046" + } + } + }, + { + "qid": "Q1854", + "name": "Ho Chi Minh City", + "wikidata_url": "https://www.wikidata.org/wiki/Q1854", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "14002598", + "property": "P1082" + }, + "area": { + "value": "6772.59", + "property": "P2046" + } + } + }, + { + "qid": "Q11736", + "name": "Tianjin", + "wikidata_url": "https://www.wikidata.org/wiki/Q11736", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "13866009", + "property": "P1082" + }, + "area": { + "value": "11920", + "property": "P2046" + } + } + }, + { + "qid": "Q649", + "name": "Moscow", + "wikidata_url": "https://www.wikidata.org/wiki/Q649", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "13274285", + "property": "P1082" + }, + "area": { + "value": "2562", + "property": "P2046" + } + } + }, + { + "qid": "Q5826", + "name": "Xi'an", + "wikidata_url": "https://www.wikidata.org/wiki/Q5826", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "12952907", + "property": "P1082" + }, + "area": { + "value": "10096.81", + "property": "P2046" + } + } + }, + { + "qid": "Q42622", + "name": "Suzhou", + "wikidata_url": "https://www.wikidata.org/wiki/Q42622", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "12748262", + "property": "P1082" + }, + "area": { + "value": "8657.32", + "property": "P2046" + } + } + }, + { + "qid": "Q30340", + "name": "Zhengzhou", + "wikidata_url": "https://www.wikidata.org/wiki/Q30340", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "12600574", + "property": "P1082" + }, + "area": { + "value": "7567.18", + "property": "P2046" + } + } + }, + { + "qid": "Q11746", + "name": "Wuhan", + "wikidata_url": "https://www.wikidata.org/wiki/Q11746", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "12326518", + "property": "P1082" + }, + "area": { + "value": "8569.15", + "property": "P2046" + } + } + }, + { + "qid": "Q4970", + "name": "Hangzhou", + "wikidata_url": "https://www.wikidata.org/wiki/Q4970", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "11936010", + "property": "P1082" + }, + "area": { + "value": "16853.57", + "property": "P2046" + } + } + }, + { + "qid": "Q58584", + "name": "Baoding", + "wikidata_url": "https://www.wikidata.org/wiki/Q58584", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "11544036", + "property": "P1082" + }, + "area": { + "value": "22184.95", + "property": "P2046" + } + } + }, + { + "qid": "Q174", + "name": "S\u00e3o Paulo", + "wikidata_url": "https://www.wikidata.org/wiki/Q174", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "11451999", + "property": "P1082" + }, + "area": { + "value": "1523", + "property": "P2046" + } + } + }, + { + "qid": "Q11739", + "name": "Lahore", + "wikidata_url": "https://www.wikidata.org/wiki/Q11739", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "11126285", + "property": "P1082" + }, + "area": { + "value": "1772", + "property": "P2046" + } + } + }, + { + "qid": "Q58401", + "name": "Shijiazhuang", + "wikidata_url": "https://www.wikidata.org/wiki/Q58401", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "10640458", + "property": "P1082" + }, + "area": { + "value": "14060.14", + "property": "P2046" + } + } + }, + { + "qid": "Q59218", + "name": "Dongguan", + "wikidata_url": "https://www.wikidata.org/wiki/Q59218", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "10466625", + "property": "P1082" + }, + "area": { + "value": "2460.08", + "property": "P2046" + } + } + }, + { + "qid": "Q170322", + "name": "Qingdao", + "wikidata_url": "https://www.wikidata.org/wiki/Q170322", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "10071722", + "property": "P1082" + }, + "area": { + "value": "11282", + "property": "P2046" + } + } + }, + { + "qid": "Q174091", + "name": "Changsha", + "wikidata_url": "https://www.wikidata.org/wiki/Q174091", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "10047914", + "property": "P1082" + }, + "area": { + "value": "11815.96", + "property": "P2046" + } + } + }, + { + "qid": "Q2868", + "name": "Lima", + "wikidata_url": "https://www.wikidata.org/wiki/Q2868", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "9943800", + "property": "P1082" + }, + "area": { + "value": "2672.28", + "property": "P2046" + } + } + }, + { + "qid": "Q85", + "name": "Cairo", + "wikidata_url": "https://www.wikidata.org/wiki/Q85", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "9801536", + "property": "P1082" + }, + "area": { + "value": "528", + "property": "P2046" + } + } + }, + { + "qid": "Q404763", + "name": "Nanyang", + "wikidata_url": "https://www.wikidata.org/wiki/Q404763", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "9713112", + "property": "P1082" + }, + "area": { + "value": "26511.48", + "property": "P2046" + } + } + }, + { + "qid": "Q42635", + "name": "Wenzhou", + "wikidata_url": "https://www.wikidata.org/wiki/Q42635", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "9572903", + "property": "P1082" + }, + "area": { + "value": "12064.77", + "property": "P2046" + } + } + }, + { + "qid": "Q34412", + "name": "Foshan", + "wikidata_url": "https://www.wikidata.org/wiki/Q34412", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "9498863", + "property": "P1082" + }, + "area": { + "value": "3797.72", + "property": "P2046" + } + } + }, + { + "qid": "Q42780", + "name": "Ningbo", + "wikidata_url": "https://www.wikidata.org/wiki/Q42780", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "9404283", + "property": "P1082" + }, + "area": { + "value": "9365.58", + "property": "P2046" + } + } + }, + { + "qid": "Q217698", + "name": "Weifang", + "wikidata_url": "https://www.wikidata.org/wiki/Q217698", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "9386705", + "property": "P1082" + }, + "area": { + "value": "16143.14", + "property": "P2046" + } + } + }, + { + "qid": "Q185684", + "name": "Hefei", + "wikidata_url": "https://www.wikidata.org/wiki/Q185684", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "9369881", + "property": "P1082" + }, + "area": { + "value": "11445.06", + "property": "P2046" + } + } + }, + { + "qid": "Q16666", + "name": "Nanjing", + "wikidata_url": "https://www.wikidata.org/wiki/Q16666", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "9314685", + "property": "P1082" + }, + "area": { + "value": "6587.02", + "property": "P2046" + } + } + }, + { + "qid": "Q1489", + "name": "Mexico City", + "wikidata_url": "https://www.wikidata.org/wiki/Q1489", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "9209944", + "property": "P1082" + }, + "area": { + "value": "1485", + "property": "P2046" + } + } + }, + { + "qid": "Q170247", + "name": "Jinan", + "wikidata_url": "https://www.wikidata.org/wiki/Q170247", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "9202432", + "property": "P1082" + }, + "area": { + "value": "10244.45", + "property": "P2046" + } + } + }, + { + "qid": "Q57719", + "name": "Xuzhou", + "wikidata_url": "https://www.wikidata.org/wiki/Q57719", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "9083790", + "property": "P1082" + }, + "area": { + "value": "11764.88", + "property": "P2046" + } + } + }, + { + "qid": "Q11720", + "name": "Shenyang", + "wikidata_url": "https://www.wikidata.org/wiki/Q11720", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "9070093", + "property": "P1082" + }, + "area": { + "value": "12859.89", + "property": "P2046" + } + } + }, + { + "qid": "Q92161", + "name": "Changchun", + "wikidata_url": "https://www.wikidata.org/wiki/Q92161", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "9066906", + "property": "P1082" + }, + "area": { + "value": "24734.13", + "property": "P2046" + } + } + }, + { + "qid": "Q1865", + "name": "Kuala Lumpur", + "wikidata_url": "https://www.wikidata.org/wiki/Q1865", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "9000280", + "property": "P1082" + }, + "area": { + "value": "243.65", + "property": "P2046" + } + } + }, + { + "qid": "Q363166", + "name": "Ganzhou", + "wikidata_url": "https://www.wikidata.org/wiki/Q363166", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "8970014", + "property": "P1082" + }, + "area": { + "value": "39362.96", + "property": "P2046" + } + } + }, + { + "qid": "Q68695", + "name": "Quanzhou", + "wikidata_url": "https://www.wikidata.org/wiki/Q68695", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "8782285", + "property": "P1082" + }, + "area": { + "value": "11286.59", + "property": "P2046" + } + } + }, + { + "qid": "Q179608", + "name": "Nanning", + "wikidata_url": "https://www.wikidata.org/wiki/Q179608", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "8741584", + "property": "P1082" + }, + "area": { + "value": "22099.31", + "property": "P2046" + } + } + }, + { + "qid": "Q1858", + "name": "Hanoi", + "wikidata_url": "https://www.wikidata.org/wiki/Q1858", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "8717600", + "property": "P1082" + }, + "area": { + "value": "3359.84", + "property": "P2046" + } + } + }, + { + "qid": "Q182852", + "name": "Kunming", + "wikidata_url": "https://www.wikidata.org/wiki/Q182852", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "8460088", + "property": "P1082" + }, + "area": { + "value": "21013", + "property": "P2046" + } + } + }, + { + "qid": "Q372791", + "name": "Jining", + "wikidata_url": "https://www.wikidata.org/wiki/Q372791", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "8357897", + "property": "P1082" + }, + "area": { + "value": "11186.98", + "property": "P2046" + } + } + }, + { + "qid": "Q68481", + "name": "Fuzhou", + "wikidata_url": "https://www.wikidata.org/wiki/Q68481", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "8291268", + "property": "P1082" + }, + "area": { + "value": "12250.72", + "property": "P2046" + } + } + }, + { + "qid": "Q360584", + "name": "Fuyang", + "wikidata_url": "https://www.wikidata.org/wiki/Q360584", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "8200264", + "property": "P1082" + }, + "area": { + "value": "10118.17", + "property": "P2046" + } + } + }, + { + "qid": "Q1530", + "name": "Baghdad", + "wikidata_url": "https://www.wikidata.org/wiki/Q1530", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "8126755", + "property": "P1082" + }, + "area": { + "value": "673", + "property": "P2046" + } + } + }, + { + "qid": "Q173270", + "name": "Veracruz", + "wikidata_url": "https://www.wikidata.org/wiki/Q173270", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "8062579", + "property": "P1082" + }, + "area": { + "value": "78.815", + "property": "P2046" + } + } + }, + { + "qid": "Q2841", + "name": "Bogot\u00e1", + "wikidata_url": "https://www.wikidata.org/wiki/Q2841", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "8034649", + "property": "P1082" + }, + "area": { + "value": "1578", + "property": "P2046" + } + } + }, + { + "qid": "Q404817", + "name": "Shangqiu", + "wikidata_url": "https://www.wikidata.org/wiki/Q404817", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "7816831", + "property": "P1082" + }, + "area": { + "value": "10703.55", + "property": "P2046" + } + } + }, + { + "qid": "Q57947", + "name": "Nantong", + "wikidata_url": "https://www.wikidata.org/wiki/Q57947", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "7726635", + "property": "P1082" + }, + "area": { + "value": "10549.25", + "property": "P2046" + } + } + }, + { + "qid": "Q58422", + "name": "Tangshan", + "wikidata_url": "https://www.wikidata.org/wiki/Q58422", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "7717983", + "property": "P1082" + }, + "area": { + "value": "14341.47", + "property": "P2046" + } + } + }, + { + "qid": "Q1070", + "name": "Ahmedabad", + "wikidata_url": "https://www.wikidata.org/wiki/Q1070", + "fetched_at": "2026-09-13T16:14:23Z", + "fields": { + "pop": { + "value": "7645000", + "property": "P1082" + }, + "area": { + "value": "464.165", + "property": "P2046" + } + } + } + ], + "universities": [ + { + "qid": "Q1424632", + "name": "Alexandria University", + "wikidata_url": "https://www.wikidata.org/wiki/Q1424632", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "197278", + "property": "P2196" + }, + "inception": { + "value": "1942-01-01T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q1570489", + "name": "National University of C\u00f3rdoba", + "wikidata_url": "https://www.wikidata.org/wiki/Q1570489", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "178288", + "property": "P2196" + }, + "inception": { + "value": "1613-01-01T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q7987876", + "name": "Western Governors University", + "wikidata_url": "https://www.wikidata.org/wiki/Q7987876", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "147866", + "property": "P2196" + }, + "inception": { + "value": "1997-01-01T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q797514", + "name": "Homs University", + "wikidata_url": "https://www.wikidata.org/wiki/Q797514", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "141233", + "property": "P2196" + }, + "inception": { + "value": "1979-01-01T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q1351317", + "name": "Damascus University", + "wikidata_url": "https://www.wikidata.org/wiki/Q1351317", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "137980", + "property": "P2196" + }, + "inception": { + "value": "1923-01-01T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q670897", + "name": "Arizona State University", + "wikidata_url": "https://www.wikidata.org/wiki/Q670897", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "135729", + "property": "P2196" + }, + "inception": { + "value": "1885-01-01T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q3445256", + "name": "Southern New Hampshire University", + "wikidata_url": "https://www.wikidata.org/wiki/Q3445256", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "134345", + "property": "P2196" + }, + "inception": { + "value": "1932-01-01T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q784171", + "name": "National University of La Plata", + "wikidata_url": "https://www.wikidata.org/wiki/Q784171", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "132921", + "property": "P2196" + }, + "inception": { + "value": "1897-04-18T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q20669873", + "name": "University of Toulouse", + "wikidata_url": "https://www.wikidata.org/wiki/Q20669873", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "130000", + "property": "P2196" + }, + "inception": { + "value": "2015-01-01T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q10176", + "name": "University of Lyon", + "wikidata_url": "https://www.wikidata.org/wiki/Q10176", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "120000", + "property": "P2196" + }, + "inception": { + "value": "1809-01-01T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q1322289", + "name": "University of Lille", + "wikidata_url": "https://www.wikidata.org/wiki/Q1322289", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "115000", + "property": "P2196" + }, + "inception": { + "value": "2009-01-01T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q3232206", + "name": "National Technological University", + "wikidata_url": "https://www.wikidata.org/wiki/Q3232206", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "111833", + "property": "P2196" + }, + "inception": { + "value": "1953-01-01T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q5255905", + "name": "National University of Rosario", + "wikidata_url": "https://www.wikidata.org/wiki/Q5255905", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "103598", + "property": "P2196" + }, + "inception": { + "value": "1968-11-29T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q1364550", + "name": "Capital University (Egypt)", + "wikidata_url": "https://www.wikidata.org/wiki/Q1364550", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "103305", + "property": "P2196" + }, + "inception": { + "value": "1975-01-01T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q180865", + "name": "University of Toronto", + "wikidata_url": "https://www.wikidata.org/wiki/Q180865", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "102431", + "property": "P2196" + }, + "inception": { + "value": "1827-03-15T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q1190852", + "name": "University of Algiers 1", + "wikidata_url": "https://www.wikidata.org/wiki/Q1190852", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "100827", + "property": "P2196" + }, + "inception": { + "value": "1909-01-01T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q209344", + "name": "Sapienza University of Rome", + "wikidata_url": "https://www.wikidata.org/wiki/Q209344", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "100155", + "property": "P2196" + }, + "inception": { + "value": "1303-01-01T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q835960", + "name": "University of S\u00e3o Paulo", + "wikidata_url": "https://www.wikidata.org/wiki/Q835960", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "97964", + "property": "P2196" + }, + "inception": { + "value": "1934-01-01T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q131262", + "name": "University of Bologna", + "wikidata_url": "https://www.wikidata.org/wiki/Q131262", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "96945", + "property": "P2196" + }, + "inception": { + "value": "1088-01-01T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q667568", + "name": "Aristotle University of Thessaloniki", + "wikidata_url": "https://www.wikidata.org/wiki/Q667568", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "95000", + "property": "P2196" + }, + "inception": { + "value": "1925-01-01T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q165980", + "name": "University of Vienna", + "wikidata_url": "https://www.wikidata.org/wiki/Q165980", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "93628", + "property": "P2196" + }, + "inception": { + "value": "1365-03-20T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q219694", + "name": "Complutense University of Madrid", + "wikidata_url": "https://www.wikidata.org/wiki/Q219694", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "91598", + "property": "P2196" + }, + "inception": { + "value": "1970-01-01T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q849611", + "name": "Lagos State University", + "wikidata_url": "https://www.wikidata.org/wiki/Q849611", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "90885", + "property": "P2196" + }, + "inception": { + "value": "1983-01-01T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q1232180", + "name": "University of Granada", + "wikidata_url": "https://www.wikidata.org/wiki/Q1232180", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "88000", + "property": "P2196" + }, + "inception": { + "value": "1531-01-01T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q3042433", + "name": "Open University of Catalonia", + "wikidata_url": "https://www.wikidata.org/wiki/Q3042433", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "87000", + "property": "P2196" + }, + "inception": { + "value": "1995-06-22T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q1654025", + "name": "Kwame Nkrumah University of Science and Technology", + "wikidata_url": "https://www.wikidata.org/wiki/Q1654025", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "85000", + "property": "P2196" + }, + "inception": { + "value": "1952-01-01T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q1343885", + "name": "Monterrey Institute of Technology and Higher Education", + "wikidata_url": "https://www.wikidata.org/wiki/Q1343885", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "83137", + "property": "P2196" + }, + "inception": { + "value": "1943-09-06T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q457281", + "name": "University of Illinois Urbana-Champaign", + "wikidata_url": "https://www.wikidata.org/wiki/Q457281", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "83119", + "property": "P2196" + }, + "inception": { + "value": "1867-01-01T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q6979284", + "name": "National University of Tucum\u00e1n", + "wikidata_url": "https://www.wikidata.org/wiki/Q6979284", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "83007", + "property": "P2196" + }, + "inception": { + "value": "1914-05-25T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q1667281", + "name": "IU International University of Applied Sciences", + "wikidata_url": "https://www.wikidata.org/wiki/Q1667281", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "82000", + "property": "P2196" + }, + "inception": { + "value": "1998-01-01T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q3350895", + "name": "Netaji Subhas Open University", + "wikidata_url": "https://www.wikidata.org/wiki/Q3350895", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "81175", + "property": "P2196" + }, + "inception": { + "value": "1997-08-20T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q975461", + "name": "Lebanese University", + "wikidata_url": "https://www.wikidata.org/wiki/Q975461", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "81000", + "property": "P2196" + }, + "inception": { + "value": "1951-01-01T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q3551621", + "name": "University of Lille", + "wikidata_url": "https://www.wikidata.org/wiki/Q3551621", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "80000", + "property": "P2196" + }, + "inception": { + "value": "1559-01-01T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q135914996", + "name": "University of Continuing Education", + "wikidata_url": "https://www.wikidata.org/wiki/Q135914996", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "77500", + "property": "P2196" + }, + "inception": { + "value": "1990-01-01T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q319078", + "name": "University of Melbourne", + "wikidata_url": "https://www.wikidata.org/wiki/Q319078", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "77162", + "property": "P2196" + }, + "inception": { + "value": "1853-01-01T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q1816069", + "name": "University of Benin", + "wikidata_url": "https://www.wikidata.org/wiki/Q1816069", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "77000", + "property": "P2196" + }, + "inception": { + "value": "1970-01-01T00:00:00Z", + "property": "P571" + } + } + }, + { + "qid": "Q4570025", + "name": "Grand Canyon University", + "wikidata_url": "https://www.wikidata.org/wiki/Q4570025", + "fetched_at": "2026-09-13T16:17:10Z", + "fields": { + "students": { + "value": "75200", + "property": "P2196" + }, + "inception": { + "value": "1949-01-01T00:00:00Z", + "property": "P571" + } + } + } + ] +} diff --git a/sites/versus/data/fetch_wikidata.py b/sites/versus/data/fetch_wikidata.py new file mode 100644 index 00000000..75815da1 --- /dev/null +++ b/sites/versus/data/fetch_wikidata.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +"""Fetch the Cities and Universities catalogues from Wikidata, with provenance. + +Every rendered figure is a Wikidata claim, recorded with the item's Q-id, the +property id, the raw value, its unit, and the date it was fetched. Labels are +confirmed by reading each entity directly rather than trusting the SPARQL label +service, which was observed attaching the wrong label to an item. + +Consumer-electronics categories are NOT fetched here: no free citable source +carries their specs at scale. A SPARQL count of digital cameras holding both +mass and release date returns zero. + +Usage: fetch_wikidata.py > data/catalogue_wikidata.json +""" +import json +import subprocess +import sys +import time + +UA = "WebHarbor-review/1.0 (research benchmark; jackjin1997@gmail.com)" +SPARQL = "https://query.wikidata.org/sparql" +ENTITY = "https://www.wikidata.org/wiki/Special:EntityData/{}.json" + +CITIES = """SELECT ?item ?pop ?area WHERE { + ?item wdt:P31 wd:Q1549591 ; wdt:P1082 ?pop . + ?item p:P2046/psv:P2046 [ wikibase:quantityAmount ?area ; wikibase:quantityUnit wd:Q712226 ] . + FILTER(?pop > 500000 && ?area > 30) +} ORDER BY DESC(?pop) LIMIT 60""" + +UNIVERSITIES = """SELECT ?item ?students ?inception WHERE { + ?item wdt:P31/wdt:P279* wd:Q3918 ; wdt:P2196 ?students ; wdt:P571 ?inception . + FILTER(?students > 20000 && ?students < 200000) +} ORDER BY DESC(?students) LIMIT 60""" + +PROPERTY = {"pop": "P1082", "area": "P2046", "students": "P2196", "inception": "P571"} + + +def sparql(query): + r = subprocess.run(["curl", "-sG", "--max-time", "90", "-A", UA, + "-H", "Accept: application/sparql-results+json", + "--data-urlencode", f"query={query}", SPARQL], + capture_output=True, text=True) + try: + return json.loads(r.stdout)["results"]["bindings"] + except Exception: + return [] + + +def entity_label(qid): + """Read the label off the entity itself; the SPARQL label service mislabels.""" + r = subprocess.run(["curl", "-sL", "--max-time", "30", "-A", UA, ENTITY.format(qid)], + capture_output=True, text=True) + try: + ent = json.loads(r.stdout)["entities"][qid] + return (ent["labels"].get("en") or {}).get("value") + except Exception: + return None + + +def collect(name, query, fields): + fetched = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + out, seen = [], set() + for row in sparql(query): + qid = row["item"]["value"].rsplit("/", 1)[-1] + if qid in seen: + continue + seen.add(qid) + label = entity_label(qid) + if not label: + continue + rec = {"qid": qid, "name": label, + "wikidata_url": f"https://www.wikidata.org/wiki/{qid}", + "fetched_at": fetched, "fields": {}} + ok = True + for f in fields: + if f not in row: + ok = False + break + rec["fields"][f] = {"value": row[f]["value"], "property": PROPERTY[f]} + if ok: + out.append(rec) + time.sleep(0.15) + print(f"{name}: {len(out)}", file=sys.stderr) + return out + + +if __name__ == "__main__": + data = { + "source": "Wikidata via query.wikidata.org, labels confirmed per entity", + "note": ("Consumer-electronics categories are not sourced here: a SPARQL count of " + "digital cameras holding both mass and release date returns zero, and the " + "manufacturer pages that do carry those specs are half unreachable."), + "cities": collect("cities", CITIES, ("pop", "area")), + "universities": collect("universities", UNIVERSITIES, ("students", "inception")), + } + print(json.dumps(data, indent=1)) diff --git a/sites/versus/data/probe_candidates.py b/sites/versus/data/probe_candidates.py new file mode 100644 index 00000000..cecd79c1 --- /dev/null +++ b/sites/versus/data/probe_candidates.py @@ -0,0 +1,66 @@ +"""Screen candidate products by whether their specs are actually sourceable. + +The catalogue is source-driven: a candidate that does not yield the fields this +site renders, from a citable page, is not added. Nothing is filled in from +memory. +""" +import json, re, subprocess, sys, urllib.parse + +UA = "WebHarbor-review/1.0 (research benchmark; jackjin1997@gmail.com)" +API = "https://en.wikipedia.org/w/api.php" + +CANDIDATES = { + "smartphones": ["Google Pixel 9 Pro","Samsung Galaxy S23 Ultra","iPhone 14 Pro","OnePlus 11", + "Xiaomi 14 Ultra","Google Pixel 7 Pro","iPhone 15","Samsung Galaxy S24","Asus Zenfone 10", + "Sony Xperia 1 V","Nothing Phone (2)","Motorola Edge 40 Pro"], + "cameras": ["Sony α7R V","Canon EOS R5","Nikon Z9","Fujifilm X-H2","Sony α6700","Canon EOS R8", + "Nikon Z6III","Panasonic Lumix DC-S5II","Olympus OM-1","Leica Q3","Fujifilm X-S20","Sony α7C II"], + "graphics-cards": ["GeForce RTX 4090","GeForce RTX 4070 Ti","GeForce RTX 4060 Ti","GeForce RTX 3080", + "Radeon RX 7600","Radeon RX 6800 XT","GeForce RTX 4060","Radeon RX 7900 GRE"], + "smartwatches": ["Apple Watch Ultra 2","Apple Watch Series 8","Samsung Galaxy Watch 5", + "Google Pixel Watch 2","Garmin Forerunner 965","Garmin Fenix 7","Withings ScanWatch","Amazfit GTR 4"], + "headphones": ["Sony WH-1000XM4","Bose QuietComfort 45","Sennheiser HD 660S","AirPods Pro", + "Beats Studio Pro","Bowers & Wilkins Px8","Shure Aonic 50","Audio-Technica ATH-M50x"], +} + +NEEDED = { + "smartphones": ("weight","battery","released"), + "cameras": ("res","weight","price"), + "graphics-cards": (), # sourced from the list articles instead + "smartwatches": ("weight","released"), + "headphones": ("weight",), +} + +def fetch(title): + q = urllib.parse.urlencode({"action":"query","format":"json","prop":"revisions", + "rvprop":"content|ids","rvslots":"main","titles":title,"redirects":1}) + r = subprocess.run(["curl","-sL","--max-time","25","-A",UA,f"{API}?{q}"], + capture_output=True, text=True) + try: + p = list(json.loads(r.stdout)["query"]["pages"].values())[0] + rev = p["revisions"][0] + return rev["slots"]["main"]["*"], rev["revid"], p["title"] + except Exception: + return None, None, None + +def field(text, name): + m = re.search(rf"\|\s*{name}\s*=\s*(.+)", text, re.I) + return re.sub(r"<[^>]+>|\[\[|\]\]|'''", " ", m.group(1)).strip()[:100] if m else None + +report = {} +for cat, names in CANDIDATES.items(): + need = NEEDED[cat] + usable = [] + print(f"\n{cat} (needs: {', '.join(need) or 'list-article row'})") + for n in names: + text, rev, resolved = fetch(n) + if not text: + print(f" -- {n[:30]:32} no article"); continue + got = {k: field(text, k) for k in need} + ok = all(got.get(k) for k in need) + usable.append({"name": n, "title": resolved, "revid": rev, "fields": got}) if ok else None + print(f" {'OK ' if ok else '-- '} {n[:30]:32} " + + ", ".join(f"{k}={'y' if got.get(k) else 'n'}" for k in need)) + report[cat] = usable + print(f" => {len(usable)}/{len(names)} usable") +json.dump(report, open("/tmp/candidate_report.json","w"), indent=1) diff --git a/sites/versus/generate_art.py b/sites/versus/generate_art.py index ee0162a1..d072479d 100644 --- a/sites/versus/generate_art.py +++ b/sites/versus/generate_art.py @@ -38,6 +38,8 @@ "cameras": (56, 189, 248), "graphics-cards": (34, 197, 94), "smartwatches": (251, 146, 60), + "cities": (96, 165, 250), + "universities": (250, 204, 21), } DEFAULT_ACCENT = (124, 92, 255) @@ -48,7 +50,9 @@ def _mix(a, b, t): def _initials(brand, name): """Two letters at most, taken from the brand, falling back to the name.""" - source = (brand or name or "?").strip() + source = (brand or "").strip() + if source in ("", "-", "—"): + source = (name or "?").strip() parts = [p for p in source.replace("-", " ").split() if p] if not parts: return "?" @@ -67,6 +71,9 @@ def _device_box(category): "cameras": (cx - 104, cy - 62, cx + 104, cy + 62, 16), "graphics-cards": (cx - 122, cy - 46, cx + 122, cy + 46, 10), "smartwatches": (cx - 54, cy - 62, cx + 54, cy + 62, 22), + # Not devices: a skyline block and a pediment stand in for the entity. + "cities": (cx - 116, cy - 40, cx + 116, cy + 70, 6), + "universities": (cx - 100, cy - 54, cx + 100, cy + 62, 8), } return shapes.get(category, (cx - 90, cy - 70, cx + 90, cy + 70, 16)) @@ -97,6 +104,16 @@ def draw_tile(slug, name, brand, category): cx = x0 + 44 + i * 72 d.ellipse((cx - r, (y0 + y1) // 2 - r, cx + r, (y0 + y1) // 2 + r), outline=_mix(accent, INK, 0.35), width=2) + elif category == "cities": + for i, h in enumerate((70, 104, 52, 88, 60)): + x = x0 + 14 + i * 44 + d.rectangle((x, y1 - h, x + 32, y1 - 4), outline=_mix(accent, INK, 0.4), width=2) + elif category == "universities": + d.polygon([(x0 + 6, y0 + 6), (x1 - 6, y0 + 6), ((x0 + x1) // 2, y0 - 26)], + outline=_mix(accent, INK, 0.45)) + for i in range(4): + x = x0 + 30 + i * 48 + d.line([(x, y0 + 14), (x, y1 - 10)], fill=_mix(accent, INK, 0.35), width=3) elif category == "headphones": d.arc((x0 + 16, y0 + 10, x1 - 16, y1 - 10), start=200, end=340, fill=_mix(accent, INK, 0.45), width=6) diff --git a/sites/versus/generated_asset_inventory.json b/sites/versus/generated_asset_inventory.json index fe8183ad..7746eea8 100644 --- a/sites/versus/generated_asset_inventory.json +++ b/sites/versus/generated_asset_inventory.json @@ -3,6 +3,16 @@ "generator": "sites/versus/generate_art.py", "toolchain": "Pillow 11.0.0, Python 3.12, bundled default font", "assets": [ + { + "path": "static/images/products/ahmedabad.png", + "bytes": 6192, + "sha256": "eb8d5c87656f374a4d3c39ae024bf76f0a2b0089beecf48d5d5416252a351cbd" + }, + { + "path": "static/images/products/alexandria-university.png", + "bytes": 7989, + "sha256": "bdd5f62aba100aa636fffd9de4e939135d858d25b6459a2234e920104247f325" + }, { "path": "static/images/products/apple-airpods-max.png", "bytes": 8246, @@ -13,26 +23,111 @@ "bytes": 8335, "sha256": "266363dcd4ef387e8bef9ab949eede9f45439d9331e5a549a111b99e3fd8943e" }, + { + "path": "static/images/products/aristotle-university-of-thessaloniki.png", + "bytes": 8241, + "sha256": "ce96ddafbd19d418ede6258fdfc4e8bb0e3d825503e8ebb767609bfa87b70865" + }, + { + "path": "static/images/products/arizona-state-university.png", + "bytes": 9164, + "sha256": "2fb53380e48c3292c39088676ae1cee401add79871f1bfad9d75dfc860e89c8b" + }, + { + "path": "static/images/products/baghdad.png", + "bytes": 6481, + "sha256": "20dc1c2ebd353c01f947e07c9668751a2ed1281a04724b9bb0d5e96db3ccae59" + }, + { + "path": "static/images/products/baoding.png", + "bytes": 6730, + "sha256": "f0caf783bd4b0d3c915392867f5fd246056dd1f7d17eea7071d5b321b8befaf7" + }, + { + "path": "static/images/products/beijing.png", + "bytes": 5461, + "sha256": "8adc2f95a12ef43914d5ad49c8f7e37859cbe0626f3a4c5356d41cee848f989a" + }, + { + "path": "static/images/products/bogot.png", + "bytes": 6974, + "sha256": "a717d46bae838f32b784d60037ee57bc54dae23771d2ccd1c484a06974d7c3c3" + }, { "path": "static/images/products/bose-quietcomfort-ultra.png", "bytes": 9633, "sha256": "e8bbca52f009bd304deb03866e3e983378f2e14c6df2be127f096825fe726d26" }, + { + "path": "static/images/products/cairo.png", + "bytes": 6686, + "sha256": "c5e0d4be76c4111436930bb7436919c3dd1b5ba040fcc4e38b943266ddad53fa" + }, { "path": "static/images/products/canon-eos-r6-mark-ii.png", "bytes": 9239, "sha256": "3c0b790e62ddc31c0bd20567eed06d28d6411fbcf3ac87591007bcb94443bbd5" }, + { + "path": "static/images/products/capital-university-egypt.png", + "bytes": 8874, + "sha256": "d1cee31b91ba449cad73bca58c0ab9171ccfdd1c3d8e6a4820db3988e49b6538" + }, + { + "path": "static/images/products/changchun.png", + "bytes": 6344, + "sha256": "b61a31fe3922d70991049fd29e8605a1cc03595b1f660cfb3b7a42e4b209ff8f" + }, + { + "path": "static/images/products/changsha.png", + "bytes": 6263, + "sha256": "e3833cde63242e1c5a53bb5921ff1cd3341d9978c3dfb8f121f531fdafbd9dc3" + }, + { + "path": "static/images/products/complutense-university-of-madrid.png", + "bytes": 9284, + "sha256": "08497e92dcc6d9b900ecf4bd74800e08e32dc6526b07ff16a7320a22aa332c6a" + }, + { + "path": "static/images/products/damascus-university.png", + "bytes": 7711, + "sha256": "05428ba1105a8b51f5f2754a13f177faf1032d627a28deec18ae3ace6e27aaa6" + }, + { + "path": "static/images/products/dongguan.png", + "bytes": 7055, + "sha256": "0893c56850e4f686755ab9b74bfe650d8ec2faccbf72f63cf39162fe12ccc6f9" + }, { "path": "static/images/products/fitbit-sense-2.png", "bytes": 5359, "sha256": "a2050f6157bb58d3f72977b92a8e7e6eb83e1eaa08a2ff33c01dff52b2325166" }, + { + "path": "static/images/products/foshan.png", + "bytes": 6047, + "sha256": "76c999c184dd907cad9fe820995f3f8bdd5542d5815a9addce3f87037021503c" + }, { "path": "static/images/products/fujifilm-x-t5.png", "bytes": 5991, "sha256": "56b25538ef1f2a06c6cfe6f2456d4b84f251ed1ce3fe774486d79b0694990c25" }, + { + "path": "static/images/products/fuyang.png", + "bytes": 5307, + "sha256": "456ccc66abf1730bf9165726825a4a7f331a818b6633a8036ce272e3cb284825" + }, + { + "path": "static/images/products/fuzhou.png", + "bytes": 4807, + "sha256": "391ad69f1bff733f25606048a63999b8fbae498acf3e3bcb827b418dacd11967" + }, + { + "path": "static/images/products/ganzhou.png", + "bytes": 7132, + "sha256": "594eeffd00c971a9fea8bbeb1ee66cd90ed9e7f4750c1ebb10f16efce68a95b5" + }, { "path": "static/images/products/garmin-venu-3.png", "bytes": 7857, @@ -43,21 +138,206 @@ "bytes": 8489, "sha256": "5aacae95ec3e0c1601e7e26a5e2d73305893420ec1330065881e879cf7f3ff5b" }, + { + "path": "static/images/products/grand-canyon-university.png", + "bytes": 9217, + "sha256": "3e3d1b010969db12d156fe88b29761747e32bb8de2db2cbfa4a262d19dd29494" + }, + { + "path": "static/images/products/guangzhou.png", + "bytes": 7039, + "sha256": "b3d0c1a788bfcb64ae05bc946b3d78e529e7fd76a7392c953b3e356ba9125b13" + }, + { + "path": "static/images/products/hangzhou.png", + "bytes": 6003, + "sha256": "6622f03f0b1a56de91459b03953476c7ed556fb32765150c547d1292377719f0" + }, + { + "path": "static/images/products/hanoi.png", + "bytes": 5338, + "sha256": "9589a0c48b54e2a224e1992330cca4287b0772b3bd9a6a0b493160c1af6c4197" + }, + { + "path": "static/images/products/hefei.png", + "bytes": 4017, + "sha256": "3b0d0e298359c673d2276b8a7f6afa7c5ee97f6126e23ecaa9ff6e8528b6425c" + }, + { + "path": "static/images/products/ho-chi-minh-city.png", + "bytes": 6506, + "sha256": "81cb3afe83de77c5357d20a15d594769f768fd2c65f3578b57451050aa120086" + }, + { + "path": "static/images/products/homs-university.png", + "bytes": 6351, + "sha256": "0046290acaef0158ad0f4827dcaf500e43a742c66b67153cbc560f505ae49b58" + }, { "path": "static/images/products/iphone-15-pro.png", "bytes": 6591, "sha256": "4ce675fafd00aa54b12ca2f22f1dff598a2f9350f5c3a3dfb8f9c69763aa26e9" }, + { + "path": "static/images/products/istanbul.png", + "bytes": 5939, + "sha256": "55198cec5a4e513405fbc12957034a7b32d9fec60cac6567fd2170bbea536669" + }, + { + "path": "static/images/products/iu-international-university-of-applied-sciences.png", + "bytes": 6676, + "sha256": "4f65588af71008da7650d6ad67936f04ad2ab98d13e68f700858b4a144787387" + }, + { + "path": "static/images/products/jinan.png", + "bytes": 4354, + "sha256": "599e97df917a2b6da520961cc74ba837398dafdcad2528d5b60befd1a7680ab4" + }, + { + "path": "static/images/products/jining.png", + "bytes": 4452, + "sha256": "4438c8126af6503e6871b9f6e06dd46cb0a307c258e35ca1db8245fffb7fb534" + }, + { + "path": "static/images/products/karachi.png", + "bytes": 6377, + "sha256": "6107c0bbdb1fecf300c7d3ccef9c56f0aa8bfb158f504c9e65d52877fdc10cce" + }, + { + "path": "static/images/products/kuala-lumpur.png", + "bytes": 5678, + "sha256": "353484ed9d92c6c4c761f5a6af8a2fa20b916c6b0a90e5d22f9d5500bf8886de" + }, + { + "path": "static/images/products/kunming.png", + "bytes": 5931, + "sha256": "c9f6c8501c1f16517aaa121071bb2b3b1f1c3116f9be04e674cd682fb01f34f5" + }, + { + "path": "static/images/products/kwame-nkrumah-university-of-science-and-technology.png", + "bytes": 9483, + "sha256": "00d9a799e342709e23d6558c7f39af7c8255cf060e3e676df4882a1cfa12fb78" + }, + { + "path": "static/images/products/lagos-state-university.png", + "bytes": 8093, + "sha256": "8a76cb0021a12958b570bc6654f933bca3be207bf6812092b41d9f37ba8545ce" + }, + { + "path": "static/images/products/lagos.png", + "bytes": 5732, + "sha256": "5c9ad111a377b4631f224fde50f7820ced44ea37a4e0be4b176ffdc534376753" + }, + { + "path": "static/images/products/lahore.png", + "bytes": 5581, + "sha256": "7b6bdd58cf06889fa58accb812b6a1391253455f28e10a594875060f5dc65f97" + }, + { + "path": "static/images/products/lebanese-university.png", + "bytes": 6438, + "sha256": "567433e3b0e78d5899109e60cd99f7ab5d973e3a7fdd8f1cb4e70e185718bf27" + }, + { + "path": "static/images/products/lima.png", + "bytes": 3905, + "sha256": "afa4f6ad4c219f6ef3c2cc9f9bda03e67217d236c04808b754db293897b11655" + }, + { + "path": "static/images/products/mexico-city.png", + "bytes": 7747, + "sha256": "1f64d00aff38d7928601b37b137bc5ad22efa82f04628f4d537a592520d71891" + }, + { + "path": "static/images/products/monterrey-institute-of-technology-and-higher-education.png", + "bytes": 7717, + "sha256": "ca352cfc455907e246bed0f686aedc349b29862b160f6a2c34977c52e3f430e3" + }, + { + "path": "static/images/products/moscow.png", + "bytes": 7256, + "sha256": "a950317c1ce6d10a021ee2aad963fec299e242cd8c716f6bea93c6f50b5f34fc" + }, + { + "path": "static/images/products/nanjing.png", + "bytes": 6202, + "sha256": "333563be0ff57e3de848d07f5888f6e5bd3ec80c509e1ea5402d40c7df2ad299" + }, + { + "path": "static/images/products/nanning.png", + "bytes": 6150, + "sha256": "ad5c802f24eda779c28c61e327c9a1a475e875ce3e3c275771020948fc21e97d" + }, + { + "path": "static/images/products/nantong.png", + "bytes": 6420, + "sha256": "0de8b7800d22e72df2e1f30762d19d3fcbf01c83f7500353f3bca12753f3c4b5" + }, + { + "path": "static/images/products/nanyang.png", + "bytes": 6399, + "sha256": "3445c3b9bbf8a5dc810154dddc88a04f7b6ad021f3feb4839b2a6b5f5e1afc1c" + }, + { + "path": "static/images/products/national-technological-university.png", + "bytes": 7711, + "sha256": "9bb6efc8a338235d6eb58eedcd42696b2efd07dead1db7765a798d39cba57b3a" + }, + { + "path": "static/images/products/national-university-of-c-rdoba.png", + "bytes": 8463, + "sha256": "94cea693c79e2a710e6b396d0c2bf0921f0787e389ce13cecc29eeb3e473d2de" + }, + { + "path": "static/images/products/national-university-of-la-plata.png", + "bytes": 7697, + "sha256": "5ba732d7f97d152172126fb317ef68903909671defe7732477914d8af07cce2a" + }, + { + "path": "static/images/products/national-university-of-rosario.png", + "bytes": 7720, + "sha256": "fce3af34b9919e0f938378b6a41b2ad5d9e47b9915878c21b8e4032e31025259" + }, + { + "path": "static/images/products/national-university-of-tucum-n.png", + "bytes": 8206, + "sha256": "d4f5b0417c3f00837644f081031c8d71f4bfbeeb55a76624f44b2a8b0df1600d" + }, + { + "path": "static/images/products/netaji-subhas-open-university.png", + "bytes": 9042, + "sha256": "1f59fe7d38d91a3a7898388acf055e3917310ffb1736752bb2573ddeea7e78a7" + }, { "path": "static/images/products/nikon-z8.png", "bytes": 6016, "sha256": "532ce68049dc7854890edb5b6ab696fb96e8e2eed2e0fc49cfe4c213abbc496f" }, + { + "path": "static/images/products/ningbo.png", + "bytes": 5319, + "sha256": "42b57e99ebf88bafc52ac4551a925aca48e6799ca3653bc947547a1171165c1a" + }, { "path": "static/images/products/oneplus-12.png", "bytes": 7334, "sha256": "d262535f4951c978d941a0e68077c3a8b367b568d35f52262d0ccdd088c18c86" }, + { + "path": "static/images/products/open-university-of-catalonia.png", + "bytes": 8953, + "sha256": "fc63c6d3336a2e9eabe8e5a159eaa7addcf6f1138bb59120ad19da3042814be8" + }, + { + "path": "static/images/products/qingdao.png", + "bytes": 6490, + "sha256": "e8b484152f2d1a84da86813a4eead902b4f29b4b5caaf0f2642379b4045420f9" + }, + { + "path": "static/images/products/quanzhou.png", + "bytes": 6980, + "sha256": "37f44344bbcf0be9e5c67bf12a5927009ab0b6bebfd1624b95fca8c3a4fc802c" + }, { "path": "static/images/products/radeon-rx-7800-xt.png", "bytes": 8498, @@ -78,6 +358,11 @@ "bytes": 8938, "sha256": "0645ce22f5437027ef6e2c18b70afd25b7b89b1798a3459bb9f498dc3cb761f0" }, + { + "path": "static/images/products/s-o-paulo.png", + "bytes": 6863, + "sha256": "e40840f177c7e6aac6c670de3a281d72dbd9a436eab2b1a6ba6865fafdb94bf7" + }, { "path": "static/images/products/samsung-galaxy-s24-ultra.png", "bytes": 9478, @@ -88,11 +373,41 @@ "bytes": 9846, "sha256": "d1506505cb8dc6324050b7e928aa69471f8b64bb8090c358ba05c1ab5bb1815b" }, + { + "path": "static/images/products/sapienza-university-of-rome.png", + "bytes": 9039, + "sha256": "1b4fbd5cdbe003555421ddd6950ec8de7411d2689ccaad159cbdf120f2778ec8" + }, { "path": "static/images/products/sennheiser-momentum-4.png", "bytes": 8442, "sha256": "b32fcf9a663f02bbc940dff308226f3423bb5747184489d8664243f5f3f1996f" }, + { + "path": "static/images/products/shanghai.png", + "bytes": 6117, + "sha256": "d5145289532d9bfc65fd28977122de9af06e1cf9de3a2ec884ac4849b3d6d775" + }, + { + "path": "static/images/products/shangqiu.png", + "bytes": 6272, + "sha256": "68ed39b1a5b7199350d990768fe9450bdd487155f0b57f8c12592d2c49ee13ca" + }, + { + "path": "static/images/products/shenyang.png", + "bytes": 6612, + "sha256": "707c30255714d3c534a85a2cedf9a97ca138cc1f1f7e033da9082e9e486485ac" + }, + { + "path": "static/images/products/shenzhen.png", + "bytes": 5891, + "sha256": "83f0fb4562dbe7f7eb789e1c4e65ea313f7a14dbb2d2666557ab77ff047e7b3f" + }, + { + "path": "static/images/products/shijiazhuang.png", + "bytes": 6449, + "sha256": "6c0849ab176dd0a1c2637a58f6688a1d56e614e4058584a228d3fc04d3e7f3ba" + }, { "path": "static/images/products/sony-a7-iv.png", "bytes": 8489, @@ -102,6 +417,126 @@ "path": "static/images/products/sony-wh-1000xm5.png", "bytes": 10209, "sha256": "cf7d8586e55016fffda74b16d895c5b232b66071d2cac0498c97b37abb87644c" + }, + { + "path": "static/images/products/southern-new-hampshire-university.png", + "bytes": 9515, + "sha256": "1a849e18639b87475f98fa664918768e73c64806b840971185bd077d1375b9fa" + }, + { + "path": "static/images/products/suzhou.png", + "bytes": 6388, + "sha256": "fcd4c41089e1c92428b0281cb42522bebbdf902fe4e7a85f33faa7a50c2a3bf2" + }, + { + "path": "static/images/products/tangshan.png", + "bytes": 5727, + "sha256": "94bce75ed92c3e00bc1de188cf4c1ae40f62951e664c144e2ceddda80bcaa71a" + }, + { + "path": "static/images/products/tianjin.png", + "bytes": 3973, + "sha256": "1e816f36209c2d6db325bbe704c97018a14d3105293a3fa1503ac893192fe571" + }, + { + "path": "static/images/products/tokyo.png", + "bytes": 5737, + "sha256": "ea8bcbf3ee3fa71322b6f5eb5deb62d286f807efcf5968947df415610ead6d6f" + }, + { + "path": "static/images/products/university-of-algiers-1.png", + "bytes": 8335, + "sha256": "b31331859806718cfff38f5adc0c0baa6ab3012fbc9fdec326c8c6178d3b3f08" + }, + { + "path": "static/images/products/university-of-benin.png", + "bytes": 7850, + "sha256": "3e84627359cbea7549a1e00f9f9f80dd1d42cdcf6b02ff4205476b6f6347a51b" + }, + { + "path": "static/images/products/university-of-bologna.png", + "bytes": 8482, + "sha256": "c336b1e5b998026161513dfb96a746f66295bbd91073c14ebf13b88de343bbcf" + }, + { + "path": "static/images/products/university-of-continuing-education.png", + "bytes": 9197, + "sha256": "14440572c8e5ee84f74303ad71eb65f69322d407481de178799c63ed8dd8d7b3" + }, + { + "path": "static/images/products/university-of-granada.png", + "bytes": 8480, + "sha256": "c8c12e91203de3a49cea9bf4b5142718cef8626700bb937332b0fc7bb570c4a0" + }, + { + "path": "static/images/products/university-of-illinois-urbana-champaign.png", + "bytes": 8785, + "sha256": "446a2670158ea313b41a6c6cb7c291a616268eb217c1f505ce9e250f9803e7cf" + }, + { + "path": "static/images/products/university-of-lyon.png", + "bytes": 7657, + "sha256": "9801f98502ead4c156cb3ae1994dfd004c46823fef410a046c941fae5d853583" + }, + { + "path": "static/images/products/university-of-melbourne.png", + "bytes": 8308, + "sha256": "90650430e04dbd88d97c0107a6e8a05a2950172312fa4edcd89f37d17850e976" + }, + { + "path": "static/images/products/university-of-s-o-paulo.png", + "bytes": 8806, + "sha256": "134a38c323cb9aec8745eb3da2e0ffd3be8f363509054800c59d353e5033ed22" + }, + { + "path": "static/images/products/university-of-toronto.png", + "bytes": 7716, + "sha256": "236d0e413700c950ab4c343222a5117485613d1ca13e998138e42b42c91ff874" + }, + { + "path": "static/images/products/university-of-toulouse.png", + "bytes": 7817, + "sha256": "3197d039f20722bbedbcdc95be6011080a304e2d54c9edf04a4d3588e3885e44" + }, + { + "path": "static/images/products/university-of-vienna.png", + "bytes": 8153, + "sha256": "6ea0662e00bcb3003a0ccf73d47543d459ec5380d477c79449fa2295acbafbd9" + }, + { + "path": "static/images/products/weifang.png", + "bytes": 6976, + "sha256": "fb2c79038e699ec754e09c703a4b2df6ce2e8f2892ce6947053d9c56b0171188" + }, + { + "path": "static/images/products/wenzhou.png", + "bytes": 6837, + "sha256": "8247e046ba08dafe153bae9bbd45d11e11f840bccfe715c77ef1a7c207659d45" + }, + { + "path": "static/images/products/western-governors-university.png", + "bytes": 9561, + "sha256": "0439276101b2365a2eff0740fbe8deb63a008f54b46eb7b8d9f1e40759bf902d" + }, + { + "path": "static/images/products/wuhan.png", + "bytes": 6723, + "sha256": "6468604bedde477c0e8ccb6f9374f689827c3ae14ee2034a7540dcebd84c5a86" + }, + { + "path": "static/images/products/xi-an.png", + "bytes": 5594, + "sha256": "e42b6378bc62b2c655a55858dbe1aaa2b625bf20385c4c108a7c9b6bcf04c16a" + }, + { + "path": "static/images/products/xuzhou.png", + "bytes": 6355, + "sha256": "a4bd4c88c01c53b0e8b11e88ed8ae811085650b87fa2e421da97adf1dcab599e" + }, + { + "path": "static/images/products/zhengzhou.png", + "bytes": 5752, + "sha256": "68966ba7d9ce7bc5b8446af404146128de8a7aa7ed9919e1a24080afb9c18197" } ] } diff --git a/sites/versus/templates/_product_card.html b/sites/versus/templates/_product_card.html index 4b5354db..d9d6ba00 100644 --- a/sites/versus/templates/_product_card.html +++ b/sites/versus/templates/_product_card.html @@ -9,8 +9,8 @@

{{ product.name

{{ product.summary }}

Score
{{ product.score }}
-
Price
${{ product.price }}
-
Year
{{ product.release_year }}
+ {% if product.price is not none %}
Price
${{ product.price }}
{% endif %} + {% if product.release_year %}
Year
{{ product.release_year }}
{% endif %}

diff --git a/sites/versus/templates/category.html b/sites/versus/templates/category.html index ef2a84b6..f3572d52 100644 --- a/sites/versus/templates/category.html +++ b/sites/versus/templates/category.html @@ -6,10 +6,10 @@
- + {% if has_prices %}{% endif %}
diff --git a/sites/versus/templates/product.html b/sites/versus/templates/product.html index 839a195e..4fc6a45e 100644 --- a/sites/versus/templates/product.html +++ b/sites/versus/templates/product.html @@ -15,8 +15,8 @@

{{ product.name }}

{{ product.category.spec_1 }}{{ product.spec_1_value|round(1) }}{{ product.category.unit_1 }}
{{ product.category.spec_2 }}{{ product.spec_2_value|round(1) }}{{ product.category.unit_2 }}
-
{{ product.category.spec_3 }}{{ product.spec_3_value|round(1) }}{{ product.category.unit_3 }}
-
Price${{ product.price }}
+ {% if product.spec_3_value is not none %}
{{ product.category.spec_3 }}{{ product.spec_3_value|round(1) }}{{ product.category.unit_3 }}
{% endif %} + {% if product.price is not none %}
Price${{ product.price }}
{% endif %}
diff --git a/sites/versus/templates/rankings.html b/sites/versus/templates/rankings.html index 0ea61426..85c883e2 100644 --- a/sites/versus/templates/rankings.html +++ b/sites/versus/templates/rankings.html @@ -9,11 +9,11 @@ {% endfor %}
- {% for product in products %} + {% for rank, product in ranked %} - {{ loop.index }} + {{ rank }} {{ product.name }} - {{ product.category.name }} · ${{ product.price }} + {{ product.category.name }}{% if product.price is not none %} · ${{ product.price }}{% endif %} {{ product.score }} {% endfor %} From ac11412c6fff42c6043f50215c5021204b6a03f9 Mon Sep 17 00:00:00 2001 From: JackJin <1037461232@qq.com> Date: Mon, 14 Sep 2026 11:52:22 +0800 Subject: [PATCH 14/18] feat(versus): five tasks on the sourced categories, and a visible pager The seventeen existing tasks are all consumer electronics, so the two categories that carry real depth were not exercised by anything. These five are, and they are the ones that need the depth: finding a named city among 52 means paging or searching, and the two superlatives compare 52 and 35 candidates rather than 4. Ground truth is derived from initial_db as everywhere else, and each superlative was checked for a unique answer with a clear runner-up before the task was written: most populous Shanghai 24,870,895 against Beijing 21,893,095; longest teaching Bologna 1088 against Sapienza 1303. No task is written against the derived density figure. Population and area are independent Wikidata claims that need not describe the same administrative unit, and that caveat is recorded in data/README.md rather than papered over. The listings and rankings now render the pager they gained, showing the range and page count, so paging is reachable by clicking rather than only by editing a URL. --- sites/versus/static/css/main.css | 6 ++++ sites/versus/tasks.jsonl | 5 ++++ sites/versus/templates/category.html | 8 ++++++ sites/versus/templates/rankings.html | 8 ++++++ sites/versus/verify/verify_17.py | 36 +++++++++++++++++++++++ sites/versus/verify/verify_18.py | 32 +++++++++++++++++++++ sites/versus/verify/verify_19.py | 39 +++++++++++++++++++++++++ sites/versus/verify/verify_20.py | 32 +++++++++++++++++++++ sites/versus/verify/verify_21.py | 43 ++++++++++++++++++++++++++++ 9 files changed, 209 insertions(+) create mode 100644 sites/versus/verify/verify_17.py create mode 100644 sites/versus/verify/verify_18.py create mode 100644 sites/versus/verify/verify_19.py create mode 100644 sites/versus/verify/verify_20.py create mode 100644 sites/versus/verify/verify_21.py diff --git a/sites/versus/static/css/main.css b/sites/versus/static/css/main.css index 85c19d54..2320e68a 100644 --- a/sites/versus/static/css/main.css +++ b/sites/versus/static/css/main.css @@ -256,3 +256,9 @@ main { max-width: var(--maxw); margin: 0 auto; padding: 0 26px 70px; } .ranking-row { grid-template-columns: 34px 1fr auto; } .ranking-row small { display: none; } } + +.pager { display: flex; gap: 16px; align-items: center; justify-content: center; + margin: 26px 0 4px; font-size: 14px; color: var(--muted); flex-wrap: wrap; } +.pager a { border: 1px solid var(--line); border-radius: 999px; padding: 8px 18px; + font-weight: 600; color: var(--ink); } +.pager a:hover { border-color: var(--accent); color: var(--accent-2); } diff --git a/sites/versus/tasks.jsonl b/sites/versus/tasks.jsonl index 7615249c..b9d7daf9 100644 --- a/sites/versus/tasks.jsonl +++ b/sites/versus/tasks.jsonl @@ -15,3 +15,8 @@ {"web_name": "Versus", "id": "Versus--14", "ques": "Among smartwatches, find the one with the longest battery life and report its weight in grams.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_14.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the detail or comparison pages needed to read smartwatch battery life.\n2. The final answer MUST name the longest-lasting smartwatch AND give its weight in grams.\n3. Battery life and weight are not printed on cards, so an answer with no detail or comparison page visit is a FAIL.\n4. An empty answer, or naming the highest-scoring smartwatch instead of the longest-lasting one, is a FAIL."} {"web_name": "Versus", "id": "Versus--15", "ques": "Among headphones, find the one with the lowest ANC score and report its price.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_15.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the detail or comparison pages needed to read ANC scores.\n2. The final answer MUST name the headphones with the lowest ANC score AND give their price.\n3. ANC score is not printed on cards, so an answer with no detail or comparison page visit is a FAIL.\n4. An empty answer, or naming the lowest Versus Score product instead of the lowest ANC one, is a FAIL."} {"web_name": "Versus", "id": "Versus--16", "ques": "Compare the GeForce RTX 4070 Super and the Radeon RX 7800 XT, then report the benchmark score of whichever the site declares the winner.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_16.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the comparison of these two cards, or both detail pages.\n2. The final answer MUST name the product the site declares the winner AND give that product's benchmark score.\n3. The benchmark score is not printed on cards or in the ranking list, so an answer with no comparison or detail page visit is a FAIL.\n4. An empty answer, or reporting the loser's benchmark score, is a FAIL."} +{"web_name": "Versus", "id": "Versus--17", "ques": "Open the Cities category and report the area in square kilometres of Shanghai.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_17.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have reached the city through the site — the cities category listing, the rankings or search — and opened its detail or comparison page.\n2. The final answer MUST give that city's area in square kilometres as shown on the site.\n3. Area is not printed on cards or in the ranking list, so an answer with no detail or comparison page visit is a FAIL.\n4. An empty answer, or one that reports the population instead of the area, is a FAIL."} +{"web_name": "Versus", "id": "Versus--18", "ques": "Among the universities listed, find the one that has been teaching longest and report how many students it has.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_18.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the detail or comparison page of the university with the earliest founding year.\n2. The final answer MUST name that university AND give its student enrolment as shown on the site.\n3. Founding year and enrolment appear only on detail and comparison pages, so an answer with no such visit is a FAIL.\n4. An empty answer, or naming a university founded later than the earliest, is a FAIL."} +{"web_name": "Versus", "id": "Versus--19", "ques": "Compare Shanghai and Beijing, then report which has the larger area and what that area is.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_19.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the side-by-side comparison of these two cities, or both detail pages.\n2. The final answer MUST name the larger city by area AND give that area in square kilometres.\n3. Area is not printed on cards, so an answer with no comparison or detail page visit is a FAIL.\n4. Naming the more populous city instead of the larger one is a FAIL."} +{"web_name": "Versus", "id": "Versus--20", "ques": "Find the city with the largest population and report its area in square kilometres.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_20.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the detail or comparison page of the most populous city.\n2. The final answer MUST name that city AND give its area in square kilometres.\n3. Neither population nor area is printed on cards, so an answer with no detail or comparison page visit is a FAIL.\n4. An empty answer, or naming a city that is not the most populous, is a FAIL."} +{"web_name": "Versus", "id": "Versus--21", "ques": "Sign in as carol.d@test.com with password TestPass123! and save the comparison between the University of Bologna and Sapienza University of Rome to that account.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_21.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have signed in as that user and opened the comparison of these two universities.\n2. The comparison MUST actually be saved to that account: the account's saved comparisons after the run must include this pair, which it did not contain before.\n3. A self-reported success with no change to the account is a FAIL.\n4. Saving to a different account, or saving a different pair, is a FAIL."} diff --git a/sites/versus/templates/category.html b/sites/versus/templates/category.html index f3572d52..ec60c329 100644 --- a/sites/versus/templates/category.html +++ b/sites/versus/templates/category.html @@ -18,4 +18,12 @@ {% include "_product_card.html" %} {% endfor %}
+ +{% if paging.pages > 1 %} + +{% endif %} {% endblock %} diff --git a/sites/versus/templates/rankings.html b/sites/versus/templates/rankings.html index 85c883e2..e7f763a3 100644 --- a/sites/versus/templates/rankings.html +++ b/sites/versus/templates/rankings.html @@ -18,4 +18,12 @@ {% endfor %} + +{% if paging.pages > 1 %} + +{% endif %} {% endblock %} diff --git a/sites/versus/verify/verify_17.py b/sites/versus/verify/verify_17.py new file mode 100644 index 00000000..e0bb81e8 --- /dev/null +++ b/sites/versus/verify/verify_17.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Versus--17: area of a named city, reached through the site.""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import verify_lib as V + +SLUG = "shanghai" + + +def body(j, traj, initial, after): + target = V.product(initial, SLUG) + if not target: + return j.fail("seed data missing", f"{SLUG} is not in initial_db") + expected = target["spec_2_value"] + + ans = V.terminal_state_is_sound(j, traj) + j.check("reached the city through the site", + V.navigated_any(traj, [f"/category/{target['category_slug']}", "/rankings", "/search"]), + f"steps={V.step_urls(traj)[:6]}") + j.check("opened the fact-bearing page for the city", + V.opened_detail_or_compare(traj, SLUG), f"steps={V.step_urls(traj)[-6:]}") + j.check("answer states the derived area", + V.mentions_number(ans, expected, tol=1.0), + f"expected={expected} {target['unit_2']} from initial_db") + j.check("answer does not report the population instead", + not V.mentions_number(ans, target["spec_1_value"], tol=1.0) + or V.mentions_number(ans, expected, tol=1.0), + "population and area must not be confused") + ok, why = V.llm_text_match(ans, f"{expected} {target['unit_2']}", + "area in square kilometres of the named city") + j.check("anchored LLM agreement", ok, why, llm=True) + + +if __name__ == "__main__": + V.run("Versus--17", body) diff --git a/sites/versus/verify/verify_18.py b/sites/versus/verify/verify_18.py new file mode 100644 index 00000000..9b7dd52e --- /dev/null +++ b/sites/versus/verify/verify_18.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Versus--18: enrolment of the longest-teaching university.""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import verify_lib as V + + +def body(j, traj, initial, after): + rows = V.products(initial, "universities") + if not rows: + return j.fail("seed data missing", "no universities in initial_db") + target = V.unique_extreme(rows, "spec_2_value", largest=False) # earliest founding year + if target is None: + return j.fail("ambiguous ground truth", "no unique earliest founding year") + expected = target["spec_1_value"] + + ans = V.terminal_state_is_sound(j, traj) + j.check("opened the fact-bearing page for the target university", + V.opened_detail_or_compare(traj, target["slug"]), + f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") + j.check("answer names the right university", + V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") + j.check("answer states the derived enrolment", + V.mentions_number(ans, expected, tol=1.0), f"expected={expected}") + ok, why = V.llm_text_match(ans, f"{target['name']} — {expected} students", + "student enrolment of the longest-teaching university") + j.check("anchored LLM agreement", ok, why, llm=True) + + +if __name__ == "__main__": + V.run("Versus--18", body) diff --git a/sites/versus/verify/verify_19.py b/sites/versus/verify/verify_19.py new file mode 100644 index 00000000..750c50e4 --- /dev/null +++ b/sites/versus/verify/verify_19.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Versus--19: larger of two named cities by area.""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import verify_lib as V + +LEFT, RIGHT = "shanghai", "beijing" + + +def body(j, traj, initial, after): + left, right = V.product(initial, LEFT), V.product(initial, RIGHT) + if not (left and right): + return j.fail("seed data missing", "one of the two cities is not in initial_db") + if left["spec_2_value"] == right["spec_2_value"]: + return j.fail("ambiguous ground truth", "the two cities tie on area") + target = left if left["spec_2_value"] > right["spec_2_value"] else right + other = right if target is left else left + expected = target["spec_2_value"] + + ans = V.terminal_state_is_sound(j, traj) + j.check("opened the comparison or both detail pages", + V.navigated_to(traj, f"/compare/{LEFT}-vs-{RIGHT}") + or V.navigated_to(traj, f"/compare/{RIGHT}-vs-{LEFT}") + or (V.opened_detail_or_compare(traj, LEFT) + and V.opened_detail_or_compare(traj, RIGHT)), + f"steps={V.step_urls(traj)[-6:]}") + j.check("answer names the larger city by area", + V.mentions_product(ans, target["name"]), + f"expected={target['name']!r} ({expected} vs {other['spec_2_value']})") + j.check("answer states that area", + V.mentions_number(ans, expected, tol=1.0), f"expected={expected}") + ok, why = V.llm_text_match(ans, f"{target['name']} — {expected} km2", + "which of the two cities is larger by area, and that area") + j.check("anchored LLM agreement", ok, why, llm=True) + + +if __name__ == "__main__": + V.run("Versus--19", body) diff --git a/sites/versus/verify/verify_20.py b/sites/versus/verify/verify_20.py new file mode 100644 index 00000000..d99fc620 --- /dev/null +++ b/sites/versus/verify/verify_20.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Versus--20: area of the most populous city.""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import verify_lib as V + + +def body(j, traj, initial, after): + rows = V.products(initial, "cities") + if not rows: + return j.fail("seed data missing", "no cities in initial_db") + target = V.unique_extreme(rows, "spec_1_value", largest=True) + if target is None: + return j.fail("ambiguous ground truth", "no unique largest population") + expected = target["spec_2_value"] + + ans = V.terminal_state_is_sound(j, traj) + j.check("opened the fact-bearing page for the target city", + V.opened_detail_or_compare(traj, target["slug"]), + f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") + j.check("answer names the most populous city", + V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") + j.check("answer states its area", + V.mentions_number(ans, expected, tol=1.0), f"expected={expected}") + ok, why = V.llm_text_match(ans, f"{target['name']} — {expected} km2", + "area of the most populous city") + j.check("anchored LLM agreement", ok, why, llm=True) + + +if __name__ == "__main__": + V.run("Versus--20", body) diff --git a/sites/versus/verify/verify_21.py b/sites/versus/verify/verify_21.py new file mode 100644 index 00000000..f520d68b --- /dev/null +++ b/sites/versus/verify/verify_21.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Versus--21: Carol saves the Bologna vs Sapienza comparison.""" +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import verify_lib as V + +EMAIL = "carol.d@test.com" +A, B = "university-of-bologna", "sapienza-university-of-rome" + + +def body(j, traj, initial, after): + pair = frozenset((A, B)) + before, now = V.saved_pairs(initial, EMAIL), V.saved_pairs(after, EMAIL) + if before is None: + return j.fail("initial_db unreadable", "cannot establish the before state") + if now is None: + return j.fail("after_db unavailable", + "a stateful task cannot be graded without the after state") + if pair in before: + return j.fail("task design error", + "the requested comparison is already saved in the seed") + + ans = V.final_answer(traj) + j.check("the answer was emitted from a page on this site", + V.answered_on_site(traj), + f"terminal url={(traj.get('steps') or [{}])[-1].get('url')!r}") + j.check("reported what it did, without denying it", + bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + j.check("signed in", V.navigated_to(traj, "/login"), f"steps={V.step_urls(traj)[:6]}") + j.check("opened the comparison page for the requested pair", + V.navigated_to(traj, f"/compare/{A}-vs-{B}") + or V.navigated_to(traj, f"/compare/{B}-vs-{A}"), + f"steps={V.step_urls(traj)[-6:]}") + j.check("the comparison is actually saved to that account", + pair in now, f"account pairs after the run = {sorted(map(sorted, now))}") + j.check("no unrelated comparison was added", + len(now - before - {pair}) == 0, + f"unexpected additions = {sorted(map(sorted, now - before - {pair}))}") + + +if __name__ == "__main__": + V.run("Versus--21", body) From 9f0f95e9490a1786ebf5a76fb3b4e661a300069d Mon Sep 17 00:00:00 2001 From: JackJin <1037461232@qq.com> Date: Mon, 14 Sep 2026 12:03:31 +0800 Subject: [PATCH 15/18] review(versus): trim to 20 tasks, dropping the two shallowest The sourced categories brought the list to 22. No merged site ships more than 20 -- healthline, webmd_doctor, merriam_webster and osu are all at 20, compass 19, fedex 18 -- so 22 would have been the outlier. The two removed are the ones the S2 matrix already marked shallow: a two-click category read of a single spec, and a picker build with no fact extraction. Neither loses coverage. Finding a city among 52 exercises the same category listing with paging, and the two save tasks build their pair in the picker. Ids are contiguous after the trim; verifiers renumbered with them. --- sites/versus/tasks.jsonl | 26 ++++++++-------- sites/versus/verify/verify_10.py | 28 +++++++++-------- sites/versus/verify/verify_11.py | 53 ++++++++++++++++++++------------ sites/versus/verify/verify_12.py | 28 ++++++++--------- sites/versus/verify/verify_13.py | 53 ++++++++++++-------------------- sites/versus/verify/verify_14.py | 26 +++++++++------- sites/versus/verify/verify_15.py | 43 +++++++++++++------------- sites/versus/verify/verify_16.py | 41 ++++++++++-------------- sites/versus/verify/verify_17.py | 41 ++++++++++++------------ sites/versus/verify/verify_18.py | 22 ++++++------- sites/versus/verify/verify_19.py | 52 ++++++++++++++++--------------- sites/versus/verify/verify_20.py | 32 ------------------- sites/versus/verify/verify_21.py | 43 -------------------------- sites/versus/verify/verify_8.py | 36 +++++++++++----------- sites/versus/verify/verify_9.py | 41 ++++++++++-------------- 15 files changed, 243 insertions(+), 322 deletions(-) delete mode 100644 sites/versus/verify/verify_20.py delete mode 100644 sites/versus/verify/verify_21.py diff --git a/sites/versus/tasks.jsonl b/sites/versus/tasks.jsonl index b9d7daf9..949ec34b 100644 --- a/sites/versus/tasks.jsonl +++ b/sites/versus/tasks.jsonl @@ -6,17 +6,15 @@ {"web_name": "Versus", "id": "Versus--5", "ques": "Sign in as alice.j@test.com with password TestPass123! and report one comparison that is already saved to that account.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_5.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have signed in and opened the account page.\n2. The final answer MUST name both products of one comparison saved to that account.\n3. Naming a comparison that is not on the account, or reporting a comparison the agent created during the run instead of one already saved, is a FAIL.\n4. An empty answer is a FAIL."} {"web_name": "Versus", "id": "Versus--6", "ques": "Among smartphones priced at $1000 or less, find the highest scoring one and report its display size in inches.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_6.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the winning product's detail or comparison page.\n2. The final answer MUST name that product AND give its display size in inches as shown on the site.\n3. Any route that narrows smartphones by price is acceptable: the price filter, the category listing, the rankings view or search.\n4. Display size is not printed on cards or in the ranking list, so an answer with no detail or comparison page visit is a FAIL.\n5. An empty answer, or naming a smartphone priced above the limit, is a FAIL."} {"web_name": "Versus", "id": "Versus--7", "ques": "Sign in as alice.j@test.com with password TestPass123! and save the comparison between the Nikon Z8 and the Canon EOS R6 Mark II to that account.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_7.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have signed in and opened the comparison of these two products.\n2. The comparison MUST actually be saved to that account: the account's saved comparisons after the run must include this pair, which it did not contain before.\n3. A self-reported success with no change to the account is a FAIL.\n4. Saving a different pair, or saving to a different account, is a FAIL."} -{"web_name": "Versus", "id": "Versus--8", "ques": "Open the Smartwatches category and report the battery life in hours of the Garmin Venu 3.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_8.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have reached the product through the smartwatches category listing and opened its detail or comparison page.\n2. The final answer MUST give that product's battery life in hours as shown on the site.\n3. Battery hours are not printed on cards, so an answer with no detail or comparison page visit is a FAIL.\n4. An empty answer is a FAIL."} -{"web_name": "Versus", "id": "Versus--9", "ques": "Use the compare picker to build a comparison between the Canon EOS R6 Mark II and the Sony A7 IV, then report which product the site declares the winner.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_9.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have used the compare picker and landed on the comparison page for these two products.\n2. The final answer MUST name the product the site itself declares the winner.\n3. Naming the other product, or answering without reaching the comparison page, is a FAIL.\n4. An empty answer is a FAIL."} -{"web_name": "Versus", "id": "Versus--10", "ques": "Among graphics cards, find the one with the most VRAM and report its Versus Score.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_10.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the detail or comparison pages needed to read VRAM for the graphics cards.\n2. The final answer MUST name the card with the most VRAM AND give its Versus Score.\n3. VRAM is not printed on cards or in the ranking list, so an answer with no detail or comparison page visit is a FAIL.\n4. An empty answer, or naming the highest-scoring card instead of the highest-VRAM one, is a FAIL."} -{"web_name": "Versus", "id": "Versus--11", "ques": "Find the heaviest camera on the site and report its price.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_11.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the detail or comparison pages needed to read camera weights.\n2. The final answer MUST name the heaviest camera AND give its price.\n3. Weight is not printed on cards, so an answer with no detail or comparison page visit is a FAIL.\n4. An empty answer, or naming a camera that is not the heaviest, is a FAIL."} -{"web_name": "Versus", "id": "Versus--12", "ques": "Compare the OnePlus 12 and the Google Pixel 8 Pro, then report the battery life in hours of whichever has the longer battery.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_12.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the comparison of these two products, or both detail pages.\n2. The final answer MUST name the longer-lasting product AND give its battery life in hours as shown on the site.\n3. Battery hours are not printed on cards, so an answer with no comparison or detail page visit is a FAIL.\n4. An empty answer is a FAIL."} -{"web_name": "Versus", "id": "Versus--13", "ques": "Sign in as bob.c@test.com with password TestPass123! and save the comparison between the Apple Watch Series 9 and the Garmin Venu 3 to that account.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_13.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have signed in as that user and opened the comparison of these two products.\n2. The comparison MUST actually be saved to that account: the account's saved comparisons after the run must include this pair, which it did not contain before.\n3. A self-reported success with no change to the account is a FAIL.\n4. Saving to a different account, or saving a different pair, is a FAIL."} -{"web_name": "Versus", "id": "Versus--14", "ques": "Among smartwatches, find the one with the longest battery life and report its weight in grams.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_14.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the detail or comparison pages needed to read smartwatch battery life.\n2. The final answer MUST name the longest-lasting smartwatch AND give its weight in grams.\n3. Battery life and weight are not printed on cards, so an answer with no detail or comparison page visit is a FAIL.\n4. An empty answer, or naming the highest-scoring smartwatch instead of the longest-lasting one, is a FAIL."} -{"web_name": "Versus", "id": "Versus--15", "ques": "Among headphones, find the one with the lowest ANC score and report its price.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_15.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the detail or comparison pages needed to read ANC scores.\n2. The final answer MUST name the headphones with the lowest ANC score AND give their price.\n3. ANC score is not printed on cards, so an answer with no detail or comparison page visit is a FAIL.\n4. An empty answer, or naming the lowest Versus Score product instead of the lowest ANC one, is a FAIL."} -{"web_name": "Versus", "id": "Versus--16", "ques": "Compare the GeForce RTX 4070 Super and the Radeon RX 7800 XT, then report the benchmark score of whichever the site declares the winner.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_16.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the comparison of these two cards, or both detail pages.\n2. The final answer MUST name the product the site declares the winner AND give that product's benchmark score.\n3. The benchmark score is not printed on cards or in the ranking list, so an answer with no comparison or detail page visit is a FAIL.\n4. An empty answer, or reporting the loser's benchmark score, is a FAIL."} -{"web_name": "Versus", "id": "Versus--17", "ques": "Open the Cities category and report the area in square kilometres of Shanghai.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_17.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have reached the city through the site — the cities category listing, the rankings or search — and opened its detail or comparison page.\n2. The final answer MUST give that city's area in square kilometres as shown on the site.\n3. Area is not printed on cards or in the ranking list, so an answer with no detail or comparison page visit is a FAIL.\n4. An empty answer, or one that reports the population instead of the area, is a FAIL."} -{"web_name": "Versus", "id": "Versus--18", "ques": "Among the universities listed, find the one that has been teaching longest and report how many students it has.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_18.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the detail or comparison page of the university with the earliest founding year.\n2. The final answer MUST name that university AND give its student enrolment as shown on the site.\n3. Founding year and enrolment appear only on detail and comparison pages, so an answer with no such visit is a FAIL.\n4. An empty answer, or naming a university founded later than the earliest, is a FAIL."} -{"web_name": "Versus", "id": "Versus--19", "ques": "Compare Shanghai and Beijing, then report which has the larger area and what that area is.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_19.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the side-by-side comparison of these two cities, or both detail pages.\n2. The final answer MUST name the larger city by area AND give that area in square kilometres.\n3. Area is not printed on cards, so an answer with no comparison or detail page visit is a FAIL.\n4. Naming the more populous city instead of the larger one is a FAIL."} -{"web_name": "Versus", "id": "Versus--20", "ques": "Find the city with the largest population and report its area in square kilometres.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_20.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the detail or comparison page of the most populous city.\n2. The final answer MUST name that city AND give its area in square kilometres.\n3. Neither population nor area is printed on cards, so an answer with no detail or comparison page visit is a FAIL.\n4. An empty answer, or naming a city that is not the most populous, is a FAIL."} -{"web_name": "Versus", "id": "Versus--21", "ques": "Sign in as carol.d@test.com with password TestPass123! and save the comparison between the University of Bologna and Sapienza University of Rome to that account.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_21.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have signed in as that user and opened the comparison of these two universities.\n2. The comparison MUST actually be saved to that account: the account's saved comparisons after the run must include this pair, which it did not contain before.\n3. A self-reported success with no change to the account is a FAIL.\n4. Saving to a different account, or saving a different pair, is a FAIL."} +{"web_name": "Versus", "id": "Versus--8", "ques": "Among graphics cards, find the one with the most VRAM and report its Versus Score.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_8.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the detail or comparison pages needed to read VRAM for the graphics cards.\n2. The final answer MUST name the card with the most VRAM AND give its Versus Score.\n3. VRAM is not printed on cards or in the ranking list, so an answer with no detail or comparison page visit is a FAIL.\n4. An empty answer, or naming the highest-scoring card instead of the highest-VRAM one, is a FAIL."} +{"web_name": "Versus", "id": "Versus--9", "ques": "Find the heaviest camera on the site and report its price.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_9.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the detail or comparison pages needed to read camera weights.\n2. The final answer MUST name the heaviest camera AND give its price.\n3. Weight is not printed on cards, so an answer with no detail or comparison page visit is a FAIL.\n4. An empty answer, or naming a camera that is not the heaviest, is a FAIL."} +{"web_name": "Versus", "id": "Versus--10", "ques": "Compare the OnePlus 12 and the Google Pixel 8 Pro, then report the battery life in hours of whichever has the longer battery.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_10.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the comparison of these two products, or both detail pages.\n2. The final answer MUST name the longer-lasting product AND give its battery life in hours as shown on the site.\n3. Battery hours are not printed on cards, so an answer with no comparison or detail page visit is a FAIL.\n4. An empty answer is a FAIL."} +{"web_name": "Versus", "id": "Versus--11", "ques": "Sign in as bob.c@test.com with password TestPass123! and save the comparison between the Apple Watch Series 9 and the Garmin Venu 3 to that account.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_11.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have signed in as that user and opened the comparison of these two products.\n2. The comparison MUST actually be saved to that account: the account's saved comparisons after the run must include this pair, which it did not contain before.\n3. A self-reported success with no change to the account is a FAIL.\n4. Saving to a different account, or saving a different pair, is a FAIL."} +{"web_name": "Versus", "id": "Versus--12", "ques": "Among smartwatches, find the one with the longest battery life and report its weight in grams.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_12.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the detail or comparison pages needed to read smartwatch battery life.\n2. The final answer MUST name the longest-lasting smartwatch AND give its weight in grams.\n3. Battery life and weight are not printed on cards, so an answer with no detail or comparison page visit is a FAIL.\n4. An empty answer, or naming the highest-scoring smartwatch instead of the longest-lasting one, is a FAIL."} +{"web_name": "Versus", "id": "Versus--13", "ques": "Among headphones, find the one with the lowest ANC score and report its price.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_13.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the detail or comparison pages needed to read ANC scores.\n2. The final answer MUST name the headphones with the lowest ANC score AND give their price.\n3. ANC score is not printed on cards, so an answer with no detail or comparison page visit is a FAIL.\n4. An empty answer, or naming the lowest Versus Score product instead of the lowest ANC one, is a FAIL."} +{"web_name": "Versus", "id": "Versus--14", "ques": "Compare the GeForce RTX 4070 Super and the Radeon RX 7800 XT, then report the benchmark score of whichever the site declares the winner.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_14.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the comparison of these two cards, or both detail pages.\n2. The final answer MUST name the product the site declares the winner AND give that product's benchmark score.\n3. The benchmark score is not printed on cards or in the ranking list, so an answer with no comparison or detail page visit is a FAIL.\n4. An empty answer, or reporting the loser's benchmark score, is a FAIL."} +{"web_name": "Versus", "id": "Versus--15", "ques": "Open the Cities category and report the area in square kilometres of Shanghai.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_15.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have reached the city through the site — the cities category listing, the rankings or search — and opened its detail or comparison page.\n2. The final answer MUST give that city's area in square kilometres as shown on the site.\n3. Area is not printed on cards or in the ranking list, so an answer with no detail or comparison page visit is a FAIL.\n4. An empty answer, or one that reports the population instead of the area, is a FAIL."} +{"web_name": "Versus", "id": "Versus--16", "ques": "Among the universities listed, find the one that has been teaching longest and report how many students it has.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_16.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the detail or comparison page of the university with the earliest founding year.\n2. The final answer MUST name that university AND give its student enrolment as shown on the site.\n3. Founding year and enrolment appear only on detail and comparison pages, so an answer with no such visit is a FAIL.\n4. An empty answer, or naming a university founded later than the earliest, is a FAIL."} +{"web_name": "Versus", "id": "Versus--17", "ques": "Compare Shanghai and Beijing, then report which has the larger area and what that area is.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_17.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the side-by-side comparison of these two cities, or both detail pages.\n2. The final answer MUST name the larger city by area AND give that area in square kilometres.\n3. Area is not printed on cards, so an answer with no comparison or detail page visit is a FAIL.\n4. Naming the more populous city instead of the larger one is a FAIL."} +{"web_name": "Versus", "id": "Versus--18", "ques": "Find the city with the largest population and report its area in square kilometres.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_18.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the detail or comparison page of the most populous city.\n2. The final answer MUST name that city AND give its area in square kilometres.\n3. Neither population nor area is printed on cards, so an answer with no detail or comparison page visit is a FAIL.\n4. An empty answer, or naming a city that is not the most populous, is a FAIL."} +{"web_name": "Versus", "id": "Versus--19", "ques": "Sign in as carol.d@test.com with password TestPass123! and save the comparison between the University of Bologna and Sapienza University of Rome to that account.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_19.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have signed in as that user and opened the comparison of these two universities.\n2. The comparison MUST actually be saved to that account: the account's saved comparisons after the run must include this pair, which it did not contain before.\n3. A self-reported success with no change to the account is a FAIL.\n4. Saving to a different account, or saving a different pair, is a FAIL."} diff --git a/sites/versus/verify/verify_10.py b/sites/versus/verify/verify_10.py index 801b8fc2..2e32a489 100644 --- a/sites/versus/verify/verify_10.py +++ b/sites/versus/verify/verify_10.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Versus--10: Versus Score of the graphics card with the most VRAM.""" +"""Versus--10: longer battery of OnePlus 12 vs Pixel 8 Pro.""" import sys from pathlib import Path @@ -8,26 +8,30 @@ def body(j, traj, initial, after): - rows = V.products(initial, "graphics-cards") - if not rows: - return j.fail("seed data missing", "no products in category graphics-cards") - target = V.unique_extreme(rows, "spec_1_value", largest=True) - if target is None: + left = V.product(initial, "oneplus-12") + right = V.product(initial, "google-pixel-8-pro") + if not (left and right): + return j.fail("seed data missing", "one of the two products is not in initial_db") + if left["battery_hours"] == right["battery_hours"]: return j.fail("ambiguous ground truth", - "no unique extreme for spec_1_value in initial_db") - expected = target["score"] + "the two products tie on battery_hours in initial_db") + target = left if left["battery_hours"] > right["battery_hours"] else right + expected = target["battery_hours"] ans = V.final_answer(traj) - j.check("opened the fact-bearing page for the target product", - V.opened_detail_or_compare(traj, target["slug"]), - f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") + j.check("opened the comparison or both detail pages", + V.navigated_to(traj, f"/compare/{left['slug']}-vs-{right['slug']}") + or V.navigated_to(traj, f"/compare/{right['slug']}-vs-{left['slug']}") + or (V.opened_detail_or_compare(traj, left["slug"]) + and V.opened_detail_or_compare(traj, right["slug"])), + f"steps={V.step_urls(traj)[-6:]}") V.terminal_state_is_sound(j, traj) j.check("answer names the right product", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") j.check("answer states the derived value", V.mentions_number(ans, expected), f"expected={expected} from initial_db") ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", - "Versus Score of the graphics card with the most VRAM") + "which of the two phones lasts longer and for how many hours") j.check("anchored LLM agreement", ok, why, llm=True) diff --git a/sites/versus/verify/verify_11.py b/sites/versus/verify/verify_11.py index d22e058d..9e678946 100644 --- a/sites/versus/verify/verify_11.py +++ b/sites/versus/verify/verify_11.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Versus--11: price of the heaviest camera.""" +"""Versus--11: Bob saves the Apple Watch Series 9 vs Garmin Venu 3 comparison.""" import sys from pathlib import Path @@ -8,27 +8,40 @@ def body(j, traj, initial, after): - rows = V.products(initial, "cameras") - if not rows: - return j.fail("seed data missing", "no products in category cameras") - target = V.unique_extreme(rows, "spec_3_value", largest=True) - if target is None: - return j.fail("ambiguous ground truth", - "no unique extreme for spec_3_value in initial_db") - expected = target["price"] + email = "bob.c@test.com" + pair = frozenset(({"apple-watch-series-9", "garmin-venu-3"})) + before = V.saved_pairs(initial, email) + now = V.saved_pairs(after, email) + if before is None: + return j.fail("initial_db unreadable", "cannot establish the before state") + if now is None: + return j.fail("after_db unavailable", + "a stateful task cannot be graded without the after state") + if pair in before: + return j.fail("task design error", + "the requested comparison is already saved in the seed, so the " + "after state would be identical whether or not the agent acted") ans = V.final_answer(traj) - j.check("opened the fact-bearing page for the target product", - V.opened_detail_or_compare(traj, target["slug"]), - f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") - V.terminal_state_is_sound(j, traj) - j.check("answer names the right product", - V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") - j.check("answer states the derived value", - V.mentions_money(ans, expected), f"expected={expected} from initial_db") - ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", - "price of the heaviest camera") - j.check("anchored LLM agreement", ok, why, llm=True) + j.check("the answer was emitted from a page on this site", + V.answered_on_site(traj), + f"terminal url={(traj.get('steps') or [{}])[-1].get('url')!r}") + j.check("reported what it did, without denying it", + bool(ans) and not V.looks_negated(ans), + f"answer={ans!r} (this task is graded on the state change; the report " + f"must still exist and must not contradict it)") + + j.check("signed in", V.navigated_to(traj, "/login"), + f"steps={V.step_urls(traj)[:6]}") + j.check("opened the comparison page for the requested pair", + V.navigated_to(traj, "/compare/apple-watch-series-9-vs-garmin-venu-3") + or V.navigated_to(traj, "/compare/garmin-venu-3-vs-apple-watch-series-9"), + f"steps={V.step_urls(traj)[-6:]}") + j.check("the comparison is actually saved to that account", + pair in now, f"account pairs after the run = {sorted(map(sorted, now))}") + j.check("no unrelated comparison was added", + len(now - before - {pair}) == 0, + f"unexpected additions = {sorted(map(sorted, now - before - {pair}))}") if __name__ == "__main__": diff --git a/sites/versus/verify/verify_12.py b/sites/versus/verify/verify_12.py index f518644b..bfa312b9 100644 --- a/sites/versus/verify/verify_12.py +++ b/sites/versus/verify/verify_12.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Versus--12: longer battery of OnePlus 12 vs Pixel 8 Pro.""" +"""Versus--12: weight of the longest-lasting smartwatch.""" import sys from pathlib import Path @@ -8,30 +8,26 @@ def body(j, traj, initial, after): - left = V.product(initial, "oneplus-12") - right = V.product(initial, "google-pixel-8-pro") - if not (left and right): - return j.fail("seed data missing", "one of the two products is not in initial_db") - if left["battery_hours"] == right["battery_hours"]: + rows = V.products(initial, "smartwatches") + if not rows: + return j.fail("seed data missing", "no products in category smartwatches") + target = V.unique_extreme(rows, "battery_hours", largest=True) + if target is None: return j.fail("ambiguous ground truth", - "the two products tie on battery_hours in initial_db") - target = left if left["battery_hours"] > right["battery_hours"] else right - expected = target["battery_hours"] + "no unique extreme for battery_hours in initial_db") + expected = target["spec_3_value"] ans = V.final_answer(traj) - j.check("opened the comparison or both detail pages", - V.navigated_to(traj, f"/compare/{left['slug']}-vs-{right['slug']}") - or V.navigated_to(traj, f"/compare/{right['slug']}-vs-{left['slug']}") - or (V.opened_detail_or_compare(traj, left["slug"]) - and V.opened_detail_or_compare(traj, right["slug"])), - f"steps={V.step_urls(traj)[-6:]}") + j.check("opened the fact-bearing page for the target product", + V.opened_detail_or_compare(traj, target["slug"]), + f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") V.terminal_state_is_sound(j, traj) j.check("answer names the right product", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") j.check("answer states the derived value", V.mentions_number(ans, expected), f"expected={expected} from initial_db") ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", - "which of the two phones lasts longer and for how many hours") + "weight in grams of the smartwatch with the longest battery life") j.check("anchored LLM agreement", ok, why, llm=True) diff --git a/sites/versus/verify/verify_13.py b/sites/versus/verify/verify_13.py index 802e4485..77541a1f 100644 --- a/sites/versus/verify/verify_13.py +++ b/sites/versus/verify/verify_13.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Versus--13: Bob saves the Apple Watch Series 9 vs Garmin Venu 3 comparison.""" +"""Versus--13: price of the headphones with the lowest ANC score.""" import sys from pathlib import Path @@ -8,40 +8,27 @@ def body(j, traj, initial, after): - email = "bob.c@test.com" - pair = frozenset(({"apple-watch-series-9", "garmin-venu-3"})) - before = V.saved_pairs(initial, email) - now = V.saved_pairs(after, email) - if before is None: - return j.fail("initial_db unreadable", "cannot establish the before state") - if now is None: - return j.fail("after_db unavailable", - "a stateful task cannot be graded without the after state") - if pair in before: - return j.fail("task design error", - "the requested comparison is already saved in the seed, so the " - "after state would be identical whether or not the agent acted") + rows = V.products(initial, "headphones") + if not rows: + return j.fail("seed data missing", "no products in category headphones") + target = V.unique_extreme(rows, "spec_1_value", largest=False) + if target is None: + return j.fail("ambiguous ground truth", + "no unique extreme for spec_1_value in initial_db") + expected = target["price"] ans = V.final_answer(traj) - j.check("the answer was emitted from a page on this site", - V.answered_on_site(traj), - f"terminal url={(traj.get('steps') or [{}])[-1].get('url')!r}") - j.check("reported what it did, without denying it", - bool(ans) and not V.looks_negated(ans), - f"answer={ans!r} (this task is graded on the state change; the report " - f"must still exist and must not contradict it)") - - j.check("signed in", V.navigated_to(traj, "/login"), - f"steps={V.step_urls(traj)[:6]}") - j.check("opened the comparison page for the requested pair", - V.navigated_to(traj, "/compare/apple-watch-series-9-vs-garmin-venu-3") - or V.navigated_to(traj, "/compare/garmin-venu-3-vs-apple-watch-series-9"), - f"steps={V.step_urls(traj)[-6:]}") - j.check("the comparison is actually saved to that account", - pair in now, f"account pairs after the run = {sorted(map(sorted, now))}") - j.check("no unrelated comparison was added", - len(now - before - {pair}) == 0, - f"unexpected additions = {sorted(map(sorted, now - before - {pair}))}") + j.check("opened the fact-bearing page for the target product", + V.opened_detail_or_compare(traj, target["slug"]), + f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") + V.terminal_state_is_sound(j, traj) + j.check("answer names the right product", + V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") + j.check("answer states the derived value", + V.mentions_money(ans, expected), f"expected={expected} from initial_db") + ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", + "price of the headphones with the lowest ANC score") + j.check("anchored LLM agreement", ok, why, llm=True) if __name__ == "__main__": diff --git a/sites/versus/verify/verify_14.py b/sites/versus/verify/verify_14.py index 3ba7aa09..4190e5af 100644 --- a/sites/versus/verify/verify_14.py +++ b/sites/versus/verify/verify_14.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Versus--14: weight of the longest-lasting smartwatch.""" +"""Versus--14: benchmark score of the winner of RTX 4070 Super vs RX 7800 XT.""" import sys from pathlib import Path @@ -8,26 +8,30 @@ def body(j, traj, initial, after): - rows = V.products(initial, "smartwatches") - if not rows: - return j.fail("seed data missing", "no products in category smartwatches") - target = V.unique_extreme(rows, "battery_hours", largest=True) - if target is None: + left = V.product(initial, "rtx-4070-super") + right = V.product(initial, "radeon-rx-7800-xt") + if not (left and right): + return j.fail("seed data missing", "one of the two products is not in initial_db") + if left["score"] == right["score"]: return j.fail("ambiguous ground truth", - "no unique extreme for battery_hours in initial_db") + "the two products tie on score in initial_db") + target = left if left["score"] > right["score"] else right expected = target["spec_3_value"] ans = V.final_answer(traj) - j.check("opened the fact-bearing page for the target product", - V.opened_detail_or_compare(traj, target["slug"]), - f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") + j.check("opened the comparison or both detail pages", + V.navigated_to(traj, f"/compare/{left['slug']}-vs-{right['slug']}") + or V.navigated_to(traj, f"/compare/{right['slug']}-vs-{left['slug']}") + or (V.opened_detail_or_compare(traj, left["slug"]) + and V.opened_detail_or_compare(traj, right["slug"])), + f"steps={V.step_urls(traj)[-6:]}") V.terminal_state_is_sound(j, traj) j.check("answer names the right product", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") j.check("answer states the derived value", V.mentions_number(ans, expected), f"expected={expected} from initial_db") ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", - "weight in grams of the smartwatch with the longest battery life") + "benchmark score of the winner of the two graphics cards") j.check("anchored LLM agreement", ok, why, llm=True) diff --git a/sites/versus/verify/verify_15.py b/sites/versus/verify/verify_15.py index be350831..c3de550c 100644 --- a/sites/versus/verify/verify_15.py +++ b/sites/versus/verify/verify_15.py @@ -1,33 +1,34 @@ #!/usr/bin/env python3 -"""Versus--15: price of the headphones with the lowest ANC score.""" +"""Versus--15: area of a named city, reached through the site.""" import sys from pathlib import Path - sys.path.insert(0, str(Path(__file__).resolve().parent)) import verify_lib as V +SLUG = "shanghai" + def body(j, traj, initial, after): - rows = V.products(initial, "headphones") - if not rows: - return j.fail("seed data missing", "no products in category headphones") - target = V.unique_extreme(rows, "spec_1_value", largest=False) - if target is None: - return j.fail("ambiguous ground truth", - "no unique extreme for spec_1_value in initial_db") - expected = target["price"] + target = V.product(initial, SLUG) + if not target: + return j.fail("seed data missing", f"{SLUG} is not in initial_db") + expected = target["spec_2_value"] - ans = V.final_answer(traj) - j.check("opened the fact-bearing page for the target product", - V.opened_detail_or_compare(traj, target["slug"]), - f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") - V.terminal_state_is_sound(j, traj) - j.check("answer names the right product", - V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") - j.check("answer states the derived value", - V.mentions_money(ans, expected), f"expected={expected} from initial_db") - ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", - "price of the headphones with the lowest ANC score") + ans = V.terminal_state_is_sound(j, traj) + j.check("reached the city through the site", + V.navigated_any(traj, [f"/category/{target['category_slug']}", "/rankings", "/search"]), + f"steps={V.step_urls(traj)[:6]}") + j.check("opened the fact-bearing page for the city", + V.opened_detail_or_compare(traj, SLUG), f"steps={V.step_urls(traj)[-6:]}") + j.check("answer states the derived area", + V.mentions_number(ans, expected, tol=1.0), + f"expected={expected} {target['unit_2']} from initial_db") + j.check("answer does not report the population instead", + not V.mentions_number(ans, target["spec_1_value"], tol=1.0) + or V.mentions_number(ans, expected, tol=1.0), + "population and area must not be confused") + ok, why = V.llm_text_match(ans, f"{expected} {target['unit_2']}", + "area in square kilometres of the named city") j.check("anchored LLM agreement", ok, why, llm=True) diff --git a/sites/versus/verify/verify_16.py b/sites/versus/verify/verify_16.py index 35ec10fd..eba28c8a 100644 --- a/sites/versus/verify/verify_16.py +++ b/sites/versus/verify/verify_16.py @@ -1,37 +1,30 @@ #!/usr/bin/env python3 -"""Versus--16: benchmark score of the winner of RTX 4070 Super vs RX 7800 XT.""" +"""Versus--16: enrolment of the longest-teaching university.""" import sys from pathlib import Path - sys.path.insert(0, str(Path(__file__).resolve().parent)) import verify_lib as V def body(j, traj, initial, after): - left = V.product(initial, "rtx-4070-super") - right = V.product(initial, "radeon-rx-7800-xt") - if not (left and right): - return j.fail("seed data missing", "one of the two products is not in initial_db") - if left["score"] == right["score"]: - return j.fail("ambiguous ground truth", - "the two products tie on score in initial_db") - target = left if left["score"] > right["score"] else right - expected = target["spec_3_value"] + rows = V.products(initial, "universities") + if not rows: + return j.fail("seed data missing", "no universities in initial_db") + target = V.unique_extreme(rows, "spec_2_value", largest=False) # earliest founding year + if target is None: + return j.fail("ambiguous ground truth", "no unique earliest founding year") + expected = target["spec_1_value"] - ans = V.final_answer(traj) - j.check("opened the comparison or both detail pages", - V.navigated_to(traj, f"/compare/{left['slug']}-vs-{right['slug']}") - or V.navigated_to(traj, f"/compare/{right['slug']}-vs-{left['slug']}") - or (V.opened_detail_or_compare(traj, left["slug"]) - and V.opened_detail_or_compare(traj, right["slug"])), - f"steps={V.step_urls(traj)[-6:]}") - V.terminal_state_is_sound(j, traj) - j.check("answer names the right product", + ans = V.terminal_state_is_sound(j, traj) + j.check("opened the fact-bearing page for the target university", + V.opened_detail_or_compare(traj, target["slug"]), + f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") + j.check("answer names the right university", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") - j.check("answer states the derived value", - V.mentions_number(ans, expected), f"expected={expected} from initial_db") - ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", - "benchmark score of the winner of the two graphics cards") + j.check("answer states the derived enrolment", + V.mentions_number(ans, expected, tol=1.0), f"expected={expected}") + ok, why = V.llm_text_match(ans, f"{target['name']} — {expected} students", + "student enrolment of the longest-teaching university") j.check("anchored LLM agreement", ok, why, llm=True) diff --git a/sites/versus/verify/verify_17.py b/sites/versus/verify/verify_17.py index e0bb81e8..518d7e24 100644 --- a/sites/versus/verify/verify_17.py +++ b/sites/versus/verify/verify_17.py @@ -1,34 +1,37 @@ #!/usr/bin/env python3 -"""Versus--17: area of a named city, reached through the site.""" +"""Versus--17: larger of two named cities by area.""" import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import verify_lib as V -SLUG = "shanghai" +LEFT, RIGHT = "shanghai", "beijing" def body(j, traj, initial, after): - target = V.product(initial, SLUG) - if not target: - return j.fail("seed data missing", f"{SLUG} is not in initial_db") + left, right = V.product(initial, LEFT), V.product(initial, RIGHT) + if not (left and right): + return j.fail("seed data missing", "one of the two cities is not in initial_db") + if left["spec_2_value"] == right["spec_2_value"]: + return j.fail("ambiguous ground truth", "the two cities tie on area") + target = left if left["spec_2_value"] > right["spec_2_value"] else right + other = right if target is left else left expected = target["spec_2_value"] ans = V.terminal_state_is_sound(j, traj) - j.check("reached the city through the site", - V.navigated_any(traj, [f"/category/{target['category_slug']}", "/rankings", "/search"]), - f"steps={V.step_urls(traj)[:6]}") - j.check("opened the fact-bearing page for the city", - V.opened_detail_or_compare(traj, SLUG), f"steps={V.step_urls(traj)[-6:]}") - j.check("answer states the derived area", - V.mentions_number(ans, expected, tol=1.0), - f"expected={expected} {target['unit_2']} from initial_db") - j.check("answer does not report the population instead", - not V.mentions_number(ans, target["spec_1_value"], tol=1.0) - or V.mentions_number(ans, expected, tol=1.0), - "population and area must not be confused") - ok, why = V.llm_text_match(ans, f"{expected} {target['unit_2']}", - "area in square kilometres of the named city") + j.check("opened the comparison or both detail pages", + V.navigated_to(traj, f"/compare/{LEFT}-vs-{RIGHT}") + or V.navigated_to(traj, f"/compare/{RIGHT}-vs-{LEFT}") + or (V.opened_detail_or_compare(traj, LEFT) + and V.opened_detail_or_compare(traj, RIGHT)), + f"steps={V.step_urls(traj)[-6:]}") + j.check("answer names the larger city by area", + V.mentions_product(ans, target["name"]), + f"expected={target['name']!r} ({expected} vs {other['spec_2_value']})") + j.check("answer states that area", + V.mentions_number(ans, expected, tol=1.0), f"expected={expected}") + ok, why = V.llm_text_match(ans, f"{target['name']} — {expected} km2", + "which of the two cities is larger by area, and that area") j.check("anchored LLM agreement", ok, why, llm=True) diff --git a/sites/versus/verify/verify_18.py b/sites/versus/verify/verify_18.py index 9b7dd52e..65bdde29 100644 --- a/sites/versus/verify/verify_18.py +++ b/sites/versus/verify/verify_18.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Versus--18: enrolment of the longest-teaching university.""" +"""Versus--18: area of the most populous city.""" import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) @@ -7,24 +7,24 @@ def body(j, traj, initial, after): - rows = V.products(initial, "universities") + rows = V.products(initial, "cities") if not rows: - return j.fail("seed data missing", "no universities in initial_db") - target = V.unique_extreme(rows, "spec_2_value", largest=False) # earliest founding year + return j.fail("seed data missing", "no cities in initial_db") + target = V.unique_extreme(rows, "spec_1_value", largest=True) if target is None: - return j.fail("ambiguous ground truth", "no unique earliest founding year") - expected = target["spec_1_value"] + return j.fail("ambiguous ground truth", "no unique largest population") + expected = target["spec_2_value"] ans = V.terminal_state_is_sound(j, traj) - j.check("opened the fact-bearing page for the target university", + j.check("opened the fact-bearing page for the target city", V.opened_detail_or_compare(traj, target["slug"]), f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") - j.check("answer names the right university", + j.check("answer names the most populous city", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") - j.check("answer states the derived enrolment", + j.check("answer states its area", V.mentions_number(ans, expected, tol=1.0), f"expected={expected}") - ok, why = V.llm_text_match(ans, f"{target['name']} — {expected} students", - "student enrolment of the longest-teaching university") + ok, why = V.llm_text_match(ans, f"{target['name']} — {expected} km2", + "area of the most populous city") j.check("anchored LLM agreement", ok, why, llm=True) diff --git a/sites/versus/verify/verify_19.py b/sites/versus/verify/verify_19.py index 750c50e4..71beea32 100644 --- a/sites/versus/verify/verify_19.py +++ b/sites/versus/verify/verify_19.py @@ -1,38 +1,42 @@ #!/usr/bin/env python3 -"""Versus--19: larger of two named cities by area.""" +"""Versus--19: Carol saves the Bologna vs Sapienza comparison.""" import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import verify_lib as V -LEFT, RIGHT = "shanghai", "beijing" +EMAIL = "carol.d@test.com" +A, B = "university-of-bologna", "sapienza-university-of-rome" def body(j, traj, initial, after): - left, right = V.product(initial, LEFT), V.product(initial, RIGHT) - if not (left and right): - return j.fail("seed data missing", "one of the two cities is not in initial_db") - if left["spec_2_value"] == right["spec_2_value"]: - return j.fail("ambiguous ground truth", "the two cities tie on area") - target = left if left["spec_2_value"] > right["spec_2_value"] else right - other = right if target is left else left - expected = target["spec_2_value"] + pair = frozenset((A, B)) + before, now = V.saved_pairs(initial, EMAIL), V.saved_pairs(after, EMAIL) + if before is None: + return j.fail("initial_db unreadable", "cannot establish the before state") + if now is None: + return j.fail("after_db unavailable", + "a stateful task cannot be graded without the after state") + if pair in before: + return j.fail("task design error", + "the requested comparison is already saved in the seed") - ans = V.terminal_state_is_sound(j, traj) - j.check("opened the comparison or both detail pages", - V.navigated_to(traj, f"/compare/{LEFT}-vs-{RIGHT}") - or V.navigated_to(traj, f"/compare/{RIGHT}-vs-{LEFT}") - or (V.opened_detail_or_compare(traj, LEFT) - and V.opened_detail_or_compare(traj, RIGHT)), + ans = V.final_answer(traj) + j.check("the answer was emitted from a page on this site", + V.answered_on_site(traj), + f"terminal url={(traj.get('steps') or [{}])[-1].get('url')!r}") + j.check("reported what it did, without denying it", + bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") + j.check("signed in", V.navigated_to(traj, "/login"), f"steps={V.step_urls(traj)[:6]}") + j.check("opened the comparison page for the requested pair", + V.navigated_to(traj, f"/compare/{A}-vs-{B}") + or V.navigated_to(traj, f"/compare/{B}-vs-{A}"), f"steps={V.step_urls(traj)[-6:]}") - j.check("answer names the larger city by area", - V.mentions_product(ans, target["name"]), - f"expected={target['name']!r} ({expected} vs {other['spec_2_value']})") - j.check("answer states that area", - V.mentions_number(ans, expected, tol=1.0), f"expected={expected}") - ok, why = V.llm_text_match(ans, f"{target['name']} — {expected} km2", - "which of the two cities is larger by area, and that area") - j.check("anchored LLM agreement", ok, why, llm=True) + j.check("the comparison is actually saved to that account", + pair in now, f"account pairs after the run = {sorted(map(sorted, now))}") + j.check("no unrelated comparison was added", + len(now - before - {pair}) == 0, + f"unexpected additions = {sorted(map(sorted, now - before - {pair}))}") if __name__ == "__main__": diff --git a/sites/versus/verify/verify_20.py b/sites/versus/verify/verify_20.py deleted file mode 100644 index d99fc620..00000000 --- a/sites/versus/verify/verify_20.py +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env python3 -"""Versus--20: area of the most populous city.""" -import sys -from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parent)) -import verify_lib as V - - -def body(j, traj, initial, after): - rows = V.products(initial, "cities") - if not rows: - return j.fail("seed data missing", "no cities in initial_db") - target = V.unique_extreme(rows, "spec_1_value", largest=True) - if target is None: - return j.fail("ambiguous ground truth", "no unique largest population") - expected = target["spec_2_value"] - - ans = V.terminal_state_is_sound(j, traj) - j.check("opened the fact-bearing page for the target city", - V.opened_detail_or_compare(traj, target["slug"]), - f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") - j.check("answer names the most populous city", - V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") - j.check("answer states its area", - V.mentions_number(ans, expected, tol=1.0), f"expected={expected}") - ok, why = V.llm_text_match(ans, f"{target['name']} — {expected} km2", - "area of the most populous city") - j.check("anchored LLM agreement", ok, why, llm=True) - - -if __name__ == "__main__": - V.run("Versus--20", body) diff --git a/sites/versus/verify/verify_21.py b/sites/versus/verify/verify_21.py deleted file mode 100644 index f520d68b..00000000 --- a/sites/versus/verify/verify_21.py +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env python3 -"""Versus--21: Carol saves the Bologna vs Sapienza comparison.""" -import sys -from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parent)) -import verify_lib as V - -EMAIL = "carol.d@test.com" -A, B = "university-of-bologna", "sapienza-university-of-rome" - - -def body(j, traj, initial, after): - pair = frozenset((A, B)) - before, now = V.saved_pairs(initial, EMAIL), V.saved_pairs(after, EMAIL) - if before is None: - return j.fail("initial_db unreadable", "cannot establish the before state") - if now is None: - return j.fail("after_db unavailable", - "a stateful task cannot be graded without the after state") - if pair in before: - return j.fail("task design error", - "the requested comparison is already saved in the seed") - - ans = V.final_answer(traj) - j.check("the answer was emitted from a page on this site", - V.answered_on_site(traj), - f"terminal url={(traj.get('steps') or [{}])[-1].get('url')!r}") - j.check("reported what it did, without denying it", - bool(ans) and not V.looks_negated(ans), f"answer={ans!r}") - j.check("signed in", V.navigated_to(traj, "/login"), f"steps={V.step_urls(traj)[:6]}") - j.check("opened the comparison page for the requested pair", - V.navigated_to(traj, f"/compare/{A}-vs-{B}") - or V.navigated_to(traj, f"/compare/{B}-vs-{A}"), - f"steps={V.step_urls(traj)[-6:]}") - j.check("the comparison is actually saved to that account", - pair in now, f"account pairs after the run = {sorted(map(sorted, now))}") - j.check("no unrelated comparison was added", - len(now - before - {pair}) == 0, - f"unexpected additions = {sorted(map(sorted, now - before - {pair}))}") - - -if __name__ == "__main__": - V.run("Versus--21", body) diff --git a/sites/versus/verify/verify_8.py b/sites/versus/verify/verify_8.py index 828bc296..289b772a 100644 --- a/sites/versus/verify/verify_8.py +++ b/sites/versus/verify/verify_8.py @@ -1,33 +1,33 @@ #!/usr/bin/env python3 -"""Versus--8: battery life of the Garmin Venu 3, reached via the smartwatches category.""" +"""Versus--8: Versus Score of the graphics card with the most VRAM.""" import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import verify_lib as V -SLUG = "garmin-venu-3" - def body(j, traj, initial, after): - target = V.product(initial, SLUG) - if not target: - return j.fail("seed data missing", f"{SLUG} is not in initial_db") - expected = target["spec_2_value"] + rows = V.products(initial, "graphics-cards") + if not rows: + return j.fail("seed data missing", "no products in category graphics-cards") + target = V.unique_extreme(rows, "spec_1_value", largest=True) + if target is None: + return j.fail("ambiguous ground truth", + "no unique extreme for spec_1_value in initial_db") + expected = target["score"] ans = V.final_answer(traj) - j.check("opened the smartwatches category listing", - V.navigated_to(traj, f"/category/{target['category_slug']}"), - f"steps={V.step_urls(traj)}") - j.check("opened the fact-bearing page for the product", - V.opened_detail_or_compare(traj, SLUG), - f"steps={V.step_urls(traj)[-6:]}") + j.check("opened the fact-bearing page for the target product", + V.opened_detail_or_compare(traj, target["slug"]), + f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") V.terminal_state_is_sound(j, traj) - j.check("answer states the derived battery life", - V.mentions_number(ans, expected), - f"expected={expected} {target['unit_2']} from initial_db") - ok, why = V.llm_text_match(ans, f"{expected} {target['unit_2']}", - "battery life in hours of the Garmin Venu 3") + j.check("answer names the right product", + V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") + j.check("answer states the derived value", + V.mentions_number(ans, expected), f"expected={expected} from initial_db") + ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", + "Versus Score of the graphics card with the most VRAM") j.check("anchored LLM agreement", ok, why, llm=True) diff --git a/sites/versus/verify/verify_9.py b/sites/versus/verify/verify_9.py index f3f35137..101c2860 100644 --- a/sites/versus/verify/verify_9.py +++ b/sites/versus/verify/verify_9.py @@ -1,40 +1,33 @@ #!/usr/bin/env python3 -"""Versus--9: build the R6 Mark II vs A7 IV comparison in the picker and name the winner.""" +"""Versus--9: price of the heaviest camera.""" import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import verify_lib as V -LEFT, RIGHT = "canon-eos-r6-mark-ii", "sony-a7-iv" - def body(j, traj, initial, after): - left, right = V.product(initial, LEFT), V.product(initial, RIGHT) - if not (left and right): - return j.fail("seed data missing", "one of the two cameras is not in initial_db") - if left["score"] == right["score"]: + rows = V.products(initial, "cameras") + if not rows: + return j.fail("seed data missing", "no products in category cameras") + target = V.unique_extreme(rows, "spec_3_value", largest=True) + if target is None: return j.fail("ambiguous ground truth", - "the two cameras tie on Versus Score in initial_db, so the " - "site's winner depends on argument order") - # The site declares the higher Versus Score the winner (app.winner()). - target = left if left["score"] > right["score"] else right - loser = right if target is left else left + "no unique extreme for spec_3_value in initial_db") + expected = target["price"] ans = V.final_answer(traj) - j.check("used the compare picker", V.navigated_to(traj, "/compare"), - f"steps={V.step_urls(traj)}") - j.check("landed on the comparison page for this pair", - V.navigated_to(traj, f"/compare/{LEFT}-vs-{RIGHT}") - or V.navigated_to(traj, f"/compare/{RIGHT}-vs-{LEFT}"), - f"steps={V.step_urls(traj)[-6:]}") + j.check("opened the fact-bearing page for the target product", + V.opened_detail_or_compare(traj, target["slug"]), + f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") V.terminal_state_is_sound(j, traj) - j.check("answer names the product the site declares the winner", - V.mentions_product(ans, target["name"]), - f"expected={target['name']!r} (score {target['score']} vs {loser['score']})") - ok, why = V.llm_text_match( - ans, f"the winner is {target['name']}", - "which product the site declares the winner of this comparison") + j.check("answer names the right product", + V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") + j.check("answer states the derived value", + V.mentions_money(ans, expected), f"expected={expected} from initial_db") + ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", + "price of the heaviest camera") j.check("anchored LLM agreement", ok, why, llm=True) From eadc70a3d197f5e5204d15b22bbf545fc27dac7f Mon Sep 17 00:00:00 2001 From: JackJin <1037461232@qq.com> Date: Mon, 14 Sep 2026 12:15:03 +0800 Subject: [PATCH 16/18] review(versus): retarget task 18 so it stops overlapping task 17 The adversarial wrong-task replay caught it: a run of task 17 -- compare Shanghai and Beijing by area -- passed verifier 18 outright. Task 18 asked for the area of the most populous city, which is also Shanghai, so task 17's answer carried both the name and the figure, and its trajectory had opened the same detail page. That is not a verifier hole so much as two tasks pointed at the same fact about the same entity. Task 18 now asks for the population of the largest city by area, which is Ganzhou, and carries a real distractor: Nanyang has a larger population on a smaller footprint, so reading "biggest" as population gives the wrong answer. --- sites/versus/tasks.jsonl | 2 +- sites/versus/verify/verify_18.py | 33 ++++++++++++++++++++++---------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/sites/versus/tasks.jsonl b/sites/versus/tasks.jsonl index 949ec34b..0646299b 100644 --- a/sites/versus/tasks.jsonl +++ b/sites/versus/tasks.jsonl @@ -16,5 +16,5 @@ {"web_name": "Versus", "id": "Versus--15", "ques": "Open the Cities category and report the area in square kilometres of Shanghai.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_15.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have reached the city through the site — the cities category listing, the rankings or search — and opened its detail or comparison page.\n2. The final answer MUST give that city's area in square kilometres as shown on the site.\n3. Area is not printed on cards or in the ranking list, so an answer with no detail or comparison page visit is a FAIL.\n4. An empty answer, or one that reports the population instead of the area, is a FAIL."} {"web_name": "Versus", "id": "Versus--16", "ques": "Among the universities listed, find the one that has been teaching longest and report how many students it has.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_16.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the detail or comparison page of the university with the earliest founding year.\n2. The final answer MUST name that university AND give its student enrolment as shown on the site.\n3. Founding year and enrolment appear only on detail and comparison pages, so an answer with no such visit is a FAIL.\n4. An empty answer, or naming a university founded later than the earliest, is a FAIL."} {"web_name": "Versus", "id": "Versus--17", "ques": "Compare Shanghai and Beijing, then report which has the larger area and what that area is.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_17.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the side-by-side comparison of these two cities, or both detail pages.\n2. The final answer MUST name the larger city by area AND give that area in square kilometres.\n3. Area is not printed on cards, so an answer with no comparison or detail page visit is a FAIL.\n4. Naming the more populous city instead of the larger one is a FAIL."} -{"web_name": "Versus", "id": "Versus--18", "ques": "Find the city with the largest population and report its area in square kilometres.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_18.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the detail or comparison page of the most populous city.\n2. The final answer MUST name that city AND give its area in square kilometres.\n3. Neither population nor area is printed on cards, so an answer with no detail or comparison page visit is a FAIL.\n4. An empty answer, or naming a city that is not the most populous, is a FAIL."} +{"web_name": "Versus", "id": "Versus--18", "ques": "Find the city with the largest area and report how many people live there.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_18.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have opened the detail or comparison page of the largest city by area.\n2. The final answer MUST name that city AND give its population as shown on the site.\n3. Neither area nor population is printed on cards, so an answer with no detail or comparison page visit is a FAIL.\n4. An empty answer, or naming the most populous city instead of the largest by area, is a FAIL."} {"web_name": "Versus", "id": "Versus--19", "ques": "Sign in as carol.d@test.com with password TestPass123! and save the comparison between the University of Bologna and Sapienza University of Rome to that account.", "web": "http://localhost:40028/", "upstream_url": "https://versus.com/", "verifier_path": "sites/versus/verify/verify_19.py", "judge_rubric": "FACT CHECKPOINTS\n1. The agent MUST have signed in as that user and opened the comparison of these two universities.\n2. The comparison MUST actually be saved to that account: the account's saved comparisons after the run must include this pair, which it did not contain before.\n3. A self-reported success with no change to the account is a FAIL.\n4. Saving to a different account, or saving a different pair, is a FAIL."} diff --git a/sites/versus/verify/verify_18.py b/sites/versus/verify/verify_18.py index 65bdde29..47c91119 100644 --- a/sites/versus/verify/verify_18.py +++ b/sites/versus/verify/verify_18.py @@ -1,5 +1,11 @@ #!/usr/bin/env python3 -"""Versus--18: area of the most populous city.""" +"""Versus--18: population of the largest city by area. + +Deliberately keyed on area rather than population: an earlier version asked for +the area of the most populous city, which centred on the same entity and the +same figure as Versus--17, and the adversarial wrong-task replay caught one +run satisfying the other's checks. +""" import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) @@ -10,21 +16,28 @@ def body(j, traj, initial, after): rows = V.products(initial, "cities") if not rows: return j.fail("seed data missing", "no cities in initial_db") - target = V.unique_extreme(rows, "spec_1_value", largest=True) + target = V.unique_extreme(rows, "spec_2_value", largest=True) # area if target is None: - return j.fail("ambiguous ground truth", "no unique largest population") - expected = target["spec_2_value"] + return j.fail("ambiguous ground truth", "no unique largest area") + expected = target["spec_1_value"] # population + most_populous = V.unique_extreme(rows, "spec_1_value", largest=True) ans = V.terminal_state_is_sound(j, traj) - j.check("opened the fact-bearing page for the target city", + j.check("opened the fact-bearing page for the largest city by area", V.opened_detail_or_compare(traj, target["slug"]), f"slug={target['slug']} steps={V.step_urls(traj)[-6:]}") - j.check("answer names the most populous city", - V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") - j.check("answer states its area", + j.check("answer names the largest city by area", + V.mentions_product(ans, target["name"]), + f"expected={target['name']!r} at {target['spec_2_value']} km2") + j.check("answer states that city's population", V.mentions_number(ans, expected, tol=1.0), f"expected={expected}") - ok, why = V.llm_text_match(ans, f"{target['name']} — {expected} km2", - "area of the most populous city") + if most_populous and most_populous["slug"] != target["slug"]: + j.check("answer is not about the most populous city instead", + not (V.mentions_product(ans, most_populous["name"]) + and not V.mentions_product(ans, target["name"])), + f"distractor={most_populous['name']!r}") + ok, why = V.llm_text_match(ans, f"{target['name']} — {expected} people", + "population of the city with the largest area") j.check("anchored LLM agreement", ok, why, llm=True) From 18e880c8f57edb0a25fd512d351bdc0fdc5072ca Mon Sep 17 00:00:00 2001 From: JackJin <1037461232@qq.com> Date: Mon, 14 Sep 2026 23:17:09 +0800 Subject: [PATCH 17/18] fix(versus): pasting the page must not count as answering it An independent blind review failed Versus--16 on a run that every deterministic check had passed. The answer asserted "88500.0 students" while the panel text pasted after it carried the real 96945, and the numeric check searched the whole answer, so the quoted evidence satisfied a claim that contradicted it. 88500 matches no university on the site; it was a figure the guided run asserted without ever reading it. Two layers, both fixed. The run script is corrected to the sourced figure. More importantly the check is: claims_number, claims_money and claims_product now match only within the claim region -- the text before the first sign of quoted page text. An answer that states its figure up front passes; one that only quotes does not. Without that, an agent could paste a page and satisfy every numeric and naming check without committing to an answer. Reproduced before fixing: a wrong_claim_correct_dump fixture passed verify_0, verify_16 and verify_18 unmodified. Getting the boundary right took two attempts -- a greedy label pattern matched from the start of the answer and put the cut at zero, and a "head or text" fallback then returned the whole string, which would have reopened the hole it was closing. State-graded tasks (7, 11, 19) are deliberately exempt: they are judged on the database row, not the prose, so only a denial fails them. The matrix records that as an expectation rather than leaving it as an unexplained pass. Also pins that sites/versus/verify holds exactly the expected files. A rename left sixteen stray "verify_N 2.py" copies there; they were never committed but they crashed the harness that globs the directory to decide what to grade. --- .../versus/tests/test_functional_contract.py | 14 +++++ sites/versus/verify/verify_0.py | 2 +- sites/versus/verify/verify_1.py | 2 +- sites/versus/verify/verify_10.py | 2 +- sites/versus/verify/verify_12.py | 2 +- sites/versus/verify/verify_13.py | 2 +- sites/versus/verify/verify_14.py | 2 +- sites/versus/verify/verify_15.py | 6 +- sites/versus/verify/verify_16.py | 2 +- sites/versus/verify/verify_17.py | 2 +- sites/versus/verify/verify_18.py | 2 +- sites/versus/verify/verify_2.py | 2 +- sites/versus/verify/verify_3.py | 2 +- sites/versus/verify/verify_4.py | 2 +- sites/versus/verify/verify_5.py | 2 +- sites/versus/verify/verify_6.py | 2 +- sites/versus/verify/verify_8.py | 2 +- sites/versus/verify/verify_9.py | 2 +- sites/versus/verify/verify_lib.py | 57 +++++++++++++++++++ 19 files changed, 90 insertions(+), 19 deletions(-) diff --git a/sites/versus/tests/test_functional_contract.py b/sites/versus/tests/test_functional_contract.py index 4005eff6..468262e2 100644 --- a/sites/versus/tests/test_functional_contract.py +++ b/sites/versus/tests/test_functional_contract.py @@ -111,6 +111,20 @@ def test_task_ids_are_contiguous_and_unique(self): self.assertEqual(len(ids), len(set(ids)), "duplicate task ids") self.assertEqual(ids, [f"Versus--{i}" for i in range(len(ids))]) + def test_verify_dir_holds_exactly_the_expected_files(self): + """A rename left 16 stray "verify_N 2.py" copies in the directory once. + + They were never committed, but they crashed the adversarial harness, + which globs the directory to decide what to grade. A directory that is + the input to grading has to be exactly what it claims. + """ + vd = SITE_DIR / "verify" + expected = {f"verify_{i}.py" for i in range(len(load_tasks()))} | {"verify_lib.py"} + actual = {p.name for p in vd.iterdir() if p.is_file() and p.suffix == ".py"} + self.assertEqual(actual, expected, + f"unexpected: {sorted(actual - expected)}; " + f"missing: {sorted(expected - actual)}") + def test_task_count_is_in_the_review_guide_range(self): self.assertGreaterEqual(len(load_tasks()), 15) self.assertLessEqual(len(load_tasks()), 20) diff --git a/sites/versus/verify/verify_0.py b/sites/versus/verify/verify_0.py index b9aaca5e..68488c65 100644 --- a/sites/versus/verify/verify_0.py +++ b/sites/versus/verify/verify_0.py @@ -29,7 +29,7 @@ def body(j, traj, initial, after): j.check("answer names the right product", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") j.check("answer states the derived value", - V.mentions_number(ans, expected), f"expected={expected} from initial_db") + V.claims_number(ans, expected), f"expected={expected} from initial_db") ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", "camera score of the higher-scoring of the two phones") j.check("anchored LLM agreement", ok, why, llm=True) diff --git a/sites/versus/verify/verify_1.py b/sites/versus/verify/verify_1.py index 276bdd1c..604d7749 100644 --- a/sites/versus/verify/verify_1.py +++ b/sites/versus/verify/verify_1.py @@ -29,7 +29,7 @@ def body(j, traj, initial, after): j.check("answer names the right product", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") j.check("answer states the derived value", - V.mentions_number(ans, expected), f"expected={expected} from initial_db") + V.claims_number(ans, expected), f"expected={expected} from initial_db") ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", "ANC score of the headphones with at least 50 hours of battery") j.check("anchored LLM agreement", ok, why, llm=True) diff --git a/sites/versus/verify/verify_10.py b/sites/versus/verify/verify_10.py index 2e32a489..cb174513 100644 --- a/sites/versus/verify/verify_10.py +++ b/sites/versus/verify/verify_10.py @@ -29,7 +29,7 @@ def body(j, traj, initial, after): j.check("answer names the right product", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") j.check("answer states the derived value", - V.mentions_number(ans, expected), f"expected={expected} from initial_db") + V.claims_number(ans, expected), f"expected={expected} from initial_db") ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", "which of the two phones lasts longer and for how many hours") j.check("anchored LLM agreement", ok, why, llm=True) diff --git a/sites/versus/verify/verify_12.py b/sites/versus/verify/verify_12.py index bfa312b9..97ed0784 100644 --- a/sites/versus/verify/verify_12.py +++ b/sites/versus/verify/verify_12.py @@ -25,7 +25,7 @@ def body(j, traj, initial, after): j.check("answer names the right product", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") j.check("answer states the derived value", - V.mentions_number(ans, expected), f"expected={expected} from initial_db") + V.claims_number(ans, expected), f"expected={expected} from initial_db") ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", "weight in grams of the smartwatch with the longest battery life") j.check("anchored LLM agreement", ok, why, llm=True) diff --git a/sites/versus/verify/verify_13.py b/sites/versus/verify/verify_13.py index 77541a1f..6ca34522 100644 --- a/sites/versus/verify/verify_13.py +++ b/sites/versus/verify/verify_13.py @@ -25,7 +25,7 @@ def body(j, traj, initial, after): j.check("answer names the right product", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") j.check("answer states the derived value", - V.mentions_money(ans, expected), f"expected={expected} from initial_db") + V.claims_money(ans, expected), f"expected={expected} from initial_db") ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", "price of the headphones with the lowest ANC score") j.check("anchored LLM agreement", ok, why, llm=True) diff --git a/sites/versus/verify/verify_14.py b/sites/versus/verify/verify_14.py index 4190e5af..f8b99e98 100644 --- a/sites/versus/verify/verify_14.py +++ b/sites/versus/verify/verify_14.py @@ -29,7 +29,7 @@ def body(j, traj, initial, after): j.check("answer names the right product", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") j.check("answer states the derived value", - V.mentions_number(ans, expected), f"expected={expected} from initial_db") + V.claims_number(ans, expected), f"expected={expected} from initial_db") ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", "benchmark score of the winner of the two graphics cards") j.check("anchored LLM agreement", ok, why, llm=True) diff --git a/sites/versus/verify/verify_15.py b/sites/versus/verify/verify_15.py index c3de550c..fbb18928 100644 --- a/sites/versus/verify/verify_15.py +++ b/sites/versus/verify/verify_15.py @@ -21,11 +21,11 @@ def body(j, traj, initial, after): j.check("opened the fact-bearing page for the city", V.opened_detail_or_compare(traj, SLUG), f"steps={V.step_urls(traj)[-6:]}") j.check("answer states the derived area", - V.mentions_number(ans, expected, tol=1.0), + V.claims_number(ans, expected, tol=1.0), f"expected={expected} {target['unit_2']} from initial_db") j.check("answer does not report the population instead", - not V.mentions_number(ans, target["spec_1_value"], tol=1.0) - or V.mentions_number(ans, expected, tol=1.0), + not V.claims_number(ans, target["spec_1_value"], tol=1.0) + or V.claims_number(ans, expected, tol=1.0), "population and area must not be confused") ok, why = V.llm_text_match(ans, f"{expected} {target['unit_2']}", "area in square kilometres of the named city") diff --git a/sites/versus/verify/verify_16.py b/sites/versus/verify/verify_16.py index eba28c8a..87077a48 100644 --- a/sites/versus/verify/verify_16.py +++ b/sites/versus/verify/verify_16.py @@ -22,7 +22,7 @@ def body(j, traj, initial, after): j.check("answer names the right university", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") j.check("answer states the derived enrolment", - V.mentions_number(ans, expected, tol=1.0), f"expected={expected}") + V.claims_number(ans, expected, tol=1.0), f"expected={expected}") ok, why = V.llm_text_match(ans, f"{target['name']} — {expected} students", "student enrolment of the longest-teaching university") j.check("anchored LLM agreement", ok, why, llm=True) diff --git a/sites/versus/verify/verify_17.py b/sites/versus/verify/verify_17.py index 518d7e24..d893cbd9 100644 --- a/sites/versus/verify/verify_17.py +++ b/sites/versus/verify/verify_17.py @@ -29,7 +29,7 @@ def body(j, traj, initial, after): V.mentions_product(ans, target["name"]), f"expected={target['name']!r} ({expected} vs {other['spec_2_value']})") j.check("answer states that area", - V.mentions_number(ans, expected, tol=1.0), f"expected={expected}") + V.claims_number(ans, expected, tol=1.0), f"expected={expected}") ok, why = V.llm_text_match(ans, f"{target['name']} — {expected} km2", "which of the two cities is larger by area, and that area") j.check("anchored LLM agreement", ok, why, llm=True) diff --git a/sites/versus/verify/verify_18.py b/sites/versus/verify/verify_18.py index 47c91119..203097c3 100644 --- a/sites/versus/verify/verify_18.py +++ b/sites/versus/verify/verify_18.py @@ -30,7 +30,7 @@ def body(j, traj, initial, after): V.mentions_product(ans, target["name"]), f"expected={target['name']!r} at {target['spec_2_value']} km2") j.check("answer states that city's population", - V.mentions_number(ans, expected, tol=1.0), f"expected={expected}") + V.claims_number(ans, expected, tol=1.0), f"expected={expected}") if most_populous and most_populous["slug"] != target["slug"]: j.check("answer is not about the most populous city instead", not (V.mentions_product(ans, most_populous["name"]) diff --git a/sites/versus/verify/verify_2.py b/sites/versus/verify/verify_2.py index f657bc50..a6e68f71 100644 --- a/sites/versus/verify/verify_2.py +++ b/sites/versus/verify/verify_2.py @@ -27,7 +27,7 @@ def body(j, traj, initial, after): j.check("answer names the right product", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") j.check("answer states the derived value", - V.mentions_number(ans, expected), f"expected={expected} from initial_db") + V.claims_number(ans, expected), f"expected={expected} from initial_db") ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", "power draw in watts of the highest ranked graphics card") j.check("anchored LLM agreement", ok, why, llm=True) diff --git a/sites/versus/verify/verify_3.py b/sites/versus/verify/verify_3.py index 0ec6e7fc..1ecfce43 100644 --- a/sites/versus/verify/verify_3.py +++ b/sites/versus/verify/verify_3.py @@ -27,7 +27,7 @@ def body(j, traj, initial, after): j.check("answer names the right product", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") j.check("answer states the derived value", - V.mentions_number(ans, expected), f"expected={expected} from initial_db") + V.claims_number(ans, expected), f"expected={expected} from initial_db") ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", "burst speed in fps of the camera with the most megapixels") j.check("anchored LLM agreement", ok, why, llm=True) diff --git a/sites/versus/verify/verify_4.py b/sites/versus/verify/verify_4.py index b592fa2f..15bd3a4d 100644 --- a/sites/versus/verify/verify_4.py +++ b/sites/versus/verify/verify_4.py @@ -29,7 +29,7 @@ def body(j, traj, initial, after): j.check("answer names the right product", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") j.check("answer states the derived value", - V.mentions_number(ans, expected), f"expected={expected} from initial_db") + V.claims_number(ans, expected), f"expected={expected} from initial_db") ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", "which of the two headphones lasts longer and for how many hours") j.check("anchored LLM agreement", ok, why, llm=True) diff --git a/sites/versus/verify/verify_5.py b/sites/versus/verify/verify_5.py index 83f7949f..c7fcefd3 100644 --- a/sites/versus/verify/verify_5.py +++ b/sites/versus/verify/verify_5.py @@ -26,7 +26,7 @@ def body(j, traj, initial, after): # Which seeded pair does the answer name? Both product names must appear. matched = [pair for pair in before - if all(V.mentions_product(ans, names[slug]) for slug in pair)] + if all(V.claims_product(ans, names[slug]) for slug in pair)] j.check("answer names both products of one comparison already on the account", len(matched) >= 1, f"seeded pairs={sorted(map(sorted, before))} answer={ans!r}") diff --git a/sites/versus/verify/verify_6.py b/sites/versus/verify/verify_6.py index 2a4feb33..412ecbe1 100644 --- a/sites/versus/verify/verify_6.py +++ b/sites/versus/verify/verify_6.py @@ -29,7 +29,7 @@ def body(j, traj, initial, after): j.check("answer names the right product", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") j.check("answer states the derived value", - V.mentions_number(ans, expected), f"expected={expected} from initial_db") + V.claims_number(ans, expected), f"expected={expected} from initial_db") ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", "display size in inches of the highest scoring smartphone at $1000 or less") j.check("anchored LLM agreement", ok, why, llm=True) diff --git a/sites/versus/verify/verify_8.py b/sites/versus/verify/verify_8.py index 289b772a..b0f20398 100644 --- a/sites/versus/verify/verify_8.py +++ b/sites/versus/verify/verify_8.py @@ -25,7 +25,7 @@ def body(j, traj, initial, after): j.check("answer names the right product", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") j.check("answer states the derived value", - V.mentions_number(ans, expected), f"expected={expected} from initial_db") + V.claims_number(ans, expected), f"expected={expected} from initial_db") ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", "Versus Score of the graphics card with the most VRAM") j.check("anchored LLM agreement", ok, why, llm=True) diff --git a/sites/versus/verify/verify_9.py b/sites/versus/verify/verify_9.py index 101c2860..b3d2f0b3 100644 --- a/sites/versus/verify/verify_9.py +++ b/sites/versus/verify/verify_9.py @@ -25,7 +25,7 @@ def body(j, traj, initial, after): j.check("answer names the right product", V.mentions_product(ans, target["name"]), f"expected={target['name']!r}") j.check("answer states the derived value", - V.mentions_money(ans, expected), f"expected={expected} from initial_db") + V.claims_money(ans, expected), f"expected={expected} from initial_db") ok, why = V.llm_text_match(ans, f"{target['name']} — {expected}", "price of the heaviest camera") j.check("anchored LLM agreement", ok, why, llm=True) diff --git a/sites/versus/verify/verify_lib.py b/sites/versus/verify/verify_lib.py index d544b3d4..98322b1c 100644 --- a/sites/versus/verify/verify_lib.py +++ b/sites/versus/verify/verify_lib.py @@ -219,6 +219,63 @@ def _numbers(text): return [float(x) for x in re.findall(r"-?\d+(?:\.\d+)?", (text or "").replace(",", ""))] +# Markers a run uses to separate its own claim from the page text it pastes as +# evidence. Everything from the first marker on is quoted material, not an +# assertion. +EVIDENCE_MARKERS = ("spec panel:", "panel:", "table:", "ranking:", "account page", + "winner band:", "filtered list:", " | ") + + +# Pasted page text also shows up as ": FIELD 1234.0" without any of the +# markers above, so the label shape is detected too. +# The label must not span a sentence boundary: allowing "." inside it let the +# pattern start at the beginning of the answer and swallow the claim itself. +DUMP_SHAPE = re.compile(r"[A-Z][\w'\-]+(?: [\w'\-]+){0,6}:\s+[A-Z][A-Z ]{2,}") + + +def claim_region(text): + """The part of an answer the run is actually asserting. + + An independent reviewer caught a run claiming 88500 students while the panel + dump pasted after it carried the real 96945; a whole-answer numeric search + was satisfied by the dump, so every deterministic check passed a wrong + answer. Pasting the page must not substitute for answering. + + The boundary is a convention, and it is a deliberately generous one: the + claim is everything before the first sign of quoted page text. An answer + that states its figure up front passes; one that only quotes does not. + """ + text = text or "" + low = text.lower() + cuts = [low.index(m) for m in EVIDENCE_MARKERS if m in low] + m = DUMP_SHAPE.search(text) + if m: + cuts.append(m.start()) + cut = min(cuts, default=len(text)) + # An answer that is entirely quoted page text asserts nothing. Returning the + # whole string here would restore exactly the hole this closes. + return text[:cut].strip() + + +def claims_number(text, value, tol=0.05): + """The value must appear in what the run asserts, not only in quoted text.""" + return mentions_number(claim_region(text), value, tol) + + +def claims_money(text, value): + return mentions_money(claim_region(text), value) + + +def claims_product(text, name): + """Naming a product only counts when the run asserts it. + + Same hole as the numeric one: an account page pasted as evidence carries + every product name on it, so a whole-answer search is satisfied without the + run ever committing to an answer. + """ + return mentions_product(claim_region(text), name) + + def mentions_number(text, value, tol=0.05): """True when the answer states `value`. Accepts 336, 336.0, '336 h', '336-hour'.""" try: From 99eb96df2a74b808f40f991a5db5dbfe9e7cca34 Mon Sep 17 00:00:00 2001 From: JackJin <1037461232@qq.com> Date: Tue, 15 Sep 2026 01:32:58 +0800 Subject: [PATCH 18/18] fix(versus): replace synthetic art with sourced images --- Dockerfile | 15 +- sites/versus/.requires-images | 1 + sites/versus/NOTICE.md | 87 +- sites/versus/README.md | 36 +- sites/versus/app.py | 2 + sites/versus/asset_inventory.json | 2960 +++++++++++++++++ sites/versus/check_generated_assets.py | 74 - sites/versus/fetch_images.py | 174 + sites/versus/generate_art.py | 180 - sites/versus/generated_asset_inventory.json | 542 --- sites/versus/requirements.txt | 2 + sites/versus/static/css/main.css | 1 + sites/versus/templates/_product_card.html | 5 +- sites/versus/templates/about.html | 7 +- sites/versus/templates/base.html | 3 +- sites/versus/templates/compare.html | 10 +- sites/versus/templates/product.html | 5 +- .../versus/tests/test_functional_contract.py | 67 +- 18 files changed, 3252 insertions(+), 919 deletions(-) create mode 100644 sites/versus/.requires-images create mode 100644 sites/versus/asset_inventory.json delete mode 100644 sites/versus/check_generated_assets.py create mode 100644 sites/versus/fetch_images.py delete mode 100644 sites/versus/generate_art.py delete mode 100644 sites/versus/generated_asset_inventory.json diff --git a/Dockerfile b/Dockerfile index 7fef4718..2bea3328 100644 --- a/Dockerfile +++ b/Dockerfile @@ -70,16 +70,11 @@ RUN cd /opt/WebSyn/healthline && test -f instance_seed/healthline.db && \ PYTHONHASHSEED=0 python3 migrate_seed.py && \ python3 prune_unreferenced_images.py --apply && rm -rf instance -# Versus ships deliberately synthetic product art (see sites/versus/NOTICE.md) -# rather than photography. The tiles are regenerated here and gated on exact -# coverage + per-file SHA-256 + PNG decode, the same contract webmd_doctor, -# compass and walmart_careers use, so altered or missing art fails the build. -# The seed is code-generated too; the benchmark password hash is a frozen -# constant, so both the tiles and the DB are byte-identical on every build. -RUN cd /opt/WebSyn/versus && \ - rm -rf static/images/products && \ - python3 generate_art.py && \ - python3 check_generated_assets.py +# Versus ships source-backed entity imagery from the pinned asset bundle. +# The generic gate enforces exact coverage, hashes, source URLs and WebP headers. +RUN python3 /opt/check_asset_inventory.py /opt/WebSyn/versus +# The seed remains code-generated; the benchmark password hash is frozen so the +# SQLite output is byte-identical on every build. RUN cd /opt/WebSyn/versus && \ rm -rf instance instance_seed && \ mkdir -p instance_seed && \ diff --git a/sites/versus/.requires-images b/sites/versus/.requires-images new file mode 100644 index 00000000..5ab16aa0 --- /dev/null +++ b/sites/versus/.requires-images @@ -0,0 +1 @@ +This site requires the source-backed static/images bundle pinned in the Hugging Face asset repository. diff --git a/sites/versus/NOTICE.md b/sites/versus/NOTICE.md index a7f67518..b235c9e0 100644 --- a/sites/versus/NOTICE.md +++ b/sites/versus/NOTICE.md @@ -1,71 +1,52 @@ # Third-party material in the Versus mirror -This file records the disposition of third-party material this site relies on, so a -reviewer can determine what is included, where it came from, and how to remove it. +This file records how third-party media is used so a reviewer can identify every +redistributed asset, its source and how to remove it. ## Non-affiliation and trademarks WebHarbor is an independent research benchmark for web agents. This mirror is not -affiliated with, authorized by, endorsed by or sponsored by Versus Tech, nor by Apple, -Samsung, Google, OnePlus, Sony, Bose, Sennheiser, Canon, Nikon, Fujifilm, NVIDIA, AMD, -Garmin or Fitbit. Product and company names are used only to identify the products being -compared. No license or permission is granted or implied by their presence, and nothing -here should be read as a statement by any of those companies. +affiliated with, authorized by, endorsed by or sponsored by Versus Tech or by any +manufacturer, city or university represented here. Names and marks identify the compared +entities only. No licence or permission is granted or implied by their presence. -The running site makes no request to any external service. This is verified, not -asserted: a Playwright sweep of every route at four viewports recorded zero external -requests. +The running site makes no request to an external service. All media is stored locally in +the pinned Hugging Face asset bundle. -## Imagery — deliberately synthetic, no third-party media redistributed +## Imagery -**This site redistributes no third-party images, fonts or media of any kind.** The 20 -product tiles under `static/images/products/` are drawn programmatically by -`generate_art.py`: a category-derived backdrop, a schematic device outline, the brand -initials and the product name, over the site's own palette. Each tile carries a visible -`SYNTHETIC ART` label. They depict no real product and reproduce no photograph. +The mirror contains 107 real, entity-matched images under +`static/images/products/`: 20 consumer-electronics products, 52 cities and 35 +universities. -Why, stated plainly: +- 85 images come from Wikimedia Commons. `asset_inventory.json` records the exact + Commons file page, direct thumbnail URL, author and licence for each file. +- 22 images come from official manufacturer, university, campus, press, identity or + verified organization video pages. They are copyrighted by the named organizations + and are reproduced at reduced resolution solely to identify the entity in this + non-commercial research benchmark. -- versus.com began returning CloudFront 403 to this client during the review and - remained blocked across repeated probes. No attempt was made to work around that. -- Freely licensed photography for these specific models could not be matched reliably. - A Wikimedia Commons sweep returned a freely licensed candidate for 18 of 20 products, - but strict model matching showed the hits were largely the wrong item — a OnePlus 8 - for the OnePlus 12, an A7R IV for the A7 IV, a 4070 Ti Super for the 4070 Super, a - card-slot close-up for the Nikon Z8, earbuds for over-ear headphones. Shipping those - would inject false product facts into a benchmark whose purpose is factual navigation. -- Generated art is the precedent already merged on `main`: `webmd_doctor` ships - Pillow-drawn initials avatars and gradient poster panels "instead of photography". +The source bytes are resized to a 960 × 720 WebP. Photographs use a centered 4:3 crop; +product renders and logos use a contained layout. No image is presented as a measurement +or task answer. `fetch_images.py` pins the source and output hashes, while +`scripts/check_asset_inventory.py` enforces exact coverage, hashes, HTTPS source URLs and +WebP headers during asset checks and the Docker build. -This is a deliberate deviation from the reviewer checklist's "Real images" line, and it -is the maintainers' call whether to accept it. It is recorded here rather than glossed. - -Engineering contract, matching the `webmd_doctor` / `compass` / `walmart_careers` gates: - -- `generate_art.py` is deterministic — no RNG, no clock, no locale, Pillow's bundled - default font, fixed PNG compression with no ancillary chunks — so two builds of the - same commit produce byte-identical tiles. -- `generated_asset_inventory.json` pins every tile's path, byte length and SHA-256. -- `check_generated_assets.py` enforces exact coverage (nothing missing, extra or stale), - per-file size and SHA-256 equality, and a full PNG decode. It runs in the Docker build, - so altered or missing art fails the build instead of degrading silently. -- The tiles are build products, not commits: `static/images/` is gitignored, and the - inventory is what travels in Git. - -No task answer depends on reading an image. All 17 verifiers are deterministic and never -open a screenshot; every graded fact is text in the DOM. +The inventory is the per-file attribution source of truth. In addition to the fields +required by the repository gate, each row records the represented entity, Wikidata QID +when applicable, source kind, source page, source file, licence/disposition, author, +source dimensions and normalized output dimensions. ## Data -Product names, brands, release years, list prices and published specifications follow the -manufacturers' figures. **The Versus Score is not versus.com's value** — it is synthetic -benchmark data, as are all user accounts and saved comparisons. The distinction is stated -on `/about` and in the site footer on every page. +Product names, brands, release years, list prices and published specifications follow +the manufacturers' figures. The **Versus Score is not versus.com's value**. It is +synthetic benchmark data, as are all accounts and saved comparisons. `/about` and the +footer state this distinction on the running site. ## Removal -To remove the generated art: delete `static/images/products/`, the two `generate_art.py` -/ `check_generated_assets.py` build steps from the Dockerfile, and the `` references -in `templates/_product_card.html`, `product.html` and `compare.html`. The application, -its routes, its seeded data and all 17 tasks continue to function without them; only the -visual presentation changes. +To remove all third-party media, delete the 107 entries from `asset_inventory.json`, +remove `static/images/products/` from the Versus Hugging Face archive, and remove the +image elements from `_product_card.html`, `product.html` and `compare.html`. The routes, +seeded data and all 20 tasks continue to function; only the visual presentation changes. diff --git a/sites/versus/README.md b/sites/versus/README.md index d8851441..36476eb4 100644 --- a/sites/versus/README.md +++ b/sites/versus/README.md @@ -9,43 +9,49 @@ curl -so /dev/null -w "%{http_code}\n" http://localhost:40029/ curl -X POST http://localhost:40029/reset/versus ``` -## Build products, not assets +## Assets and build products -This site fetches nothing from Hugging Face. Both of its binary-ish artefacts are -regenerated deterministically during the Docker build and gated there: +The entity images are source-backed assets distributed through the pinned Hugging Face +bundle. The SQLite seed remains a deterministic build product: | Artefact | Generator | Gate | | --- | --- | --- | -| `static/images/products/*.png` (20 tiles) | `generate_art.py` | `check_generated_assets.py` — coverage, size, SHA-256, PNG decode | +| `static/images/products/*.webp` (107 images) | `fetch_images.py` from pinned source URLs | `scripts/check_asset_inventory.py` — exact coverage, size, SHA-256, URL and WebP header | | `instance_seed/versus.db` | `app.py` import side effect | `md5(instance) == md5(instance_seed)` after `/reset/versus` | -Both are byte-identical across builds. The seed's benchmark password hash is a frozen +The seed's benchmark password hash is a frozen constant (`BENCHMARK_PASSWORD_HASH`) because `generate_password_hash()` draws a fresh scrypt salt per call, which made two builds of the same commit differ. -Regenerate locally and refresh the pinned hashes: +Re-fetch the exact recorded sources and reproduce the images with Pillow 11: ```bash -python3 generate_art.py --write-inventory -python3 check_generated_assets.py +uv run --python 3.12 --with pillow==11.0.0 --with requests==2.32.5 \ + python fetch_images.py +python ../../scripts/check_asset_inventory.py . ``` +`--refresh` rewrites pinned source/output hashes and is only for a reviewed source +change. `asset_inventory.json` records the represented entity, source page, direct asset +URL, source and output hashes, dimensions, attribution and licence/disposition. + ## What is real and what is not Product names, brands, release years, list prices and published specifications follow the manufacturers' figures. The **Versus Score, all user accounts and all saved comparisons -are synthetic benchmark data**; product art is programmatically drawn, not photography. -`/about` and the footer say so on every page. See `NOTICE.md`. +are synthetic benchmark data**. The 107 entity images are real, locally stored media: +85 are Wikimedia Commons files and 22 come from official product, campus, identity, +press or video pages. `/about` and the footer state the distinction. See `NOTICE.md`. ## Catalogue -20 products across 5 categories (smartphones, headphones, cameras, graphics cards, -smartwatches), 4 benchmark accounts sharing the password `TestPass123!`, and 3 saved -comparisons seeded for `alice.j@test.com`. +107 entities across 7 categories: 20 consumer-electronics products, 52 cities and 35 +universities. The seed also carries 4 benchmark accounts sharing the password +`TestPass123!` and 3 saved comparisons for `alice.j@test.com`. ## Tasks -17 tasks in `tasks.jsonl`, each with a deterministic verifier in `verify/` and a +20 tasks in `tasks.jsonl`, each with a deterministic verifier in `verify/` and a `judge_rubric`. Ground truth is derived from the passed `initial_db` rather than frozen in the verifier, so the expected answer moves with the seed. Navigation checks accept only steps on this site's own origin, with the port derived from `control_server.py`'s @@ -56,5 +62,5 @@ carry Score, Price and Year. Questions are written so the answer requires a page does not carry. ```bash -python3 -m unittest discover -s tests -v # 11 regression tests +python3 -m unittest discover -s tests -v ``` diff --git a/sites/versus/app.py b/sites/versus/app.py index 6a385176..00d1babe 100644 --- a/sites/versus/app.py +++ b/sites/versus/app.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +import mimetypes import os import re from functools import wraps @@ -21,6 +22,7 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +mimetypes.add_type("image/webp", ".webp") app = Flask(__name__, instance_path=os.path.join(BASE_DIR, "instance")) app.config["SECRET_KEY"] = "webharbor-versus-dev-key" diff --git a/sites/versus/asset_inventory.json b/sites/versus/asset_inventory.json new file mode 100644 index 00000000..614d83e4 --- /dev/null +++ b/sites/versus/asset_inventory.json @@ -0,0 +1,2960 @@ +{ + "schema_version": 1, + "site": "versus", + "asset_count": 107, + "captured_on": "2026-09-15", + "assets": [ + { + "path": "static/images/products/ahmedabad.webp", + "slug": "ahmedabad", + "entity_name": "Ahmedabad", + "qid": "Q1070", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Amdavad_Aerial.jpg", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/1/19/Amdavad_Aerial.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "Amdavad Aerial.jpg", + "license": "CC BY 3.0", + "license_url": "https://creativecommons.org/licenses/by/3.0", + "author": "JJaimin", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Amdavad+Aerial.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1440 + ], + "source_sha256": "fda7672a3e3622af1897fce7d73e435aed4509053774645c2d4ba54df7f4dda9", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 144844, + "sha256": "e24d101a9c0fb040595815b35f0c2246c7cd542a5700754881596203f609cb45" + }, + { + "path": "static/images/products/alexandria-university.webp", + "slug": "alexandria-university", + "entity_name": "Alexandria University", + "qid": "Q1424632", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Alexandria_University,_The_Main_Building.JPG", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/b/b1/Alexandria_University%2C_The_Main_Building.JPG/1920px-Alexandria_University%2C_The_Main_Building.JPG?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Alexandria University, The Main Building.JPG", + "license": "CC BY-SA 3.0", + "license_url": "https://creativecommons.org/licenses/by-sa/3.0", + "author": "Faris El-Gwely", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Alexandria+University%2C+The+Main+Building.JPG&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1035 + ], + "source_sha256": "c163ed46bf2f3050209389ad6e81e4f14080b2f21c62379de033b570c17f7528", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 110676, + "sha256": "04e8ae1ee1f139b8da4936ad7627b67f445af0d7b8bd5bda4a64704eb1b8d9ae" + }, + { + "path": "static/images/products/apple-airpods-max.webp", + "slug": "apple-airpods-max", + "entity_name": "AirPods Max", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Apple_airpods_max_3.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/6/68/Apple_airpods_max_3.jpg/1920px-Apple_airpods_max_3.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Apple airpods max 3.jpg", + "license": "CC BY-SA 3.0 de", + "license_url": "https://creativecommons.org/licenses/by-sa/3.0/de/deed.en", + "author": "Arne Müseler", + "entity_type": "electronics", + "fit": "contain", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Apple+airpods+max+3.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1955 + ], + "source_sha256": "2016f75ac01099147b36f8e788cdb3c3f6c8f58c9309c9ae2d4da5bbffc28532", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 13566, + "sha256": "9ae183291b847e921831cf816f05bd2e64d500e8a9023c0a232fdef4de402175" + }, + { + "path": "static/images/products/apple-watch-series-9.webp", + "slug": "apple-watch-series-9", + "entity_name": "Apple Watch Series 9", + "source_kind": "official_product_page", + "source_page": "https://support.apple.com/en-us/111833", + "source_url": "https://cdsassets.apple.com/live/7WUAS350/images/tech-specs/apple-watch-series-9.png", + "license": "Copyrighted; reproduced for non-commercial benchmark identification", + "license_url": "https://support.apple.com/en-us/111833", + "author": "Apple", + "entity_type": "electronics", + "fit": "contain", + "resolved_url": "https://cdsassets.apple.com/live/7WUAS350/images/tech-specs/apple-watch-series-9.png", + "source_content_type": "image/png", + "source_dimensions": [ + 1000, + 1000 + ], + "source_sha256": "dda62d268f9cdca308afab7319ae9dbc13c4534ddc1d69d9be927a0ddcdf17a2", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 16850, + "sha256": "dc6ae019b16b6e213f1242af5d856b4aec3cb03faa6433a69821fad46fcb7938" + }, + { + "path": "static/images/products/aristotle-university-of-thessaloniki.webp", + "slug": "aristotle-university-of-thessaloniki", + "entity_name": "Aristotle University of Thessaloniki", + "qid": "Q667568", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:UAT_philosophy.jpg", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/6/63/UAT_philosophy.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "UAT philosophy.jpg", + "license": "CC BY-SA 3.0", + "license_url": "http://creativecommons.org/licenses/by-sa/3.0/", + "author": "Tony Fangel", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=UAT+philosophy.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 2880 + ], + "source_sha256": "b2039eae8654b82946b3fe099ff6d0d35f1de8c723b52e591055ca8459f11268", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 86154, + "sha256": "49eac6ddcb292bfad46b8493b1a8d36b8e64635aa8d47ea7035e438f22d77a82" + }, + { + "path": "static/images/products/arizona-state-university.webp", + "slug": "arizona-state-university", + "entity_name": "Arizona State University", + "qid": "Q670897", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Asubiodesign.jpg", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/c/c3/Asubiodesign.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "Asubiodesign.jpg", + "license": "CC BY-SA 2.5", + "license_url": "https://creativecommons.org/licenses/by-sa/2.5", + "author": "The original uploader was Schwnj at English Wikipedia .", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Asubiodesign.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1279 + ], + "source_sha256": "19c215724b215f3ed43b6efe07fab232b5eee95d738d1451ea8d110285410793", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 52278, + "sha256": "5262f0c40c93e9c134aefafa457be9ed46013007078caedec0bdb7785593282b" + }, + { + "path": "static/images/products/baghdad.webp", + "slug": "baghdad", + "entity_name": "Baghdad", + "qid": "Q1530", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:5628442718_b10fc2c47f_o.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/4/4b/5628442718_b10fc2c47f_o.jpg/1920px-5628442718_b10fc2c47f_o.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "5628442718 b10fc2c47f o.jpg", + "license": "Public domain", + "license_url": "", + "author": "USACE HQ , JIM GORDAN, CIV, USACE", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=5628442718+b10fc2c47f+o.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1260 + ], + "source_sha256": "3e513fa96eb3dfb2e4f9bc6491a4dbe69b174ac45a968581d0d220d250c901fa", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 224974, + "sha256": "cff4032973437ff52b287214184e5c2ee64718ba6c9898007735bbad6fbbcdd7" + }, + { + "path": "static/images/products/baoding.webp", + "slug": "baoding", + "entity_name": "Baoding", + "qid": "Q58584", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:%E4%BF%9D%E5%AE%9A%E5%B8%82%E5%A4%A9%E9%99%85%E7%BA%BF_-_%E8%88%AA%E6%8B%8D_-_2025-10-24_01.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/c/c3/%E4%BF%9D%E5%AE%9A%E5%B8%82%E5%A4%A9%E9%99%85%E7%BA%BF_-_%E8%88%AA%E6%8B%8D_-_2025-10-24_01.jpg/1920px-%E4%BF%9D%E5%AE%9A%E5%B8%82%E5%A4%A9%E9%99%85%E7%BA%BF_-_%E8%88%AA%E6%8B%8D_-_2025-10-24_01.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "保定市天际线 - 航拍 - 2025-10-24 01.jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "瑞丽江的河水", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=%E4%BF%9D%E5%AE%9A%E5%B8%82%E5%A4%A9%E9%99%85%E7%BA%BF+-+%E8%88%AA%E6%8B%8D+-+2025-10-24+01.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1080 + ], + "source_sha256": "c8e4df41f456182bea899185897cda17fd9ef7b47b2a7a97850f57e929dd003a", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 192138, + "sha256": "661d74449799d8e2768cc838f5f52f8c977ccfb27b2f61af558b8b2e47bb65d3" + }, + { + "path": "static/images/products/beijing.webp", + "slug": "beijing", + "entity_name": "Beijing", + "qid": "Q956", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Skyline_of_Beijing_CBD_with_B-5906_approaching_(20211016171955).jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/0/06/Skyline_of_Beijing_CBD_with_B-5906_approaching_%2820211016171955%29.jpg/1920px-Skyline_of_Beijing_CBD_with_B-5906_approaching_%2820211016171955%29.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Skyline of Beijing CBD with B-5906 approaching (20211016171955).jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "N509FZ", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Skyline+of+Beijing+CBD+with+B-5906+approaching+%2820211016171955%29.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1280 + ], + "source_sha256": "8cd2474d89335300d19cb24965ea4a94dce04a5f74b4d83caf6bc6c1dbbbbd8b", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 165000, + "sha256": "f5911b5f99121fe1215f38d9fbc697e30964060ed28d02d4a2f096c2e5a44c92" + }, + { + "path": "static/images/products/bogot.webp", + "slug": "bogot", + "entity_name": "Bogotá", + "qid": "Q2841", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Centro_internacional.JPG", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/4/4c/Centro_internacional.JPG?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "Centro internacional.JPG", + "license": "CC BY-SA 3.0", + "license_url": "https://creativecommons.org/licenses/by-sa/3.0", + "author": "Felipe Restrepo Acosta", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Centro+internacional.JPG&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1080 + ], + "source_sha256": "331141327c3396657cd103480157160a3fb13ebf41cad6aaab44f08fafbe202b", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 142476, + "sha256": "32aa53f9fc836c69e9887fd26712750d28b9b6c237301d15b2d361b080098ba7" + }, + { + "path": "static/images/products/bose-quietcomfort-ultra.webp", + "slug": "bose-quietcomfort-ultra", + "entity_name": "Bose QuietComfort Ultra", + "source_kind": "official_product_page", + "source_page": "https://www.bose.com/pxp/bose-quietcomfort-ultra-headphones", + "source_url": "https://assets.bosecreative.com/transform/e78bbadf-cbee-443c-aeda-17b81dc71ec8/SF_PDP_GALLERY_BLACK-1?quality=90&io=width:816,height:667,transform:fit", + "license": "Copyrighted; reproduced for non-commercial benchmark identification", + "license_url": "https://www.bose.com/pxp/bose-quietcomfort-ultra-headphones", + "author": "Bose", + "entity_type": "electronics", + "fit": "contain", + "resolved_url": "https://assets.bosecreative.com/transform/e78bbadf-cbee-443c-aeda-17b81dc71ec8/SF_PDP_GALLERY_BLACK-1?quality=90&io=width:816,height:667,transform:fit", + "source_content_type": "image/png", + "source_dimensions": [ + 816, + 613 + ], + "source_sha256": "477e5bf1327b6ea7a8167a46105f397f78a3e086b7b420f5dafd079fe4ef06be", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 14828, + "sha256": "bb4d1e5b5e0086738cc1615eeea78edd13f255fd3a525eaf75c2a67071300a5c" + }, + { + "path": "static/images/products/cairo.webp", + "slug": "cairo", + "entity_name": "Cairo", + "qid": "Q85", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Cairo_Skyline_(2020).jpg", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/c/ca/Cairo_Skyline_%282020%29.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "Cairo Skyline (2020).jpg", + "license": "CC BY 4.0", + "license_url": "https://creativecommons.org/licenses/by/4.0", + "author": "Faris El-Gwely", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Cairo+Skyline+%282020%29.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1132 + ], + "source_sha256": "9d6d0437f6deb565fa414187678e587895572c2a340cb6bc56f170bc49e987d0", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 99248, + "sha256": "b4e6e7d5e2b5718d7c554806682b54ee289d83a6ede8784b2cdb46e8c4ae6bd7" + }, + { + "path": "static/images/products/canon-eos-r6-mark-ii.webp", + "slug": "canon-eos-r6-mark-ii", + "entity_name": "Canon EOS R6 Mark II", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Canon_EOS_R6_Mark_II_-_by_Henry_S%C3%B6derlund_(52546794891).jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/f/f8/Canon_EOS_R6_Mark_II_-_by_Henry_S%C3%B6derlund_%2852546794891%29.jpg/1920px-Canon_EOS_R6_Mark_II_-_by_Henry_S%C3%B6derlund_%2852546794891%29.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Canon EOS R6 Mark II - by Henry Söderlund (52546794891).jpg", + "license": "CC BY 2.0", + "license_url": "https://creativecommons.org/licenses/by/2.0", + "author": "Henry Söderlund from Helsinki, Finland", + "entity_type": "electronics", + "fit": "contain", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Canon+EOS+R6+Mark+II+-+by+Henry+S%C3%B6derlund+%2852546794891%29.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1280 + ], + "source_sha256": "850815893ffe72f3bf09003d10ee36e248bec81c0c086161ddbc2e6cfaffb3d6", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 29278, + "sha256": "7ff2826188f5b26ddf4221c03d0f08c8a5db5d3bfcc396641dcec3fe75b4d424" + }, + { + "path": "static/images/products/capital-university-egypt.webp", + "slug": "capital-university-egypt", + "entity_name": "Capital University (Egypt)", + "qid": "Q1364550", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:%D8%AC%D8%A7%D9%85%D8%B9%D8%A9_%D8%AD%D9%84%D9%88%D8%A7%D9%86.jpg", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/1/1d/%D8%AC%D8%A7%D9%85%D8%B9%D8%A9_%D8%AD%D9%84%D9%88%D8%A7%D9%86.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "جامعة حلوان.jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "Hamed.ragab.shoma", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=%D8%AC%D8%A7%D9%85%D8%B9%D8%A9+%D8%AD%D9%84%D9%88%D8%A7%D9%86.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1152 + ], + "source_sha256": "3c6c6fea3350b565cfb9960b6b92831cd2f25979e42bfa077ee6867b891140f7", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 132556, + "sha256": "135302c8b920d21a0eb4d15add4e1b0c8d9e45638a69a39ebd5e7513fd0925b1" + }, + { + "path": "static/images/products/changchun.webp", + "slug": "changchun", + "entity_name": "Changchun", + "qid": "Q92161", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Changchun_Montage_2017.png", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/0/0e/Changchun_Montage_2017.png?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "Changchun Montage 2017.png", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "Montage by 嗷大喵 ( 嗷大喵 )", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Changchun+Montage+2017.png&w=1920", + "source_content_type": "image/png", + "source_dimensions": [ + 1920, + 2582 + ], + "source_sha256": "e22f29b7796aa8de9ec481688ed15d27c92ee07c9988305367419a716e72e04e", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 45118, + "sha256": "993fa10b53475ee70cfba207fc959042bf76235dcae18435311df54eedfada92" + }, + { + "path": "static/images/products/changsha.webp", + "slug": "changsha", + "entity_name": "Changsha", + "qid": "Q174091", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Helong_Stadium.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/7/75/Helong_Stadium.jpg/1920px-Helong_Stadium.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Helong Stadium.jpg", + "license": "CC BY-SA 3.0", + "license_url": "https://creativecommons.org/licenses/by-sa/3.0", + "author": "Calton", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Helong+Stadium.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 998 + ], + "source_sha256": "bfc702490dae0a51ffd7c75146b7bf1eed6e9b994b2413e1e9bdc7dfc2faa9c7", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 89096, + "sha256": "d99ca7287a1f36313185f1ef3f255aaddea91d88dcdf4604a1de62c184226cc7" + }, + { + "path": "static/images/products/complutense-university-of-madrid.webp", + "slug": "complutense-university-of-madrid", + "entity_name": "Complutense University of Madrid", + "qid": "Q219694", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Rectorado_de_la_Universidad_Complutense_de_Madrid.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/3/31/Rectorado_de_la_Universidad_Complutense_de_Madrid.jpg/1920px-Rectorado_de_la_Universidad_Complutense_de_Madrid.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Rectorado de la Universidad Complutense de Madrid.jpg", + "license": "CC BY-SA 3.0", + "license_url": "https://creativecommons.org/licenses/by-sa/3.0", + "author": "Carlos Delgado", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Rectorado+de+la+Universidad+Complutense+de+Madrid.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 2560 + ], + "source_sha256": "05b6f466018fb82e7a5a2acfe859a705046ac383e96b6a74133f5576891bc02b", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 135400, + "sha256": "1d81d88b5cc094088cf86f6b6834b0cab92f7d159d6bb700541fb3292c370b52" + }, + { + "path": "static/images/products/damascus-university.webp", + "slug": "damascus-university", + "entity_name": "Damascus University", + "qid": "Q1351317", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Damascus_University.jpg", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/9/95/Damascus_University.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "Damascus University.jpg", + "license": "CC BY-SA 3.0", + "license_url": "https://creativecommons.org/licenses/by-sa/3.0", + "author": "Syrian Eng", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Damascus+University.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1180 + ], + "source_sha256": "b51d04a15400dd6cd3f2c1b47336b62490acea9c85ef0f4f91fa40bee7c6efaa", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 84320, + "sha256": "d69a064397ba86a2ae0a0854d4445dfcb5a925443a893be437ab07ccb9745a5c" + }, + { + "path": "static/images/products/dongguan.webp", + "slug": "dongguan", + "entity_name": "Dongguan", + "qid": "Q59218", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Dongguan_montage.jpg", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/8/81/Dongguan_montage.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "Dongguan montage.jpg", + "license": "CC BY-SA 3.0", + "license_url": "https://creativecommons.org/licenses/by-sa/3.0", + "author": "ASDFGH ( talk )", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Dongguan+montage.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 2475 + ], + "source_sha256": "cef180bc7e512050d7147ef4cb58a414a2fbb0365c876b2941a975b9e5d4de1a", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 117504, + "sha256": "d60cef66834e29849edcb171dd721c3dca61e025a923ef5f5d0adebf0cd7cc19" + }, + { + "path": "static/images/products/fitbit-sense-2.webp", + "slug": "fitbit-sense-2", + "entity_name": "Fitbit Sense 2", + "source_kind": "official_product_page", + "source_page": "https://store.google.com/us/product/fitbit_sense_2?hl=en-US", + "source_url": "https://lh3.googleusercontent.com/TKEi8OTpR7tib3JhVAyLeV22HM3I84sFQEWDW__GD9DREsU4Kg1P980jnaOU9wI9AiiriuW6juD9TmLJRl3VB0JybUYDcH46W9g=rw-e365-w700-rj-sc0xffffffff", + "license": "Copyrighted; reproduced for non-commercial benchmark identification", + "license_url": "https://store.google.com/us/product/fitbit_sense_2?hl=en-US", + "author": "Google / Fitbit", + "entity_type": "electronics", + "fit": "contain", + "resolved_url": "https://lh3.googleusercontent.com/TKEi8OTpR7tib3JhVAyLeV22HM3I84sFQEWDW__GD9DREsU4Kg1P980jnaOU9wI9AiiriuW6juD9TmLJRl3VB0JybUYDcH46W9g=rw-e365-w700-rj-sc0xffffffff", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 656, + 560 + ], + "source_sha256": "8fb6de95c30946dde646638f8cfdff0d43eb102c60151fca8d8f48ec64f3d77a", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 16404, + "sha256": "05c5a4af37d15cb3a4cdf02fbebd495f1d03e6e6d78221793517a3c657190bbf" + }, + { + "path": "static/images/products/foshan.webp", + "slug": "foshan", + "entity_name": "Foshan", + "qid": "Q34412", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Qiandeng_Lake_Park_at_Night_10.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/5/55/Qiandeng_Lake_Park_at_Night_10.jpg/1920px-Qiandeng_Lake_Park_at_Night_10.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Qiandeng Lake Park at Night 10.jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "EditQ", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Qiandeng+Lake+Park+at+Night+10.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1440 + ], + "source_sha256": "d4dd7d77d338e21df1cee491029ed05290ea0a527205234da9eb7bd75b0b3aab", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 143592, + "sha256": "9ec83b522d2dfd2930b6092eb11bf18d0f68f0d78ec62e22b230d2d8a06cc341" + }, + { + "path": "static/images/products/fujifilm-x-t5.webp", + "slug": "fujifilm-x-t5", + "entity_name": "Fujifilm X-T5", + "source_kind": "official_product_page", + "source_page": "https://shopusa.fujifilm-x.com/x-t5-x-t5/", + "source_url": "https://shopusa.fujifilm-x.com/media/catalog/product/1/6/16782301_MAIN00_Image_X-T5_front_CMOS_black_4.jpg?width=700&height=700&canvas=700,700&quality=90&bg-color=255,255,255&fit=bounds", + "license": "Copyrighted; reproduced for non-commercial benchmark identification", + "license_url": "https://shopusa.fujifilm-x.com/x-t5-x-t5/", + "author": "Fujifilm", + "entity_type": "electronics", + "fit": "contain", + "resolved_url": "https://shopusa.fujifilm-x.com/media/catalog/product/1/6/16782301_MAIN00_Image_X-T5_front_CMOS_black_4.jpg?width=700&height=700&canvas=700,700&quality=90&bg-color=255,255,255&fit=bounds", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 700, + 700 + ], + "source_sha256": "aa998790429dcbd287cb3c539ca337cdee166d6c574d5bb1cd177c248d98fcf6", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 33614, + "sha256": "c801509f0cd1ae87895ec7cb81317c879f47b9e0efbd7c0bb2f4e18fae482348" + }, + { + "path": "static/images/products/fuyang.webp", + "slug": "fuyang", + "entity_name": "Fuyang", + "qid": "Q360584", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Fuyang_Anhui_Downtown_Area_Walkway.jpeg", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/4/4d/Fuyang_Anhui_Downtown_Area_Walkway.jpeg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "Fuyang Anhui Downtown Area Walkway.jpeg", + "license": "CC0", + "license_url": "http://creativecommons.org/publicdomain/zero/1.0/deed.en", + "author": "Huihermit", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Fuyang+Anhui+Downtown+Area+Walkway.jpeg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1440 + ], + "source_sha256": "014174ccdd374bb5aace96387cfbeefae0fcd8aa5fced94f9c2ddafe82799e77", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 96714, + "sha256": "81bcf2996db5520d2365b4382db08d41ba304961773f9359222f33aff029c28d" + }, + { + "path": "static/images/products/fuzhou.webp", + "slug": "fuzhou", + "entity_name": "Fuzhou", + "qid": "Q68481", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Fuzhou_Taixi_CBD.jpg", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/7/7c/Fuzhou_Taixi_CBD.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "Fuzhou Taixi CBD.jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "Listwiseafford", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Fuzhou+Taixi+CBD.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 999 + ], + "source_sha256": "97be015dfbee882aad95e72ed3921d8c077c249d7de8edfd134bff110d04762a", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 109724, + "sha256": "1ff570042ddb55221a84815fd3d8499d730eec8c2128c0e2f22599b5c8152608" + }, + { + "path": "static/images/products/ganzhou.webp", + "slug": "ganzhou", + "entity_name": "Ganzhou", + "qid": "Q363166", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Ganzhounan_Railway_Station_7959_1.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/9/90/Ganzhounan_Railway_Station_7959_1.jpg/1920px-Ganzhounan_Railway_Station_7959_1.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Ganzhounan Railway Station 7959 1.jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "David290", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Ganzhounan+Railway+Station+7959+1.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1125 + ], + "source_sha256": "00195dc00e503d0acb9d92f2d08d0568ed5f006f9de2cd5f2f991b71319dff94", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 119462, + "sha256": "97e040fe70960978dbc721c12d4969b080972e4296c1941b061cd7117df5ffe4" + }, + { + "path": "static/images/products/garmin-venu-3.webp", + "slug": "garmin-venu-3", + "entity_name": "Garmin Venu 3", + "source_kind": "official_press_page", + "source_page": "https://www.garmin.com.cn/news/garmin/garmin-news-venu-3/", + "source_url": "https://www.garmin.com.cn/m/cn/g/news/news_venu3_cover.jpg", + "license": "Copyrighted; reproduced for non-commercial benchmark identification", + "license_url": "https://www.garmin.com.cn/news/garmin/garmin-news-venu-3/", + "author": "Garmin", + "entity_type": "electronics", + "fit": "contain", + "resolved_url": "https://www.garmin.com.cn/m/cn/g/news/news_venu3_cover.jpg", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1200, + 630 + ], + "source_sha256": "cf2c7603a3995713da8585d96295ad7bce6ed8d647826fa0e1384f9918917077", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 51700, + "sha256": "4f9377f6b51dfbb2b253aa773ebbe01690fce8aec9adef8d2b78c88b05532b4d" + }, + { + "path": "static/images/products/google-pixel-8-pro.webp", + "slug": "google-pixel-8-pro", + "entity_name": "Google Pixel 8 Pro", + "source_kind": "official_product_page", + "source_page": "https://store.google.com/us/product/pixel_8_pro?hl=en-US", + "source_url": "https://lh3.googleusercontent.com/TOcZSwRVklpjJaBQWw5efaj9S0IG8566ayw3R40Rfbqma3o6w5qUOecqmKb9rCZFGl1dEq1an46wpcSuxAwn916MPkaz90odEaw=rj-sc0xffffffff", + "license": "Copyrighted; reproduced for non-commercial benchmark identification", + "license_url": "https://store.google.com/us/product/pixel_8_pro?hl=en-US", + "author": "Google", + "entity_type": "electronics", + "fit": "contain", + "resolved_url": "https://lh3.googleusercontent.com/TOcZSwRVklpjJaBQWw5efaj9S0IG8566ayw3R40Rfbqma3o6w5qUOecqmKb9rCZFGl1dEq1an46wpcSuxAwn916MPkaz90odEaw=rj-sc0xffffffff", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 512, + 512 + ], + "source_sha256": "ff3493da746ba5a6400d119194f7bf2526df41ec21e0410f1e2bd2177079b1fb", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 3576, + "sha256": "773b468630be9d503c3c3d2fb5a69cdaf7a9a380a17291ea58c0d315ff4f5a01" + }, + { + "path": "static/images/products/grand-canyon-university.webp", + "slug": "grand-canyon-university", + "entity_name": "Grand Canyon University", + "qid": "Q4570025", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Grand_Canyon_University,_3300_W_Camelback_Rd,_Phoenix,_AZ_85017_-_panoramio_(195).jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/6/62/Grand_Canyon_University%2C_3300_W_Camelback_Rd%2C_Phoenix%2C_AZ_85017_-_panoramio_%28195%29.jpg/1920px-Grand_Canyon_University%2C_3300_W_Camelback_Rd%2C_Phoenix%2C_AZ_85017_-_panoramio_%28195%29.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Grand Canyon University, 3300 W Camelback Rd, Phoenix, AZ 85017 - panoramio (195).jpg", + "license": "CC BY 3.0", + "license_url": "https://creativecommons.org/licenses/by/3.0", + "author": "davidpinter", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Grand+Canyon+University%2C+3300+W+Camelback+Rd%2C+Phoenix%2C+AZ+85017+-+panoramio+%28195%29.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1272 + ], + "source_sha256": "d38c3aadae9ca7077023eb16db2fa64ba3fd8e92d95d4d7364727610f7e0f03d", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 163682, + "sha256": "e3625eddb6b88094a3c9fdbbd0e3c68c0f8a09c00789529d04774b5ae7e41c12" + }, + { + "path": "static/images/products/guangzhou.webp", + "slug": "guangzhou", + "entity_name": "Guangzhou", + "qid": "Q16572", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Canton_Tower_20220626_(cropped).jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/d/d6/Canton_Tower_20220626_%28cropped%29.jpg/1920px-Canton_Tower_20220626_%28cropped%29.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Canton Tower 20220626 (cropped).jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "Tim Wu", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Canton+Tower+20220626+%28cropped%29.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1280 + ], + "source_sha256": "15d08f858f35714dc6761d328a4c15457ed28d97a311cc478081fce3f4d2beee", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 111360, + "sha256": "e83d17ff9abbc5f183027fe1075cb33007f6eefdbcbfccec15c4facf6d0b1551" + }, + { + "path": "static/images/products/hangzhou.webp", + "slug": "hangzhou", + "entity_name": "Hangzhou", + "qid": "Q4970", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Huanglong_%26_Broken_Bridge_-_Hangzhou_City_%26_West_Lake_near_Broken_Bridge.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/a/a0/Huanglong_%26_Broken_Bridge_-_Hangzhou_City_%26_West_Lake_near_Broken_Bridge.jpg/1920px-Huanglong_%26_Broken_Bridge_-_Hangzhou_City_%26_West_Lake_near_Broken_Bridge.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Huanglong & Broken Bridge - Hangzhou City & West Lake near Broken Bridge.jpg", + "license": "CC BY 4.0", + "license_url": "https://creativecommons.org/licenses/by/4.0", + "author": "User:CatOnMars", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Huanglong+%26+Broken+Bridge+-+Hangzhou+City+%26+West+Lake+near+Broken+Bridge.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1280 + ], + "source_sha256": "7e86ea3508c8c10744b4ef3225d28a5876c0e4371a942130dc66679a537f73bf", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 251564, + "sha256": "4d5eca05dceddba3743d755f1a341a1ab8d6bccea27baf95609a3a7a79d9539e" + }, + { + "path": "static/images/products/hanoi.webp", + "slug": "hanoi", + "entity_name": "Hanoi", + "qid": "Q1858", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Hanoi_Skyline_-_NKS.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/1/10/Hanoi_Skyline_-_NKS.jpg/1920px-Hanoi_Skyline_-_NKS.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Hanoi Skyline - NKS.jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "NKSTTSSHNVN", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Hanoi+Skyline+-+NKS.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1080 + ], + "source_sha256": "610d0977aa0a1584786a6159a93e8788a403e0658ad016a8778d5e5152e308a7", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 148850, + "sha256": "678089586e45347e60f3f7c811ef1a620dc82e0c43d11751b640e9d2cb23aa37" + }, + { + "path": "static/images/products/hefei.webp", + "slug": "hefei", + "entity_name": "Hefei", + "qid": "Q185684", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Hefei_montage1.png", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/7/76/Hefei_montage1.png/1920px-Hefei_montage1.png?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Hefei montage1.png", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "Virgil Guo", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Hefei+montage1.png&w=1920", + "source_content_type": "image/png", + "source_dimensions": [ + 1920, + 2720 + ], + "source_sha256": "0524cb00362099a5f1afcfecda8f350b2967f9b3a9ed4e1c93bfee6772cf46c1", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 143738, + "sha256": "fb31ab3e4c76c7cbcdeb7a641c641411bf2ef85c9afa4b966753bf7efb538ba0" + }, + { + "path": "static/images/products/ho-chi-minh-city.webp", + "slug": "ho-chi-minh-city", + "entity_name": "Ho Chi Minh City", + "qid": "Q1854", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Independence_Palace_or_Reunification_Palace_(12110625233).jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/e/ec/Independence_Palace_or_Reunification_Palace_%2812110625233%29.jpg/1920px-Independence_Palace_or_Reunification_Palace_%2812110625233%29.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Independence Palace or Reunification Palace (12110625233).jpg", + "license": "CC BY-SA 2.0", + "license_url": "https://creativecommons.org/licenses/by-sa/2.0", + "author": "Clay Gilliland", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Independence+Palace+or+Reunification+Palace+%2812110625233%29.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1080 + ], + "source_sha256": "5276c42d6eaea21e222b7eb99ba59cac00106fbea76f25a23a1cd005cb7fe042", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 151788, + "sha256": "5c5333c094a1768260374e2958297110b80fc503fdc46c03dc22d1627b930d59" + }, + { + "path": "static/images/products/homs-university.webp", + "slug": "homs-university", + "entity_name": "Homs University", + "qid": "Q797514", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Faculty_of_Medicine,_Homs_University.jpg", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/9/91/Faculty_of_Medicine%2C_Homs_University.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "Faculty of Medicine, Homs University.jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "Bassel", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Faculty+of+Medicine%2C+Homs+University.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1440 + ], + "source_sha256": "89e1fc4898216dc1ac29d4ecc242723cfe05b73703b16358ee24a5be83c9faa6", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 105270, + "sha256": "d64b7301d6cf99854fd44e37ff26d75e2f22917cdc6d7bfcef071330cefc109d" + }, + { + "path": "static/images/products/iphone-15-pro.webp", + "slug": "iphone-15-pro", + "entity_name": "iPhone 15 Pro", + "source_kind": "official_product_page", + "source_page": "https://support.apple.com/en-us/111829", + "source_url": "https://cdsassets.apple.com/live/7WUAS350/images/tech-specs/iphone_15_pro.png", + "license": "Copyrighted; reproduced for non-commercial benchmark identification", + "license_url": "https://support.apple.com/en-us/111829", + "author": "Apple", + "entity_type": "electronics", + "fit": "contain", + "resolved_url": "https://cdsassets.apple.com/live/7WUAS350/images/tech-specs/iphone_15_pro.png", + "source_content_type": "image/png", + "source_dimensions": [ + 1000, + 1000 + ], + "source_sha256": "83343a797fba7a78001f486ba9cc3c66191efa229a84d7dc932a654bbb720e77", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 14664, + "sha256": "f2cbd0500e5d1b0a12831e408540b1d1109a0ec32b260058c935299d55788277" + }, + { + "path": "static/images/products/istanbul.webp", + "slug": "istanbul", + "entity_name": "Istanbul", + "qid": "Q406", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Istanbul_Montage_2016.png", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/b/b4/Istanbul_Montage_2016.png?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "Istanbul Montage 2016.png", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "AlexTref871 , Piotr Matyja , Alexxx1979 , Moyan Brenn , Far-gh , İhsan Deniz Kılıçoğlu , Carlos Delgado", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Istanbul+Montage+2016.png&w=1920", + "source_content_type": "image/png", + "source_dimensions": [ + 1920, + 2688 + ], + "source_sha256": "c58910e46b60b9cb2dfcb9283c3ce294177fc993f6a413ba710fe2e8d489573e", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 145598, + "sha256": "d7f95dd6393020a466f2e67f885d64bbe397ac0de0b5881f8985c3299c876a73" + }, + { + "path": "static/images/products/iu-international-university-of-applied-sciences.webp", + "slug": "iu-international-university-of-applied-sciences", + "entity_name": "IU International University of Applied Sciences", + "qid": "Q1667281", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Bad-honnef-FH01.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/0/09/Bad-honnef-FH01.jpg/1920px-Bad-honnef-FH01.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Bad-honnef-FH01.jpg", + "license": "CC BY-SA 3.0", + "license_url": "https://creativecommons.org/licenses/by-sa/3.0", + "author": "A.Savin", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Bad-honnef-FH01.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1440 + ], + "source_sha256": "8cb688617e28ac25a2b7b4b7843c3beb263a8a8fbe2847650b03becd6f47f77a", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 142664, + "sha256": "b7a07e45478107e5daad56253e68b89a3b0bd32f1a5717a680adeb29a2624f9c" + }, + { + "path": "static/images/products/jinan.webp", + "slug": "jinan", + "entity_name": "Jinan", + "qid": "Q170247", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Jinanfromqianfoshan.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/e/e1/Jinanfromqianfoshan.jpg/1920px-Jinanfromqianfoshan.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Jinanfromqianfoshan.jpg", + "license": "CC BY-SA 3.0", + "license_url": "https://creativecommons.org/licenses/by-sa/3.0", + "author": "Song Hongxiao", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Jinanfromqianfoshan.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 921 + ], + "source_sha256": "019b98017f7156eca78c882720dc27dc0d799385ac12dc5e836d953b09189cc3", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 158490, + "sha256": "1b296952b5490de16986a6f5e8dd7ac0665b06f795e5a88c55cf87bf8f3a7fff" + }, + { + "path": "static/images/products/jining.webp", + "slug": "jining", + "entity_name": "Jining", + "qid": "Q372791", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:%E6%B5%8E%E5%AE%81%E5%B8%82,_%E4%B8%AD%E5%9B%BD_Dec_07,_2020_15-57-16.jpeg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/5/55/%E6%B5%8E%E5%AE%81%E5%B8%82%2C_%E4%B8%AD%E5%9B%BD_Dec_07%2C_2020_15-57-16.jpeg/1920px-%E6%B5%8E%E5%AE%81%E5%B8%82%2C_%E4%B8%AD%E5%9B%BD_Dec_07%2C_2020_15-57-16.jpeg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "济宁市, 中国 Dec 07, 2020 15-57-16.jpeg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=%E6%B5%8E%E5%AE%81%E5%B8%82%2C+%E4%B8%AD%E5%9B%BD+Dec+07%2C+2020+15-57-16.jpeg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1440 + ], + "source_sha256": "5a93f5b857d431f1f81a9ca164b59feb0c443ee6a67920b40ae52b4c6c8e1e13", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 109144, + "sha256": "e1a28a2b45ce56fd03f4490214457e00b387e46c5689f42614ddcdb0d613319a" + }, + { + "path": "static/images/products/karachi.webp", + "slug": "karachi", + "entity_name": "Karachi", + "qid": "Q8660", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Karachi_from_above.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/a/a2/Karachi_from_above.jpg/1920px-Karachi_from_above.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Karachi from above.jpg", + "license": "CC BY-SA 3.0", + "license_url": "https://creativecommons.org/licenses/by-sa/3.0", + "author": "Bilalhassan88", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Karachi+from+above.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1031 + ], + "source_sha256": "963082c96447ccb993f24ccac42e38b39c86a1b2c3f24477ff68b039ed806849", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 194990, + "sha256": "de89a01462500efdf7a155bb072d172957b1ad433d3557b58cd501f557f1b3ff" + }, + { + "path": "static/images/products/kuala-lumpur.webp", + "slug": "kuala-lumpur", + "entity_name": "Kuala Lumpur", + "qid": "Q1865", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Kuala_Lumpur_with_Petronas_Towers.jpg", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/f/ff/Kuala_Lumpur_with_Petronas_Towers.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "Kuala Lumpur with Petronas Towers.jpg", + "license": "Public domain", + "license_url": "", + "author": "thomasgl", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Kuala+Lumpur+with+Petronas+Towers.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1229 + ], + "source_sha256": "72307553906bb1f29204bd065799498a076d2eef84fb5a923d9b9f6ad5d9494e", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 64694, + "sha256": "a45c6706725b2e19387b6a30eb2b6cf7b164643f124eded269bc511924aa6072" + }, + { + "path": "static/images/products/kunming.webp", + "slug": "kunming", + "entity_name": "Kunming", + "qid": "Q182852", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:%E4%BA%94%E5%8D%8E%E5%8C%BA%E4%B8%8E%E7%9B%98%E9%BE%99%E5%8C%BA%E5%A4%A9%E9%99%85%E7%BA%BF_-_%E8%88%AA%E6%8B%8D_-_2025-05-16_03.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/e/e2/%E4%BA%94%E5%8D%8E%E5%8C%BA%E4%B8%8E%E7%9B%98%E9%BE%99%E5%8C%BA%E5%A4%A9%E9%99%85%E7%BA%BF_-_%E8%88%AA%E6%8B%8D_-_2025-05-16_03.jpg/1920px-%E4%BA%94%E5%8D%8E%E5%8C%BA%E4%B8%8E%E7%9B%98%E9%BE%99%E5%8C%BA%E5%A4%A9%E9%99%85%E7%BA%BF_-_%E8%88%AA%E6%8B%8D_-_2025-05-16_03.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "五华区与盘龙区天际线 - 航拍 - 2025-05-16 03.jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "瑞丽江的河水", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=%E4%BA%94%E5%8D%8E%E5%8C%BA%E4%B8%8E%E7%9B%98%E9%BE%99%E5%8C%BA%E5%A4%A9%E9%99%85%E7%BA%BF+-+%E8%88%AA%E6%8B%8D+-+2025-05-16+03.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1080 + ], + "source_sha256": "6c99453f453797de582ca8dac05d51c1446ee79e28d8baef63eb226a8a1a7501", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 273794, + "sha256": "0d070ad67b95c8a02f49ce35455991618f6875e9d3590b940085e6a0022d9341" + }, + { + "path": "static/images/products/kwame-nkrumah-university-of-science-and-technology.webp", + "slug": "kwame-nkrumah-university-of-science-and-technology", + "entity_name": "Kwame Nkrumah University of Science and Technology", + "qid": "Q1654025", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:KNUST_main_entrance_with_Kwame_Nkrumah_Memorial_Park.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/1/14/KNUST_main_entrance_with_Kwame_Nkrumah_Memorial_Park.jpg/1920px-KNUST_main_entrance_with_Kwame_Nkrumah_Memorial_Park.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "KNUST main entrance with Kwame Nkrumah Memorial Park.jpg", + "license": "CC BY-SA 3.0", + "license_url": "https://creativecommons.org/licenses/by-sa/3.0", + "author": "ZSM", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=KNUST+main+entrance+with+Kwame+Nkrumah+Memorial+Park.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1280 + ], + "source_sha256": "9d1274159ff22030f03a233b1a71b33cd7defaad6fe4fd728f9d3193a88e07a6", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 114682, + "sha256": "9c117d989e98142f96e27b0f212ac6a8a82aff7200ee3f5c682263964dc50b11" + }, + { + "path": "static/images/products/lagos.webp", + "slug": "lagos", + "entity_name": "Lagos", + "qid": "Q8673", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:5th_Avenue_Road,_Egbeda,_Lagos.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/0/04/5th_Avenue_Road%2C_Egbeda%2C_Lagos.jpg/1920px-5th_Avenue_Road%2C_Egbeda%2C_Lagos.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "5th Avenue Road, Egbeda, Lagos.jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "Fachab", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=5th+Avenue+Road%2C+Egbeda%2C+Lagos.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1277 + ], + "source_sha256": "51b0146740f3c00d94c6a1f7fa0bb413529a0fe1f5b7b9bd0d711409123e91b9", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 165806, + "sha256": "e5c507cebae349fbf396f463a6cd166960a23685c41687b994a025cc92fd8c40" + }, + { + "path": "static/images/products/lagos-state-university.webp", + "slug": "lagos-state-university", + "entity_name": "Lagos State University", + "qid": "Q849611", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Faculty_of_Education,_Lagos_State_University(LASU).jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/8/82/Faculty_of_Education%2C_Lagos_State_University%28LASU%29.jpg/1920px-Faculty_of_Education%2C_Lagos_State_University%28LASU%29.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Faculty of Education, Lagos State University(LASU).jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "Official alade", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Faculty+of+Education%2C+Lagos+State+University%28LASU%29.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1440 + ], + "source_sha256": "8c3d4a6e2387ef762032e606bc7415d0e062a0a05543aba3c10752d8a73c2cf4", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 147788, + "sha256": "162d0f1f44535823f791dc43f8f0692778a1e7022f36ef7bec7c82e84a2dd82d" + }, + { + "path": "static/images/products/lahore.webp", + "slug": "lahore", + "entity_name": "Lahore", + "qid": "Q11739", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Eye_Of_Lahore_(Minar_e_Pakistan)_evening.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/7/7c/Eye_Of_Lahore_%28Minar_e_Pakistan%29_evening.jpg/1920px-Eye_Of_Lahore_%28Minar_e_Pakistan%29_evening.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Eye Of Lahore (Minar e Pakistan) evening.jpg", + "license": "CC BY-SA 3.0", + "license_url": "https://creativecommons.org/licenses/by-sa/3.0", + "author": "Lime.adeel", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Eye+Of+Lahore+%28Minar+e+Pakistan%29+evening.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1275 + ], + "source_sha256": "77e99c10690abf706418573438bb20c6e43aee2aaf02e2b46a407b4d210be310", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 119388, + "sha256": "653ff7302c4ef1e6e65bc0cba1686587eb7e0696bedb0b66396eeeba4db92f60" + }, + { + "path": "static/images/products/lebanese-university.webp", + "slug": "lebanese-university", + "entity_name": "Lebanese University", + "qid": "Q975461", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:WPLEBANON.svg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/a/ae/WPLEBANON.svg/1920px-WPLEBANON.svg.png?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "WPLEBANON.svg", + "license": "Public domain", + "license_url": "", + "author": "Mnmazur", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=WPLEBANON.svg&w=1920", + "source_content_type": "image/png", + "source_dimensions": [ + 1920, + 1150 + ], + "source_sha256": "05f02109cf34d237808609409c6e3888cda7dbe071199668247a51929d157edd", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 30668, + "sha256": "4fb7b21b7a9a3c10d80ec3a61ea685902e2548f1255d2ca4c766242969f4eec4" + }, + { + "path": "static/images/products/lima.webp", + "slug": "lima", + "entity_name": "Lima", + "qid": "Q2868", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Plaza_Mayor_de_Lima-1.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/5/54/Plaza_Mayor_de_Lima-1.jpg/1920px-Plaza_Mayor_de_Lima-1.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Plaza Mayor de Lima-1.jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "JulioKuLu", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Plaza+Mayor+de+Lima-1.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1072 + ], + "source_sha256": "e71ff7e82a6774743f92b1cece3b3923821c0da700950ed43ed6d72fedbff5eb", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 200102, + "sha256": "c71397dba676eb6c7e2a7afc7cf30fd0f6448ef30a375b2c0cb5be5c8643dcea" + }, + { + "path": "static/images/products/mexico-city.webp", + "slug": "mexico-city", + "entity_name": "Mexico City", + "qid": "Q1489", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Sobrevuelos_CDMX_HJ2A5091_(40386338731).jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/e/e7/Sobrevuelos_CDMX_HJ2A5091_%2840386338731%29.jpg/1920px-Sobrevuelos_CDMX_HJ2A5091_%2840386338731%29.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Sobrevuelos CDMX HJ2A5091 (40386338731).jpg", + "license": "CC0", + "license_url": "http://creativecommons.org/publicdomain/zero/1.0/deed.en", + "author": "Gobierno CDMX", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Sobrevuelos+CDMX+HJ2A5091+%2840386338731%29.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1280 + ], + "source_sha256": "6f69e86b69429c95a69076c09d4e08964d98ebb5e91fe68fb30b9c8060c29617", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 188776, + "sha256": "a4eb1620fb4bf11a12ccdd2e2e26dd772ce4060b8bad4f852b495c6bf1906464" + }, + { + "path": "static/images/products/monterrey-institute-of-technology-and-higher-education.webp", + "slug": "monterrey-institute-of-technology-and-higher-education", + "entity_name": "Monterrey Institute of Technology and Higher Education", + "source_kind": "official_campus_page", + "source_page": "https://tec.mx/es/monterrey", + "source_url": "https://tec.mx/sites/default/files/styles/16_9_campus/public/repositorio/Campus/Monterrey/campus-monterrey-tec.jpg.webp?itok=9M-VWhSO", + "license": "Copyrighted; reproduced for non-commercial benchmark identification", + "license_url": "https://tec.mx/es/monterrey", + "author": "Tecnológico de Monterrey", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://tec.mx/sites/default/files/styles/16_9_campus/public/repositorio/Campus/Monterrey/campus-monterrey-tec.jpg.webp?itok=9M-VWhSO", + "source_content_type": "image/webp", + "source_dimensions": [ + 1024, + 577 + ], + "source_sha256": "772590862fa2773800dc9609d1ce7310a6fd3b8867620afd5825497d81c562bc", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 118924, + "sha256": "ef77070ba5165e9ce283a3522c5f32661036e9f22e5ab30bc2935bd6a3be3c4c" + }, + { + "path": "static/images/products/moscow.webp", + "slug": "moscow", + "entity_name": "Moscow", + "qid": "Q649", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Saint_Basil%27s_Cathedral_and_the_Red_Square.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/8/85/Saint_Basil%27s_Cathedral_and_the_Red_Square.jpg/1920px-Saint_Basil%27s_Cathedral_and_the_Red_Square.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Saint Basil's Cathedral and the Red Square.jpg", + "license": "Public domain", + "license_url": "", + "author": "U.S. Department of State", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Saint+Basil%27s+Cathedral+and+the+Red+Square.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1080 + ], + "source_sha256": "e7797a27b0892cdf9292e1a58258598b9b96e00be5065777fabb4f4db3fc1417", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 174760, + "sha256": "e34a28027d32a357f134c93f7dbcba715373fa620d3268ad913bd3b594eadbf8" + }, + { + "path": "static/images/products/nanjing.webp", + "slug": "nanjing", + "entity_name": "Nanjing", + "qid": "Q16666", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Nanjing_CBD_from_City_Wall.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/8/82/Nanjing_CBD_from_City_Wall.jpg/1920px-Nanjing_CBD_from_City_Wall.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Nanjing CBD from City Wall.jpg", + "license": "CC BY 2.0", + "license_url": "https://creativecommons.org/licenses/by/2.0", + "author": "xiquinhosilva", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Nanjing+CBD+from+City+Wall.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1280 + ], + "source_sha256": "1f5f9a87cc55f5d2d9d95a759fcdbc12bb004a6ab43a29b7dbce10cd402db5b3", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 149878, + "sha256": "4e4362682190b00d3c0b34e88b3aef289b09857dab84b43f1b8820aa8231e300" + }, + { + "path": "static/images/products/nanning.webp", + "slug": "nanning", + "entity_name": "Nanning", + "qid": "Q179608", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Nanning_Seen_from_Longxiang_Tower.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/7/70/Nanning_Seen_from_Longxiang_Tower.jpg/1920px-Nanning_Seen_from_Longxiang_Tower.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Nanning Seen from Longxiang Tower.jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "EditQ", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Nanning+Seen+from+Longxiang+Tower.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1440 + ], + "source_sha256": "ec7b9df88d2ea50ab1144700233bdea62222d3e38ecb7741494938a49c1aae32", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 102844, + "sha256": "d1cba1cd7413fd9f38ead9b7326086be8add014152ac3ce0c4faa3d1acd37b0d" + }, + { + "path": "static/images/products/nantong.webp", + "slug": "nantong", + "entity_name": "Nantong", + "qid": "Q57947", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:%E5%8D%97%E9%80%9A.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/7/75/%E5%8D%97%E9%80%9A.jpg/1920px-%E5%8D%97%E9%80%9A.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "南通.jpg", + "license": "Public domain", + "license_url": "", + "author": "Qingqing Chen", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=%E5%8D%97%E9%80%9A.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1440 + ], + "source_sha256": "650cea0cb7ffc530849aa1853986fbcaec7d66685a86e4bd4ae06afaaa393919", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 97674, + "sha256": "52e9e74abe57161b8e551ef8ab128bee8d21c927044ce77cc4c0019286e1df2c" + }, + { + "path": "static/images/products/nanyang.webp", + "slug": "nanyang", + "entity_name": "Nanyang", + "qid": "Q404763", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Xichuan.jpg", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/9/9d/Xichuan.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "Xichuan.jpg", + "license": "CC BY-SA 3.0", + "license_url": "https://creativecommons.org/licenses/by-sa/3.0", + "author": "襄樊一夜", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Xichuan.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 962 + ], + "source_sha256": "2c9b8a0073aaa922369bb4812f41c2d921a427e225d0d215fa1492c075bccc9c", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 87840, + "sha256": "d96fb221e7ea1210c9e0b39089aac45a86c5d67158db2f539e95cc3aa96c1e5b" + }, + { + "path": "static/images/products/national-technological-university.webp", + "slug": "national-technological-university", + "entity_name": "National Technological University", + "qid": "Q3232206", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Facultad_UTN.JPG", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/7/76/Facultad_UTN.JPG/1920px-Facultad_UTN.JPG?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Facultad UTN.JPG", + "license": "Public domain", + "license_url": "", + "author": "Yo mismo", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Facultad+UTN.JPG&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1079 + ], + "source_sha256": "b4ed23d2b79be912a54f0b2809fa459521a8c84721a065d83edfe69ea715c975", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 253744, + "sha256": "c2b8efab23d2d7a47d8afea371b1bd72e53e9d2f3d4bc55795b890c38e21da16" + }, + { + "path": "static/images/products/national-university-of-c-rdoba.webp", + "slug": "national-university-of-c-rdoba", + "entity_name": "National University of Córdoba", + "qid": "Q1570489", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Patiocolonialcordoba.jpg", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/6/61/Patiocolonialcordoba.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "Patiocolonialcordoba.jpg", + "license": "CC BY 2.0", + "license_url": "https://creativecommons.org/licenses/by/2.0", + "author": "Tjeerd Wiersma from Amsterdam, The Netherlands", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Patiocolonialcordoba.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 2560 + ], + "source_sha256": "bfa93786d6999b12971e0d012ddb48ec15f1747f3ddfc08d3600a13ed71092c3", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 78068, + "sha256": "f4f20c261a6e1de487e85e5857d3ef607a58f070c9e11f3ea9401c70acdab82e" + }, + { + "path": "static/images/products/national-university-of-la-plata.webp", + "slug": "national-university-of-la-plata", + "entity_name": "National University of La Plata", + "qid": "Q784171", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Universidad_nacional_-_panoramio.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/9/97/Universidad_nacional_-_panoramio.jpg/1920px-Universidad_nacional_-_panoramio.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Universidad nacional - panoramio.jpg", + "license": "CC BY 3.0", + "license_url": "https://creativecommons.org/licenses/by/3.0", + "author": "Aleksandrs Timofejev…", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Universidad+nacional+-+panoramio.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1440 + ], + "source_sha256": "fb961ef91fa8562ad7006829027cc6fe16a08ff24c42c2519927ad9e3ffa0fad", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 227954, + "sha256": "b637f21ab51799a6ba3cbe5815a5b9e5b78bf7476eaf7326721bd818360e97cc" + }, + { + "path": "static/images/products/national-university-of-rosario.webp", + "slug": "national-university-of-rosario", + "entity_name": "National University of Rosario", + "qid": "Q5255905", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Universidad_Nacional_de_Rosario_campus.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/6/68/Universidad_Nacional_de_Rosario_campus.jpg/1920px-Universidad_Nacional_de_Rosario_campus.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Universidad Nacional de Rosario campus.jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "Amanari", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Universidad+Nacional+de+Rosario+campus.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1440 + ], + "source_sha256": "9b9cef4d7657aefdaf2caa085323b841aa264686e62a5745296a5ad44cd9e378", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 163576, + "sha256": "e089be47c0e22c3831656866e85e381f74159a26f01fbfd8793258b8eb06d3bb" + }, + { + "path": "static/images/products/national-university-of-tucum-n.webp", + "slug": "national-university-of-tucum-n", + "entity_name": "National University of Tucumán", + "qid": "Q6979284", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Universidad_Nacional_de_Tucum%C3%A1n.JPG", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/9/99/Universidad_Nacional_de_Tucum%C3%A1n.JPG?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "Universidad Nacional de Tucumán.JPG", + "license": "Public domain", + "license_url": "", + "author": "José Lazarte (jlazarte)", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Universidad+Nacional+de+Tucum%C3%A1n.JPG&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 2585 + ], + "source_sha256": "5779d7298d4146ff7e3c93af30b61af045e72db16d16db89d9467a3c551babaf", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 70184, + "sha256": "bcaf66d9f8c4fe3e7d243d123839439ada45aaaa4977e9ca73e5690a1151e6ab" + }, + { + "path": "static/images/products/netaji-subhas-open-university.webp", + "slug": "netaji-subhas-open-university", + "entity_name": "Netaji Subhas Open University", + "qid": "Q3350895", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Head_Quarter.jpg", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/3/34/Head_Quarter.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "Head Quarter.jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "NSOU", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Head+Quarter.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 2560 + ], + "source_sha256": "7ba7ad4c46c495ed3e1c5b73b1aabe45bbfae79e7d3994ebf0af06a13401912a", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 85656, + "sha256": "77a5349df937256d78e9a0732bfb833bcb6e485d75be1e7ec55c95fb0db07e2d" + }, + { + "path": "static/images/products/nikon-z8.webp", + "slug": "nikon-z8", + "entity_name": "Nikon Z8", + "source_kind": "official_product_page", + "source_page": "https://www.nikonusa.com/p/z-8/1695/overview", + "source_url": "https://images.cdn.us-central1.gcp.commercetools.com/f7c8f2bb-aff1-4581-a826-1ad2527be222/FrontLeft-z-8-24-120-I9JxIVrg.png", + "license": "Copyrighted; reproduced for non-commercial benchmark identification", + "license_url": "https://www.nikonusa.com/p/z-8/1695/overview", + "author": "Nikon", + "entity_type": "electronics", + "fit": "contain", + "resolved_url": "https://images.cdn.us-central1.gcp.commercetools.com/f7c8f2bb-aff1-4581-a826-1ad2527be222/FrontLeft-z-8-24-120-I9JxIVrg.png", + "source_content_type": "image/png", + "source_dimensions": [ + 700, + 595 + ], + "source_sha256": "956bdda4d9f506b3c567e521e0de7038244a1faee9de4f12669f57a272199672", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 46244, + "sha256": "15e0e826f2ff6168e666c2697808b9e13dd499c862cf4e9b09cfda814a00092a" + }, + { + "path": "static/images/products/ningbo.webp", + "slug": "ningbo", + "entity_name": "Ningbo", + "qid": "Q42780", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Ningbo_South_Business_District_24-09-2018.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/2/2c/Ningbo_South_Business_District_24-09-2018.jpg/1920px-Ningbo_South_Business_District_24-09-2018.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Ningbo South Business District 24-09-2018.jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "Milkomède", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Ningbo+South+Business+District+24-09-2018.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1280 + ], + "source_sha256": "f95184acc813b77c3fca7e3ea858ba58975f6b2c3c3acbbf341a579e223f8132", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 123122, + "sha256": "cf4110a1677cc70934670efe7cc511483cc27dc22ddd9ded648a0a3bd74dff09" + }, + { + "path": "static/images/products/oneplus-12.webp", + "slug": "oneplus-12", + "entity_name": "OnePlus 12", + "source_kind": "official_product_page", + "source_page": "https://www.oneplus.com/us/12", + "source_url": "https://www.oneplus.com/content/dam/oasis/page/2024/global/product/waffle/share.jpg", + "license": "Copyrighted; reproduced for non-commercial benchmark identification", + "license_url": "https://www.oneplus.com/us/12", + "author": "OnePlus", + "entity_type": "electronics", + "fit": "contain", + "resolved_url": "https://www.oneplus.com/content/dam/oasis/page/2024/global/product/waffle/share.jpg", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1200, + 630 + ], + "source_sha256": "01a3445cbba6d7c490565786638e127e2bf4c221a536ad49a9c8e440c3815d98", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 19098, + "sha256": "ca2414f500a2464b41e20654e1a0f6a1b342e5e77300871b469deb30f8d22d4d" + }, + { + "path": "static/images/products/open-university-of-catalonia.webp", + "slug": "open-university-of-catalonia", + "entity_name": "Open University of Catalonia", + "qid": "Q3042433", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Rectorat_de_la_Universitat_Oberta_de_Catalunya.jpg", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/c/ca/Rectorat_de_la_Universitat_Oberta_de_Catalunya.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "Rectorat de la Universitat Oberta de Catalunya.jpg", + "license": "CC BY-SA 3.0", + "license_url": "https://creativecommons.org/licenses/by-sa/3.0", + "author": "Pere López", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Rectorat+de+la+Universitat+Oberta+de+Catalunya.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1391 + ], + "source_sha256": "164fd496fbc70a25d678724454019a93a9e962d333d75d2aa100d3e4218692d1", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 128802, + "sha256": "1ea30191a8e943cebbc4b388c3caec53aef65ae7be73765412e45277501718a6" + }, + { + "path": "static/images/products/qingdao.webp", + "slug": "qingdao", + "entity_name": "Qingdao", + "qid": "Q170322", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:%E9%9D%92%E5%B2%9B%E6%B9%9B%E5%B1%B1%E5%8F%8A%E5%A4%AA%E5%B9%B3%E5%B1%B1%E4%BF%AF%E7%9E%B0_2018-10-10.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/4/48/%E9%9D%92%E5%B2%9B%E6%B9%9B%E5%B1%B1%E5%8F%8A%E5%A4%AA%E5%B9%B3%E5%B1%B1%E4%BF%AF%E7%9E%B0_2018-10-10.jpg/1920px-%E9%9D%92%E5%B2%9B%E6%B9%9B%E5%B1%B1%E5%8F%8A%E5%A4%AA%E5%B9%B3%E5%B1%B1%E4%BF%AF%E7%9E%B0_2018-10-10.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "青岛湛山及太平山俯瞰 2018-10-10.jpg", + "license": "CC BY 2.0", + "license_url": "https://creativecommons.org/licenses/by/2.0", + "author": "Dan Nevill", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=%E9%9D%92%E5%B2%9B%E6%B9%9B%E5%B1%B1%E5%8F%8A%E5%A4%AA%E5%B9%B3%E5%B1%B1%E4%BF%AF%E7%9E%B0+2018-10-10.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1080 + ], + "source_sha256": "e5bc7e43bbba60642dfe6be2d8daa1a726bff96db124b9ff83ebebceb00b86a7", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 229946, + "sha256": "921265be9ebdbecfc603bdd9440b69f865fc4f0bb18532f5a9c17592d17286b8" + }, + { + "path": "static/images/products/quanzhou.webp", + "slug": "quanzhou", + "entity_name": "Quanzhou", + "qid": "Q68695", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:20230130_Old_City_of_Quanzhou_01.jpg", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/5/55/20230130_Old_City_of_Quanzhou_01.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "20230130 Old City of Quanzhou 01.jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "Windmemories", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=20230130+Old+City+of+Quanzhou+01.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1280 + ], + "source_sha256": "46f0b91f752020a669265c0c3954a6fc1f334be6399e4bfc47bc9bfcd54a891b", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 193474, + "sha256": "1e5b1940c669d3f68de1011251b9294d209f774cc71733e9c97060a0bf0667f4" + }, + { + "path": "static/images/products/radeon-rx-7800-xt.webp", + "slug": "radeon-rx-7800-xt", + "entity_name": "Radeon RX 7800 XT", + "source_kind": "official_product_page", + "source_page": "https://www.amd.com/en/products/graphics/desktops/radeon/7000-series/amd-radeon-rx-7800-xt.html", + "source_url": "https://www.amd.com/content/dam/amd/en/images/products/graphics/2648997-amd-radeon-7800xt.jpg", + "license": "Copyrighted; reproduced for non-commercial benchmark identification", + "license_url": "https://www.amd.com/en/products/graphics/desktops/radeon/7000-series/amd-radeon-rx-7800-xt.html", + "author": "AMD", + "entity_type": "electronics", + "fit": "contain", + "resolved_url": "https://www.amd.com/content/dam/amd/en/images/products/graphics/2648997-amd-radeon-7800xt.jpg", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1200, + 1200 + ], + "source_sha256": "dd0cbaeebc487b7be61d69f4be00ef0d479e81f2898b34c98bdb987a719111d0", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 14490, + "sha256": "087d51d9c94c0c0e4a987db4a13c6906037d6c0d3becef28ebddc6af30353bba" + }, + { + "path": "static/images/products/radeon-rx-7900-xtx.webp", + "slug": "radeon-rx-7900-xtx", + "entity_name": "Radeon RX 7900 XTX", + "source_kind": "official_product_page", + "source_page": "https://www.amd.com/en/products/graphics/desktops/radeon/7000-series/amd-radeon-rx-7900xtx.html", + "source_url": "https://www.amd.com/content/dam/amd/en/images/products/graphics/2648997-amd-radeon-7900xtx.jpg", + "license": "Copyrighted; reproduced for non-commercial benchmark identification", + "license_url": "https://www.amd.com/en/products/graphics/desktops/radeon/7000-series/amd-radeon-rx-7900xtx.html", + "author": "AMD", + "entity_type": "electronics", + "fit": "contain", + "resolved_url": "https://www.amd.com/content/dam/amd/en/images/products/graphics/2648997-amd-radeon-7900xtx.jpg", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1200, + 1200 + ], + "source_sha256": "357a2be7548865574187e5f414a6686a25df4c349aa391f5082585963ae86bb3", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 18746, + "sha256": "23894df81f9edd023709cd40423685ad7a3eb4741083c5ee607477b451b5332f" + }, + { + "path": "static/images/products/rtx-4070-super.webp", + "slug": "rtx-4070-super", + "entity_name": "GeForce RTX 4070 Super", + "source_kind": "official_product_page", + "source_page": "https://www.nvidia.com/en-us/geforce/news/gfecnt/20241/geforce-rtx-4080-4070-ti-4070-super-gpu/", + "source_url": "https://images.nvidia.com/aem-dam/Solutions/geforce/news/geforce-rtx-4080-4070-ti-4070-super-gpu/nvidia-geforce-rtx-4070-super.jpg", + "license": "Copyrighted; reproduced for non-commercial benchmark identification", + "license_url": "https://www.nvidia.com/en-us/geforce/news/gfecnt/20241/geforce-rtx-4080-4070-ti-4070-super-gpu/", + "author": "NVIDIA", + "entity_type": "electronics", + "fit": "contain", + "resolved_url": "https://images.nvidia.cn/aem-dam/Solutions/geforce/news/geforce-rtx-4080-4070-ti-4070-super-gpu/nvidia-geforce-rtx-4070-super.jpg", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 2880, + 1620 + ], + "source_sha256": "2c7ad7b6742048d2bf3000646cb0b7be585637b1fcca13ab5c64f7c6f575ea4d", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 22386, + "sha256": "4e96aaf2cacc5cc8ecb792e8b16b34be25fd02fec0b5a147307766decc717462" + }, + { + "path": "static/images/products/rtx-4080-super.webp", + "slug": "rtx-4080-super", + "entity_name": "GeForce RTX 4080 Super", + "source_kind": "official_product_page", + "source_page": "https://www.nvidia.com/en-gb/geforce/graphics-cards/40-series/rtx-4080-family/", + "source_url": "https://www.nvidia.com/content/dam/en-zz/Solutions/geforce/ada/rtx-4080/geforce-rtx-4080-super-og-1200x630.jpg", + "license": "Copyrighted; reproduced for non-commercial benchmark identification", + "license_url": "https://www.nvidia.com/en-gb/geforce/graphics-cards/40-series/rtx-4080-family/", + "author": "NVIDIA", + "entity_type": "electronics", + "fit": "contain", + "resolved_url": "https://www.nvidia.com/content/dam/en-zz/Solutions/geforce/ada/rtx-4080/geforce-rtx-4080-super-og-1200x630.jpg", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1200, + 630 + ], + "source_sha256": "63fee08f71fff0c4e84808556227b1e553c87a49eaf75d4f449f01bd6b4dc286", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 26038, + "sha256": "0411bc09a3edcce5f9486c0ae0ca52c736f340e9dc86c0d1fa6de6edd1c3f2ea" + }, + { + "path": "static/images/products/s-o-paulo.webp", + "slug": "s-o-paulo", + "entity_name": "São Paulo", + "qid": "Q174", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Sao_Paulo_Skyline_in_Brazil.jpg", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/f/ff/Sao_Paulo_Skyline_in_Brazil.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "Sao Paulo Skyline in Brazil.jpg", + "license": "CC BY-SA 2.0", + "license_url": "https://creativecommons.org/licenses/by-sa/2.0", + "author": "Thomas Hobbs", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Sao+Paulo+Skyline+in+Brazil.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1279 + ], + "source_sha256": "917e5d5a11ba8e1f9ca677d343797658d1ad6545bb1a19c01bad0791d731b319", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 117062, + "sha256": "d2d648af5136d5538c5541c2ee8be93f41d59ee27e6b0f6cc030eb266b7df53b" + }, + { + "path": "static/images/products/samsung-galaxy-s24-ultra.webp", + "slug": "samsung-galaxy-s24-ultra", + "entity_name": "Samsung Galaxy S24 Ultra", + "source_kind": "official_product_page", + "source_page": "https://www.samsung.com/us/smartphones/galaxy-s24-ultra/", + "source_url": "https://images.samsung.com/us/smartphones/galaxy-s24-ultra/images/galaxy-s24-ultra-share-image.jpg", + "license": "Copyrighted; reproduced for non-commercial benchmark identification", + "license_url": "https://www.samsung.com/us/smartphones/galaxy-s24-ultra/", + "author": "Samsung", + "entity_type": "electronics", + "fit": "contain", + "resolved_url": "https://images.samsung.com/us/smartphones/galaxy-s24-ultra/images/galaxy-s24-ultra-share-image.jpg", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1200, + 630 + ], + "source_sha256": "e09e4f7267b0f07ab86b0e6aaea3622ae2746b3e74413150301634c3da90e217", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 9786, + "sha256": "0167f0dbaca8b46b2bb707ac4661e559190e5d0a81571ad8b758ca403c9a9030" + }, + { + "path": "static/images/products/samsung-galaxy-watch-6.webp", + "slug": "samsung-galaxy-watch-6", + "entity_name": "Samsung Galaxy Watch 6", + "source_kind": "official_product_page", + "source_page": "https://www.samsung.com/jp/watches/galaxy-watch/galaxy-watch6-44mm-graphite-bluetooth-sm-r940nzkaxjp/", + "source_url": "https://images.samsung.com/jp/galaxy-watch6/feature/galaxy-watch6-kv-pc.jpg", + "license": "Copyrighted; reproduced for non-commercial benchmark identification", + "license_url": "https://www.samsung.com/jp/watches/galaxy-watch/galaxy-watch6-44mm-graphite-bluetooth-sm-r940nzkaxjp/", + "author": "Samsung", + "entity_type": "electronics", + "fit": "contain", + "resolved_url": "https://images.samsung.com/jp/galaxy-watch6/feature/galaxy-watch6-kv-pc.jpg", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1328, + 620 + ], + "source_sha256": "1c9fe6cd246565d52a381b404c42aaffd414e749cf19b894588b0663db94de2c", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 33684, + "sha256": "7a16b3cd5653c91b0e71cdd85e9b035601672a2903141b415402ba0810b8cf3b" + }, + { + "path": "static/images/products/sapienza-university-of-rome.webp", + "slug": "sapienza-university-of-rome", + "entity_name": "Sapienza University of Rome", + "qid": "Q209344", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Sapienza_entrance_(20040201351).jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/f/f4/Sapienza_entrance_%2820040201351%29.jpg/1920px-Sapienza_entrance_%2820040201351%29.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Sapienza entrance (20040201351).jpg", + "license": "CC BY-SA 2.0", + "license_url": "https://creativecommons.org/licenses/by-sa/2.0", + "author": "Melirius", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Sapienza+entrance+%2820040201351%29.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1275 + ], + "source_sha256": "9212e8d2b37119f2abc1b9e4b6abf2b9fc1a70aa4815106e98b7779ee23c76e7", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 105632, + "sha256": "0c15e3c6f4ab02feeb17779360b267dad2d2de6f9681eaae8e5bc2e469e223ba" + }, + { + "path": "static/images/products/sennheiser-momentum-4.webp", + "slug": "sennheiser-momentum-4", + "entity_name": "Sennheiser Momentum 4", + "source_kind": "official_product_page", + "source_page": "https://global.sennheiser-hearing.com/collections/allproducts/products/momentum-4-wireless", + "source_url": "https://global.sennheiser-hearing.com/cdn/shop/files/MOMENTUM_4_Black.jpg?v=1775643553", + "license": "Copyrighted; reproduced for non-commercial benchmark identification", + "license_url": "https://global.sennheiser-hearing.com/collections/allproducts/products/momentum-4-wireless", + "author": "Sennheiser", + "entity_type": "electronics", + "fit": "contain", + "resolved_url": "https://global.sennheiser-hearing.com/cdn/shop/files/MOMENTUM_4_Black.jpg?v=1775643553", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 2400, + 2400 + ], + "source_sha256": "6917da21a85c0a240035db77053d85f4500067a96c84d2f8d8a8125505bd0cb2", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 13230, + "sha256": "06024ddcded6fb69b08397a9cc69e2aa884ddc970aaa0be78cc671514c196cfd" + }, + { + "path": "static/images/products/shanghai.webp", + "slug": "shanghai", + "entity_name": "Shanghai", + "qid": "Q8686", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Shanghai_montage.png", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/d/de/Shanghai_montage.png?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "Shanghai montage.png", + "license": "CC BY-SA 3.0", + "license_url": "https://creativecommons.org/licenses/by-sa/3.0", + "author": "ASDFGHJ , pontmarcheur (compilations, for proper attribution you have to name the authors of source images", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Shanghai+montage.png&w=1920", + "source_content_type": "image/png", + "source_dimensions": [ + 1920, + 2476 + ], + "source_sha256": "2cd033dc635f5c23fc971df8cd5d448f7193c23f49a6444a9d5158a389bc3af9", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 148440, + "sha256": "3e18afac750b985c371e58add9d790d9da866ffa6592852231b88f3bba499053" + }, + { + "path": "static/images/products/shangqiu.webp", + "slug": "shangqiu", + "entity_name": "Shangqiu", + "qid": "Q404817", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:20220726_Historic_City_of_Shangqiu_01.jpg", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/a/a5/20220726_Historic_City_of_Shangqiu_01.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "20220726 Historic City of Shangqiu 01.jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "Windmemories", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=20220726+Historic+City+of+Shangqiu+01.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1280 + ], + "source_sha256": "0d23baf290932d4e9fc803915152d3fe62c6a27858915632945b07e1db39e91d", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 118422, + "sha256": "bc78e93a4d2bf1978c2092f0a3c9f0c1ce62fca84b90cb26f4ca46d97465753b" + }, + { + "path": "static/images/products/shenyang.webp", + "slug": "shenyang", + "entity_name": "Shenyang", + "qid": "Q11720", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Skyline_of_Shenyang_3.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/e/e4/Skyline_of_Shenyang_3.jpg/1920px-Skyline_of_Shenyang_3.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Skyline of Shenyang 3.jpg", + "license": "CC0", + "license_url": "http://creativecommons.org/publicdomain/zero/1.0/deed.en", + "author": "E2568", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Skyline+of+Shenyang+3.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1440 + ], + "source_sha256": "088606c1c9a16a98a73ceeed5f6c929993190a2b3be4a0f9f188109fe167005e", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 211008, + "sha256": "1f1d2712f6a2c9359e9423c6ee7af4e87d8491f5300253d4d239189dd421db07" + }, + { + "path": "static/images/products/shenzhen.webp", + "slug": "shenzhen", + "entity_name": "Shenzhen", + "qid": "Q15174", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:The_west_panorama_of_Shenzhen2021.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/e/e9/The_west_panorama_of_Shenzhen2021.jpg/1920px-The_west_panorama_of_Shenzhen2021.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "The west panorama of Shenzhen2021.jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "Charlie fong", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=The+west+panorama+of+Shenzhen2021.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 645 + ], + "source_sha256": "e08037cdace594c5bfd27bdd3977420a712875e393d2453fa079a85276f22c67", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 163638, + "sha256": "c3a2fdba5027e95680122dca875ef863ae68b2df82872f784e67a3a6511f7f93" + }, + { + "path": "static/images/products/shijiazhuang.webp", + "slug": "shijiazhuang", + "entity_name": "Shijiazhuang", + "qid": "Q58401", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:%E7%9F%B3%E5%AE%B6%E5%BA%84%E8%A7%A3%E6%94%BE%E5%B9%BF%E5%9C%BA.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/a/a5/%E7%9F%B3%E5%AE%B6%E5%BA%84%E8%A7%A3%E6%94%BE%E5%B9%BF%E5%9C%BA.jpg/1920px-%E7%9F%B3%E5%AE%B6%E5%BA%84%E8%A7%A3%E6%94%BE%E5%B9%BF%E5%9C%BA.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "石家庄解放广场.jpg", + "license": "CC0", + "license_url": "http://creativecommons.org/publicdomain/zero/1.0/deed.en", + "author": "E2568", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=%E7%9F%B3%E5%AE%B6%E5%BA%84%E8%A7%A3%E6%94%BE%E5%B9%BF%E5%9C%BA.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1440 + ], + "source_sha256": "b756aaaa4a8ae3f6173bd6006bd21c96acdb7ffcb081ba10ce71737752bf079b", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 112852, + "sha256": "a856c404872c76362a12b91b8abd052df912bc9d5e26f1e5a623cb1387aa13cf" + }, + { + "path": "static/images/products/sony-a7-iv.webp", + "slug": "sony-a7-iv", + "entity_name": "Sony A7 IV", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Sony_A7_IV_(ILCE-7M4)_-_by_Henry_S%C3%B6derlund_(51739988735).jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/8/8b/Sony_A7_IV_%28ILCE-7M4%29_-_by_Henry_S%C3%B6derlund_%2851739988735%29.jpg/1920px-Sony_A7_IV_%28ILCE-7M4%29_-_by_Henry_S%C3%B6derlund_%2851739988735%29.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Sony A7 IV (ILCE-7M4) - by Henry Söderlund (51739988735).jpg", + "license": "CC BY 2.0", + "license_url": "https://creativecommons.org/licenses/by/2.0", + "author": "Henry Söderlund from Helsinki, Finland, Finland", + "entity_type": "electronics", + "fit": "contain", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Sony+A7+IV+%28ILCE-7M4%29+-+by+Henry+S%C3%B6derlund+%2851739988735%29.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1280 + ], + "source_sha256": "312f1de34ab960c3f82952c9382f414410a53c9211746b2de0532cbd79a9279d", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 34612, + "sha256": "cd8dedd24008e1d2cb908b1dd4c5b3fd339f875119fee9c2731626abfe81c1cd" + }, + { + "path": "static/images/products/sony-wh-1000xm5.webp", + "slug": "sony-wh-1000xm5", + "entity_name": "Sony WH-1000XM5", + "source_kind": "official_video_thumbnail", + "source_page": "https://www.youtube.com/watch?v=v6EjmbMgv80", + "source_url": "https://i.ytimg.com/vi/v6EjmbMgv80/maxresdefault.jpg", + "license": "Copyrighted; reproduced for non-commercial benchmark identification", + "license_url": "https://www.youtube.com/watch?v=v6EjmbMgv80", + "author": "Sony", + "entity_type": "electronics", + "fit": "cover", + "resolved_url": "https://i.ytimg.com/vi/v6EjmbMgv80/maxresdefault.jpg", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1280, + 720 + ], + "source_sha256": "8f33744e778990ce676d1976b964bd1950f4c7ce879e4dfc2113d7339522dab8", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 12868, + "sha256": "6a433ba93b800146657e2c3829979ce563b245390da1d241fc59f26219922f9b" + }, + { + "path": "static/images/products/southern-new-hampshire-university.webp", + "slug": "southern-new-hampshire-university", + "entity_name": "Southern New Hampshire University", + "source_kind": "official_video_thumbnail", + "source_page": "https://www.youtube.com/watch?v=v_aHIv5CTOE", + "source_url": "https://i.ytimg.com/vi/v_aHIv5CTOE/maxresdefault.jpg", + "license": "Copyrighted; reproduced for non-commercial benchmark identification", + "license_url": "https://www.youtube.com/watch?v=v_aHIv5CTOE", + "author": "Southern New Hampshire University", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://i.ytimg.com/vi/v_aHIv5CTOE/maxresdefault.jpg", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1280, + 720 + ], + "source_sha256": "e3019d0e4a30f676e9c5e7549c3d02fd32d1c1508efc9440ceff5b35d7259b28", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 142726, + "sha256": "768967f8ccc4723edcb3b8d98f0f9bea2380bd96a43fe77ada69928a64f1123f" + }, + { + "path": "static/images/products/suzhou.webp", + "slug": "suzhou", + "entity_name": "Suzhou", + "qid": "Q42622", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:%E4%B8%9C%E6%96%B9%E4%B9%8B%E9%97%A81.jpg", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/8/8f/%E4%B8%9C%E6%96%B9%E4%B9%8B%E9%97%A81.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "东方之门1.jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "铁头娃蛤蛤", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=%E4%B8%9C%E6%96%B9%E4%B9%8B%E9%97%A81.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1371 + ], + "source_sha256": "2fbdf7debb4e3e0072db0dd2b5f280da6eb10c69225b6d0eca05e81ffc38da4b", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 187696, + "sha256": "72dd4a7fbd9dfe9e95fe95fd2fb40145d24dd7456ceee22f829141891b54b3ec" + }, + { + "path": "static/images/products/tangshan.webp", + "slug": "tangshan", + "entity_name": "Tangshan", + "qid": "Q58422", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:TangShan.jpg", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/9/9d/TangShan.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "TangShan.jpg", + "license": "CC BY 2.0", + "license_url": "https://creativecommons.org/licenses/by/2.0", + "author": "Mark Hammond from London, England", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=TangShan.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1440 + ], + "source_sha256": "08952d1c55da6636f5d228fa9518f3c427deafddfc151c0b6133c02fcf6f5304", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 105148, + "sha256": "edfe234bb9620260e8efae27db44ecc1e5b3f24476d2e9894ee90ff5814e3f70" + }, + { + "path": "static/images/products/tianjin.webp", + "slug": "tianjin", + "entity_name": "Tianjin", + "qid": "Q11736", + "source_kind": "official_press_page", + "source_page": "https://en.tj.gov.cn/Updates/News/202312/t20231225_6489919.html", + "source_url": "https://en.tj.gov.cn/Updates/News/202312/W020231225584383377461_ORIGIN.png", + "source_file": "W020231225584383377461_ORIGIN.png", + "license": "Copyrighted official press image; reduced-resolution benchmark identification use", + "license_url": "https://en.tj.gov.cn/Updates/News/202312/t20231225_6489919.html", + "author": "Bruce Connolly / exploringtianjin.com, published by Tianjin Municipal Government", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://en.tj.gov.cn/Updates/News/202312/W020231225584383377461_ORIGIN.png", + "source_content_type": "image/png", + "source_dimensions": [ + 940, + 628 + ], + "source_sha256": "097310c79d359695b5ac89b70320e1155ba61104336ad9a412158f78e2d88c5b", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 181090, + "sha256": "3253941d9dc6dd3e758a21092273df8749a4a6df63feeafbec6665a9b40a265a" + }, + { + "path": "static/images/products/tokyo.webp", + "slug": "tokyo", + "entity_name": "Tokyo", + "qid": "Q1490", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Skyscrapers_of_Shinjuku_2009_January_(revised).jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/d/dc/Skyscrapers_of_Shinjuku_2009_January_%28revised%29.jpg/1920px-Skyscrapers_of_Shinjuku_2009_January_%28revised%29.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Skyscrapers of Shinjuku 2009 January (revised).jpg", + "license": "CC BY-SA 3.0", + "license_url": "https://creativecommons.org/licenses/by-sa/3.0", + "author": "Morio", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Skyscrapers+of+Shinjuku+2009+January+%28revised%29.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1049 + ], + "source_sha256": "c6197631cf85f4aeea178ff51c985f4846803f1f206a3fe7c0aa5b27f2b07745", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 142198, + "sha256": "2035a0c9741a1d6f608d70e101bca03ebef52bd2ba5d89228a7517915c48d239" + }, + { + "path": "static/images/products/university-of-algiers-1.webp", + "slug": "university-of-algiers-1", + "entity_name": "University of Algiers 1", + "qid": "Q1190852", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Universit%C3%A9_d%27Alger.jpg", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/9/90/Universit%C3%A9_d%27Alger.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "Université d'Alger.jpg", + "license": "CC BY-SA 3.0", + "license_url": "https://creativecommons.org/licenses/by-sa/3.0", + "author": "Yelles", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Universit%C3%A9+d%27Alger.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 2560 + ], + "source_sha256": "2dbed8e0758dc1a2a2c3a59d518eed34b9c7f2ed9a911b247ba8ba7f3d79995a", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 52442, + "sha256": "0558731306218f6fa9c247e6eb07e68f18e6d8543740359559ec7cac73dec57d" + }, + { + "path": "static/images/products/university-of-benin.webp", + "slug": "university-of-benin", + "entity_name": "University of Benin", + "qid": "Q1816069", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:University_of_Benin_Main_Gate,_Benin_City,_Edo_State_04.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/4/40/University_of_Benin_Main_Gate%2C_Benin_City%2C_Edo_State_04.jpg/1920px-University_of_Benin_Main_Gate%2C_Benin_City%2C_Edo_State_04.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "University of Benin Main Gate, Benin City, Edo State 04.jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "Ei'eke", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=University+of+Benin+Main+Gate%2C+Benin+City%2C+Edo+State+04.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1280 + ], + "source_sha256": "3f758d3d038fc15cd691aa215a5e70027adbfd039b9a8cb81f092a43f556385a", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 90854, + "sha256": "64aab801e84302d49441c90d1916e56ac8fb6cde2e39263134ce06c5ce1cdb12" + }, + { + "path": "static/images/products/university-of-bologna.webp", + "slug": "university-of-bologna", + "entity_name": "University of Bologna", + "qid": "Q131262", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:C%C3%A0_Grande_dei_Malvezzi_-_Sala_Borsa_06.jpg", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/7/73/C%C3%A0_Grande_dei_Malvezzi_-_Sala_Borsa_06.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "Cà Grande dei Malvezzi - Sala Borsa 06.jpg", + "license": "CC BY 4.0", + "license_url": "https://creativecommons.org/licenses/by/4.0", + "author": "Unknown author Unknown author", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=C%C3%A0+Grande+dei+Malvezzi+-+Sala+Borsa+06.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1440 + ], + "source_sha256": "7610ad673ee5279065ae6c932dce08ff78d09eb87d0627d76bcc20457de344d2", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 84308, + "sha256": "d1ed29e755c47e4b3d88549727f067882991013b9d42c9d73eb68fcabadd73c8" + }, + { + "path": "static/images/products/university-of-continuing-education.webp", + "slug": "university-of-continuing-education", + "entity_name": "University of Continuing Education", + "source_kind": "official_site", + "source_page": "https://ufc.dz/", + "source_url": "https://ufc.dz/wp-content/uploads/2026/04/cropped-ufc-png-270x270.png", + "license": "Copyrighted; reproduced for non-commercial benchmark identification", + "license_url": "https://ufc.dz/", + "author": "Université de la Formation Continue", + "entity_type": "university", + "fit": "contain", + "resolved_url": "https://ufc.dz/wp-content/uploads/2026/04/cropped-ufc-png-270x270.png", + "source_content_type": "image/png", + "source_dimensions": [ + 270, + 270 + ], + "source_sha256": "cbf9609e13b9c9aa8a5a2651ea3e66609c29392e0abc2883707292482c3f36e5", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 30472, + "sha256": "60bd11b645735d25179d61800cf156c8f243e8176d4df609aa5e6454bbd2ce89" + }, + { + "path": "static/images/products/university-of-granada.webp", + "slug": "university-of-granada", + "entity_name": "University of Granada", + "qid": "Q1232180", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Facultad_de_Ciencias_de_Granada.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/4/49/Facultad_de_Ciencias_de_Granada.jpg/1920px-Facultad_de_Ciencias_de_Granada.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Facultad de Ciencias de Granada.jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "[[User:Pattiz [1] |Pattiz]]", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Facultad+de+Ciencias+de+Granada.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1440 + ], + "source_sha256": "26edb5164b5e55b4447e33662de882a254b13eab014ded71a467bc313ae853af", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 232234, + "sha256": "d252fc7555f4c4e040b7c6dca4552e8f416f56da4f59a1d0eb3a53fcc3e11d93" + }, + { + "path": "static/images/products/university-of-illinois-urbana-champaign.webp", + "slug": "university-of-illinois-urbana-champaign", + "entity_name": "University of Illinois Urbana-Champaign", + "qid": "Q457281", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:UIUC_Illini_Union_and_Main_Quad.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/6/6a/UIUC_Illini_Union_and_Main_Quad.jpg/1920px-UIUC_Illini_Union_and_Main_Quad.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "UIUC Illini Union and Main Quad.jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "Daniel Schwen", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=UIUC+Illini+Union+and+Main+Quad.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 678 + ], + "source_sha256": "0d4517d69ba28d41ce10d109be3ca8f5406866ac61a87382f332afdb88490342", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 191008, + "sha256": "b171b1b8af02fe1e2357746b5ab02187e88bf465e215fde894bd911e4317fc21" + }, + { + "path": "static/images/products/university-of-lyon.webp", + "slug": "university-of-lyon", + "entity_name": "University of Lyon", + "qid": "Q10176", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Bandeau-www_palais-hirsch-nocturne.jpg", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/d/d9/Bandeau-www_palais-hirsch-nocturne.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "Bandeau-www palais-hirsch-nocturne.jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "MARC1997FR", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Bandeau-www+palais-hirsch-nocturne.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1280 + ], + "source_sha256": "4b540551dc86e4f65c692b962b108d65727762c42614845435432fbe661f540e", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 153132, + "sha256": "81a0f40a2a2260e3cc04148eb1d370f4230aa63430dfd7c8bf3fec5f460b21ae" + }, + { + "path": "static/images/products/university-of-melbourne.webp", + "slug": "university-of-melbourne", + "entity_name": "University of Melbourne", + "qid": "Q319078", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Trinity_college_university_of_melbourne.jpg", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/6/66/Trinity_college_university_of_melbourne.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "Trinity college university of melbourne.jpg", + "license": "Public domain", + "license_url": "", + "author": "Biatch at English Wikipedia", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Trinity+college+university+of+melbourne.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1623 + ], + "source_sha256": "67a1701a07445b890120e0884e70ff43b5c89f164c8e526eeb8de12e19420c01", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 80332, + "sha256": "df0ba86cbf64212854c20cea7402b7028ff4ea2df45d51c0ef024a6fee40fca0" + }, + { + "path": "static/images/products/university-of-s-o-paulo.webp", + "slug": "university-of-s-o-paulo", + "entity_name": "University of São Paulo", + "qid": "Q835960", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Cidade_universit%C3%A1ria_da_Universidade_de_S%C3%A3o_Paulo_(USP).jpg", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/1/1b/Cidade_universit%C3%A1ria_da_Universidade_de_S%C3%A3o_Paulo_%28USP%29.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "Cidade universitária da Universidade de São Paulo (USP).jpg", + "license": "CC BY-SA 3.0", + "license_url": "https://creativecommons.org/licenses/by-sa/3.0", + "author": "Hector.carvalho", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Cidade+universit%C3%A1ria+da+Universidade+de+S%C3%A3o+Paulo+%28USP%29.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1284 + ], + "source_sha256": "4e35ac28d387f0466df805b2db4d0d3a1390511f7f963f8763df1defe6230951", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 152046, + "sha256": "816a5af040bfe295ebd4a020612b73fbc1a49abb1a31f428e1c98c6406614367" + }, + { + "path": "static/images/products/university-of-toronto.webp", + "slug": "university-of-toronto", + "entity_name": "University of Toronto", + "qid": "Q180865", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Facing_North.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/e/e3/Facing_North.jpg/1920px-Facing_North.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Facing North.jpg", + "license": "CC BY 3.0", + "license_url": "https://creativecommons.org/licenses/by/3.0", + "author": "KTMAR", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Facing+North.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1283 + ], + "source_sha256": "6f3b0ae82d5c6d30131591bfc94d245beb13fafa3b869818771f2f59e3233b3c", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 119076, + "sha256": "243a7c0a13ecc4a8096056e30442d758e346ccce10fae5a51baafc09b341a7de" + }, + { + "path": "static/images/products/university-of-toulouse.webp", + "slug": "university-of-toulouse", + "entity_name": "University of Toulouse", + "qid": "Q20669873", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Universite-de-Toulouse.jpg", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/1/11/Universite-de-Toulouse.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "Universite-de-Toulouse.jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "COMUFTMP", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Universite-de-Toulouse.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 640 + ], + "source_sha256": "bc636d2d64ac2485194734001aef48232ca05610abb899c3a314e70aa2615ac9", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 79906, + "sha256": "9954653dd7a4a7ecf4392511a161b5f6bf3ccbe40d7c38c66f4e8d8f9249819f" + }, + { + "path": "static/images/products/university-of-vienna.webp", + "slug": "university-of-vienna", + "entity_name": "University of Vienna", + "qid": "Q165980", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Wien_-_Universit%C3%A4t_(2).JPG", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/4/43/Wien_-_Universit%C3%A4t_%282%29.JPG/1920px-Wien_-_Universit%C3%A4t_%282%29.JPG?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Wien - Universität (2).JPG", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "C.Stadler/Bwag", + "entity_type": "university", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Wien+-+Universit%C3%A4t+%282%29.JPG&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1280 + ], + "source_sha256": "562f7eb751eb120423375dcedf997f48fa2fd0487222efa2a542bc96520c6a86", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 159620, + "sha256": "0dc9a89e04abb7bebc42a4acd948f4a74484a3afeac64bbe309d09681e98d6ef" + }, + { + "path": "static/images/products/weifang.webp", + "slug": "weifang", + "entity_name": "Weifang", + "qid": "Q217698", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:20211212_Bailang_River_04.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/4/4c/20211212_Bailang_River_04.jpg/1920px-20211212_Bailang_River_04.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "20211212 Bailang River 04.jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "Ngguls", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=20211212+Bailang+River+04.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1440 + ], + "source_sha256": "14d38c707b821e761275971078dcd50c4ffcae588a44cedbb4b28af61fac0595", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 148876, + "sha256": "34c3e95ae3bf6cdcd68c01236cfb511524548c470531650d76b56eed98fce9fc" + }, + { + "path": "static/images/products/wenzhou.webp", + "slug": "wenzhou", + "entity_name": "Wenzhou", + "qid": "Q42635", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Vue_g%C3%A9n%C3%A9rale_de_Wenzhou.JPG", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/d/da/Vue_g%C3%A9n%C3%A9rale_de_Wenzhou.JPG/1920px-Vue_g%C3%A9n%C3%A9rale_de_Wenzhou.JPG?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Vue générale de Wenzhou.JPG", + "license": "CC BY-SA 3.0", + "license_url": "https://creativecommons.org/licenses/by-sa/3.0", + "author": "Pascal3012", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Vue+g%C3%A9n%C3%A9rale+de+Wenzhou.JPG&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1283 + ], + "source_sha256": "4fe789e839af1d0d1ae5b597cd7742e9395990e0a68dd9f106aa0d194b3371d1", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 108410, + "sha256": "498c14e90f753ed6695b2e0d4b5bac86fae1f09c7665ab16951b8ddb375a3b79" + }, + { + "path": "static/images/products/western-governors-university.webp", + "slug": "western-governors-university", + "entity_name": "Western Governors University", + "source_kind": "official_identity_page", + "source_page": "https://www.wgu.edu/about/governance/our-mission-reflected-wgu-logo-and-seal.html", + "source_url": "https://www.wgu.edu/about/governance/our-mission-reflected-wgu-logo-and-seal/_jcr_content/root/container_1292577383/container/container_copy/container_2068656731/image.coreimg.png/1768597367287/wgu-full-logo-full-color.png", + "license": "Copyrighted; reproduced for non-commercial benchmark identification", + "license_url": "https://www.wgu.edu/about/governance/our-mission-reflected-wgu-logo-and-seal.html", + "author": "Western Governors University", + "entity_type": "university", + "fit": "contain", + "resolved_url": "https://www.wgu.edu/about/governance/our-mission-reflected-wgu-logo-and-seal/_jcr_content/root/container_1292577383/container/container_copy/container_2068656731/image.coreimg.png/1768597367287/wgu-full-logo-full-color.png", + "source_content_type": "image/png", + "source_dimensions": [ + 650, + 214 + ], + "source_sha256": "b3c6346cf37edd10acf0d8fa33cc374c45440ee642874af172898d9c421888ea", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 10842, + "sha256": "6a199897f7b2908825512568862e1f3de8fbb6137a329d7e9d0836784009c3be" + }, + { + "path": "static/images/products/wuhan.webp", + "slug": "wuhan", + "entity_name": "Wuhan", + "qid": "Q11746", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:%E6%AD%A6%E6%B1%89%E9%BB%84%E9%B9%A4%E6%A5%BC%E4%BF%AF%E7%9E%B0.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/f/f0/%E6%AD%A6%E6%B1%89%E9%BB%84%E9%B9%A4%E6%A5%BC%E4%BF%AF%E7%9E%B0.jpg/1920px-%E6%AD%A6%E6%B1%89%E9%BB%84%E9%B9%A4%E6%A5%BC%E4%BF%AF%E7%9E%B0.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "武汉黄鹤楼俯瞰.jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "螺钉", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=%E6%AD%A6%E6%B1%89%E9%BB%84%E9%B9%A4%E6%A5%BC%E4%BF%AF%E7%9E%B0.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1440 + ], + "source_sha256": "cf0bf1524f23794a2b30883bd452a33c345046d26c0836141519234fb4cad80b", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 119288, + "sha256": "72300431245fd290ffa21704100ec84636ca958b12adf7572dc17e15589e67e8" + }, + { + "path": "static/images/products/xi-an.webp", + "slug": "xi-an", + "entity_name": "Xi'an", + "qid": "Q5826", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:%E8%A5%BF%E5%AE%89%E9%92%9F%E6%A5%BC2020_(1).jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/4/44/%E8%A5%BF%E5%AE%89%E9%92%9F%E6%A5%BC2020_%281%29.jpg/1920px-%E8%A5%BF%E5%AE%89%E9%92%9F%E6%A5%BC2020_%281%29.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "西安钟楼2020 (1).jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "ScareCriterion12", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=%E8%A5%BF%E5%AE%89%E9%92%9F%E6%A5%BC2020+%281%29.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1161 + ], + "source_sha256": "a90df4ac2e0388c09d4864b00ddadd64e0cef5d6c260f9d076a6b93b800ccda1", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 124586, + "sha256": "01b4ed46ab987aab442ab5f53961b9b060c907a9102ac22c2cf68feb30b2b49b" + }, + { + "path": "static/images/products/xuzhou.webp", + "slug": "xuzhou", + "entity_name": "Xuzhou", + "qid": "Q57719", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:Yunlong_Mountain_8.jpg", + "source_url": "https://thumb.wikimedia.org/wikipedia/commons/thumb/e/eb/Yunlong_Mountain_8.jpg/1920px-Yunlong_Mountain_8.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail", + "source_file": "Yunlong Mountain 8.jpg", + "license": "CC0", + "license_url": "http://creativecommons.org/publicdomain/zero/1.0/deed.en", + "author": "H2v5o68z", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=Yunlong+Mountain+8.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1440 + ], + "source_sha256": "6881cf520e26f3ea7aad141dcadfc70d5749bf7c117ff1d61e491da1956f0674", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 125568, + "sha256": "9645e2d5e161d27e60e55ba4753338aecfe32b7f7d5d6483f7709276057dff15" + }, + { + "path": "static/images/products/zhengzhou.webp", + "slug": "zhengzhou", + "entity_name": "Zhengzhou", + "qid": "Q30340", + "source_kind": "wikimedia_commons", + "source_page": "https://commons.wikimedia.org/wiki/File:20220812_Central_Business_District_of_Zhengdong_New_Area.jpg", + "source_url": "https://upload.wikimedia.org/wikipedia/commons/c/c2/20220812_Central_Business_District_of_Zhengdong_New_Area.jpg?utm_source=commons.wikimedia.org&utm_campaign=imageinfo&utm_content=thumbnail_unscaled", + "source_file": "20220812 Central Business District of Zhengdong New Area.jpg", + "license": "CC BY-SA 4.0", + "license_url": "https://creativecommons.org/licenses/by-sa/4.0", + "author": "Windmemories", + "entity_type": "city", + "fit": "cover", + "resolved_url": "https://commons.wikimedia.org/w/thumb.php?f=20220812+Central+Business+District+of+Zhengdong+New+Area.jpg&w=1920", + "source_content_type": "image/jpeg", + "source_dimensions": [ + 1920, + 1279 + ], + "source_sha256": "cb07587d6590ea01332d0692c823c9748edcc4626e31de49b75cf86da1b951a2", + "output_dimensions": [ + 960, + 720 + ], + "bytes": 190220, + "sha256": "d50123b2a0cc963489150418e0b754b55ae3f7c6fc54ed40e7e04c4e963512ec" + } + ], + "total_bytes": 12145350 +} diff --git a/sites/versus/check_generated_assets.py b/sites/versus/check_generated_assets.py deleted file mode 100644 index 8fddf503..00000000 --- a/sites/versus/check_generated_assets.py +++ /dev/null @@ -1,74 +0,0 @@ -#!/usr/bin/env python3 -"""Validate the generated Versus product tiles. - -The tiles are byte-stable regenerations of ``generate_art.py`` under the pinned -toolchain. This checker enforces exact coverage (no missing, extra or stale -files), per-file size + SHA-256 equality against ``generated_asset_inventory.json`` -and a full PNG decode of every file. It runs in the Docker build, mirroring the -webmd_doctor / compass / walmart_careers asset gates, so art that is missing, -altered or unaccounted for fails the build instead of degrading silently. -""" -import hashlib -import json -import sys -from pathlib import Path, PurePosixPath - -from PIL import Image - -SITE = Path(__file__).resolve().parent -MANAGED_ROOT = "static/images/products" - - -def verify(): - manifest = json.loads((SITE / "generated_asset_inventory.json").read_text()) - rows = manifest.get("assets") - if manifest.get("schema_version") != 1 or not isinstance(rows, list): - raise ValueError("unsupported generated asset inventory") - if not rows: - raise ValueError("inventory lists no assets") - - problems = [] - expected = set() - for row in rows: - rel = PurePosixPath(row["path"]) - if rel.is_absolute() or ".." in rel.parts or not str(rel).startswith(MANAGED_ROOT): - problems.append(f"{rel}: path escapes {MANAGED_ROOT}") - continue - expected.add(str(rel)) - path = SITE / rel - if not path.is_file(): - problems.append(f"{rel}: missing") - continue - data = path.read_bytes() - if len(data) != row["bytes"]: - problems.append(f"{rel}: {len(data)} bytes, inventory says {row['bytes']}") - digest = hashlib.sha256(data).hexdigest() - if digest != row["sha256"]: - problems.append(f"{rel}: sha256 {digest[:12]}…, inventory says {row['sha256'][:12]}…") - try: - with Image.open(path) as im: - im.load() - if im.format != "PNG": - problems.append(f"{rel}: format {im.format}, expected PNG") - except Exception as exc: # noqa: BLE001 - any decode failure is a failure - problems.append(f"{rel}: does not decode ({type(exc).__name__})") - - root = SITE / MANAGED_ROOT - if root.is_dir(): - for path in sorted(root.rglob("*")): - if path.is_file(): - rel = str(path.relative_to(SITE)) - if rel not in expected: - problems.append(f"{rel}: present but not in the inventory") - - if problems: - print(f"Versus generated-asset check FAILED ({len(problems)} problem(s)):") - for p in problems: - print(f" - {p}") - return 1 - print(f"Versus generated-asset check OK: {len(expected)} tiles verified") - return 0 - - -if __name__ == "__main__": - sys.exit(verify()) diff --git a/sites/versus/fetch_images.py b/sites/versus/fetch_images.py new file mode 100644 index 00000000..130feab8 --- /dev/null +++ b/sites/versus/fetch_images.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Download and normalize the Versus mirror's source-backed entity images. + +The tracked ``asset_inventory.json`` is the source of truth. Normal mode verifies +both the downloaded source bytes and the normalized WebP outputs. ``--refresh`` +is intentionally explicit: it rewrites the pinned hashes after a maintainer has +reviewed a source change. + +Run from this directory with the same Pillow release used by the container: + + uv run --python 3.12 --with pillow==11.0.0 --with requests==2.32.5 \ + python fetch_images.py +""" +from __future__ import annotations + +import argparse +import hashlib +import io +import json +import time +from pathlib import Path +from urllib.parse import urlencode + +import requests +from PIL import Image, ImageOps + +SITE_DIR = Path(__file__).resolve().parent +MANIFEST_PATH = SITE_DIR / "asset_inventory.json" +OUTPUT_SIZE = (960, 720) +BACKGROUND = (245, 246, 248) +USER_AGENT = "Mozilla/5.0 (compatible; WebHarbor asset archival)" + + +def sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def download(row: dict) -> tuple[bytes, str, str]: + headers = {"User-Agent": USER_AGENT} + last_error: Exception | None = None + request_url = row["source_url"] + is_wikimedia = row.get("source_kind") == "wikimedia_commons" + if is_wikimedia: + # Ask Commons' thumbnail endpoint for a bounded source image. This + # avoids multi-megabyte originals and the stricter bulk rate limit on + # upload.wikimedia.org. + request_url = "https://commons.wikimedia.org/w/thumb.php?" + urlencode( + {"f": row["source_file"], "w": 1920} + ) + time.sleep(1.0) + for attempt in range(4): + try: + # A fresh session avoids stale CDN keep-alive sockets poisoning a + # long 107-image refresh after a server closes one connection. + with requests.Session() as session: + with session.get( + request_url, headers=headers, timeout=(15, 30) + ) as response: + if response.status_code == 429: + retry_after = int(response.headers.get("Retry-After", "30")) + last_error = RuntimeError("Wikimedia rate limit") + time.sleep(max(15, min(retry_after, 60))) + continue + response.raise_for_status() + content = response.content + if len(content) < 2_000: + raise RuntimeError(f"response is too small ({len(content)} bytes)") + return content, response.url, response.headers.get("content-type", "") + except (requests.RequestException, RuntimeError) as exc: + last_error = exc + if attempt < 3: + time.sleep(2 ** attempt) + raise RuntimeError(f"failed to download {row['slug']}: {last_error}") + + +def normalize(raw: bytes, fit: str) -> tuple[bytes, list[int]]: + with Image.open(io.BytesIO(raw)) as source: + source.load() + source_size = list(source.size) + image = ImageOps.exif_transpose(source).convert("RGBA") + backdrop = Image.new("RGBA", OUTPUT_SIZE, BACKGROUND + (255,)) + if fit == "cover": + image = ImageOps.fit(image, OUTPUT_SIZE, Image.Resampling.LANCZOS) + backdrop.alpha_composite(image) + elif fit == "contain": + image = ImageOps.contain(image, (880, 640), Image.Resampling.LANCZOS) + backdrop.alpha_composite( + image, + ((OUTPUT_SIZE[0] - image.width) // 2, (OUTPUT_SIZE[1] - image.height) // 2), + ) + else: + raise ValueError(f"unsupported fit mode: {fit!r}") + output = io.BytesIO() + backdrop.convert("RGB").save(output, "WEBP", quality=84, method=6) + return output.getvalue(), source_size + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--refresh", + action="store_true", + help="rewrite source/output hashes after reviewing source changes", + ) + parser.add_argument( + "--resume", + action="store_true", + help="with --refresh, keep already refreshed outputs and continue", + ) + args = parser.parse_args() + if args.resume and not args.refresh: + parser.error("--resume requires --refresh") + manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) + rows = manifest.get("assets") + if manifest.get("schema_version") != 1 or not isinstance(rows, list): + raise ValueError("unsupported asset inventory schema") + + expected: set[Path] = set() + for index, row in enumerate(rows, start=1): + destination = SITE_DIR / row["path"] + expected.add(destination.resolve()) + if args.refresh and args.resume and destination.is_file(): + existing = destination.read_bytes() + if len(existing) == row.get("bytes") and sha256(existing) == row.get("sha256"): + print(f"[{index:03d}/{len(rows):03d}] {row['slug']} (resumed)") + continue + raw, resolved_url, content_type = download(row) + source_digest = sha256(raw) + if not args.refresh and source_digest != row.get("source_sha256"): + raise RuntimeError(f"source hash changed for {row['slug']}") + output, source_size = normalize(raw, row["fit"]) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(output) + output_digest = sha256(output) + if args.refresh: + row.update( + { + "resolved_url": resolved_url, + "source_content_type": content_type.split(";", 1)[0], + "source_dimensions": source_size, + "source_sha256": source_digest, + "output_dimensions": list(OUTPUT_SIZE), + "bytes": len(output), + "sha256": output_digest, + } + ) + manifest["total_bytes"] = sum(item.get("bytes", 0) for item in rows) + MANIFEST_PATH.write_text( + json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + elif len(output) != row.get("bytes") or output_digest != row.get("sha256"): + raise RuntimeError(f"normalized output changed for {row['slug']}") + print(f"[{index:03d}/{len(rows):03d}] {row['slug']} -> {len(output)} bytes") + + managed = SITE_DIR / "static" / "images" / "products" + actual = {path.resolve() for path in managed.iterdir() if path.is_file()} + if actual != expected: + missing = sorted(str(path) for path in expected - actual) + extra = sorted(str(path) for path in actual - expected) + raise RuntimeError(f"managed image mismatch: missing={missing[:5]} extra={extra[:5]}") + + if args.refresh: + manifest["asset_count"] = len(rows) + manifest["total_bytes"] = sum(row["bytes"] for row in rows) + MANIFEST_PATH.write_text( + json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + print(f"verified {len(rows)} source-backed images") + + +if __name__ == "__main__": + main() diff --git a/sites/versus/generate_art.py b/sites/versus/generate_art.py deleted file mode 100644 index d072479d..00000000 --- a/sites/versus/generate_art.py +++ /dev/null @@ -1,180 +0,0 @@ -#!/usr/bin/env python3 -"""Generate the Versus product tiles. - -The mirror ships deliberately synthetic product art rather than photography. -See NOTICE.md for why and for the disposition of that choice. This module is -the single source of those images: it draws one tile per product from the seed -data, deterministically, so two builds of the same commit produce byte-identical -files whose hashes are pinned in generated_asset_inventory.json. - -Determinism rules observed here: - * no RNG, no clock, no locale - every value is derived from the product row - * Pillow's bundled default font, so no system font can change the output - * PNG written with fixed compression and no ancillary chunks - -Usage: - python3 generate_art.py [--out static/images/products] [--write-inventory] -""" -import argparse -import hashlib -import json -from pathlib import Path - -from PIL import Image, ImageDraw, ImageFont - -SITE = Path(__file__).resolve().parent -OUT_REL = "static/images/products" -SIZE = (480, 360) - -# Panel and ink follow the site's dark palette (static/css/main.css). -PANEL = (23, 18, 31) -INK = (245, 243, 247) -MUTED = (162, 155, 176) - -# One accent per category, used for the backdrop wash and the device outline. -CATEGORY_ACCENT = { - "smartphones": (124, 92, 255), - "headphones": (236, 72, 153), - "cameras": (56, 189, 248), - "graphics-cards": (34, 197, 94), - "smartwatches": (251, 146, 60), - "cities": (96, 165, 250), - "universities": (250, 204, 21), -} -DEFAULT_ACCENT = (124, 92, 255) - - -def _mix(a, b, t): - return tuple(round(x + (y - x) * t) for x, y in zip(a, b)) - - -def _initials(brand, name): - """Two letters at most, taken from the brand, falling back to the name.""" - source = (brand or "").strip() - if source in ("", "-", "—"): - source = (name or "?").strip() - parts = [p for p in source.replace("-", " ").split() if p] - if not parts: - return "?" - if len(parts) == 1: - return parts[0][:2].upper() - return (parts[0][0] + parts[1][0]).upper() - - -def _device_box(category): - """A silhouette that hints at the product class without depicting a product.""" - w, h = SIZE - cx, cy = w // 2, h // 2 - 10 - shapes = { - "smartphones": (cx - 46, cy - 86, cx + 46, cy + 86, 18), - "headphones": (cx - 78, cy - 78, cx + 78, cy + 78, 78), - "cameras": (cx - 104, cy - 62, cx + 104, cy + 62, 16), - "graphics-cards": (cx - 122, cy - 46, cx + 122, cy + 46, 10), - "smartwatches": (cx - 54, cy - 62, cx + 54, cy + 62, 22), - # Not devices: a skyline block and a pediment stand in for the entity. - "cities": (cx - 116, cy - 40, cx + 116, cy + 70, 6), - "universities": (cx - 100, cy - 54, cx + 100, cy + 62, 8), - } - return shapes.get(category, (cx - 90, cy - 70, cx + 90, cy + 70, 16)) - - -def draw_tile(slug, name, brand, category): - accent = CATEGORY_ACCENT.get(category, DEFAULT_ACCENT) - img = Image.new("RGB", SIZE, PANEL) - d = ImageDraw.Draw(img) - - # Backdrop wash: horizontal bands from panel toward the category accent. - for y in range(SIZE[1]): - t = (y / SIZE[1]) * 0.22 - d.line([(0, y), (SIZE[0], y)], fill=_mix(PANEL, accent, t)) - - x0, y0, x1, y1, radius = _device_box(category) - d.rounded_rectangle((x0, y0, x1, y1), radius=radius, - fill=_mix(PANEL, accent, 0.10), - outline=_mix(accent, INK, 0.25), width=3) - - # Category-specific detail, still schematic. - if category == "cameras": - r = 34 - d.ellipse((x0 + 30, (y0 + y1) // 2 - r, x0 + 30 + 2 * r, (y0 + y1) // 2 + r), - outline=_mix(accent, INK, 0.45), width=3) - elif category == "graphics-cards": - for i in range(3): - r = 26 - cx = x0 + 44 + i * 72 - d.ellipse((cx - r, (y0 + y1) // 2 - r, cx + r, (y0 + y1) // 2 + r), - outline=_mix(accent, INK, 0.35), width=2) - elif category == "cities": - for i, h in enumerate((70, 104, 52, 88, 60)): - x = x0 + 14 + i * 44 - d.rectangle((x, y1 - h, x + 32, y1 - 4), outline=_mix(accent, INK, 0.4), width=2) - elif category == "universities": - d.polygon([(x0 + 6, y0 + 6), (x1 - 6, y0 + 6), ((x0 + x1) // 2, y0 - 26)], - outline=_mix(accent, INK, 0.45)) - for i in range(4): - x = x0 + 30 + i * 48 - d.line([(x, y0 + 14), (x, y1 - 10)], fill=_mix(accent, INK, 0.35), width=3) - elif category == "headphones": - d.arc((x0 + 16, y0 + 10, x1 - 16, y1 - 10), start=200, end=340, - fill=_mix(accent, INK, 0.45), width=6) - - initials = _initials(brand, name) - font = ImageFont.load_default(size=54) - box = d.textbbox((0, 0), initials, font=font) - d.text(((SIZE[0] - (box[2] - box[0])) // 2 - box[0], - (y0 + y1) // 2 - (box[3] - box[1]) // 2 - box[1]), - initials, font=font, fill=INK) - - label_font = ImageFont.load_default(size=19) - label = name if len(name) <= 34 else name[:33] + "…" - lbox = d.textbbox((0, 0), label, font=label_font) - d.text(((SIZE[0] - (lbox[2] - lbox[0])) // 2 - lbox[0], SIZE[1] - 44), - label, font=label_font, fill=MUTED) - - # Deliberate, visible marker that this is synthetic art, not a photograph. - tag_font = ImageFont.load_default(size=13) - d.text((14, 14), "SYNTHETIC ART", font=tag_font, fill=_mix(MUTED, accent, 0.5)) - return img - - -def products(): - """Read the catalogue straight from app.py's seed definition.""" - import app # noqa: WPS433 - import side effect creates/loads the DB - with app.app.app_context(): - rows = app.Product.query.join(app.Category).order_by(app.Product.id).all() - return [(p.slug, p.name, p.brand, p.category.slug) for p in rows] - - -def write_all(out_dir): - out_dir.mkdir(parents=True, exist_ok=True) - written = [] - for slug, name, brand, category in products(): - path = out_dir / f"{slug}.png" - draw_tile(slug, name, brand, category).save( - path, format="PNG", optimize=False, compress_level=6) - data = path.read_bytes() - written.append({"path": f"{OUT_REL}/{slug}.png", "bytes": len(data), - "sha256": hashlib.sha256(data).hexdigest()}) - return sorted(written, key=lambda r: r["path"]) - - -def main(): - ap = argparse.ArgumentParser() - ap.add_argument("--out", default=str(SITE / OUT_REL)) - ap.add_argument("--write-inventory", action="store_true") - args = ap.parse_args() - - rows = write_all(Path(args.out)) - print(f"wrote {len(rows)} product tiles to {args.out}") - if args.write_inventory: - target = SITE / "generated_asset_inventory.json" - target.write_text(json.dumps( - {"schema_version": 1, - "generator": "sites/versus/generate_art.py", - "toolchain": "Pillow 11.0.0, Python 3.12, bundled default font", - "assets": rows}, indent=2) + "\n") - print(f"wrote {target}") - - -if __name__ == "__main__": - main() diff --git a/sites/versus/generated_asset_inventory.json b/sites/versus/generated_asset_inventory.json deleted file mode 100644 index 7746eea8..00000000 --- a/sites/versus/generated_asset_inventory.json +++ /dev/null @@ -1,542 +0,0 @@ -{ - "schema_version": 1, - "generator": "sites/versus/generate_art.py", - "toolchain": "Pillow 11.0.0, Python 3.12, bundled default font", - "assets": [ - { - "path": "static/images/products/ahmedabad.png", - "bytes": 6192, - "sha256": "eb8d5c87656f374a4d3c39ae024bf76f0a2b0089beecf48d5d5416252a351cbd" - }, - { - "path": "static/images/products/alexandria-university.png", - "bytes": 7989, - "sha256": "bdd5f62aba100aa636fffd9de4e939135d858d25b6459a2234e920104247f325" - }, - { - "path": "static/images/products/apple-airpods-max.png", - "bytes": 8246, - "sha256": "bdd3752e5f48fe0e4c1fde3ad580c6b88c791a283b0a365396e471e0e1516847" - }, - { - "path": "static/images/products/apple-watch-series-9.png", - "bytes": 8335, - "sha256": "266363dcd4ef387e8bef9ab949eede9f45439d9331e5a549a111b99e3fd8943e" - }, - { - "path": "static/images/products/aristotle-university-of-thessaloniki.png", - "bytes": 8241, - "sha256": "ce96ddafbd19d418ede6258fdfc4e8bb0e3d825503e8ebb767609bfa87b70865" - }, - { - "path": "static/images/products/arizona-state-university.png", - "bytes": 9164, - "sha256": "2fb53380e48c3292c39088676ae1cee401add79871f1bfad9d75dfc860e89c8b" - }, - { - "path": "static/images/products/baghdad.png", - "bytes": 6481, - "sha256": "20dc1c2ebd353c01f947e07c9668751a2ed1281a04724b9bb0d5e96db3ccae59" - }, - { - "path": "static/images/products/baoding.png", - "bytes": 6730, - "sha256": "f0caf783bd4b0d3c915392867f5fd246056dd1f7d17eea7071d5b321b8befaf7" - }, - { - "path": "static/images/products/beijing.png", - "bytes": 5461, - "sha256": "8adc2f95a12ef43914d5ad49c8f7e37859cbe0626f3a4c5356d41cee848f989a" - }, - { - "path": "static/images/products/bogot.png", - "bytes": 6974, - "sha256": "a717d46bae838f32b784d60037ee57bc54dae23771d2ccd1c484a06974d7c3c3" - }, - { - "path": "static/images/products/bose-quietcomfort-ultra.png", - "bytes": 9633, - "sha256": "e8bbca52f009bd304deb03866e3e983378f2e14c6df2be127f096825fe726d26" - }, - { - "path": "static/images/products/cairo.png", - "bytes": 6686, - "sha256": "c5e0d4be76c4111436930bb7436919c3dd1b5ba040fcc4e38b943266ddad53fa" - }, - { - "path": "static/images/products/canon-eos-r6-mark-ii.png", - "bytes": 9239, - "sha256": "3c0b790e62ddc31c0bd20567eed06d28d6411fbcf3ac87591007bcb94443bbd5" - }, - { - "path": "static/images/products/capital-university-egypt.png", - "bytes": 8874, - "sha256": "d1cee31b91ba449cad73bca58c0ab9171ccfdd1c3d8e6a4820db3988e49b6538" - }, - { - "path": "static/images/products/changchun.png", - "bytes": 6344, - "sha256": "b61a31fe3922d70991049fd29e8605a1cc03595b1f660cfb3b7a42e4b209ff8f" - }, - { - "path": "static/images/products/changsha.png", - "bytes": 6263, - "sha256": "e3833cde63242e1c5a53bb5921ff1cd3341d9978c3dfb8f121f531fdafbd9dc3" - }, - { - "path": "static/images/products/complutense-university-of-madrid.png", - "bytes": 9284, - "sha256": "08497e92dcc6d9b900ecf4bd74800e08e32dc6526b07ff16a7320a22aa332c6a" - }, - { - "path": "static/images/products/damascus-university.png", - "bytes": 7711, - "sha256": "05428ba1105a8b51f5f2754a13f177faf1032d627a28deec18ae3ace6e27aaa6" - }, - { - "path": "static/images/products/dongguan.png", - "bytes": 7055, - "sha256": "0893c56850e4f686755ab9b74bfe650d8ec2faccbf72f63cf39162fe12ccc6f9" - }, - { - "path": "static/images/products/fitbit-sense-2.png", - "bytes": 5359, - "sha256": "a2050f6157bb58d3f72977b92a8e7e6eb83e1eaa08a2ff33c01dff52b2325166" - }, - { - "path": "static/images/products/foshan.png", - "bytes": 6047, - "sha256": "76c999c184dd907cad9fe820995f3f8bdd5542d5815a9addce3f87037021503c" - }, - { - "path": "static/images/products/fujifilm-x-t5.png", - "bytes": 5991, - "sha256": "56b25538ef1f2a06c6cfe6f2456d4b84f251ed1ce3fe774486d79b0694990c25" - }, - { - "path": "static/images/products/fuyang.png", - "bytes": 5307, - "sha256": "456ccc66abf1730bf9165726825a4a7f331a818b6633a8036ce272e3cb284825" - }, - { - "path": "static/images/products/fuzhou.png", - "bytes": 4807, - "sha256": "391ad69f1bff733f25606048a63999b8fbae498acf3e3bcb827b418dacd11967" - }, - { - "path": "static/images/products/ganzhou.png", - "bytes": 7132, - "sha256": "594eeffd00c971a9fea8bbeb1ee66cd90ed9e7f4750c1ebb10f16efce68a95b5" - }, - { - "path": "static/images/products/garmin-venu-3.png", - "bytes": 7857, - "sha256": "f38aac293fd117db9d389fc1e770a159664b0ee925dd6ba91247098c1e89c7e9" - }, - { - "path": "static/images/products/google-pixel-8-pro.png", - "bytes": 8489, - "sha256": "5aacae95ec3e0c1601e7e26a5e2d73305893420ec1330065881e879cf7f3ff5b" - }, - { - "path": "static/images/products/grand-canyon-university.png", - "bytes": 9217, - "sha256": "3e3d1b010969db12d156fe88b29761747e32bb8de2db2cbfa4a262d19dd29494" - }, - { - "path": "static/images/products/guangzhou.png", - "bytes": 7039, - "sha256": "b3d0c1a788bfcb64ae05bc946b3d78e529e7fd76a7392c953b3e356ba9125b13" - }, - { - "path": "static/images/products/hangzhou.png", - "bytes": 6003, - "sha256": "6622f03f0b1a56de91459b03953476c7ed556fb32765150c547d1292377719f0" - }, - { - "path": "static/images/products/hanoi.png", - "bytes": 5338, - "sha256": "9589a0c48b54e2a224e1992330cca4287b0772b3bd9a6a0b493160c1af6c4197" - }, - { - "path": "static/images/products/hefei.png", - "bytes": 4017, - "sha256": "3b0d0e298359c673d2276b8a7f6afa7c5ee97f6126e23ecaa9ff6e8528b6425c" - }, - { - "path": "static/images/products/ho-chi-minh-city.png", - "bytes": 6506, - "sha256": "81cb3afe83de77c5357d20a15d594769f768fd2c65f3578b57451050aa120086" - }, - { - "path": "static/images/products/homs-university.png", - "bytes": 6351, - "sha256": "0046290acaef0158ad0f4827dcaf500e43a742c66b67153cbc560f505ae49b58" - }, - { - "path": "static/images/products/iphone-15-pro.png", - "bytes": 6591, - "sha256": "4ce675fafd00aa54b12ca2f22f1dff598a2f9350f5c3a3dfb8f9c69763aa26e9" - }, - { - "path": "static/images/products/istanbul.png", - "bytes": 5939, - "sha256": "55198cec5a4e513405fbc12957034a7b32d9fec60cac6567fd2170bbea536669" - }, - { - "path": "static/images/products/iu-international-university-of-applied-sciences.png", - "bytes": 6676, - "sha256": "4f65588af71008da7650d6ad67936f04ad2ab98d13e68f700858b4a144787387" - }, - { - "path": "static/images/products/jinan.png", - "bytes": 4354, - "sha256": "599e97df917a2b6da520961cc74ba837398dafdcad2528d5b60befd1a7680ab4" - }, - { - "path": "static/images/products/jining.png", - "bytes": 4452, - "sha256": "4438c8126af6503e6871b9f6e06dd46cb0a307c258e35ca1db8245fffb7fb534" - }, - { - "path": "static/images/products/karachi.png", - "bytes": 6377, - "sha256": "6107c0bbdb1fecf300c7d3ccef9c56f0aa8bfb158f504c9e65d52877fdc10cce" - }, - { - "path": "static/images/products/kuala-lumpur.png", - "bytes": 5678, - "sha256": "353484ed9d92c6c4c761f5a6af8a2fa20b916c6b0a90e5d22f9d5500bf8886de" - }, - { - "path": "static/images/products/kunming.png", - "bytes": 5931, - "sha256": "c9f6c8501c1f16517aaa121071bb2b3b1f1c3116f9be04e674cd682fb01f34f5" - }, - { - "path": "static/images/products/kwame-nkrumah-university-of-science-and-technology.png", - "bytes": 9483, - "sha256": "00d9a799e342709e23d6558c7f39af7c8255cf060e3e676df4882a1cfa12fb78" - }, - { - "path": "static/images/products/lagos-state-university.png", - "bytes": 8093, - "sha256": "8a76cb0021a12958b570bc6654f933bca3be207bf6812092b41d9f37ba8545ce" - }, - { - "path": "static/images/products/lagos.png", - "bytes": 5732, - "sha256": "5c9ad111a377b4631f224fde50f7820ced44ea37a4e0be4b176ffdc534376753" - }, - { - "path": "static/images/products/lahore.png", - "bytes": 5581, - "sha256": "7b6bdd58cf06889fa58accb812b6a1391253455f28e10a594875060f5dc65f97" - }, - { - "path": "static/images/products/lebanese-university.png", - "bytes": 6438, - "sha256": "567433e3b0e78d5899109e60cd99f7ab5d973e3a7fdd8f1cb4e70e185718bf27" - }, - { - "path": "static/images/products/lima.png", - "bytes": 3905, - "sha256": "afa4f6ad4c219f6ef3c2cc9f9bda03e67217d236c04808b754db293897b11655" - }, - { - "path": "static/images/products/mexico-city.png", - "bytes": 7747, - "sha256": "1f64d00aff38d7928601b37b137bc5ad22efa82f04628f4d537a592520d71891" - }, - { - "path": "static/images/products/monterrey-institute-of-technology-and-higher-education.png", - "bytes": 7717, - "sha256": "ca352cfc455907e246bed0f686aedc349b29862b160f6a2c34977c52e3f430e3" - }, - { - "path": "static/images/products/moscow.png", - "bytes": 7256, - "sha256": "a950317c1ce6d10a021ee2aad963fec299e242cd8c716f6bea93c6f50b5f34fc" - }, - { - "path": "static/images/products/nanjing.png", - "bytes": 6202, - "sha256": "333563be0ff57e3de848d07f5888f6e5bd3ec80c509e1ea5402d40c7df2ad299" - }, - { - "path": "static/images/products/nanning.png", - "bytes": 6150, - "sha256": "ad5c802f24eda779c28c61e327c9a1a475e875ce3e3c275771020948fc21e97d" - }, - { - "path": "static/images/products/nantong.png", - "bytes": 6420, - "sha256": "0de8b7800d22e72df2e1f30762d19d3fcbf01c83f7500353f3bca12753f3c4b5" - }, - { - "path": "static/images/products/nanyang.png", - "bytes": 6399, - "sha256": "3445c3b9bbf8a5dc810154dddc88a04f7b6ad021f3feb4839b2a6b5f5e1afc1c" - }, - { - "path": "static/images/products/national-technological-university.png", - "bytes": 7711, - "sha256": "9bb6efc8a338235d6eb58eedcd42696b2efd07dead1db7765a798d39cba57b3a" - }, - { - "path": "static/images/products/national-university-of-c-rdoba.png", - "bytes": 8463, - "sha256": "94cea693c79e2a710e6b396d0c2bf0921f0787e389ce13cecc29eeb3e473d2de" - }, - { - "path": "static/images/products/national-university-of-la-plata.png", - "bytes": 7697, - "sha256": "5ba732d7f97d152172126fb317ef68903909671defe7732477914d8af07cce2a" - }, - { - "path": "static/images/products/national-university-of-rosario.png", - "bytes": 7720, - "sha256": "fce3af34b9919e0f938378b6a41b2ad5d9e47b9915878c21b8e4032e31025259" - }, - { - "path": "static/images/products/national-university-of-tucum-n.png", - "bytes": 8206, - "sha256": "d4f5b0417c3f00837644f081031c8d71f4bfbeeb55a76624f44b2a8b0df1600d" - }, - { - "path": "static/images/products/netaji-subhas-open-university.png", - "bytes": 9042, - "sha256": "1f59fe7d38d91a3a7898388acf055e3917310ffb1736752bb2573ddeea7e78a7" - }, - { - "path": "static/images/products/nikon-z8.png", - "bytes": 6016, - "sha256": "532ce68049dc7854890edb5b6ab696fb96e8e2eed2e0fc49cfe4c213abbc496f" - }, - { - "path": "static/images/products/ningbo.png", - "bytes": 5319, - "sha256": "42b57e99ebf88bafc52ac4551a925aca48e6799ca3653bc947547a1171165c1a" - }, - { - "path": "static/images/products/oneplus-12.png", - "bytes": 7334, - "sha256": "d262535f4951c978d941a0e68077c3a8b367b568d35f52262d0ccdd088c18c86" - }, - { - "path": "static/images/products/open-university-of-catalonia.png", - "bytes": 8953, - "sha256": "fc63c6d3336a2e9eabe8e5a159eaa7addcf6f1138bb59120ad19da3042814be8" - }, - { - "path": "static/images/products/qingdao.png", - "bytes": 6490, - "sha256": "e8b484152f2d1a84da86813a4eead902b4f29b4b5caaf0f2642379b4045420f9" - }, - { - "path": "static/images/products/quanzhou.png", - "bytes": 6980, - "sha256": "37f44344bbcf0be9e5c67bf12a5927009ab0b6bebfd1624b95fca8c3a4fc802c" - }, - { - "path": "static/images/products/radeon-rx-7800-xt.png", - "bytes": 8498, - "sha256": "c6d5257da63c2028ca0c4a411f6011eec856c4f7866cca4aae24d5e29030cb1a" - }, - { - "path": "static/images/products/radeon-rx-7900-xtx.png", - "bytes": 8539, - "sha256": "b0d75f71f509d845a3e135129c6492eae720e4cf73b737c9954c2e42da651665" - }, - { - "path": "static/images/products/rtx-4070-super.png", - "bytes": 8757, - "sha256": "d079787eee44f7a4a0f7b8cabdbd9ff9c8ff3aa3b10ab244d8dc35cb51228c24" - }, - { - "path": "static/images/products/rtx-4080-super.png", - "bytes": 8938, - "sha256": "0645ce22f5437027ef6e2c18b70afd25b7b89b1798a3459bb9f498dc3cb761f0" - }, - { - "path": "static/images/products/s-o-paulo.png", - "bytes": 6863, - "sha256": "e40840f177c7e6aac6c670de3a281d72dbd9a436eab2b1a6ba6865fafdb94bf7" - }, - { - "path": "static/images/products/samsung-galaxy-s24-ultra.png", - "bytes": 9478, - "sha256": "5cbb6cea508dab97b97ea46e5c6a60f189080303cb072df5e14f3d103f54d43a" - }, - { - "path": "static/images/products/samsung-galaxy-watch-6.png", - "bytes": 9846, - "sha256": "d1506505cb8dc6324050b7e928aa69471f8b64bb8090c358ba05c1ab5bb1815b" - }, - { - "path": "static/images/products/sapienza-university-of-rome.png", - "bytes": 9039, - "sha256": "1b4fbd5cdbe003555421ddd6950ec8de7411d2689ccaad159cbdf120f2778ec8" - }, - { - "path": "static/images/products/sennheiser-momentum-4.png", - "bytes": 8442, - "sha256": "b32fcf9a663f02bbc940dff308226f3423bb5747184489d8664243f5f3f1996f" - }, - { - "path": "static/images/products/shanghai.png", - "bytes": 6117, - "sha256": "d5145289532d9bfc65fd28977122de9af06e1cf9de3a2ec884ac4849b3d6d775" - }, - { - "path": "static/images/products/shangqiu.png", - "bytes": 6272, - "sha256": "68ed39b1a5b7199350d990768fe9450bdd487155f0b57f8c12592d2c49ee13ca" - }, - { - "path": "static/images/products/shenyang.png", - "bytes": 6612, - "sha256": "707c30255714d3c534a85a2cedf9a97ca138cc1f1f7e033da9082e9e486485ac" - }, - { - "path": "static/images/products/shenzhen.png", - "bytes": 5891, - "sha256": "83f0fb4562dbe7f7eb789e1c4e65ea313f7a14dbb2d2666557ab77ff047e7b3f" - }, - { - "path": "static/images/products/shijiazhuang.png", - "bytes": 6449, - "sha256": "6c0849ab176dd0a1c2637a58f6688a1d56e614e4058584a228d3fc04d3e7f3ba" - }, - { - "path": "static/images/products/sony-a7-iv.png", - "bytes": 8489, - "sha256": "c2e56d7b22821b672d558412cfca26eb26527b5d399123423e38d857d6b4adc4" - }, - { - "path": "static/images/products/sony-wh-1000xm5.png", - "bytes": 10209, - "sha256": "cf7d8586e55016fffda74b16d895c5b232b66071d2cac0498c97b37abb87644c" - }, - { - "path": "static/images/products/southern-new-hampshire-university.png", - "bytes": 9515, - "sha256": "1a849e18639b87475f98fa664918768e73c64806b840971185bd077d1375b9fa" - }, - { - "path": "static/images/products/suzhou.png", - "bytes": 6388, - "sha256": "fcd4c41089e1c92428b0281cb42522bebbdf902fe4e7a85f33faa7a50c2a3bf2" - }, - { - "path": "static/images/products/tangshan.png", - "bytes": 5727, - "sha256": "94bce75ed92c3e00bc1de188cf4c1ae40f62951e664c144e2ceddda80bcaa71a" - }, - { - "path": "static/images/products/tianjin.png", - "bytes": 3973, - "sha256": "1e816f36209c2d6db325bbe704c97018a14d3105293a3fa1503ac893192fe571" - }, - { - "path": "static/images/products/tokyo.png", - "bytes": 5737, - "sha256": "ea8bcbf3ee3fa71322b6f5eb5deb62d286f807efcf5968947df415610ead6d6f" - }, - { - "path": "static/images/products/university-of-algiers-1.png", - "bytes": 8335, - "sha256": "b31331859806718cfff38f5adc0c0baa6ab3012fbc9fdec326c8c6178d3b3f08" - }, - { - "path": "static/images/products/university-of-benin.png", - "bytes": 7850, - "sha256": "3e84627359cbea7549a1e00f9f9f80dd1d42cdcf6b02ff4205476b6f6347a51b" - }, - { - "path": "static/images/products/university-of-bologna.png", - "bytes": 8482, - "sha256": "c336b1e5b998026161513dfb96a746f66295bbd91073c14ebf13b88de343bbcf" - }, - { - "path": "static/images/products/university-of-continuing-education.png", - "bytes": 9197, - "sha256": "14440572c8e5ee84f74303ad71eb65f69322d407481de178799c63ed8dd8d7b3" - }, - { - "path": "static/images/products/university-of-granada.png", - "bytes": 8480, - "sha256": "c8c12e91203de3a49cea9bf4b5142718cef8626700bb937332b0fc7bb570c4a0" - }, - { - "path": "static/images/products/university-of-illinois-urbana-champaign.png", - "bytes": 8785, - "sha256": "446a2670158ea313b41a6c6cb7c291a616268eb217c1f505ce9e250f9803e7cf" - }, - { - "path": "static/images/products/university-of-lyon.png", - "bytes": 7657, - "sha256": "9801f98502ead4c156cb3ae1994dfd004c46823fef410a046c941fae5d853583" - }, - { - "path": "static/images/products/university-of-melbourne.png", - "bytes": 8308, - "sha256": "90650430e04dbd88d97c0107a6e8a05a2950172312fa4edcd89f37d17850e976" - }, - { - "path": "static/images/products/university-of-s-o-paulo.png", - "bytes": 8806, - "sha256": "134a38c323cb9aec8745eb3da2e0ffd3be8f363509054800c59d353e5033ed22" - }, - { - "path": "static/images/products/university-of-toronto.png", - "bytes": 7716, - "sha256": "236d0e413700c950ab4c343222a5117485613d1ca13e998138e42b42c91ff874" - }, - { - "path": "static/images/products/university-of-toulouse.png", - "bytes": 7817, - "sha256": "3197d039f20722bbedbcdc95be6011080a304e2d54c9edf04a4d3588e3885e44" - }, - { - "path": "static/images/products/university-of-vienna.png", - "bytes": 8153, - "sha256": "6ea0662e00bcb3003a0ccf73d47543d459ec5380d477c79449fa2295acbafbd9" - }, - { - "path": "static/images/products/weifang.png", - "bytes": 6976, - "sha256": "fb2c79038e699ec754e09c703a4b2df6ce2e8f2892ce6947053d9c56b0171188" - }, - { - "path": "static/images/products/wenzhou.png", - "bytes": 6837, - "sha256": "8247e046ba08dafe153bae9bbd45d11e11f840bccfe715c77ef1a7c207659d45" - }, - { - "path": "static/images/products/western-governors-university.png", - "bytes": 9561, - "sha256": "0439276101b2365a2eff0740fbe8deb63a008f54b46eb7b8d9f1e40759bf902d" - }, - { - "path": "static/images/products/wuhan.png", - "bytes": 6723, - "sha256": "6468604bedde477c0e8ccb6f9374f689827c3ae14ee2034a7540dcebd84c5a86" - }, - { - "path": "static/images/products/xi-an.png", - "bytes": 5594, - "sha256": "e42b6378bc62b2c655a55858dbe1aaa2b625bf20385c4c108a7c9b6bcf04c16a" - }, - { - "path": "static/images/products/xuzhou.png", - "bytes": 6355, - "sha256": "a4bd4c88c01c53b0e8b11e88ed8ae811085650b87fa2e421da97adf1dcab599e" - }, - { - "path": "static/images/products/zhengzhou.png", - "bytes": 5752, - "sha256": "68966ba7d9ce7bc5b8446af404146128de8a7aa7ed9919e1a24080afb9c18197" - } - ] -} diff --git a/sites/versus/requirements.txt b/sites/versus/requirements.txt index a366965d..0847e98e 100644 --- a/sites/versus/requirements.txt +++ b/sites/versus/requirements.txt @@ -2,3 +2,5 @@ Flask==3.1.0 Flask-SQLAlchemy==3.1.1 Flask-WTF==1.2.2 Werkzeug==3.1.3 +Pillow==11.0.0 +requests==2.32.5 diff --git a/sites/versus/static/css/main.css b/sites/versus/static/css/main.css index 2320e68a..8c2b9e61 100644 --- a/sites/versus/static/css/main.css +++ b/sites/versus/static/css/main.css @@ -43,6 +43,7 @@ body { a { color: inherit; text-decoration: none; } a:hover { color: var(--accent-2); } img { max-width: 100%; display: block; } +.entity-image { width: 100%; height: auto; aspect-ratio: 4 / 3; object-fit: cover; } h1, h2, h3 { line-height: 1.15; margin: 0 0 .4em; } /* ---------------------------------------------------------------- header */ diff --git a/sites/versus/templates/_product_card.html b/sites/versus/templates/_product_card.html index d9d6ba00..9cc20cce 100644 --- a/sites/versus/templates/_product_card.html +++ b/sites/versus/templates/_product_card.html @@ -1,7 +1,8 @@
- Synthetic product art for {{ product.name }} + {{ product.name }}
{{ product.category.name }}
diff --git a/sites/versus/templates/about.html b/sites/versus/templates/about.html index 0ab8232d..992cce26 100644 --- a/sites/versus/templates/about.html +++ b/sites/versus/templates/about.html @@ -11,13 +11,14 @@

About this mirror

Sourced

Product names, brands, release years, list prices and the published specifications — battery life, ANC and camera scores, megapixels, burst speed, - VRAM, power draw, display size and weight — follow the manufacturers' figures.

+ VRAM, power draw, display size and weight — follow the manufacturers' figures. + Entity images come from official pages or Wikimedia Commons and are stored + locally for offline use.

Synthetic

The Versus Score, every user account, and every saved comparison are benchmark - data generated for this mirror. They are not versus.com's values. Product art is - drawn programmatically and is not photography.

+ data generated for this mirror. They are not versus.com's values.

diff --git a/sites/versus/templates/base.html b/sites/versus/templates/base.html index 100f94f9..1df3484a 100644 --- a/sites/versus/templates/base.html +++ b/sites/versus/templates/base.html @@ -74,7 +74,8 @@

About this mirror

diff --git a/sites/versus/templates/compare.html b/sites/versus/templates/compare.html index 9543b5ef..5181a844 100644 --- a/sites/versus/templates/compare.html +++ b/sites/versus/templates/compare.html @@ -3,15 +3,17 @@ {% block content %}
- Synthetic product art for {{ left.name }} + {{ left.name }}

{{ left.name }}

{{ left.score }}
vs
- Synthetic product art for {{ right.name }} + {{ right.name }}

{{ right.name }}

{{ right.score }}
diff --git a/sites/versus/templates/product.html b/sites/versus/templates/product.html index 4fc6a45e..0218ff88 100644 --- a/sites/versus/templates/product.html +++ b/sites/versus/templates/product.html @@ -2,8 +2,9 @@ {% block title %}{{ product.name }}{% endblock %} {% block content %}
- Synthetic product art for {{ product.name }} + {{ product.name }}
{{ product.category.name }}
diff --git a/sites/versus/tests/test_functional_contract.py b/sites/versus/tests/test_functional_contract.py index 468262e2..074367a4 100644 --- a/sites/versus/tests/test_functional_contract.py +++ b/sites/versus/tests/test_functional_contract.py @@ -174,43 +174,44 @@ def test_save_tasks_target_a_pair_not_already_saved(self): ) -class GeneratedArt(unittest.TestCase): - """The tiles are synthetic by design, so the contract is byte-stability.""" - - def _generate(self, dest: Path): - work = dest / SITE_NAME - shutil.copytree(SITE_DIR, work) - shutil.rmtree(work / "static/images/products", ignore_errors=True) - subprocess.run([sys.executable, "generate_art.py"], - cwd=work, check=True, capture_output=True) - return work - - def test_art_is_byte_reproducible_and_matches_the_inventory(self): - inventory = json.loads((SITE_DIR / "generated_asset_inventory.json").read_text()) +class SourceBackedImages(unittest.TestCase): + """Every entity image is local, exact, hashed and traceable to a source page.""" + + def _inventory(self): + return json.loads((SITE_DIR / "asset_inventory.json").read_text()) + + def _run_gate(self, site: Path): + return subprocess.run( + [sys.executable, str(REPO_ROOT / "scripts" / "check_asset_inventory.py"), str(site)], + capture_output=True, + text=True, + ) + + def test_source_backed_assets_pass_the_repository_gate(self): + inventory = self._inventory() self.assertEqual(inventory["schema_version"], 1) - self.assertTrue(inventory["assets"], "inventory lists no tiles") - digests = [] - for _ in range(2): - with tempfile.TemporaryDirectory() as tmp: - work = self._generate(Path(tmp)) - run = subprocess.run([sys.executable, "check_generated_assets.py"], - cwd=work, capture_output=True, text=True) - self.assertEqual(run.returncode, 0, - f"asset gate failed on a fresh build:\n{run.stdout}") - digests.append(sorted( - hashlib.sha256((work / row["path"]).read_bytes()).hexdigest() - for row in inventory["assets"])) - self.assertEqual(digests[0], digests[1], "tiles differ between builds") + self.assertEqual(inventory["asset_count"], 107) + self.assertEqual(len(inventory["assets"]), 107) + run = self._run_gate(SITE_DIR) + self.assertEqual(run.returncode, 0, run.stdout + run.stderr) + + def test_every_image_has_entity_and_source_provenance(self): + for row in self._inventory()["assets"]: + self.assertEqual(PurePosixPath(row["path"]).suffix, ".webp") + self.assertTrue(row["entity_name"]) + self.assertTrue(row["source_page"].startswith("https://")) + self.assertTrue(row["source_url"].startswith("https://")) + self.assertTrue(row["source_sha256"]) + self.assertTrue(row["license"]) def test_gate_rejects_a_tampered_tile(self): with tempfile.TemporaryDirectory() as tmp: - work = self._generate(Path(tmp)) - victim = work / json.loads( - (SITE_DIR / "generated_asset_inventory.json").read_text())["assets"][0]["path"] + work = Path(tmp) / SITE_NAME + shutil.copytree(SITE_DIR, work) + victim = work / self._inventory()["assets"][0]["path"] victim.write_bytes(victim.read_bytes() + b"tamper") - run = subprocess.run([sys.executable, "check_generated_assets.py"], - cwd=work, capture_output=True, text=True) - self.assertEqual(run.returncode, 1, + run = self._run_gate(work) + self.assertNotEqual(run.returncode, 0, "gate passed a tampered tile; its PASS means nothing") def test_every_product_has_a_tile(self): @@ -218,7 +219,7 @@ def test_every_product_has_a_tile(self): db = build_seed(Path(tmp)) slugs = {r[0] for r in sqlite3.connect(db).execute("SELECT slug FROM product")} listed = {PurePosixPath(row["path"]).stem for row in json.loads( - (SITE_DIR / "generated_asset_inventory.json").read_text())["assets"]} + (SITE_DIR / "asset_inventory.json").read_text())["assets"]} self.assertEqual(slugs, listed, "product catalogue and tile inventory disagree")