diff --git a/Dockerfile b/Dockerfile index c5a2cbab..2bea3328 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # WebHarbor — slim, self-contained image. -# 29 Flask mirror sites + control plane on :8101. +# 30 Flask mirror sites + control plane on :8101. FROM python:3.12-slim-bookworm @@ -70,6 +70,18 @@ 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 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 && \ + python3 -c "from app import app" && \ + cp instance/versus.db instance_seed/versus.db && \ + rm -rf instance __pycache__ + COPY websyn_start.sh /opt/websyn_start.sh COPY control_server.py /opt/control_server.py COPY site_runner.py /opt/site_runner.py @@ -92,6 +104,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-40028 +EXPOSE 8101 40000-40029 CMD ["/opt/websyn_start.sh"] diff --git a/control_server.py b/control_server.py index 9e2ca716..685f1509 100644 --- a/control_server.py +++ b/control_server.py @@ -31,6 +31,7 @@ 'osu', 'rotten_tomatoes', 'compass', 'walmart_careers', 'fedex', 'webmd_doctor', 'healthline', 'kaggle', 'nvidia', + 'versus', ] BASE_PORT = 40000 WEBSYN_DIR = '/opt/WebSyn' 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 new file mode 100644 index 00000000..b235c9e0 --- /dev/null +++ b/sites/versus/NOTICE.md @@ -0,0 +1,52 @@ +# Third-party material in the Versus mirror + +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 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 an external service. All media is stored locally in +the pinned Hugging Face asset bundle. + +## Imagery + +The mirror contains 107 real, entity-matched images under +`static/images/products/`: 20 consumer-electronics products, 52 cities and 35 +universities. + +- 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. + +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. + +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 accounts and saved comparisons. `/about` and the +footer state this distinction on the running site. + +## Removal + +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 new file mode 100644 index 00000000..36476eb4 --- /dev/null +++ b/sites/versus/README.md @@ -0,0 +1,66 @@ +# Versus mirror + +Offline Flask mirror of `https://versus.com/` for the WebHarbor benchmark. In the +30-site registry it is site index 29 and runs on container port `40029`. + +```bash +docker run -d --rm --name wh-versus -p 8101:8101 -p 40000-40029:40000-40029 webharbor:dev +curl -so /dev/null -w "%{http_code}\n" http://localhost:40029/ +curl -X POST http://localhost:40029/reset/versus +``` + +## Assets and build products + +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/*.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` | + +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. + +Re-fetch the exact recorded sources and reproduce the images with Pillow 11: + +```bash +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**. 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 + +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 + +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 +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 +``` 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..00d1babe --- /dev/null +++ b/sites/versus/app.py @@ -0,0 +1,595 @@ +"""Versus mirror — product comparison and ranking workflows.""" +from __future__ import annotations + +import json +import mimetypes +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 flask_wtf.csrf import CSRFProtect +from werkzeug.security import check_password_hash + + +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" +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) + 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) + # 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=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="") + + +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=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=True) + 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 + + +# 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 + 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"] + 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() + 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) + + +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() + 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)] + 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/") +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") + 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"]) +@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] + 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") +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("/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") +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", "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, 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 + + 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}.", + )) + 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 + 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=BENCHMARK_PASSWORD_HASH)) + 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/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/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/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/requirements.txt b/sites/versus/requirements.txt new file mode 100644 index 00000000..0847e98e --- /dev/null +++ b/sites/versus/requirements.txt @@ -0,0 +1,6 @@ +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/.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..8c2b9e61 --- /dev/null +++ b/sites/versus/static/css/main.css @@ -0,0 +1,265 @@ +/* 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 { + --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: -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; } +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 */ +.site-header { + 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 { + 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: 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; } +} + +.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/static/icons/.gitkeep b/sites/versus/static/icons/.gitkeep new file mode 100644 index 00000000..e69de29b 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/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..6adbd727 --- /dev/null +++ b/sites/versus/tasks.jsonl @@ -0,0 +1,20 @@ +{"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:40029/", "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:40029/", "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:40029/", "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:40029/", "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:40029/", "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:40029/", "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:40029/", "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:40029/", "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": "Among graphics cards, find the one with the most VRAM and report its Versus Score.", "web": "http://localhost:40029/", "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:40029/", "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:40029/", "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:40029/", "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:40029/", "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:40029/", "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:40029/", "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:40029/", "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:40029/", "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:40029/", "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 area and report how many people live there.", "web": "http://localhost:40029/", "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:40029/", "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/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..9cc20cce --- /dev/null +++ b/sites/versus/templates/_product_card.html @@ -0,0 +1,17 @@ +
+ + {{ product.name }} + +
+
{{ product.category.name }}
+

{{ product.name }}

+

{{ product.summary }}

+
+
Score
{{ product.score }}
+ {% 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/about.html b/sites/versus/templates/about.html new file mode 100644 index 00000000..992cce26 --- /dev/null +++ b/sites/versus/templates/about.html @@ -0,0 +1,31 @@ +{% 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. + 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.

+
+
+
+

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/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..1df3484a --- /dev/null +++ b/sites/versus/templates/base.html @@ -0,0 +1,82 @@ + + + + + + {% block title %}Versus{% endblock %} + + + + + + + {% block hero %}{% 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/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..ec60c329 --- /dev/null +++ b/sites/versus/templates/category.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}{{ category.name }}{% endblock %} +{% block content %} +
+
Category

{{ category.name }}

{{ category.tagline }}

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

{{ left.name }}

+ {{ left.score }} +
+
vs
+
+ {{ right.name }} +

{{ right.name }}

+ {{ right.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 }}Margin
{{ 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 }}
+
+{% 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..405e734d --- /dev/null +++ b/sites/versus/templates/index.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}Versus | Compare everything{% endblock %} +{% block hero %} +
+

compare
everything

+

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

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

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..313896a3 --- /dev/null +++ b/sites/versus/templates/login.html @@ -0,0 +1,14 @@ +{% 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..0218ff88 --- /dev/null +++ b/sites/versus/templates/product.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}{{ product.name }}{% endblock %} +{% block content %} +
+ {{ product.name }} +
+
{{ product.category.name }}
+
+

{{ product.name }}

+ {{ product.score }} +
+

{{ product.summary }}

+ 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 }}
+ {% 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 %} +
+
+
+
+

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..e7f763a3 --- /dev/null +++ b/sites/versus/templates/rankings.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% block title %}Rankings{% endblock %} +{% block content %} +
Ranked by Versus Score

Rankings

+
+ All + {% for category in categories %} + {{ category.name }} + {% endfor %} +
+
+ {% for rank, product in ranked %} + + {{ rank }} + {{ product.name }} + {{ product.category.name }}{% if product.price is not none %} · ${{ product.price }}{% endif %} + {{ product.score }} + + {% endfor %} +
+ +{% if paging.pages > 1 %} + +{% endif %} +{% 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/sites/versus/tests/test_functional_contract.py b/sites/versus/tests/test_functional_contract.py new file mode 100644 index 00000000..074367a4 --- /dev/null +++ b/sites/versus/tests/test_functional_contract.py @@ -0,0 +1,317 @@ +"""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, PurePosixPath + +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_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) + + +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 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.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 = 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 = 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): + 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 / "asset_inventory.json").read_text())["assets"]} + 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() + # 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 = {"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({"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")): + 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.""" + + 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.""" + + 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() diff --git a/sites/versus/verify/verify_0.py b/sites/versus/verify/verify_0.py new file mode 100644 index 00000000..68488c65 --- /dev/null +++ b/sites/versus/verify/verify_0.py @@ -0,0 +1,39 @@ +#!/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:]}") + 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.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) + + +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..604d7749 --- /dev/null +++ b/sites/versus/verify/verify_1.py @@ -0,0 +1,39 @@ +#!/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:]}") + 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.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) + + +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..cb174513 --- /dev/null +++ b/sites/versus/verify/verify_10.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Versus--10: 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:]}") + 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.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) + + +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..9e678946 --- /dev/null +++ b/sites/versus/verify/verify_11.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Versus--11: 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") + + 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}))}") + + +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..97ed0784 --- /dev/null +++ b/sites/versus/verify/verify_12.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Versus--12: 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:]}") + 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.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) + + +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..6ca34522 --- /dev/null +++ b/sites/versus/verify/verify_13.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Versus--13: 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:]}") + 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.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) + + +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..f8b99e98 --- /dev/null +++ b/sites/versus/verify/verify_14.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Versus--14: 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:]}") + 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.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) + + +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..fbb18928 --- /dev/null +++ b/sites/versus/verify/verify_15.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""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): + 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.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.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") + 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..87077a48 --- /dev/null +++ b/sites/versus/verify/verify_16.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""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): + 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.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) + + +if __name__ == "__main__": + V.run("Versus--16", body) diff --git a/sites/versus/verify/verify_17.py b/sites/versus/verify/verify_17.py new file mode 100644 index 00000000..d893cbd9 --- /dev/null +++ b/sites/versus/verify/verify_17.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""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 + +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.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) + + +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..203097c3 --- /dev/null +++ b/sites/versus/verify/verify_18.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""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)) +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_2_value", largest=True) # area + if target is None: + 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 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 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.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"]) + 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) + + +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..71beea32 --- /dev/null +++ b/sites/versus/verify/verify_19.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""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 + +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--19", body) diff --git a/sites/versus/verify/verify_2.py b/sites/versus/verify/verify_2.py new file mode 100644 index 00000000..a6e68f71 --- /dev/null +++ b/sites/versus/verify/verify_2.py @@ -0,0 +1,37 @@ +#!/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:]}") + 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.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) + + +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..1ecfce43 --- /dev/null +++ b/sites/versus/verify/verify_3.py @@ -0,0 +1,37 @@ +#!/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:]}") + 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.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) + + +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..15bd3a4d --- /dev/null +++ b/sites/versus/verify/verify_4.py @@ -0,0 +1,39 @@ +#!/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:]}") + 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.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) + + +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..c7fcefd3 --- /dev/null +++ b/sites/versus/verify/verify_5.py @@ -0,0 +1,52 @@ +#!/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)}") + 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 + 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}") + + # 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..412ecbe1 --- /dev/null +++ b/sites/versus/verify/verify_6.py @@ -0,0 +1,39 @@ +#!/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:]}") + 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.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) + + +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..b639230a --- /dev/null +++ b/sites/versus/verify/verify_7.py @@ -0,0 +1,48 @@ +#!/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") + + 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/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..b0f20398 --- /dev/null +++ b/sites/versus/verify/verify_8.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""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 + + +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:]}") + 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.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) + + +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..b3d2f0b3 --- /dev/null +++ b/sites/versus/verify/verify_9.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""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 + + +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:]}") + 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.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) + + +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..98322b1c --- /dev/null +++ b/sites/versus/verify/verify_lib.py @@ -0,0 +1,508 @@ +#!/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. +""" +import base64 +import json +import os +import re +import sqlite3 +import subprocess +import sys +import tempfile +import urllib.parse +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 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 run_origin(traj): + """The origin this run was actually recorded against, from the run itself. + + Deliberately NOT the live registry port. A trajectory is evidence about the + run that produced it, and pinning the check to whatever port the site holds + today made every recorded run expire the next time an upstream merge + re-slotted the site -- four times in one day, at which point the checker is + the thing breaking, not the evidence. + + What actually binds a run to this environment is recorded elsewhere and does + not rot: the before/after seed SHA-256 in the run manifest, and the code SHA. + A cross-site replay is caught by the path checks, not by the port -- the + other mirrors serve /article/, /section/, /track/, not /item/ and /compare/. + + What this still buys, and why it is not simply dropped: the run must be + internally consistent. Every fact-bearing step has to sit on the same origin + the run started from, so a step recorded on chrome-error://chromewebdata/ or + about:blank after a failed navigation is not evidence that a page was seen. + """ + start = (traj.get("start_url") or "").rstrip("/") + if not start: + return () + parsed = urllib.parse.urlsplit(start) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + return () + base = f"{parsed.scheme}://{parsed.netloc}" + env = os.environ.get("WH_SITE_ORIGINS") + if env: + return tuple(x.strip().rstrip("/") for x in env.split(",") if x.strip()) + (base,) + return (base,) + + +def step_urls(traj): + """Only steps on the origin this run was recorded against. A step elsewhere + is not evidence that a page on this site was seen.""" + origins = run_origin(traj) + if not origins: + return [] + 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", [])] + + +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 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 + origins = run_origin(traj) + if not origins: + return False + done = [s for s in steps if s.get("action") == "done"] or [steps[-1]] + return (done[-1].get("url") or "").startswith(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 + 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(",", ""))] + + +# 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: + 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() diff --git a/websyn_start.sh b/websyn_start.sh index 941bf323..91d22f71 100644 --- a/websyn_start.sh +++ b/websyn_start.sh @@ -7,7 +7,7 @@ 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 - fedex webmd_doctor healthline kaggle nvidia) + fedex webmd_doctor healthline kaggle nvidia versus) BASE_PORT=40000 SITE_COUNT=${#SITES[@]} PID_DIR=/tmp/websyn_pids