From aaa60d0bb644c1e6d964c82ff3bc6260d3d2b196 Mon Sep 17 00:00:00 2001 From: FlowRays <1581561246@qq.com> Date: Wed, 13 May 2026 19:30:30 +0800 Subject: [PATCH 1/4] feat(craigslist): add mirror site --- .gitignore | 8 +- Dockerfile | 2 +- README.md | 10 +- control_server.py | 2 +- sites/craigslist/_health.py | 3 + sites/craigslist/app.py | 676 ++++ sites/craigslist/requirements.txt | 4 + sites/craigslist/seed_data.py | 907 +++++ sites/craigslist/static/css/.gitkeep | 0 sites/craigslist/static/css/icomoon.css | 659 ++++ sites/craigslist/static/css/main.css | 2977 +++++++++++++++++ sites/craigslist/static/icons/.gitkeep | 0 sites/craigslist/static/js/.gitkeep | 0 sites/craigslist/static/js/main.js | 57 + sites/craigslist/tasks.jsonl | 20 + sites/craigslist/templates/.gitkeep | 0 sites/craigslist/templates/account.html | 69 + sites/craigslist/templates/account_edit.html | 22 + sites/craigslist/templates/base.html | 67 + sites/craigslist/templates/index.html | 357 ++ .../craigslist/templates/listing_detail.html | 110 + sites/craigslist/templates/login.html | 16 + sites/craigslist/templates/messages.html | 30 + sites/craigslist/templates/post.html | 77 + sites/craigslist/templates/register.html | 27 + sites/craigslist/templates/reply.html | 26 + sites/craigslist/templates/saved.html | 34 + sites/craigslist/templates/search.html | 336 ++ websyn_start.sh | 13 +- 29 files changed, 6493 insertions(+), 16 deletions(-) create mode 100644 sites/craigslist/_health.py create mode 100644 sites/craigslist/app.py create mode 100644 sites/craigslist/requirements.txt create mode 100644 sites/craigslist/seed_data.py create mode 100644 sites/craigslist/static/css/.gitkeep create mode 100644 sites/craigslist/static/css/icomoon.css create mode 100644 sites/craigslist/static/css/main.css create mode 100644 sites/craigslist/static/icons/.gitkeep create mode 100644 sites/craigslist/static/js/.gitkeep create mode 100644 sites/craigslist/static/js/main.js create mode 100644 sites/craigslist/tasks.jsonl create mode 100644 sites/craigslist/templates/.gitkeep create mode 100644 sites/craigslist/templates/account.html create mode 100644 sites/craigslist/templates/account_edit.html create mode 100644 sites/craigslist/templates/base.html create mode 100644 sites/craigslist/templates/index.html create mode 100644 sites/craigslist/templates/listing_detail.html create mode 100644 sites/craigslist/templates/login.html create mode 100644 sites/craigslist/templates/messages.html create mode 100644 sites/craigslist/templates/post.html create mode 100644 sites/craigslist/templates/register.html create mode 100644 sites/craigslist/templates/reply.html create mode 100644 sites/craigslist/templates/saved.html create mode 100644 sites/craigslist/templates/search.html diff --git a/.gitignore b/.gitignore index 24ce1529..e22bad8c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,9 +9,10 @@ sites/*/static/external_cache/ # ============================================================= # Intermediate / volatile — never committed anywhere. # ============================================================= -# scrape pipeline intermediate; runtime data lives in instance_seed/*.db +# Scrape pipeline intermediate; runtime data lives in instance_seed/*.db. sites/*/scraped_data/ -# rebuilt at every container boot from instance_seed/ + +# Rebuilt at every container boot from instance_seed/. sites/*/instance/ sites/*/venv/ @@ -37,6 +38,7 @@ __pycache__/ .mypy_cache/ .ruff_cache/ .tox/ +.playwright-cli/ .coverage htmlcov/ dist/ @@ -94,4 +96,4 @@ secrets.json # ============================================================ # Agent demo results # ============================================================= -agent_demo/runs/ \ No newline at end of file +agent_demo/runs/ diff --git a/Dockerfile b/Dockerfile index 1e86b1d0..ad6d5d9f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -33,6 +33,6 @@ COPY control_server.py /opt/control_server.py COPY site_runner.py /opt/site_runner.py RUN chmod +x /opt/websyn_start.sh -EXPOSE 8101 40000-40015 +EXPOSE 8101 40000-40016 CMD ["/opt/websyn_start.sh"] diff --git a/README.md b/README.md index dce3f934..63a66683 100644 --- a/README.md +++ b/README.md @@ -36,17 +36,17 @@ WebHarbor takes a different approach. We leverage coding agent (e.g., Claude Cod - **Deep features unlocked** — carts, checkouts, accounts, all fully testable - **Evolving** — harder tasks drive richer mirrors; the environment grows with agents - **RL-ready** — sub-second database resets between rollouts -- **Community-driven** — 15 sites today, scaling to 100+ together +- **Community-driven** — 17 sites today, scaling to 100+ together ## 🚀 Quickstart One command to run all web environments: ```bash -docker run -p 8101:8101 -p 40000-40014:40000-40014 battalion7244/webharbor:latest +docker run -p 8101:8101 -p 40000-40016:40000-40016 battalion7244/webharbor:latest ``` -Then point your agent at `http://localhost:40000` through `http://localhost:40014` to explore 15 local mirrors of webvoyager sites: `Allrecipes, Amazon, Apple, ArXiv, BBC News, Booking, GitHub, Google Flights, Google Maps, Google Search, Hugging Face, Wolfram Alpha, Cambridge Dictionary, Coursera, and ESPN`. +Then point your agent at `http://localhost:40000` through `http://localhost:40016` to explore 17 local mirrors of webvoyager sites: `Allrecipes, Amazon, Apple, ArXiv, BBC News, Booking, GitHub, Google Flights, Google Maps, Google Search, Hugging Face, Wolfram Alpha, Cambridge Dictionary, Coursera, ESPN, Merriam-Webster, and Craigslist`. For sub-second reset between rollouts, expose the control plane and call `/reset/`: @@ -65,7 +65,7 @@ git clone https://github.com/aiming-lab/WebHarbor && cd WebHarbor ## 🤝 Contribute -We have built 15 high-quality mirrors covering the [WebVoyager](https://github.com/MinorJerry/WebVoyager) benchmark. The next goal is **100+ sites**, covering everything in [Online-Mind2Web](https://huggingface.co/datasets/osunlp/Online-Mind2Web). We are inviting the community to build this together. +We have built 16 high-quality mirrors covering the [WebVoyager](https://github.com/MinorJerry/WebVoyager) benchmark. The next goal is **100+ sites**, covering everything in [Online-Mind2Web](https://huggingface.co/datasets/osunlp/Online-Mind2Web). We are inviting the community to build this together. There are two ways to join the author list: @@ -111,4 +111,4 @@ WebHarbor is initiated by UNC-Chapel Hill and Microsoft, with contributions from url = {https://aiming-lab.github.io/webharbor.github.io}, note = {Project website.} } -``` \ No newline at end of file +``` diff --git a/control_server.py b/control_server.py index 4b6b995e..5e760c98 100644 --- a/control_server.py +++ b/control_server.py @@ -26,7 +26,7 @@ 'allrecipes', 'amazon', 'apple', 'arxiv', 'bbc_news', 'booking', 'github', 'google_flights', 'google_map', 'google_search', 'huggingface', 'wolfram_alpha', 'cambridge_dictionary', - 'coursera', 'espn', 'merriam_webster', + 'coursera', 'espn', 'merriam_webster', 'craigslist', ] BASE_PORT = 40000 WEBSYN_DIR = '/opt/WebSyn' diff --git a/sites/craigslist/_health.py b/sites/craigslist/_health.py new file mode 100644 index 00000000..16b07e59 --- /dev/null +++ b/sites/craigslist/_health.py @@ -0,0 +1,3 @@ +"""Per-site health probe (optional, called by control_server).""" +def health(): + return {"ok": True, "site": "craigslist"} diff --git a/sites/craigslist/app.py b/sites/craigslist/app.py new file mode 100644 index 00000000..3e50e1fb --- /dev/null +++ b/sites/craigslist/app.py @@ -0,0 +1,676 @@ +"""Craigslist mirror - Flask app.""" +import hashlib +import json +import os +import re +from datetime import datetime + +from flask import Flask, abort, flash, redirect, render_template, request, Response, session, url_for +from flask_login import LoginManager, UserMixin, current_user, login_required, login_user, logout_user +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import or_ + +from seed_data import CATEGORY_GROUPS, deterministic_password, seed_benchmark_users, seed_database + + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +app = Flask(__name__, instance_path=os.path.join(BASE_DIR, "instance")) +app.config["SECRET_KEY"] = "webharbor-craigslist-dev-key" +app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{os.path.join(BASE_DIR, 'instance', 'craigslist.db')}" +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False + +os.makedirs(os.path.join(BASE_DIR, "instance"), exist_ok=True) + +db = SQLAlchemy(app) +login_manager = LoginManager(app) +login_manager.login_view = "login" +login_manager.login_message_category = "info" + + +class User(db.Model, UserMixin): + __tablename__ = "users" + + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(140), unique=True, nullable=False, index=True) + username = db.Column(db.String(80), unique=True, nullable=False, index=True) + name = db.Column(db.String(120), nullable=False) + area = db.Column(db.String(80), default="san francisco") + phone = db.Column(db.String(40), default="") + password_hash = db.Column(db.String(120), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + listings = db.relationship("Listing", backref="owner", lazy=True) + saved_listings = db.relationship("SavedListing", backref="user", cascade="all, delete-orphan") + saved_searches = db.relationship("SavedSearch", backref="user", cascade="all, delete-orphan") + messages = db.relationship("Message", backref="user", cascade="all, delete-orphan") + + def set_password(self, password): + self.password_hash = deterministic_password(self.email, password) + + def check_password(self, password): + return self.password_hash == deterministic_password(self.email, password) + + +class Category(db.Model): + __tablename__ = "categories" + + id = db.Column(db.Integer, primary_key=True) + slug = db.Column(db.String(80), unique=True, nullable=False, index=True) + name = db.Column(db.String(120), nullable=False) + abbrev = db.Column(db.String(20), default="") + group_slug = db.Column(db.String(50), index=True) + group_name = db.Column(db.String(80), default="") + display_order = db.Column(db.Integer, default=0) + + listings = db.relationship("Listing", backref="category", lazy=True) + + +class Listing(db.Model): + __tablename__ = "listings" + + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(220), nullable=False, index=True) + slug = db.Column(db.String(240), unique=True, nullable=False, index=True) + category_id = db.Column(db.Integer, db.ForeignKey("categories.id"), nullable=False) + category_slug = db.Column(db.String(80), index=True) + category_group = db.Column(db.String(50), index=True) + area = db.Column(db.String(80), index=True) + neighborhood = db.Column(db.String(120), default="") + price = db.Column(db.Integer, nullable=True, index=True) + bedrooms = db.Column(db.Integer, nullable=True) + sqft = db.Column(db.Integer, nullable=True) + condition = db.Column(db.String(60), default="") + compensation = db.Column(db.String(120), default="") + company = db.Column(db.String(140), default="") + employment_type = db.Column(db.String(80), default="") + description = db.Column(db.Text, default="") + details_json = db.Column(db.Text, default="{}") + image = db.Column(db.String(260), default="") + seller_name = db.Column(db.String(120), default="craigslist user") + seller_email = db.Column(db.String(160), default="") + reply_phone = db.Column(db.String(40), default="") + owner_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) + posted_at = db.Column(db.DateTime, default=datetime.utcnow) + updated_at = db.Column(db.DateTime, default=datetime.utcnow) + status = db.Column(db.String(40), default="active") + view_count = db.Column(db.Integer, default=0) + flag_count = db.Column(db.Integer, default=0) + + saved_by = db.relationship("SavedListing", backref="listing", cascade="all, delete-orphan") + messages = db.relationship("Message", backref="listing", cascade="all, delete-orphan") + + def details(self): + try: + return json.loads(self.details_json or "{}") + except json.JSONDecodeError: + return {} + + @property + def display_price(self): + if self.price is None: + return self.compensation or "" + if self.price == 0: + return "free" + return f"${self.price:,}" + + @property + def age_label(self): + delta = datetime(2026, 5, 12, 15, 0, 0) - self.posted_at + hours = max(1, int(delta.total_seconds() // 3600)) + if hours < 24: + return f"{hours}h ago" + return f"{hours // 24}d ago" + + +class SavedListing(db.Model): + __tablename__ = "saved_listings" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + listing_id = db.Column(db.Integer, db.ForeignKey("listings.id"), nullable=False) + note = db.Column(db.String(240), default="") + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + +class SavedSearch(db.Model): + __tablename__ = "saved_searches" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + name = db.Column(db.String(120), nullable=False) + query_text = db.Column(db.String(180), default="") + category_slug = db.Column(db.String(80), default="") + area = db.Column(db.String(80), default="") + min_price = db.Column(db.Integer, nullable=True) + max_price = db.Column(db.Integer, nullable=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + +class HiddenListing(db.Model): + __tablename__ = "hidden_listings" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + listing_id = db.Column(db.Integer, db.ForeignKey("listings.id"), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + +class Message(db.Model): + __tablename__ = "messages" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) + listing_id = db.Column(db.Integer, db.ForeignKey("listings.id"), nullable=False) + sender_name = db.Column(db.String(120), default="") + sender_email = db.Column(db.String(160), default="") + body = db.Column(db.Text, default="") + direction = db.Column(db.String(20), default="outbound") + is_read = db.Column(db.Boolean, default=True) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + +@login_manager.user_loader +def load_user(user_id): + return db.session.get(User, int(user_id)) + + +def slugify(value): + value = value.lower() + value = re.sub(r"[^a-z0-9]+", "-", value) + return value.strip("-") or "listing" + + +def tokenize(value): + return [t for t in re.split(r"[^a-z0-9]+", (value or "").lower()) if len(t) > 1] + + +def normalize_phrase(value): + return " ".join(t for t in re.split(r"[^a-z0-9]+", (value or "").lower()) if t) + + +def parse_int(value): + if value is None or value == "": + return None + try: + return int(re.sub(r"[^0-9]", "", str(value))) + except ValueError: + return None + + +def listing_score(listing, query): + tokens = tokenize(query) + if not tokens: + return 1 + category = listing.category.name if listing.category else "" + details = " ".join(f"{k} {v}" for k, v in listing.details().items()) + haystacks = { + "title": listing.title.lower(), + "category": category.lower(), + "area": f"{listing.area} {listing.neighborhood}".lower(), + "body": f"{listing.description} {details} {listing.condition} {listing.compensation} {listing.company}".lower(), + } + score = 0 + phrase = normalize_phrase(query) + normalized = {key: normalize_phrase(value) for key, value in haystacks.items()} + if phrase: + if phrase in normalized["title"]: + score += 25 + if phrase in normalized["body"]: + score += 10 + for token in tokens: + if token in haystacks["title"]: + score += 5 + if token in haystacks["category"]: + score += 3 + if token in haystacks["area"]: + score += 2 + if token in haystacks["body"]: + score += 1 + return score + + +def category_groups(): + cats = {c.slug: c for c in Category.query.order_by(Category.display_order).all()} + groups = [] + for group in CATEGORY_GROUPS: + rows = [] + for slug, _name, _abbrev in group["columns"]: + if slug in cats: + rows.append(cats[slug]) + groups.append({"slug": group["slug"], "name": group["name"], "categories": rows}) + return groups + + +def hidden_listing_ids(): + if not current_user.is_authenticated: + return set(session.get("hidden_listing_ids", [])) + return {h.listing_id for h in HiddenListing.query.filter_by(user_id=current_user.id).all()} + + +def is_saved(listing_id): + if not current_user.is_authenticated: + return False + return SavedListing.query.filter_by(user_id=current_user.id, listing_id=listing_id).first() is not None + + +def listing_images(listing): + details = listing.details() + values = details.get("images", []) + if isinstance(values, str): + values = [values] + images = [] + if listing.image: + images.append(listing.image) + for value in values: + if value and value not in images: + images.append(value) + return images + + +def listing_map_point(listing): + details = listing.details() + try: + x = float(details.get("map_x", 50)) + y = float(details.get("map_y", 50)) + lat = float(details.get("map_lat", 37.7749)) + lng = float(details.get("map_lng", -122.4194)) + except (TypeError, ValueError): + digest = hashlib.md5(f"{listing.id}:{listing.neighborhood}".encode()).hexdigest() + x = 18 + (int(digest[:2], 16) % 64) + y = 16 + (int(digest[2:4], 16) % 58) + lat = 37.7749 + lng = -122.4194 + return { + "x": max(6, min(94, x)), + "y": max(8, min(92, y)), + "lat": lat, + "lng": lng, + } + + +def listing_public_details(listing): + hidden = {"images", "map_x", "map_y", "map_lat", "map_lng"} + return [ + (key, value) + for key, value in listing.details().items() + if key not in hidden + ] + + +def base_listing_query(category_slug=None): + query = Listing.query.filter_by(status="active") + if category_slug: + category = Category.query.filter_by(slug=category_slug).first_or_404() + query = query.filter(Listing.category_slug == category.slug) + return query + + +def filter_listings(category_slug=None): + q = request.args.get("q", "").strip() + area = request.args.get("area", "").strip() + min_price = parse_int(request.args.get("min_price")) + max_price = parse_int(request.args.get("max_price")) + has_image = request.args.get("has_image") == "1" + sort = request.args.get("sort", "relevance") + include_hidden = request.args.get("include_hidden") == "1" + + query = base_listing_query(category_slug) + if area: + query = query.filter(or_(Listing.area == area, Listing.neighborhood.ilike(f"%{area}%"))) + if min_price is not None: + query = query.filter(or_(Listing.price == None, Listing.price >= min_price)) # noqa: E711 + if max_price is not None: + query = query.filter(or_(Listing.price == None, Listing.price <= max_price)) # noqa: E711 + if has_image: + query = query.filter(Listing.image != "") + + listings = query.all() + hidden_ids = hidden_listing_ids() + if not include_hidden: + listings = [listing for listing in listings if listing.id not in hidden_ids] + + if q: + scored = [(listing_score(listing, q), listing) for listing in listings] + listings = [listing for score, listing in scored if score > 0] + listings.sort(key=lambda pair: (listing_score(pair, q), pair.posted_at), reverse=True) + elif sort == "price_asc": + listings.sort(key=lambda listing: (listing.price is None, listing.price or 0, -listing.posted_at.timestamp())) + elif sort == "price_desc": + listings.sort(key=lambda listing: (listing.price is None, -(listing.price or 0), -listing.posted_at.timestamp())) + elif sort == "oldest": + listings.sort(key=lambda listing: listing.posted_at) + else: + listings.sort(key=lambda listing: listing.posted_at, reverse=True) + + return listings + + +@app.context_processor +def inject_globals(): + def saved_count(): + if not current_user.is_authenticated: + return 0 + return SavedListing.query.filter_by(user_id=current_user.id).count() + + return { + "category_groups": category_groups, + "is_saved": is_saved, + "saved_count": saved_count, + "listing_images": listing_images, + "listing_map_point": listing_map_point, + "listing_public_details": listing_public_details, + } + + +@app.route("/") +def index(): + featured = Listing.query.filter(Listing.image != "", Listing.status == "active").order_by(Listing.posted_at.desc()).limit(8).all() + recent = Listing.query.filter_by(status="active").order_by(Listing.posted_at.desc()).limit(12).all() + counts = { + category.slug: Listing.query.filter_by(category_slug=category.slug, status="active").count() + for category in Category.query.all() + } + return render_template("index.html", featured=featured, recent=recent, counts=counts) + + +@app.route("/favicon.ico") +def favicon(): + return Response(status=204) + + +@app.route("/search") +def search(): + listings = filter_listings() + return render_template( + "search.html", + listings=listings, + category=None, + query=request.args.get("q", "").strip(), + areas=["san francisco", "east bay", "south bay", "peninsula", "north bay", "santa cruz"], + ) + + +@app.route("/search/") +def category_search(category_slug): + category = Category.query.filter_by(slug=category_slug).first_or_404() + listings = filter_listings(category_slug) + return render_template( + "search.html", + listings=listings, + category=category, + query=request.args.get("q", "").strip(), + areas=["san francisco", "east bay", "south bay", "peninsula", "north bay", "santa cruz"], + ) + + +@app.route("/d//.html") +def listing_detail(slug, listing_id): + listing = Listing.query.get_or_404(listing_id) + if listing.slug != slug: + return redirect(url_for("listing_detail", slug=listing.slug, listing_id=listing.id), code=301) + listing.view_count += 1 + db.session.commit() + nearby = Listing.query.filter( + Listing.id != listing.id, + Listing.category_slug == listing.category_slug, + Listing.status == "active", + ).order_by(Listing.posted_at.desc()).limit(6).all() + return render_template("listing_detail.html", listing=listing, nearby=nearby) + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + email = request.form.get("email", "").strip().lower() + password = request.form.get("password", "") + user = User.query.filter_by(email=email).first() + if user and user.check_password(password): + login_user(user) + flash("logged in", "success") + return redirect(request.args.get("next") or url_for("account")) + flash("invalid email or password", "error") + return render_template("login.html") + + +@app.route("/register", methods=["GET", "POST"]) +def register(): + if request.method == "POST": + email = request.form.get("email", "").strip().lower() + username = request.form.get("username", "").strip().lower() + name = request.form.get("name", "").strip() or username + password = request.form.get("password", "") + if not email or not username or not password: + flash("email, username, and password are required", "error") + elif User.query.filter(or_(User.email == email, User.username == username)).first(): + flash("that account already exists", "error") + else: + user = User(email=email, username=username, name=name, area=request.form.get("area", "san francisco")) + user.set_password(password) + db.session.add(user) + db.session.commit() + login_user(user) + flash("account created", "success") + return redirect(url_for("account")) + return render_template("register.html") + + +@app.route("/logout") +@login_required +def logout(): + logout_user() + flash("logged out", "info") + return redirect(url_for("index")) + + +@app.route("/account") +@login_required +def account(): + posts = Listing.query.filter_by(owner_id=current_user.id).order_by(Listing.posted_at.desc()).all() + searches = SavedSearch.query.filter_by(user_id=current_user.id).order_by(SavedSearch.created_at.desc()).all() + messages = Message.query.filter_by(user_id=current_user.id).order_by(Message.created_at.desc()).limit(5).all() + return render_template("account.html", posts=posts, searches=searches, messages=messages) + + +@app.route("/account/edit", methods=["GET", "POST"]) +@login_required +def account_edit(): + if request.method == "POST": + current_user.name = request.form.get("name", current_user.name).strip() + current_user.area = request.form.get("area", current_user.area).strip() + current_user.phone = request.form.get("phone", current_user.phone).strip() + db.session.commit() + flash("account updated", "success") + return redirect(url_for("account")) + return render_template("account_edit.html") + + +@app.route("/saved") +@login_required +def saved(): + rows = SavedListing.query.filter_by(user_id=current_user.id).order_by(SavedListing.created_at.desc()).all() + return render_template("saved.html", rows=rows) + + +@app.route("/save-search", methods=["POST"]) +@login_required +def save_search(): + name = request.form.get("name", "").strip() or "saved craigslist search" + saved_search = SavedSearch( + user_id=current_user.id, + name=name, + query_text=request.form.get("q", "").strip(), + category_slug=request.form.get("category_slug", "").strip(), + area=request.form.get("area", "").strip(), + min_price=parse_int(request.form.get("min_price")), + max_price=parse_int(request.form.get("max_price")), + ) + db.session.add(saved_search) + db.session.commit() + flash("search saved", "success") + return redirect(url_for("account")) + + +@app.route("/listing//save", methods=["POST"]) +@login_required +def save_listing(listing_id): + listing = Listing.query.get_or_404(listing_id) + existing = SavedListing.query.filter_by(user_id=current_user.id, listing_id=listing.id).first() + if not existing: + db.session.add(SavedListing( + user_id=current_user.id, + listing_id=listing.id, + note=request.form.get("note", "").strip(), + )) + db.session.commit() + flash("listing saved", "success") + return redirect(request.form.get("next") or url_for("listing_detail", slug=listing.slug, listing_id=listing.id)) + + +@app.route("/listing//unsave", methods=["POST"]) +@login_required +def unsave_listing(listing_id): + saved_row = SavedListing.query.filter_by(user_id=current_user.id, listing_id=listing_id).first() + if saved_row: + db.session.delete(saved_row) + db.session.commit() + flash("listing removed", "info") + return redirect(request.form.get("next") or url_for("saved")) + + +@app.route("/listing//hide", methods=["POST"]) +def hide_listing(listing_id): + listing = Listing.query.get_or_404(listing_id) + if current_user.is_authenticated: + existing = HiddenListing.query.filter_by(user_id=current_user.id, listing_id=listing.id).first() + if not existing: + db.session.add(HiddenListing(user_id=current_user.id, listing_id=listing.id)) + db.session.commit() + else: + ids = set(session.get("hidden_listing_ids", [])) + ids.add(listing.id) + session["hidden_listing_ids"] = sorted(ids) + flash("listing hidden", "info") + return redirect(request.form.get("next") or url_for("search")) + + +@app.route("/listing//flag", methods=["POST"]) +def flag_listing(listing_id): + listing = Listing.query.get_or_404(listing_id) + listing.flag_count += 1 + db.session.commit() + flash("thanks for flagging", "info") + return redirect(url_for("listing_detail", slug=listing.slug, listing_id=listing.id)) + + +@app.route("/reply/", methods=["GET", "POST"]) +def reply(listing_id): + listing = Listing.query.get_or_404(listing_id) + if request.method == "POST": + name = request.form.get("name", "").strip() or (current_user.name if current_user.is_authenticated else "craigslist user") + email = request.form.get("email", "").strip() or (current_user.email if current_user.is_authenticated else "anonymous@example.test") + body = request.form.get("body", "").strip() + if not body: + flash("message body is required", "error") + else: + db.session.add(Message( + user_id=current_user.id if current_user.is_authenticated else None, + listing_id=listing.id, + sender_name=name, + sender_email=email, + body=body, + direction="outbound", + is_read=True, + )) + db.session.commit() + flash("reply sent", "success") + return redirect(url_for("listing_detail", slug=listing.slug, listing_id=listing.id)) + return render_template("reply.html", listing=listing) + + +@app.route("/messages") +@login_required +def messages(): + rows = Message.query.filter_by(user_id=current_user.id).order_by(Message.created_at.desc()).all() + unread = Message.query.filter_by(user_id=current_user.id, is_read=False).all() + for row in unread: + row.is_read = True + db.session.commit() + return render_template("messages.html", rows=rows) + + +@app.route("/post", methods=["GET", "POST"]) +@login_required +def post_listing(): + categories = Category.query.order_by(Category.group_slug, Category.display_order).all() + if request.method == "POST": + category = Category.query.filter_by(slug=request.form.get("category_slug", "")).first() + if not category: + flash("choose a valid category", "error") + return render_template("post.html", categories=categories) + title = request.form.get("title", "").strip() + description = request.form.get("description", "").strip() + if not title or not description: + flash("title and description are required", "error") + return render_template("post.html", categories=categories) + details = { + "posted_by": "owner", + "availability": request.form.get("availability", "available now").strip(), + "contact_preference": request.form.get("contact_preference", "email").strip(), + } + listing = Listing( + title=title, + slug=f"{slugify(title)}-{hashlib.md5((title + current_user.email).encode()).hexdigest()[:6]}", + category_id=category.id, + category_slug=category.slug, + category_group=category.group_slug, + area=request.form.get("area", current_user.area).strip(), + neighborhood=request.form.get("neighborhood", "").strip(), + price=parse_int(request.form.get("price")), + bedrooms=parse_int(request.form.get("bedrooms")), + sqft=parse_int(request.form.get("sqft")), + condition=request.form.get("condition", "").strip(), + compensation=request.form.get("compensation", "").strip(), + company=request.form.get("company", "").strip(), + employment_type=request.form.get("employment_type", "").strip(), + description=description, + details_json=json.dumps(details, sort_keys=True), + seller_name=current_user.name, + seller_email=current_user.email, + reply_phone=current_user.phone, + owner_id=current_user.id, + status="active", + ) + db.session.add(listing) + db.session.commit() + flash("posting published", "success") + return redirect(url_for("listing_detail", slug=listing.slug, listing_id=listing.id)) + return render_template("post.html", categories=categories) + + +@app.route("/posting//delete", methods=["POST"]) +@login_required +def delete_posting(listing_id): + listing = Listing.query.get_or_404(listing_id) + if listing.owner_id != current_user.id: + abort(403) + listing.status = "deleted" + db.session.commit() + flash("posting deleted", "info") + return redirect(url_for("account")) + + +@app.route("/_health") +def health(): + return {"ok": True, "site": "craigslist", "listings": Listing.query.count()} + + +with app.app_context(): + db.create_all() + seed_database(BASE_DIR, db, Category, Listing) + seed_benchmark_users(db, User, Listing, SavedListing, SavedSearch, Message) + + +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/craigslist/requirements.txt b/sites/craigslist/requirements.txt new file mode 100644 index 00000000..8a9b110b --- /dev/null +++ b/sites/craigslist/requirements.txt @@ -0,0 +1,4 @@ +Flask==3.1.0 +Flask-SQLAlchemy==3.1.1 +Flask-Login==0.6.3 +SQLAlchemy==2.0.36 diff --git a/sites/craigslist/seed_data.py b/sites/craigslist/seed_data.py new file mode 100644 index 00000000..89730b11 --- /dev/null +++ b/sites/craigslist/seed_data.py @@ -0,0 +1,907 @@ +"""Deterministic seed data for the Craigslist mirror.""" +from datetime import datetime, timedelta +import hashlib +import json +import os +import re + + +FIXED_NOW = datetime(2026, 5, 12, 9, 30, 0) + + +CATEGORY_GROUPS = [ + { + "slug": "for_sale", + "name": "for sale", + "columns": [ + ("furniture", "furniture", "fua"), + ("electronics", "electronics", "ela"), + ("bikes", "bikes", "bia"), + ("cars_trucks", "cars+trucks", "cta"), + ("appliances", "appliances", "ppa"), + ("free", "free stuff", "zip"), + ("musical", "musical instruments", "msa"), + ("sporting", "sporting goods", "sga"), + ("tools", "tools", "tla"), + ], + }, + { + "slug": "housing", + "name": "housing", + "columns": [ + ("apartments", "apts / housing", "apa"), + ("rooms_shares", "rooms / shared", "roo"), + ("sublets", "sublets / temporary", "sub"), + ("parking", "parking / storage", "prk"), + ("office_commercial", "office / commercial", "off"), + ], + }, + { + "slug": "jobs", + "name": "jobs", + "columns": [ + ("software", "software / qa", "sof"), + ("customer_service", "customer service", "csr"), + ("food_bev_hosp", "food / bev / hosp", "fbh"), + ("general_labor", "general labor", "lab"), + ("healthcare", "healthcare", "hea"), + ("education", "education", "edu"), + ("sales", "sales", "sls"), + ("skilled_trade", "skilled trades", "trd"), + ], + }, + { + "slug": "services", + "name": "services", + "columns": [ + ("automotive_services", "automotive", "aos"), + ("computer_services", "computer", "cps"), + ("creative_services", "creative", "crs"), + ("household_services", "household", "hss"), + ("labor_move", "labor / move", "lbs"), + ("lessons", "lessons", "lss"), + ], + }, + { + "slug": "community", + "name": "community", + "columns": [ + ("events", "events", "eve"), + ("volunteers", "volunteers", "vol"), + ("artists", "artists", "ats"), + ("classes", "classes", "cls"), + ("groups", "groups", "grp"), + ("lost_found", "lost+found", "laf"), + ], + }, +] + + +REGIONS = [ + "san francisco", + "east bay", + "south bay", + "peninsula", + "north bay", + "santa cruz", +] + + +def slugify(value): + value = value.lower() + value = re.sub(r"[^a-z0-9]+", "-", value) + return value.strip("-") or "listing" + + +def deterministic_password(email, password="TestPass123!"): + payload = f"{email}:{password}:webharbor-craigslist".encode("utf-8") + return "sha256$" + hashlib.sha256(payload).hexdigest() + + +def image_pool(base_dir): + img_dir = os.path.join(base_dir, "static", "images") + pools = { + "furniture": [], + "cars_trucks": [], + "apartments": [], + "jobs": [], + } + if not os.path.isdir(img_dir): + return pools + for filename in sorted(os.listdir(img_dir)): + if not filename.lower().endswith((".jpg", ".jpeg", ".png", ".webp")): + continue + rel = f"images/{filename}" + for prefix in pools: + if filename.startswith(prefix + "_"): + pools[prefix].append(rel) + break + return pools + + +def pick_image(pools, category_slug, idx): + if category_slug in pools and pools[category_slug]: + values = pools[category_slug] + return values[idx % len(values)] + if category_slug in {"software", "customer_service", "food_bev_hosp", + "general_labor", "healthcare", "education", + "sales", "skilled_trade"} and pools["jobs"]: + return pools["jobs"][idx % len(pools["jobs"])] + if category_slug in {"rooms_shares", "sublets"} and pools["apartments"]: + return pools["apartments"][idx % len(pools["apartments"])] + return "" + + +def image_gallery(pools, category_slug, idx, primary): + if not primary: + return [] + key = category_slug + if key not in pools or not pools[key]: + if category_slug in {"software", "customer_service", "food_bev_hosp", + "general_labor", "healthcare", "education", + "sales", "skilled_trade"}: + key = "jobs" + elif category_slug in {"rooms_shares", "sublets"}: + key = "apartments" + values = list(pools.get(key, [])) + if not values: + return [primary] + ordered = [primary] + for offset in range(1, min(5, len(values))): + candidate = values[(idx + offset) % len(values)] + if candidate not in ordered: + ordered.append(candidate) + return ordered + + +AREA_MAP_BASES = { + "san francisco": (37.7749, -122.4194, 48, 43), + "east bay": (37.8044, -122.2712, 63, 45), + "south bay": (37.3382, -121.8863, 70, 72), + "peninsula": (37.5630, -122.3255, 50, 62), + "north bay": (38.1074, -122.5697, 36, 24), + "santa cruz": (36.9741, -122.0308, 45, 86), +} + + +def map_details(area, neighborhood, idx): + lat, lng, x, y = AREA_MAP_BASES.get(area, AREA_MAP_BASES["san francisco"]) + digest = int(hashlib.md5(f"{area}:{neighborhood}:{idx}".encode("utf-8")).hexdigest()[:8], 16) + dx = (digest % 1700) / 100 - 8.5 + dy = ((digest // 1700) % 1500) / 100 - 7.5 + return { + "map_lat": round(lat + dy * 0.008, 5), + "map_lng": round(lng + dx * 0.01, 5), + "map_x": round(max(8, min(92, x + dx)), 1), + "map_y": round(max(10, min(90, y + dy)), 1), + } + + +def finalize_record(row, pools, idx): + image = row.get("image", "") + details = dict(row.get("details", {})) + gallery = image_gallery(pools, row["category_slug"], idx, image) + if gallery: + details["images"] = gallery + details.update(map_details(row.get("area", REGIONS[idx % len(REGIONS)]), row.get("neighborhood", "san francisco"), idx)) + row["details"] = details + return row + + +SPECIAL_LISTINGS = [ + { + "category_slug": "furniture", + "title": "Ergonomic task chair", + "area": "east bay", + "neighborhood": "berkeley", + "price": 85, + "condition": "excellent", + "description": "Black mesh home office chair from a smoke-free workspace. Detail table lists the adjustment options and pickup notes.", + "details": {"material": "mesh", "color": "black", "arms": "adjustable", "seat_height": "17-21 in", "delivery": "pickup only"}, + }, + { + "category_slug": "furniture", + "title": "Black office chair", + "area": "east bay", + "neighborhood": "oakland", + "price": 65, + "condition": "good", + "description": "Simple rolling office chair with a firm seat. Works best for a guest desk or short work sessions.", + "details": {"material": "fabric", "color": "black", "arms": "fixed", "delivery": "pickup only"}, + }, + { + "category_slug": "furniture", + "title": "Dining chair set", + "area": "east bay", + "neighborhood": "alameda", + "price": 90, + "condition": "fair", + "description": "Four matching dining chairs. Seats are sturdy, but two cushions have light wear.", + "details": {"material": "wood", "color": "brown", "quantity": "4", "delivery": "buyer pickup"}, + }, + { + "category_slug": "furniture", + "title": "Desk chair for small office", + "area": "east bay", + "neighborhood": "emeryville", + "price": 55, + "condition": "good", + "description": "Compact desk chair with wheels. Fits under a narrow writing desk.", + "details": {"material": "vinyl", "color": "gray", "arms": "none", "delivery": "pickup only"}, + }, + { + "category_slug": "furniture", + "title": "Pair of folding chairs", + "area": "east bay", + "neighborhood": "berkeley", + "price": 25, + "condition": "good", + "description": "Two folding chairs for extra seating. Lightweight and easy to store.", + "details": {"material": "metal", "color": "white", "quantity": "2", "delivery": "porch pickup"}, + }, + { + "category_slug": "furniture", + "title": "Vintage wood chair", + "area": "east bay", + "neighborhood": "oakland", + "price": 75, + "condition": "fair", + "description": "Vintage accent chair with carved back. Needs new felt pads on the legs.", + "details": {"material": "wood", "color": "oak", "style": "accent", "delivery": "pickup only"}, + }, + { + "category_slug": "furniture", + "title": "Walnut writing desk with two drawers", + "area": "san francisco", + "neighborhood": "mission district", + "price": 140, + "condition": "good", + "description": "Compact writing desk that fits a small apartment. Some edge wear on the back left corner.", + "details": {"width": "42 in", "depth": "22 in", "material": "walnut veneer", "delivery": "buyer pickup"}, + }, + { + "category_slug": "furniture", + "title": "Standing desk frame, white", + "area": "peninsula", + "neighborhood": "palo alto", + "price": 210, + "condition": "like new", + "description": "Electric standing desk frame, dual motor, controller works. Desktop not included.", + "details": {"height_range": "25-50 in", "load_rating": "220 lb", "color": "white"}, + }, + { + "category_slug": "electronics", + "title": "Dell 27 inch USB-C monitor", + "area": "south bay", + "neighborhood": "sunnyvale", + "price": 165, + "condition": "excellent", + "description": "Desk display with original power cable. Detail table lists the display specs.", + "details": {"size": "27 in", "resolution": "2560x1440", "ports": "USB-C HDMI DP"}, + }, + { + "category_slug": "electronics", + "title": "Sony noise cancelling headphones", + "area": "san francisco", + "neighborhood": "nob hill", + "price": 120, + "condition": "good", + "description": "Over-ear wireless headphones with case and USB cable. Ear pads replaced last month.", + "details": {"battery": "24 hours", "color": "silver", "included": "case cable"}, + }, + { + "category_slug": "bikes", + "title": "Marin commuter bike", + "area": "east bay", + "neighborhood": "oakland lake merritt", + "price": 460, + "condition": "good", + "description": "Medium frame city bike with fenders, rear rack, and recent tune-up. Braking details are in the table below.", + "details": {"frame": "medium", "brakes": "hydraulic disc", "gears": "1x10", "wheel_size": "700c"}, + }, + { + "category_slug": "bikes", + "title": "Trek hybrid bike, large frame", + "area": "north bay", + "neighborhood": "san rafael", + "price": 520, + "condition": "excellent", + "description": "Large hybrid bike with flat bars, new chain, and puncture-resistant tires.", + "details": {"frame": "large", "brakes": "rim", "gears": "3x8", "wheel_size": "700c"}, + }, + { + "category_slug": "cars_trucks", + "title": "2006 Honda Accord EX sedan", + "area": "south bay", + "neighborhood": "san jose north", + "price": 6200, + "condition": "good", + "description": "Accord sedan with service records, cold AC, and current registration. Title details are listed below.", + "details": {"make": "Honda", "model": "Accord EX", "mileage": "151000", "title_status": "clean", "transmission": "automatic"}, + }, + { + "category_slug": "cars_trucks", + "title": "2005 Honda Civic Hybrid", + "area": "east bay", + "neighborhood": "oakland", + "price": 5400, + "condition": "good", + "description": "Commuter Civic with recent tires and smog certificate. See detail table for title and mileage.", + "details": {"make": "Honda", "model": "Civic Hybrid", "mileage": "178000", "title_status": "rebuilt", "transmission": "automatic"}, + }, + { + "category_slug": "cars_trucks", + "title": "2011 Honda Accord EX-L", + "area": "north bay", + "neighborhood": "santa rosa", + "price": 6900, + "condition": "fair", + "description": "Leather interior and navigation. Needs suspension work soon.", + "details": {"make": "Honda", "model": "Accord EX-L", "mileage": "189000", "title_status": "clean", "transmission": "automatic"}, + }, + { + "category_slug": "cars_trucks", + "title": "2008 Honda Fit manual", + "area": "peninsula", + "neighborhood": "redwood city", + "price": 5900, + "condition": "good", + "description": "Manual hatchback with service history. Cosmetic dents on passenger door.", + "details": {"make": "Honda", "model": "Fit", "mileage": "162000", "title_status": "clean", "transmission": "manual"}, + }, + { + "category_slug": "cars_trucks", + "title": "2014 Honda Odyssey EX-L", + "area": "east bay", + "neighborhood": "pleasanton", + "price": 14950, + "condition": "excellent", + "description": "One-owner minivan with leather seats, backup camera, and recent tires.", + "details": {"make": "Honda", "model": "Odyssey", "mileage": "103000", "title_status": "clean", "seats": "8"}, + }, + { + "category_slug": "cars_trucks", + "title": "Honda Passport project SUV", + "area": "south bay", + "neighborhood": "san jose south", + "price": 1400, + "condition": "fair", + "description": "Mechanic special. Runs, but needs smog work and rear brakes.", + "details": {"make": "Honda", "model": "Passport", "mileage": "204000", "title_status": "salvage"}, + }, + { + "category_slug": "appliances", + "title": "LG washer and gas dryer pair", + "area": "east bay", + "neighborhood": "alameda", + "price": 380, + "condition": "good", + "description": "Front-load washer and gas dryer pair. Both tested before removal.", + "details": {"washer": "front load", "dryer": "gas", "delivery": "curbside available"}, + }, + { + "category_slug": "free", + "title": "Free moving boxes and packing paper", + "area": "east bay", + "neighborhood": "oakland temescal", + "price": 0, + "condition": "used", + "description": "Twenty sturdy moving boxes plus packing paper. Porch pickup after 6pm.", + "details": {"quantity": "20 boxes", "pickup": "porch", "cross_streets": "Telegraph and 45th"}, + }, + { + "category_slug": "apartments", + "title": "Sunny studio near Berkeley BART", + "area": "east bay", + "neighborhood": "berkeley", + "price": 2195, + "bedrooms": 0, + "sqft": 510, + "description": "Top-floor studio with bike room and shared roof deck. The detail table lists laundry, lease, and pet notes.", + "details": {"laundry": "in-unit", "parking": "street", "pet_policy": "cats ok", "lease": "12 months"}, + }, + { + "category_slug": "apartments", + "title": "Berkeley studio near campus", + "area": "east bay", + "neighborhood": "berkeley", + "price": 2050, + "bedrooms": 0, + "sqft": 405, + "description": "Small studio near transit and campus. Amenities are listed in the detail table.", + "details": {"laundry": "shared", "parking": "none", "pet_policy": "no pets", "lease": "12 months"}, + }, + { + "category_slug": "apartments", + "title": "Oakland garden studio", + "area": "east bay", + "neighborhood": "oakland", + "price": 1875, + "bedrooms": 0, + "sqft": 390, + "description": "Garden-level studio with private entrance. Utility and laundry notes are in the detail table.", + "details": {"laundry": "shared", "parking": "street", "pet_policy": "small dogs ok", "lease": "6 months"}, + }, + { + "category_slug": "apartments", + "title": "Alameda studio by the ferry", + "area": "east bay", + "neighborhood": "alameda", + "price": 2310, + "bedrooms": 0, + "sqft": 470, + "description": "Studio close to ferry and shoreline path. Detail table lists building amenities.", + "details": {"laundry": "coin", "parking": "included", "pet_policy": "cats ok", "lease": "12 months"}, + }, + { + "category_slug": "apartments", + "title": "Emeryville studio loft", + "area": "east bay", + "neighborhood": "emeryville", + "price": 2385, + "bedrooms": 0, + "sqft": 525, + "description": "Open loft studio with high ceilings and secure entry. See detail table for laundry policy.", + "details": {"laundry": "shared", "parking": "garage extra", "pet_policy": "cats ok", "lease": "12 months"}, + }, + { + "category_slug": "apartments", + "title": "Mission one bedroom with parking", + "area": "san francisco", + "neighborhood": "mission district", + "price": 2895, + "bedrooms": 1, + "sqft": 650, + "description": "One bedroom apartment with one assigned parking space and shared laundry.", + "details": {"laundry": "shared", "parking": "included", "pet_policy": "no pets", "lease": "12 months"}, + }, + { + "category_slug": "apartments", + "title": "Quiet studio with courtyard view", + "area": "peninsula", + "neighborhood": "san mateo", + "price": 2350, + "bedrooms": 0, + "sqft": 430, + "description": "Courtyard-facing studio, renovated kitchen, no parking, coin laundry.", + "details": {"laundry": "coin", "parking": "none", "pet_policy": "small dogs ok", "lease": "9 months"}, + }, + { + "category_slug": "rooms_shares", + "title": "Room in sunny Oakland craftsman", + "area": "east bay", + "neighborhood": "rockridge", + "price": 1250, + "bedrooms": 1, + "sqft": 140, + "description": "Room in a three-bedroom house with garden, shared kitchen, and storage for one bike.", + "details": {"utilities": "split", "bath": "shared", "move_in": "June 1"}, + }, + { + "category_slug": "software", + "title": "Backend engineer for civic data startup", + "area": "san francisco", + "neighborhood": "soma", + "price": None, + "compensation": "$155k - $180k", + "company": "Harbor Civic Labs", + "employment_type": "full-time", + "description": "Small team building public-record search tools. Python, Postgres, and data pipelines.", + "details": {"remote": "hybrid", "stack": "Python Postgres Flask", "equity": "0.15%"}, + }, + { + "category_slug": "healthcare", + "title": "Speech language pathologist school year role", + "area": "north bay", + "neighborhood": "vallejo", + "price": None, + "compensation": "$62 - $70 per hour", + "company": "Bay Learning Services", + "employment_type": "contract", + "description": "School-year clinician role with onsite team support. Detail table lists setting and license notes.", + "details": {"schedule": "2026-2027 school year", "license": "CA SLP required", "setting": "K-8"}, + }, + { + "category_slug": "food_bev_hosp", + "title": "Line cook for modern Asian bistro", + "area": "san francisco", + "neighborhood": "richmond district", + "price": None, + "compensation": "$26 - $30 per hour plus tips", + "company": "Mika Bistro", + "employment_type": "full-time", + "description": "Dinner service line cook, wok station helpful, two consecutive days off.", + "details": {"shift": "3pm-11pm", "benefits": "meals transit stipend", "experience": "2 years"}, + }, + { + "category_slug": "general_labor", + "title": "Movers needed for weekend apartment turns", + "area": "east bay", + "neighborhood": "emeryville", + "price": None, + "compensation": "$28 per hour cash", + "company": "Bay Move Crew", + "employment_type": "part-time", + "description": "Weekend work loading boxes and furniture. Must be able to lift 60 lb.", + "details": {"schedule": "Saturday and Sunday", "start": "8am", "tools": "gloves provided"}, + }, + { + "category_slug": "lessons", + "title": "Remote algebra and calculus tutoring", + "area": "san francisco", + "neighborhood": "remote", + "price": 55, + "condition": "new", + "description": "Online math lessons for high-school algebra, pre-calculus, and AP calculus.", + "details": {"format": "Zoom", "rate": "$55 per hour", "subjects": "algebra calculus"}, + }, + { + "category_slug": "computer_services", + "title": "MacBook repair and data recovery", + "area": "south bay", + "neighborhood": "santa clara", + "price": 90, + "condition": "new", + "description": "Laptop diagnostics, SSD upgrades, data migration, and screen replacement quotes.", + "details": {"diagnostic": "$90", "turnaround": "same week", "brands": "Apple Dell Lenovo"}, + }, + { + "category_slug": "events", + "title": "Saturday neighborhood plant swap", + "area": "san francisco", + "neighborhood": "mission district", + "price": 0, + "condition": "new", + "description": "Neighborhood plant swap near Dolores Park. Detail table lists the time and what to bring.", + "details": {"date": "Saturday", "time": "10:00 AM", "bring": "labeled plants"}, + }, + { + "category_slug": "volunteers", + "title": "Volunteer bike repair clinic helpers", + "area": "east bay", + "neighborhood": "oakland", + "price": 0, + "condition": "new", + "description": "Help check brakes, patch tubes, and guide neighbors through basic bike fixes.", + "details": {"date": "Saturday", "time": "1:00 PM", "skills": "basic bike repair"}, + }, + { + "category_slug": "artists", + "title": "Seeking photographer for small zine project", + "area": "south bay", + "neighborhood": "santa clara", + "price": 200, + "condition": "new", + "description": "Portrait session for a local zine. Natural light style preferred, two-hour shoot.", + "details": {"budget": "$200", "format": "digital", "deadline": "May 24"}, + }, +] + + +TARGET_NEAR_MISSES = [ + {"category_slug": "bikes", "title": "Commuter bike with rear rack", "area": "east bay", "neighborhood": "oakland", "price": 315, "condition": "good", "description": "Daily commuter bike with fenders and rack. Detail table lists frame and brake setup.", "details": {"frame": "medium", "brakes": "rim", "gears": "3x7", "wheel_size": "700c"}}, + {"category_slug": "bikes", "title": "Lightweight commuter bicycle", "area": "san francisco", "neighborhood": "inner sunset", "price": 440, "condition": "good", "description": "Reliable commuter for city errands. Detail table lists parts and fit.", "details": {"frame": "small", "brakes": "mechanical disc", "gears": "2x8", "wheel_size": "700c"}}, + {"category_slug": "bikes", "title": "Flat bar commuter bike", "area": "south bay", "neighborhood": "campbell", "price": 390, "condition": "fair", "description": "Flat bar commuter with lights and bottle cage. Detail table lists maintenance notes.", "details": {"frame": "large", "brakes": "rim", "gears": "1x8", "wheel_size": "700c"}}, + {"category_slug": "bikes", "title": "Commuter bike, step-through frame", "area": "peninsula", "neighborhood": "redwood city", "price": 285, "condition": "good", "description": "Comfort commuter with upright bars and kickstand.", "details": {"frame": "medium step-through", "brakes": "coaster", "gears": "7 speed", "wheel_size": "26 in"}}, + + {"category_slug": "cars_trucks", "title": "2003 Honda CR-V AWD", "area": "east bay", "neighborhood": "hayward", "price": 5800, "condition": "good", "description": "Older CR-V with roof rack and current smog. Detail table lists title status.", "details": {"make": "Honda", "model": "CR-V", "mileage": "177000", "title_status": "clean", "transmission": "automatic"}}, + {"category_slug": "cars_trucks", "title": "2007 Honda Civic coupe", "area": "south bay", "neighborhood": "milpitas", "price": 4900, "condition": "fair", "description": "Civic coupe with new battery and working AC. Detail table lists paperwork.", "details": {"make": "Honda", "model": "Civic", "mileage": "201000", "title_status": "salvage", "transmission": "automatic"}}, + {"category_slug": "cars_trucks", "title": "2009 Honda Element", "area": "santa cruz", "neighborhood": "aptos", "price": 6600, "condition": "good", "description": "Element with roof bars and camping platform. Detail table lists title status.", "details": {"make": "Honda", "model": "Element", "mileage": "184000", "title_status": "clean", "transmission": "automatic"}}, + + {"category_slug": "events", "title": "Succulent plant exchange", "area": "east bay", "neighborhood": "berkeley", "price": 0, "condition": "new", "description": "Casual plant exchange for cuttings and extra pots.", "details": {"date": "Sunday", "time": "11:00 AM", "bring": "small labeled cuttings"}}, + {"category_slug": "events", "title": "Houseplant swap table", "area": "north bay", "neighborhood": "san rafael", "price": 0, "condition": "new", "description": "Community table for swapping houseplants and garden starts.", "details": {"date": "Friday", "time": "4:00 PM", "bring": "healthy plants only"}}, + {"category_slug": "events", "title": "Seedling swap meetup", "area": "south bay", "neighborhood": "santa clara", "price": 0, "condition": "new", "description": "Swap vegetable seedlings and talk balcony gardening.", "details": {"date": "Saturday", "time": "9:00 AM", "bring": "seedling trays"}}, + {"category_slug": "events", "title": "Plant care workshop", "area": "san francisco", "neighborhood": "richmond district", "price": 0, "condition": "new", "description": "Beginner workshop on repotting and watering indoor plants.", "details": {"date": "Thursday", "time": "6:30 PM", "bring": "one problem plant"}}, + + {"category_slug": "free", "title": "Free wardrobe boxes", "area": "east bay", "neighborhood": "berkeley", "price": 0, "condition": "used", "description": "Tall moving boxes from a recent apartment move.", "details": {"quantity": "6 wardrobe boxes", "pickup": "curb", "cross_streets": "Shattuck and Dwight"}}, + {"category_slug": "free", "title": "Moving boxes and bubble wrap", "area": "east bay", "neighborhood": "alameda", "price": 0, "condition": "used", "description": "Stack of moving boxes, paper, and bubble wrap.", "details": {"quantity": "12 boxes", "pickup": "garage", "cross_streets": "Park and Lincoln"}}, + {"category_slug": "free", "title": "Small boxes for books", "area": "east bay", "neighborhood": "emeryville", "price": 0, "condition": "used", "description": "Free small book boxes from a move.", "details": {"quantity": "15 boxes", "pickup": "lobby", "cross_streets": "40th and Hollis"}}, + {"category_slug": "free", "title": "Flattened moving boxes", "area": "east bay", "neighborhood": "oakland", "price": 0, "condition": "used", "description": "Flattened moving boxes, clean and dry.", "details": {"quantity": "18 boxes", "pickup": "porch", "cross_streets": "Broadway and 51st"}}, + + {"category_slug": "healthcare", "title": "Pediatric speech language assistant", "area": "south bay", "neighborhood": "campbell", "price": None, "compensation": "$38 - $45 per hour", "company": "Bright Steps Therapy", "employment_type": "part-time", "description": "Clinic support role for pediatric speech therapy sessions.", "details": {"schedule": "3 weekdays", "license": "SLPA preferred", "setting": "clinic"}}, + {"category_slug": "healthcare", "title": "Speech therapist telehealth contract", "area": "san francisco", "neighborhood": "remote", "price": None, "compensation": "$58 per hour", "company": "Remote Learning Care", "employment_type": "contract", "description": "Online speech therapy sessions for middle-school students.", "details": {"schedule": "August-May", "license": "CA SLP required", "setting": "telehealth"}}, + {"category_slug": "healthcare", "title": "Language development aide", "area": "east bay", "neighborhood": "oakland", "price": None, "compensation": "$31 per hour", "company": "Oakland Child Services", "employment_type": "full-time", "description": "Support language development programs under clinician supervision.", "details": {"schedule": "school year", "license": "associate permit", "setting": "preschool"}}, + {"category_slug": "healthcare", "title": "School occupational therapist", "area": "peninsula", "neighborhood": "san mateo", "price": None, "compensation": "$65 per hour", "company": "Bay School Staffing", "employment_type": "contract", "description": "School therapist role with elementary caseload.", "details": {"schedule": "2026-2027 school year", "license": "CA OT required", "setting": "K-5"}}, + + {"category_slug": "lessons", "title": "Online math tutoring for algebra", "area": "san francisco", "neighborhood": "remote", "price": 45, "condition": "new", "description": "Remote math tutoring for algebra and geometry.", "details": {"format": "Zoom", "rate": "$45 per hour", "subjects": "algebra geometry"}}, + {"category_slug": "lessons", "title": "Calculus tutoring, weekends", "area": "peninsula", "neighborhood": "palo alto", "price": 70, "condition": "new", "description": "Weekend tutoring for calculus and statistics.", "details": {"format": "library or online", "rate": "$70 per hour", "subjects": "calculus statistics"}}, + {"category_slug": "lessons", "title": "SAT math tutor", "area": "east bay", "neighborhood": "berkeley", "price": 60, "condition": "new", "description": "SAT math prep with practice tests.", "details": {"format": "in person", "rate": "$60 per hour", "subjects": "SAT math"}}, + {"category_slug": "lessons", "title": "Middle school math tutoring", "area": "south bay", "neighborhood": "sunnyvale", "price": 50, "condition": "new", "description": "Patient math tutoring for middle school students.", "details": {"format": "online", "rate": "$50 per hour", "subjects": "pre-algebra"}}, + + {"category_slug": "labor_move", "title": "Moving help for apartments", "area": "east bay", "neighborhood": "oakland", "price": 80, "condition": "new", "description": "Two helpers for local apartment moving jobs.", "details": {"crew": "2 people", "rate": "$80 per hour", "truck": "not included"}}, + {"category_slug": "labor_move", "title": "Small moving crew with blankets", "area": "san francisco", "neighborhood": "mission district", "price": 110, "condition": "new", "description": "Moving crew for furniture, boxes, and studio apartments.", "details": {"crew": "2 people", "rate": "$110 per hour", "truck": "cargo van"}}, + {"category_slug": "labor_move", "title": "Last minute moving labor", "area": "south bay", "neighborhood": "san jose", "price": 75, "condition": "new", "description": "Labor-only moving help for stairs and loading.", "details": {"crew": "1-2 people", "rate": "$75 per hour", "truck": "not included"}}, + {"category_slug": "labor_move", "title": "Weekend moving and hauling", "area": "peninsula", "neighborhood": "san mateo", "price": 125, "condition": "new", "description": "Weekend moving and hauling for small households.", "details": {"crew": "2 people", "rate": "$125 per hour", "truck": "box truck"}}, + + {"category_slug": "electronics", "title": "LG USB-C monitor", "area": "east bay", "neighborhood": "berkeley", "price": 190, "condition": "good", "description": "USB-C display with stand and cable. Detail table lists specs.", "details": {"size": "24 in", "resolution": "1920x1080", "ports": "USB-C HDMI"}}, + {"category_slug": "electronics", "title": "BenQ office monitor", "area": "san francisco", "neighborhood": "nopa", "price": 95, "condition": "good", "description": "Office monitor with stand. Detail table lists specs.", "details": {"size": "27 in", "resolution": "1920x1080", "ports": "HDMI DP"}}, + {"category_slug": "electronics", "title": "Portable USB-C display", "area": "south bay", "neighborhood": "santa clara", "price": 145, "condition": "like new", "description": "Slim portable display with sleeve. Detail table lists specs.", "details": {"size": "15.6 in", "resolution": "1920x1080", "ports": "USB-C mini-HDMI"}}, + {"category_slug": "electronics", "title": "Ultrawide monitor with USB hub", "area": "peninsula", "neighborhood": "san mateo", "price": 260, "condition": "good", "description": "Large monitor with built-in hub. Detail table lists specs.", "details": {"size": "34 in", "resolution": "3440x1440", "ports": "HDMI DP USB-A"}}, + + {"category_slug": "volunteers", "title": "Bike lane cleanup volunteers", "area": "east bay", "neighborhood": "oakland", "price": 0, "condition": "new", "description": "Volunteers needed for weekend bike lane cleanup.", "details": {"date": "Sunday", "time": "9:30 AM", "skills": "comfortable outdoors"}}, + {"category_slug": "volunteers", "title": "Community repair cafe volunteers", "area": "san francisco", "neighborhood": "haight", "price": 0, "condition": "new", "description": "Repair cafe seeks volunteers for small household fixes.", "details": {"date": "Saturday", "time": "2:00 PM", "skills": "basic hand tools"}}, + {"category_slug": "volunteers", "title": "Youth bike rodeo helpers", "area": "south bay", "neighborhood": "santa clara", "price": 0, "condition": "new", "description": "Help kids practice bike safety at a neighborhood event.", "details": {"date": "Saturday", "time": "10:30 AM", "skills": "patience with kids"}}, + {"category_slug": "volunteers", "title": "Tool library repair shift", "area": "east bay", "neighborhood": "berkeley", "price": 0, "condition": "new", "description": "Volunteer shift repairing donated tools and sorting parts.", "details": {"date": "Wednesday", "time": "5:00 PM", "skills": "basic repair"}}, + {"category_slug": "events", "title": "Plant swap picnic table", "area": "peninsula", "neighborhood": "san mateo", "price": 0, "condition": "new", "description": "Small plant swap hosted at a public picnic table.", "details": {"date": "Sunday", "time": "3:00 PM", "bring": "pest-free cuttings"}}, + {"category_slug": "free", "title": "Free boxes for moving day", "area": "east bay", "neighborhood": "richmond", "price": 0, "condition": "used", "description": "Assorted free boxes left from moving day.", "details": {"quantity": "10 boxes", "pickup": "driveway", "cross_streets": "23rd and Barrett"}}, + {"category_slug": "apartments", "title": "West Oakland studio apartment", "area": "east bay", "neighborhood": "west oakland", "price": 2225, "bedrooms": 0, "sqft": 455, "condition": "new", "description": "Compact studio apartment close to BART. Detail table lists laundry and parking notes.", "details": {"laundry": "shared", "parking": "street", "pet_policy": "no pets", "lease": "12 months"}}, + {"category_slug": "healthcare", "title": "Travel speech pathologist opening", "area": "north bay", "neighborhood": "napa", "price": None, "compensation": "$61 per hour", "company": "North Bay Therapy", "employment_type": "contract", "description": "Travel speech clinician opening with district team support.", "details": {"schedule": "fall semester", "license": "CA SLP required", "setting": "high school"}}, + {"category_slug": "healthcare", "title": "Bilingual language pathologist", "area": "east bay", "neighborhood": "fremont", "price": None, "compensation": "$64 per hour", "company": "Fremont Student Services", "employment_type": "full-time", "description": "Bilingual language services role with onsite supervision.", "details": {"schedule": "school calendar", "license": "CA SLP or RPE", "setting": "elementary"}}, + {"category_slug": "lessons", "title": "AP math tutoring online", "area": "north bay", "neighborhood": "remote", "price": 58, "condition": "new", "description": "Online tutoring for AP math courses and exam review.", "details": {"format": "Zoom", "rate": "$58 per hour", "subjects": "AP calculus pre-calculus"}}, + {"category_slug": "labor_move", "title": "Moving help with pickup truck", "area": "north bay", "neighborhood": "san rafael", "price": 90, "condition": "new", "description": "Small moving jobs with one pickup truck and blankets.", "details": {"crew": "2 people", "rate": "$90 per hour", "truck": "pickup"}}, + {"category_slug": "labor_move", "title": "Apartment moving and packing", "area": "east bay", "neighborhood": "berkeley", "price": 100, "condition": "new", "description": "Apartment moving, packing help, and loading support.", "details": {"crew": "2 people", "rate": "$100 per hour", "truck": "van available"}}, + {"category_slug": "volunteers", "title": "Bike repair table volunteers", "area": "east bay", "neighborhood": "emeryville", "price": 0, "condition": "new", "description": "Volunteers needed at a bike repair information table.", "details": {"date": "Sunday", "time": "12:00 PM", "skills": "friendly with cyclists"}}, + {"category_slug": "volunteers", "title": "Community bike repair setup crew", "area": "east bay", "neighborhood": "berkeley", "price": 0, "condition": "new", "description": "Volunteers help set up stands and check in neighbors for a bike repair afternoon.", "details": {"date": "Saturday", "time": "11:00 AM", "skills": "event setup"}}, + {"category_slug": "volunteers", "title": "Bike day repair station helpers", "area": "east bay", "neighborhood": "oakland", "price": 0, "condition": "new", "description": "Repair station seeks helpers for basic intake, tools, and sign-in during bike day.", "details": {"date": "Saturday", "time": "3:30 PM", "skills": "organized with tools"}}, +] + + +EXTRA_BLUEPRINTS = { + "furniture": [ + ("Maple bookcase with adjustable shelves", 95, "good", "solid wood bookcase, light scratches"), + ("Round kitchen table with four chairs", 180, "good", "small dining set for apartment"), + ("Blue loveseat, pet-free home", 160, "excellent", "comfortable loveseat, no stains"), + ("Metal filing cabinet, two drawer", 45, "fair", "office cabinet with working lock"), + ("Queen bed frame with slats", 110, "good", "platform bed frame, no mattress"), + ("Glass coffee table", 70, "good", "thick glass top, chrome legs"), + ], + "electronics": [ + ("iPad Air with keyboard case", 310, "excellent", "tablet, charger, and case"), + ("Nintendo Switch bundle", 240, "good", "console, dock, two controllers"), + ("Eero mesh router three pack", 120, "good", "whole-home wifi kit"), + ("Bose bookshelf speakers", 175, "good", "pair of compact speakers"), + ("Logitech webcam and ring light", 55, "like new", "video call setup"), + ], + "bikes": [ + ("Cannondale road bike, 54cm", 690, "excellent", "aluminum road bike with carbon fork"), + ("Folding bike for Caltrain commute", 375, "good", "compact folding bike with rear rack"), + ("Kids bike, 20 inch wheels", 80, "good", "recent tubes and training stand"), + ("Single speed city bike", 220, "fair", "simple commuter with new tires"), + ], + "apartments": [ + ("Pet friendly one bedroom near Lake Merritt", 2475, "new", "balcony, dishwasher, shared laundry"), + ("South Bay studio with gated parking", 2250, "new", "studio apartment with covered parking"), + ("North Beach two bedroom flat", 3650, "new", "classic flat near restaurants"), + ("Garden level in-law apartment", 1980, "new", "private entrance, utilities included"), + ("Sunny room in shared Mission apartment", 1390, "new", "shared kitchen and roof access"), + ], + "jobs": [ + ("Customer support specialist, hybrid", None, "new", "help members by phone and email"), + ("Part-time barista morning shifts", None, "new", "espresso service and register"), + ("Senior contact center engineer", None, "new", "maintain cloud telephony systems"), + ("Painter apprentice, all levels", None, "new", "residential repaint crew"), + ("Youth tennis coach needed", None, "new", "after-school tennis program"), + ("Registered nurse floater", None, "new", "clinic float role across East Bay"), + ], + "services": [ + ("Two-person moving help with van", 95, "new", "local moves, stairs ok"), + ("Guitar lessons for beginners", 45, "new", "weekly lessons in person or online"), + ("House cleaning, green supplies", 120, "new", "apartments and small homes"), + ("Logo design for small businesses", 250, "new", "brand kit and social avatars"), + ], + "community": [ + ("East Bay board game night", 0, "new", "weekly strategy games and snacks"), + ("Lost gray tabby cat near Panhandle", 0, "new", "microchipped gray tabby"), + ("Figure drawing group seeking models", 0, "new", "weekly session with easels"), + ("Free compost workshop", 0, "new", "learn balcony compost basics"), + ], +} + + +def build_listing_records(base_dir): + pools = image_pool(base_dir) + records = [] + + for i, item in enumerate(SPECIAL_LISTINGS): + row = dict(item) + row["image"] = pick_image(pools, row["category_slug"], i) + records.append(finalize_record(row, pools, i)) + + offset = len(records) + for i, item in enumerate(TARGET_NEAR_MISSES, start=1): + row = dict(item) + row["image"] = pick_image(pools, row["category_slug"], offset + i) + records.append(finalize_record(row, pools, offset + i)) + + category_sequence = [ + ("furniture", "for_sale"), + ("electronics", "for_sale"), + ("bikes", "for_sale"), + ("apartments", "housing"), + ("rooms_shares", "housing"), + ("sublets", "housing"), + ("parking", "housing"), + ("office_commercial", "housing"), + ("software", "jobs"), + ("customer_service", "jobs"), + ("food_bev_hosp", "jobs"), + ("general_labor", "jobs"), + ("healthcare", "jobs"), + ("education", "jobs"), + ("automotive_services", "services"), + ("computer_services", "services"), + ("household_services", "services"), + ("labor_move", "services"), + ("lessons", "services"), + ("events", "community"), + ("volunteers", "community"), + ("artists", "community"), + ("groups", "community"), + ("lost_found", "community"), + ] + area_cycle = REGIONS + neighborhood_cycle = [ + "berkeley", "oakland", "mission district", "palo alto", "san jose", + "san mateo", "santa rosa", "santa cruz", "alameda", "sunnyvale", + ] + + idx = 0 + for category_slug, group in category_sequence: + if category_slug in EXTRA_BLUEPRINTS: + blueprints = EXTRA_BLUEPRINTS[category_slug] + elif group in EXTRA_BLUEPRINTS: + blueprints = EXTRA_BLUEPRINTS[group] + elif group == "jobs": + blueprints = EXTRA_BLUEPRINTS["jobs"] + elif group == "services": + blueprints = EXTRA_BLUEPRINTS["services"] + else: + blueprints = EXTRA_BLUEPRINTS["community"] + for title, price, condition, desc in blueprints[:4]: + idx += 1 + row = { + "category_slug": category_slug, + "title": title, + "area": area_cycle[idx % len(area_cycle)], + "neighborhood": neighborhood_cycle[idx % len(neighborhood_cycle)], + "price": price, + "condition": condition, + "description": desc + ". Contact by email for details.", + "details": { + "posted_by": "owner", + "availability": "available now", + "cross_streets": neighborhood_cycle[(idx + 2) % len(neighborhood_cycle)], + }, + "image": pick_image(pools, category_slug, idx), + } + records.append(finalize_record(row, pools, idx)) + return records + + +def seed_categories(db, Category): + if Category.query.count() > 0: + return + order = 0 + for group in CATEGORY_GROUPS: + for slug, name, abbrev in group["columns"]: + order += 1 + db.session.add(Category( + slug=slug, + name=name, + abbrev=abbrev, + group_slug=group["slug"], + group_name=group["name"], + display_order=order, + )) + db.session.commit() + + +def seed_database(base_dir, db, Category, Listing): + if Listing.query.count() > 0: + return + seed_categories(db, Category) + category_map = {c.slug: c for c in Category.query.all()} + records = build_listing_records(base_dir) + for idx, row in enumerate(records, start=1): + category = category_map[row["category_slug"]] + title = row["title"] + listing = Listing( + title=title, + slug=f"{slugify(title)}-{idx}", + category_id=category.id, + category_slug=category.slug, + category_group=category.group_slug, + area=row.get("area", REGIONS[idx % len(REGIONS)]), + neighborhood=row.get("neighborhood", "san francisco"), + price=row.get("price"), + bedrooms=row.get("bedrooms"), + sqft=row.get("sqft"), + condition=row.get("condition", ""), + compensation=row.get("compensation", ""), + company=row.get("company", ""), + employment_type=row.get("employment_type", ""), + description=row.get("description", ""), + details_json=json.dumps(row.get("details", {}), sort_keys=True), + image=row.get("image", ""), + seller_name=row.get("seller_name", "craigslist user"), + seller_email=row.get("seller_email", f"reply{idx}@example.test"), + reply_phone=row.get("reply_phone", ""), + posted_at=FIXED_NOW - timedelta(hours=idx * 3), + updated_at=FIXED_NOW - timedelta(hours=idx * 2), + status="active", + ) + db.session.add(listing) + db.session.commit() + + +def seed_benchmark_users(db, User, Listing, SavedListing, SavedSearch, Message): + if User.query.filter_by(email="alice.j@test.com").first(): + return + + users = [ + ("alice.j@test.com", "Alice Johnson", "alice", "san francisco"), + ("ben.k@test.com", "Ben Kim", "ben", "east bay"), + ("carla.m@test.com", "Carla Martinez", "carla", "south bay"), + ("david.p@test.com", "David Patel", "david", "peninsula"), + ] + created = [] + for email, name, username, area in users: + user = User( + email=email, + username=username, + name=name, + area=area, + phone="(415) 555-0100", + password_hash=deterministic_password(email), + created_at=FIXED_NOW - timedelta(days=45), + ) + db.session.add(user) + created.append(user) + db.session.flush() + + alice = created[0] + desk = Listing.query.filter(Listing.title.ilike("%task chair%")).first() + accord = Listing.query.filter(Listing.title.ilike("%Accord%")).first() + studio = Listing.query.filter(Listing.title.ilike("%Berkeley BART%")).first() + for listing in [desk, accord, studio]: + if listing: + db.session.add(SavedListing( + user_id=alice.id, + listing_id=listing.id, + note="compare before contacting", + created_at=FIXED_NOW - timedelta(days=2), + )) + + db.session.add(SavedSearch( + user_id=alice.id, + name="East Bay furniture under 100", + query_text="chair desk", + category_slug="furniture", + area="east bay", + max_price=100, + created_at=FIXED_NOW - timedelta(days=3), + )) + db.session.add(SavedSearch( + user_id=alice.id, + name="Studios with laundry", + query_text="studio laundry", + category_slug="apartments", + area="east bay", + max_price=2400, + created_at=FIXED_NOW - timedelta(days=1), + )) + + if studio: + db.session.add(Message( + user_id=alice.id, + listing_id=studio.id, + sender_name="Leasing office", + sender_email="leasing@example.test", + body="The Berkeley studio can be shown Wednesday at 5:30pm or Thursday at noon.", + direction="inbound", + created_at=FIXED_NOW - timedelta(hours=18), + is_read=False, + )) + if accord: + db.session.add(Message( + user_id=alice.id, + listing_id=accord.id, + sender_name="Alice Johnson", + sender_email="alice.j@test.com", + body="Hi, is the Accord still available and can I see service records?", + direction="outbound", + created_at=FIXED_NOW - timedelta(hours=10), + is_read=True, + )) + + db.session.commit() diff --git a/sites/craigslist/static/css/.gitkeep b/sites/craigslist/static/css/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/craigslist/static/css/icomoon.css b/sites/craigslist/static/css/icomoon.css new file mode 100644 index 00000000..b2070f02 --- /dev/null +++ b/sites/craigslist/static/css/icomoon.css @@ -0,0 +1,659 @@ +@font-face{font-family:'icomoon';src:url("data:application/x-font-ttf;charset=utf-8;base64,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AQIBAQEBAQEBGyUBAQEBAQEBAQIBAQEBAQEBAQEBAQECAQEBAQEBAQMBAQMCAQEBAQICBAIBBAIBAgEBAwECBAMJFQwMGQ4NHQ4PHg8DCAQBBAICAwIEBwQDCAMCAwICAwIEBgQDBwMCAwEBAgECAQMGAwICAgEDAgMFAwMFAgEBASYaAQEDBQMBAwIBAwICBAIECgUFCgYDBQMDBgMCAwIBAwIDBwMBAQEBAgEBBAEEBgQDBwQCAwICAwIEBwMEBwQCAwICAwIEBwMPGw4NGQsMFAkJEAcBAwICAQIBAwEBAgEBAQEBAQIBAQEBAQEBAQEBGiRKAgEBAQEBAQECAQEBAQEBAQIBAQMCAgECAgMCAQQCAQEBAQIBAgQCCBMKCxYMDBkNDBoNBAYDAgMCAQMCAwYEAwYDAgMBAgMBAwYDAwUDAgICAQEBAgMFAwEDAQEDAQIFAwIEAwQIBAQGAwEDAQIDAQIDAQMFAgEBFyMDAQECBQMCBgICAgIBAwEDBgIBAgEBAQIDAQMGAwMGAwIDAQIDAQMHAwMGAwIDAQIDAgMGAwwYDAsVCgoRCAgNBQICAQEBAQIBAgEBAgEBAQECAQEBAQEBAQEBAQEeFgEBAQEBAQEBAQECAQEBAQEBAwEBAwEBAQEBAQECAwECBAEBAgEBAgECBAIIEwoLFgwMGQwNGg0DBwMCAwECAwIDBgMEBgMBAwIBAwIDBgMDBQMCAgICAQEBAwUDAQMBAQMBAgUCAwQDBAgEAwcDAQMBAgMBAgMBAgUDAQEXIgQCBQMCBgICAgIBAwEDBgIBAgEBAQIDAQMGAwMGAwIDAQIDAQMHAwMGAwIDAQIDAgMGAwwYDAsVCgoRCAgNBQICAQEBAQIBAgEBAgEBAQECAQEBAQEBAQEBAQEfFgG+AwUDAgICAQMCAgQCBQkFBQoGAwYCAwYDAgMCAQMCAwcDAQEBAQIBAQQBBAcDAwcEAgMCAgMCBAcDBAcEAgMCAgMCBAcEDhwNDRkLDBQKCQ8HAQMCAgECAgIBAQIBAQEBAQECAQEBAQEBAQEBARolAQEBAQEBAQECAQEBAQEBAQEBAQEBAgEBAQEBAQEDAQEDAgEBAQECAgQCAQQCAQIBAQMBAgQDCRUMDBkODR0ODx4PAwgEAQQCAgMCBAcEAwgDAgMCAgMCBAYEAwcDAgMBAQIBAgEDBgMC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") format('truetype');font-weight:normal;font-style:normal;font-display:block}[class^="icom-"],[class*=" icom-"]{font-family:'icomoon' !important;speak:never;font-style:normal;font-weight:normal;font-variant:normal;text-transform:none;line-height:1;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.icom-academia:before{content:"\e901"}.icom-facebook:before{content:"\e902";color:#1877f2}.icom-google:before{content:"\e903";color:#4285f4}.icom-microsoftoutlook:before{content:"\e907";color:#0072c6}.icom-twitter:before{content:"\e906";color:#1da1f2}.icom-yahoo:before{content:"\e900";color:#6001d2}.icom-facebook1:before{content:"\e908";color:#1877f2}.icom-share:before{content:"\e90a"}.icom-share1:before{content:"\e90b"}.icom-share2:before{content:"\e90c"}.icom-share3:before{content:"\e913"}.icom-share4:before{content:"\e915"}.icom-target:before{content:"\e914"}.icom-equalizer:before{content:"\eb5b"}.icom-plus3:before{content:"\ed5d"}.icom-bell2:before{content:"\ea58"}.icom-map5:before{content:"\ea4a"}.icom-list:before{content:"\ec59"}.icom-grid:before{content:"\ec5d"}.icom-star-empty:before{content:"\ece0"}.icom-peace:before{content:"\ed00"}.icom-play4:before{content:"\ed85"}.icom-previous2:before{content:"\ed8c"}.icom-next2:before{content:"\ed8d"}.icom-paragraph-justify3:before{content:"\eec4"}.icom-envelop5:before{content:"\ea35"}.icom-floppy-disk:before{content:"\e904"}.icom-bin:before{content:"\e909"}.icom-plus:before{content:"\e90d"}.icom-minus:before{content:"\e90e"}.icom-close:before{content:"\e90f"}.icom-checkmark:before{content:"\e910"}.icom-radio-checked:before{content:"\e911"}.icom-radio-unchecked:before{content:"\e912"}.icom-warning:before{content:"\ea07"}.icom-notification:before{content:"\ea08"}.icom-question:before{content:"\ea09"}.icom-info:before{content:"\ea0c"}.icom-cancel-circle:before{content:"\ea0d"}.icom-pencil:before{content:"\e905"}.icom-spinner11:before{content:"\e984"}.icom-search:before{content:"\e986"}.icom-cog:before{content:"\e994"}.icom-arrow-up:before{content:"\ea32"}.icom-arrow-right:before{content:"\ea34"}.icom-arrow-down:before{content:"\ea36"}.icom-arrow-left:before{content:"\ea38"}.icom-arrow-up2:before{content:"\ea3a"}.icom-arrow-right2:before{content:"\ea3c"}.icom-arrow-down2:before{content:"\ea3e"}.icom-arrow-left2:before{content:"\ea40"}.icom-sort-alpha-asc:before{content:"\ea48"}.icom-sort-alpha-desc:before{content:"\ea49"}.icom-image2:before{content:"\e93c"}.icom-bubble-dots:before{content:"\eace"}.icom-bubble-dots2:before{content:"\ead1"}.icom-bubble-dots3:before{content:"\ead9"}.icom-bubble-dots4:before{content:"\eadc"}.icom-user:before{content:"\eaf7"}.icom-user2:before{content:"\eb00"}.icom-user3:before{content:"\eb08"}.icom-spinner:before{content:"\eb22"}.icom-spinner2:before{content:"\eb23"}.icom-spinner3:before{content:"\eb24"}.icom-spinner5:before{content:"\eb26"}.icom-spinner6:before{content:"\eb27"}.icom-spinner9:before{content:"\eb2a"}.icom-spinner10:before{content:"\eb2b"}.icom-spinner111:before{content:"\eb2c"}.icom-enlarge6:before{content:"\eb46"}.icom-shrink6:before{content:"\eb47"}.icom-enlarge7:before{content:"\eb48"}.icom-shrink7:before{content:"\eb49"}.icom-lock4:before{content:"\eb53"}.icom-unlocked:before{content:"\eb54"}.icom-cog1:before{content:"\eb5f"}.icom-cog2:before{content:"\eb61"}.icom-cog3:before{content:"\eb62"}.icom-cog4:before{content:"\eb63"}.icom-cog6:before{content:"\eb65"}.icom-bin1:before{content:"\ebfd"}.icom-bin2:before{content:"\ebfe"}.icom-attachment:before{content:"\ecac"}.icom-circle2:before{content:"\ee78"}.icom-database-insert:before{content:"\ea9a"}.icom-database-export:before{content:"\ea9b"}.icom-rotate-cw2:before{content:"\eaaa"}.icom-rotate-cw3:before{content:"\eaac"}.icom-unite:before{content:"\eab5"}.icom-user21:before{content:"\eb01"}.icom-enlarge2:before{content:"\eb3e"}.icom-shrink2:before{content:"\eb3f"}.icom-enlarge5:before{content:"\eb44"}.icom-shrink5:before{content:"\eb45"}.icom-star-full:before{content:"\ece2"}.icom-select2:before{content:"\ed2b"}.icom-warning1:before{content:"\ed4f"}.icom-notification1:before{content:"\ed51"}.icom-cancel-square:before{content:"\ed68"}.icom-checkmark1:before{content:"\ed6f"}.icom-checkmark4:before{content:"\ed72"}.icom-volume-mute5:before{content:"\eda7"}.icom-arrow-up21:before{content:"\edb8"}.icom-arrow-right21:before{content:"\edbc"}.icom-arrow-down21:before{content:"\edc0"}.icom-arrow-left21:before{content:"\edc4"}.icom-key-up:before{content:"\ee5e"}.icom-key-right:before{content:"\ee5f"}.icom-key-down:before{content:"\ee60"}.icom-key-left:before{content:"\ee61"}.icom-checkbox-checked:before{content:"\ee66"}.icom-checkbox-unchecked:before{content:"\ee67"}.icom-toggle-off:before{content:"\ee75"}.icom-compass:before{content:"\e949"}.icom-flag3:before{content:"\eca6"}.icom-flag4:before{content:"\eca7"}.icom-copy3:before{content:"\e9c7"}.icom-phone2:before{content:"\ea1d"}.icom-bubble:before{content:"\eac4"}.icom-pencil7:before{content:"\e916"}.icom-bubbles10:before{content:"\eadf"}.icom-pushpin:before{content:"\ea37"}.icom-user4:before{content:"\eb09"}.icom-search5:before{content:"\eb3a"}.icom-warning2:before{content:"\ed50"}.icom-heart:before{content:"\e9da"}.icom-stack-empty:before{content:"\ed3b"}.icom-stack-check:before{content:"\ed43"}.icom-info1:before{content:"\ed63"}.icom-info2:before{content:"\ed64"}.icom-last:before{content:"\ed8b"}.icom-previous21:before{content:"\ed8e"}.icom-location3:before{content:"\ea3b"}.icom-thumbs-up:before{content:"\ecf4"}.icom-thumbs-down:before{content:"\ecf6"}.icom-stats-bars2:before{content:"\eb8a"}.icom-eye3:before{content:"\ecb4"}.icom-point-up:before{content:"\e917"}.icom-crop:before{content:"\e918"}.icom-menu2:before{content:"\f0c9"}.icom-caret-down:before{content:"\f0d7"}.icom-caret-up:before{content:"\f0d8"}.icom-caret-left:before{content:"\f0d9"}.icom-caret-right:before{content:"\f0da"}.icom-meatballs:before{content:"\f141"}.icom-kabob:before{content:"\f142"}/* required styles */ + +.leaflet-pane, +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-tile-container, +.leaflet-pane > svg, +.leaflet-pane > canvas, +.leaflet-zoom-box, +.leaflet-image-layer, +.leaflet-layer { + position: absolute; + left: 0; + top: 0; + } +.leaflet-container { + overflow: hidden; + } +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + -webkit-user-drag: none; + } +/* Prevents IE11 from highlighting tiles in blue */ +.leaflet-tile::-moz-selection { + background: transparent; +} +.leaflet-tile::selection { + background: transparent; +} +/* Safari renders non-retina tile on retina better with this, but Chrome is worse */ +.leaflet-safari .leaflet-tile { + image-rendering: -webkit-optimize-contrast; + } +/* hack that prevents hw layers "stretching" when loading new tiles */ +.leaflet-safari .leaflet-tile-container { + width: 1600px; + height: 1600px; + -webkit-transform-origin: 0 0; + } +.leaflet-marker-icon, +.leaflet-marker-shadow { + display: block; + } +/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ +/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ +.leaflet-container .leaflet-overlay-pane svg { + max-width: none !important; + max-height: none !important; + } +.leaflet-container .leaflet-marker-pane img, +.leaflet-container .leaflet-shadow-pane img, +.leaflet-container .leaflet-tile-pane img, +.leaflet-container img.leaflet-image-layer, +.leaflet-container .leaflet-tile { + max-width: none !important; + max-height: none !important; + width: auto; + padding: 0; + } + +.leaflet-container img.leaflet-tile { + /* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */ + mix-blend-mode: plus-lighter; +} + +.leaflet-container.leaflet-touch-zoom { + touch-action: pan-x pan-y; + } +.leaflet-container.leaflet-touch-drag { + /* Fallback for FF which doesn't support pinch-zoom */ + touch-action: none; + touch-action: pinch-zoom; +} +.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { + touch-action: none; +} +.leaflet-container { + -webkit-tap-highlight-color: transparent; +} +.leaflet-container a { + -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4); +} +.leaflet-tile { + -webkit-filter: inherit; + filter: inherit; + visibility: hidden; + } +.leaflet-tile-loaded { + visibility: inherit; + } +.leaflet-zoom-box { + width: 0; + height: 0; + -moz-box-sizing: border-box; + box-sizing: border-box; + z-index: 800; + } +/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ +.leaflet-overlay-pane svg { + -moz-user-select: none; + } + +.leaflet-pane { z-index: 400; } + +.leaflet-tile-pane { z-index: 200; } +.leaflet-overlay-pane { z-index: 400; } +.leaflet-shadow-pane { z-index: 500; } +.leaflet-marker-pane { z-index: 600; } +.leaflet-tooltip-pane { z-index: 650; } +.leaflet-popup-pane { z-index: 700; } + +.leaflet-map-pane canvas { z-index: 100; } +.leaflet-map-pane svg { z-index: 200; } + +.leaflet-vml-shape { + width: 1px; + height: 1px; + } +.lvml { + behavior: url(#default#VML); + display: inline-block; + position: absolute; + } + + +/* control positioning */ + +.leaflet-control { + position: relative; + z-index: 800; + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } +.leaflet-top, +.leaflet-bottom { + position: absolute; + z-index: 1000; + pointer-events: none; + } +.leaflet-top { + top: 0; + } +.leaflet-right { + right: 0; + } +.leaflet-bottom { + bottom: 0; + } +.leaflet-left { + left: 0; + } +.leaflet-control { + float: left; + clear: both; + } +.leaflet-right .leaflet-control { + float: right; + } +.leaflet-top .leaflet-control { + margin-top: 10px; + } +.leaflet-bottom .leaflet-control { + margin-bottom: 10px; + } +.leaflet-left .leaflet-control { + margin-left: 10px; + } +.leaflet-right .leaflet-control { + margin-right: 10px; + } + + +/* zoom and fade animations */ + +.leaflet-fade-anim .leaflet-popup { + opacity: 0; + -webkit-transition: opacity 0.2s linear; + transition: opacity 0.2s linear; + } +.leaflet-fade-anim .leaflet-map-pane .leaflet-popup { + opacity: 1; + } +.leaflet-zoom-animated { + -webkit-transform-origin: 0 0; + transform-origin: 0 0; + } +svg.leaflet-zoom-animated { + will-change: transform; +} + +.leaflet-zoom-anim .leaflet-zoom-animated { + -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); + transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); + transition: transform 0.25s cubic-bezier(0,0,0.25,1); + transition: transform 0.25s cubic-bezier(0,0,0.25,1), -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); + } +.leaflet-zoom-anim .leaflet-tile, +.leaflet-pan-anim .leaflet-tile { + -webkit-transition: none; + transition: none; + } + +.leaflet-zoom-anim .leaflet-zoom-hide { + visibility: hidden; + } + + +/* cursors */ + +.leaflet-interactive { + cursor: pointer; + } +.leaflet-grab { + cursor: -webkit-grab; + cursor: grab; + } +.leaflet-crosshair, +.leaflet-crosshair .leaflet-interactive { + cursor: crosshair; + } +.leaflet-popup-pane, +.leaflet-control { + cursor: auto; + } +.leaflet-dragging .leaflet-grab, +.leaflet-dragging .leaflet-grab .leaflet-interactive, +.leaflet-dragging .leaflet-marker-draggable { + cursor: move; + cursor: -webkit-grabbing; + cursor: grabbing; + } + +/* marker & overlays interactivity */ +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-image-layer, +.leaflet-pane > svg path, +.leaflet-tile-container { + pointer-events: none; + } + +.leaflet-marker-icon.leaflet-interactive, +.leaflet-image-layer.leaflet-interactive, +.leaflet-pane > svg path.leaflet-interactive, +svg.leaflet-image-layer.leaflet-interactive path { + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } + +/* visual tweaks */ + +.leaflet-container { + background: #ddd; + outline-offset: 1px; + } +.leaflet-container a { + color: #0078A8; + } +.leaflet-zoom-box { + border: 2px dotted #38f; + background: rgba(255,255,255,0.5); + } + + +/* general typography */ +.leaflet-container { + font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; + font-size: 12px; + font-size: 0.75rem; + line-height: 1.5; + } + + +/* general toolbar styles */ + +.leaflet-bar { + box-shadow: 0 1px 5px rgba(0,0,0,0.65); + border-radius: 4px; + } +.leaflet-bar a { + background-color: #fff; + border-bottom: 1px solid #ccc; + width: 26px; + height: 26px; + line-height: 26px; + display: block; + text-align: center; + text-decoration: none; + color: black; + } +.leaflet-bar a, +.leaflet-control-layers-toggle { + background-position: 50% 50%; + background-repeat: no-repeat; + display: block; + } +.leaflet-bar a:hover, +.leaflet-bar a:focus { + background-color: #f4f4f4; + } +.leaflet-bar a:first-child { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } +.leaflet-bar a:last-child { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-bottom: none; + } +.leaflet-bar a.leaflet-disabled { + cursor: default; + background-color: #f4f4f4; + color: #bbb; + } + +.leaflet-touch .leaflet-bar a { + width: 30px; + height: 30px; + line-height: 30px; + } +.leaflet-touch .leaflet-bar a:first-child { + border-top-left-radius: 2px; + border-top-right-radius: 2px; + } +.leaflet-touch .leaflet-bar a:last-child { + border-bottom-left-radius: 2px; + border-bottom-right-radius: 2px; + } + +/* zoom control */ + +.leaflet-control-zoom-in, +.leaflet-control-zoom-out { + font: bold 18px 'Lucida Console', Monaco, monospace; + text-indent: 1px; + } + +.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { + font-size: 22px; + } + + +/* layers control */ + +.leaflet-control-layers { + box-shadow: 0 1px 5px rgba(0,0,0,0.4); + background: #fff; + border-radius: 5px; + } +.leaflet-control-layers-toggle { + background-image: url(images/layers.png); + width: 36px; + height: 36px; + } +.leaflet-retina .leaflet-control-layers-toggle { + background-image: url(images/layers-2x.png); + background-size: 26px 26px; + } +.leaflet-touch .leaflet-control-layers-toggle { + width: 44px; + height: 44px; + } +.leaflet-control-layers .leaflet-control-layers-list, +.leaflet-control-layers-expanded .leaflet-control-layers-toggle { + display: none; + } +.leaflet-control-layers-expanded .leaflet-control-layers-list { + display: block; + position: relative; + } +.leaflet-control-layers-expanded { + padding: 6px 10px 6px 6px; + color: #333; + background: #fff; + } +.leaflet-control-layers-scrollbar { + overflow-y: scroll; + overflow-x: hidden; + padding-right: 5px; + } +.leaflet-control-layers-selector { + margin-top: 2px; + position: relative; + top: 1px; + } +.leaflet-control-layers label { + display: block; + font-size: 13px; + font-size: 1.08333em; + } +.leaflet-control-layers-separator { + height: 0; + border-top: 1px solid #ddd; + margin: 5px -10px 5px -6px; + } + +/* Default icon URLs */ +.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */ + background-image: url(images/marker-icon.png); + } + + +/* attribution and scale controls */ + +.leaflet-container .leaflet-control-attribution { + background: #fff; + background: rgba(255, 255, 255, 0.8); + margin: 0; + } +.leaflet-control-attribution, +.leaflet-control-scale-line { + padding: 0 5px; + color: #333; + line-height: 1.4; + } +.leaflet-control-attribution a { + text-decoration: none; + } +.leaflet-control-attribution a:hover, +.leaflet-control-attribution a:focus { + text-decoration: underline; + } +.leaflet-attribution-flag { + display: inline !important; + vertical-align: baseline !important; + width: 1em; + height: 0.6669em; + } +.leaflet-left .leaflet-control-scale { + margin-left: 5px; + } +.leaflet-bottom .leaflet-control-scale { + margin-bottom: 5px; + } +.leaflet-control-scale-line { + border: 2px solid #777; + border-top: none; + line-height: 1.1; + padding: 2px 5px 1px; + white-space: nowrap; + -moz-box-sizing: border-box; + box-sizing: border-box; + background: rgba(255, 255, 255, 0.8); + text-shadow: 1px 1px #fff; + } +.leaflet-control-scale-line:not(:first-child) { + border-top: 2px solid #777; + border-bottom: none; + margin-top: -2px; + } +.leaflet-control-scale-line:not(:first-child):not(:last-child) { + border-bottom: 2px solid #777; + } + +.leaflet-touch .leaflet-control-attribution, +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + box-shadow: none; + } +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + border: 2px solid rgba(0,0,0,0.2); + background-clip: padding-box; + } + + +/* popup */ + +.leaflet-popup { + position: absolute; + text-align: center; + margin-bottom: 20px; + } +.leaflet-popup-content-wrapper { + padding: 1px; + text-align: left; + border-radius: 12px; + } +.leaflet-popup-content { + margin: 13px 24px 13px 20px; + line-height: 1.3; + font-size: 13px; + font-size: 1.08333em; + min-height: 1px; + } +.leaflet-popup-content p { + margin: 17px 0; + margin: 1.3em 0; + } +.leaflet-popup-tip-container { + width: 40px; + height: 20px; + position: absolute; + left: 50%; + margin-top: -1px; + margin-left: -20px; + overflow: hidden; + pointer-events: none; + } +.leaflet-popup-tip { + width: 17px; + height: 17px; + padding: 1px; + + margin: -10px auto 0; + pointer-events: auto; + + -webkit-transform: rotate(45deg); + transform: rotate(45deg); + } +.leaflet-popup-content-wrapper, +.leaflet-popup-tip { + background: white; + color: #333; + box-shadow: 0 3px 14px rgba(0,0,0,0.4); + } +.leaflet-container a.leaflet-popup-close-button { + position: absolute; + top: 0; + right: 0; + border: none; + text-align: center; + width: 24px; + height: 24px; + font: 16px/24px Tahoma, Verdana, sans-serif; + color: #757575; + text-decoration: none; + background: transparent; + } +.leaflet-container a.leaflet-popup-close-button:hover, +.leaflet-container a.leaflet-popup-close-button:focus { + color: #585858; + } +.leaflet-popup-scrolled { + overflow: auto; + } + +.leaflet-oldie .leaflet-popup-content-wrapper { + -ms-zoom: 1; + } +.leaflet-oldie .leaflet-popup-tip { + width: 24px; + margin: 0 auto; + + -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; + filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); + } + +.leaflet-oldie .leaflet-control-zoom, +.leaflet-oldie .leaflet-control-layers, +.leaflet-oldie .leaflet-popup-content-wrapper, +.leaflet-oldie .leaflet-popup-tip { + border: 1px solid #999; + } + + +/* div icon */ + +.leaflet-div-icon { + background: #fff; + border: 1px solid #666; + } + + +/* Tooltip */ +/* Base styles for the element that has a tooltip */ +.leaflet-tooltip { + position: absolute; + padding: 6px; + background-color: #fff; + border: 1px solid #fff; + border-radius: 3px; + color: #222; + white-space: nowrap; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; + box-shadow: 0 1px 3px rgba(0,0,0,0.4); + } +.leaflet-tooltip.leaflet-interactive { + cursor: pointer; + pointer-events: auto; + } +.leaflet-tooltip-top:before, +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + position: absolute; + pointer-events: none; + border: 6px solid transparent; + background: transparent; + content: ""; + } + +/* Directions */ + +.leaflet-tooltip-bottom { + margin-top: 6px; +} +.leaflet-tooltip-top { + margin-top: -6px; +} +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-top:before { + left: 50%; + margin-left: -6px; + } +.leaflet-tooltip-top:before { + bottom: 0; + margin-bottom: -12px; + border-top-color: #fff; + } +.leaflet-tooltip-bottom:before { + top: 0; + margin-top: -12px; + margin-left: -6px; + border-bottom-color: #fff; + } +.leaflet-tooltip-left { + margin-left: -6px; +} +.leaflet-tooltip-right { + margin-left: 6px; +} +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + top: 50%; + margin-top: -6px; + } +.leaflet-tooltip-left:before { + right: 0; + margin-right: -12px; + border-left-color: #fff; + } +.leaflet-tooltip-right:before { + left: 0; + margin-left: -12px; + border-right-color: #fff; + } + +/* Printing */ + +@media print { + /* Prevent printers from removing background-images of controls. */ + .leaflet-control { + -webkit-print-color-adjust: exact; + color-adjust: exact; + print-color-adjust: exact; + } + } +.leaflet-control-zoom{background:none repeat scroll 0 0 rgba(0,0,0,0.25);border-radius:7px 7px 7px 7px;padding:5px}.leaflet-control-zoom a{background-color:rgba(255,255,255,0.75);border-radius:4px 4px 4px 4px}.leaflet-popup-content-wrapper{border-radius:20px}@media print{.leaflet-control-zoom{display:none}}.leaflet-popup-content-wrapper{border-radius:12px}.leaflet-container a.leaflet-popup-hide-button{position:absolute;top:30px;right:10px;cursor:pointer}.leaflet-popup-content{margin:0;padding:1.5em 1em 1em 1em}.title-preview .leaflet-popup-content{padding-top:1em}.cl-marker-icon{background-color:hsla(249,100%,85%,0.6);border:1px solid hsla(249,100%,80%,0.6)}.bd-can-hover .cl-marker-icon:hover,.cl-marker-icon-visited{background-color:hsla(300,69%,83%,0.6);border:1px solid hsla(300,69%,78%,0.6)}.cl-marker-icon.fav,.cl-marker-icon-visited.fav{border:1px solid #888888;font-weight:bolder}.cl-marker-icon,.cl-marker-icon-visited{border-radius:100px;text-align:center}.cl-marker-icon .fav,.cl-marker-icon-visited .fav,.bd-can-hover .cl-marker-icon .fav:hover,.bd-can-hover .cl-marker-icon-visited .fav:hover{background-color:#ffd700;border-color:#daa520}.cl-marker-icon.banished,.cl-marker-icon-visited.banished{display:none}.show-banished .cl-marker-icon.banished,.show-banished .cl-marker-icon-visited.banished,.fav .cl-marker-icon.banished,.fav .cl-marker-icon-visited.banished{display:block;border-color:#a00;color:#888}table{border-collapse:collapse;border-spacing:0}.screen-reader-text{position:absolute;left:-10000px;top:auto;width:1px;height:1px;overflow:hidden;-moz-user-select:none;-webkit-user-select:none;-ms-user-select:none;user-select:none} diff --git a/sites/craigslist/static/css/main.css b/sites/craigslist/static/css/main.css new file mode 100644 index 00000000..4c8eb061 --- /dev/null +++ b/sites/craigslist/static/css/main.css @@ -0,0 +1,2977 @@ +:root { + --link: #0000ee; + --visited: #551a8b; + --text: #111; + --soft: #f4f4f4; + --panel: #eee; + --border: #ccc; + --light-border: #ddd; +} + +* { + box-sizing: border-box; +} + +html { + background: #fff; +} + +body { + margin: 0; + background: #fff; + color: var(--text); + font-family: Arial, Helvetica, sans-serif; + font-size: 14px; + line-height: 1.25; +} + +a { + color: var(--link); + text-decoration: none; +} + +a:visited { + color: var(--visited); +} + +a:hover { + text-decoration: underline; +} + +button, +input, +select, +textarea, +.buttonish { + font: inherit; +} + +button, +.buttonish { + display: inline-block; + border: 1px solid #999; + border-radius: 3px; + background: #f7f7f7; + color: #000; + padding: 4px 9px; + cursor: pointer; +} + +input, +select, +textarea { + border: 1px solid #aaa; + background: #fff; + padding: 4px 6px; +} + +ul { + margin-top: 0; +} + +.plain-button { + border: 0; + background: transparent; + color: var(--link); + padding: 0; +} + +.global-header { + width: 100%; + min-height: 62px; + display: grid; + grid-template-columns: auto 190px minmax(300px, 1fr) auto; + align-items: center; + gap: 12px; + padding: 8px 12px; + border-bottom: 1px solid var(--border); + background: #eee; +} + +.header-logo { + display: flex; + align-items: center; + gap: 6px; + color: var(--visited); + font-size: 18px; + white-space: nowrap; +} + +.header-logo span:first-child { + display: grid; + width: 34px; + height: 34px; + place-items: center; + border: 2px solid var(--visited); + border-radius: 50%; + font-size: 24px; + line-height: 1; +} + +.header-logo:visited { + color: var(--visited); +} + +.breadcrumbs { + list-style: none; + margin: 0; + padding: 0; + font-size: 20px; + font-weight: bold; +} + +.site-search { + display: grid; + grid-template-columns: minmax(220px, 1fr) 210px auto; + gap: 8px; + align-items: center; +} + +.site-search input, +.site-search select { + width: 100%; + height: 38px; + font-size: 16px; +} + +.site-search button { + height: 38px; + padding: 0 14px; + font-size: 16px; +} + +.userlinks { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 18px; + white-space: nowrap; +} + +.header-action { + display: grid; + min-width: 42px; + justify-items: center; + color: #000; + line-height: 1; + text-align: center; +} + +.header-action small { + margin-top: 2px; + color: #000; + font-size: 12px; +} + +.header-action:visited { + color: #000; +} + +.post-action span, +.account-action span, +.fave-action span { + position: relative; + display: block; + width: 30px; + height: 30px; +} + +.post-action span::before { + content: ""; + position: absolute; + left: 4px; + top: 7px; + width: 18px; + height: 18px; + border: 3px solid #008a00; + border-radius: 2px; +} + +.post-action span::after { + content: ""; + position: absolute; + left: 17px; + top: 1px; + width: 5px; + height: 30px; + background: #008a00; + transform: rotate(45deg); +} + +.account-action span::before { + content: ""; + position: absolute; + left: 10px; + top: 2px; + width: 10px; + height: 10px; + border: 2px solid #111; + border-radius: 50%; +} + +.account-action span::after { + content: ""; + position: absolute; + left: 5px; + top: 17px; + width: 20px; + height: 10px; + border: 2px solid #111; + border-bottom: 0; + border-radius: 10px 10px 0 0; +} + +.fave-action span::before { + content: "\2605"; + position: absolute; + left: 0; + top: -3px; + color: #f0c800; + font-size: 34px; + line-height: 1; +} + +.home-page .site-search { + display: none; +} + +.home-page .global-header { + display: none; +} + +.page-container { + width: min(1260px, 100%); + margin: 0 auto; + padding: 10px 12px 0; +} + +.home-page .page-container { + width: 100%; + max-width: none; + margin: 0; + padding: 0; +} + +.flash-wrap { + width: min(1260px, 100%); + margin: 8px auto 0; + padding: 0 12px; +} + +.flash { + border: 1px solid #ccc; + background: #ffffe5; + padding: 7px 9px; + margin-bottom: 6px; +} + +.flash.error { + background: #ffecec; + border-color: #d99; +} + +.flash.success { + background: #eefbea; + border-color: #9c9; +} + +/* Craigslist home page shell */ +.homepage-content { + display: grid; + width: 960px; + max-width: 100%; + margin: 0 auto; + grid-template-columns: 199px minmax(0, 608px) 113px; + grid-template-rows: 44px auto; + grid-template-areas: + "left topban right" + "left center right"; + column-gap: 20px; + row-gap: 19px; + align-items: start; + font-size: 14px; +} + +#topban { + grid-area: topban; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + min-height: 44px; + margin: 0; + padding: 0 0 0 20px; + background: var(--panel); + border-bottom: 1px solid var(--border); +} + +.regular-area { + display: flex; + align-items: center; + gap: 9px; +} + +.pin-icon { + position: relative; + display: inline-block; + width: 16px; + height: 16px; + flex: 0 0 16px; + margin-left: 1px; + background: var(--link); + border-radius: 50% 50% 50% 0; + transform: rotate(-45deg); +} + +.pin-icon::after { + content: ""; + position: absolute; + left: 5px; + top: 5px; + width: 6px; + height: 6px; + border-radius: 50%; + background: #fff; +} + +.regular-area .area { + margin: 0; + color: var(--link); + font-family: Georgia, "Times New Roman", serif; + font-size: 23px; + font-weight: normal; +} + +.sublinks { + display: inline-flex; + gap: 6px; + list-style: none; + margin: 0; + padding: 0; + font-size: 13px; +} + +.sublinks a { + display: block; + background: #fff; + padding: 4px 6px; +} + +.home-actions { + display: flex; + gap: 17px; + align-items: center; + list-style: none; + margin: 0; + padding: 2px 15px 0 0; + font-size: 10px; + text-align: center; +} + +.home-actions a, +.home-actions a:visited { + display: flex; + flex-direction: column; + align-items: center; + color: #000; +} + +.action-symbol { + position: relative; + display: block; + height: 22px; + width: 22px; + font-size: 22px; + line-height: 1; +} + +.action-symbol.star::before { + content: "\2605"; + color: #ffd900; + font-family: Arial, Helvetica, sans-serif; + font-size: 24px; + line-height: 22px; +} + +.action-symbol.post-icon::before { + content: ""; + position: absolute; + left: 4px; + top: 5px; + width: 12px; + height: 12px; + border: 2px solid #008a00; + border-radius: 2px; +} + +.action-symbol.post-icon::after { + content: ""; + position: absolute; + left: 11px; + top: 2px; + width: 3px; + height: 17px; + background: #008a00; + transform: rotate(45deg); + transform-origin: center; +} + +.action-symbol.post-icon { + color: #008a00; +} + +.action-symbol.acct-icon::before { + content: ""; + position: absolute; + left: 7px; + top: 2px; + width: 9px; + height: 9px; + border: 2px solid #111; + border-radius: 50%; +} + +.action-symbol.acct-icon::after { + content: ""; + position: absolute; + left: 3px; + top: 14px; + width: 16px; + height: 8px; + border: 2px solid #111; + border-bottom: 0; + border-radius: 18px 18px 0 0; +} + +.action-symbol.acct-icon { + color: #111; +} + +.leftbar { + grid-area: left; + padding: 6px 7px 15px; + border-right: 1px solid var(--border); + background: #f7f7f7; +} + +#logo { + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + margin: 0 0 16px; + color: var(--visited); + font-family: Georgia, "Times New Roman", serif; + font-size: 30px; + font-weight: normal; + text-align: center; +} + +.peace-mark { + font-family: Arial, Helvetica, sans-serif; + font-size: 29px; + line-height: 1; +} + +#postlks, +.leftlinks, +.menu, +.col ul { + list-style: none; + padding: 0; +} + +#postlks { + width: 112px; + margin: 0 auto 16px; + padding: 5px 0; + background: #fff; + text-align: center; + font-size: 16px; +} + +#postlks a, +#postlks a:visited { + color: #008a00; +} + +.home-search { + position: relative; + display: grid; + gap: 5px; + width: 186px; + margin: 0 auto 59px; +} + +.home-search::before { + content: ""; + position: absolute; + left: 7px; + top: 5px; + width: 9px; + height: 9px; + border: 2px solid #aaa; + border-radius: 50%; + pointer-events: none; +} + +.home-search::after { + content: ""; + position: absolute; + left: 17px; + top: 16px; + width: 9px; + height: 2px; + background: #aaa; + transform: rotate(45deg); + transform-origin: left center; + pointer-events: none; +} + +.home-search input, +.home-search select, +.home-search button { + width: 100%; +} + +.home-search input { + height: 30px; + padding-left: 25px; + font-size: 15px; +} + +.home-search select, +.home-search button { + display: none; +} + +#calban { + margin: 0; + padding: 0 0 2px; + background: var(--panel); + border: 0; + text-align: center; + font-size: 13px; +} + +.cal { + width: 155px; + margin: 0 auto; + border-collapse: collapse; + background: #fff; + border: 1px solid var(--border); + text-align: center; + font-size: 13px; +} + +.cal th, +.cal td { + width: 22px; + padding: 2px 1px; + border: 1px solid var(--border); +} + +.cal .days th { + color: #555; + font-weight: normal; +} + +.cal .today { + background: #ffffcc; + font-weight: bold; +} + +.leftlinks { + margin: 56px 0 0; + text-align: center; + font-size: 14px; +} + +.leftlinks li { + margin: 9px 0; +} + +.charity-links { + margin-top: 44px; +} + +.charity-icons { + margin-top: 10px !important; + font-size: 12px; + letter-spacing: 1px; +} + +#center { + grid-area: center; + display: grid; + grid-template-columns: minmax(180px, .95fr) minmax(225px, 1.05fr) minmax(170px, .9fr); + gap: 25px; +} + +.center-col { + display: flex; + flex-direction: column; + gap: 17px; +} + +.col { + min-height: 0; +} + +.ban { + margin: 0 0 4px; + padding: 4px 4px; + background: var(--panel); + border: 1px solid var(--border); + color: #000; + font-size: 16px; + line-height: 1.1; + text-align: center; +} + +.ban a, +.ban a:visited { + color: var(--link); + font-weight: bold; +} + +.col ul { + margin: 0; + font-size: 14px; + line-height: 1.15; +} + +.col li { + margin: 0; + padding: 0 0 1px; + border-bottom: 1px solid #ddd; + white-space: normal; +} + +.two-col { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 22px; +} + +.community-stack .two-col { + column-gap: 12px; +} + +.three-col { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + column-gap: 22px; +} + +.jobs-stack { + min-width: 0; +} + +.jobs-stack .two-col { + grid-template-columns: 1fr; +} + +.jobs-stack .gigs-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 18px; +} + +.resume-col .ban { + margin-bottom: 0; +} + +.extra-cats { + margin-top: 0 !important; +} + +#rightbar { + grid-area: right; + padding: 15px 5px; + border-left: 1px solid var(--border); + background: #f7f7f7; + font-size: 12px; + text-align: center; +} + +#langlinks select { + width: 100%; + margin-bottom: 12px; + font-size: 12px; +} + +.menu { + margin: 0 0 11px; +} + +.menu h5.ban { + font-size: 13px; + text-align: center; +} + +.menu ul { + list-style: none; + margin: 0; + padding: 0 0 0 2px; +} + +.menu li li { + margin: 0; + line-height: 1.18; + padding: 0; +} + +.quick-world { + border-top: 2px solid #ddd; + font-weight: bold; +} + +.quick-world li { + border-bottom: 2px solid #ddd; + padding: 5px 0; +} + +/* Modern Craigslist-style search result pages */ +.search-page .global-header, +.search-page .footer { + display: none; +} + +.search-page .page-container { + width: 100%; + max-width: none; + margin: 0; + padding: 0; +} + +.search-shell { + width: 100%; + min-height: 100vh; + color: #333; + background: #fff; + font-size: 14px; +} + +.cl-search-topbar { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + min-height: 45px; + padding: 5px 8px; + border-bottom: 1px solid #ccc; + background: #eee; +} + +.cl-mini-logo { + display: flex; + align-items: center; + gap: 6px; + color: var(--visited); + font-size: 17px; + white-space: nowrap; +} + +.cl-mini-logo span:first-child { + display: grid; + width: 25px; + height: 25px; + place-items: center; + border: 2px solid var(--visited); + border-radius: 50%; + font-size: 18px; + line-height: 1; +} + +.top-selects { + display: grid; + grid-template-columns: 190px 190px 132px minmax(220px, 300px); + gap: 8px; + min-width: 0; +} + +.top-selects select, +.main-search-box input, +.sort-form select, +.modern-filters input, +.modern-filters select { + min-width: 0; + border: 1px solid #bbb; + border-radius: 4px; + background: #fff; +} + +.top-selects select { + height: 34px; + padding: 2px 28px 2px 9px; + font-size: 15px; +} + +.top-actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; +} + +.top-icon { + display: grid; + min-width: 32px; + justify-items: center; + color: #000; + line-height: 1; +} + +.top-icon:visited { + color: #000; +} + +.top-icon small { + margin-top: 2px; + font-size: 12px; +} + +.top-icon span { + position: relative; + display: block; + width: 25px; + height: 25px; +} + +.top-post span { + border: 3px solid #008a00; + border-radius: 2px; +} + +.top-post span::after { + content: ""; + position: absolute; + right: -5px; + top: -8px; + width: 5px; + height: 29px; + background: #008a00; + transform: rotate(45deg); +} + +.top-account span::before { + content: ""; + position: absolute; + left: 9px; + top: 2px; + width: 10px; + height: 10px; + border: 2px solid #111; + border-radius: 50%; +} + +.top-account span::after { + content: ""; + position: absolute; + left: 4px; + top: 16px; + width: 20px; + height: 10px; + border: 2px solid #111; + border-bottom: 0; + border-radius: 11px 11px 0 0; +} + +.search-command { + display: grid; + grid-template-columns: 192px minmax(260px, 1fr) auto; + gap: 8px; + align-items: center; + padding: 7px 8px; +} + +.search-back { + overflow: hidden; + color: var(--link); + font-size: 21px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.main-search-box { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) 40px; + gap: 8px; +} + +.main-search-box input { + height: 40px; + padding-left: 37px; + font-size: 18px; +} + +.main-search-box button { + position: relative; + overflow: hidden; + width: 40px; + height: 40px; + border-color: #bbb; + color: transparent; +} + +.main-search-box button::before { + content: ""; + position: absolute; + left: 11px; + top: 10px; + width: 13px; + height: 13px; + border: 3px solid var(--link); + border-radius: 50%; +} + +.main-search-box button::after { + content: ""; + position: absolute; + left: 24px; + top: 24px; + width: 12px; + height: 3px; + background: var(--link); + transform: rotate(45deg); +} + +.search-glass { + position: absolute; + left: 11px; + top: 10px; + width: 14px; + height: 14px; + border: 3px solid #bbb; + border-radius: 50%; + z-index: 1; +} + +.search-glass::after { + content: ""; + position: absolute; + left: 15px; + top: 15px; + width: 13px; + height: 3px; + background: #bbb; + transform: rotate(45deg); +} + +.result-count { + color: #333; + font-size: 14px; + white-space: nowrap; +} + +.search-tools { + display: grid; + grid-template-columns: 192px 118px 118px minmax(300px, 1fr); + gap: 10px; + align-items: center; + padding: 6px 8px 8px; +} + +.owner-tabs, +.view-buttons, +.quick-filter-buttons { + display: flex; + gap: 0; +} + +.owner-tabs button, +.view-buttons a, +.quick-filter-buttons button { + display: grid; + place-items: center; + height: 34px; + border: 1px solid #ccc; + border-radius: 0; + background: #fff; + color: #333; + font-size: 14.4px; + text-decoration: none; +} + +.owner-tabs button { + min-width: 62px; +} + +.view-buttons a { + width: 34px; +} + +.owner-tabs button:first-child, +.view-buttons a:first-child, +.quick-filter-buttons button:first-child { + border-radius: 4px 0 0 4px; +} + +.owner-tabs button:last-child, +.view-buttons a:last-child, +.quick-filter-buttons button:last-child { + border-radius: 0 4px 4px 0; +} + +.owner-tabs .selected, +.view-buttons .selected { + border-color: var(--link); + background: #dbe5ff; + color: var(--link); +} + +.sort-form select { + width: 118px; + height: 34px; + font-size: 14.4px; +} + +.quick-filter-buttons { + gap: 8px; +} + +.quick-filter-buttons button { + border-radius: 4px; + padding: 0 11px; + font-size: 14px; +} + +.modern-search-layout { + display: grid; + grid-template-columns: 192px minmax(0, 1fr); + gap: 0; + align-items: start; + padding: 0 0 24px 8px; +} + +.modern-filters { + position: sticky; + top: 0; + min-height: calc(100vh - 88px); + padding: 0 6px 12px; + border: 0; + background: #fff; + color: #444; +} + +.modern-filters form { + margin: 0; +} + +.modern-filters label { + display: block; + margin: 5px 0; + font-weight: normal; +} + +.modern-filters .check { + font-size: 13px; +} + +.modern-filters input[type="checkbox"] { + width: auto; + margin-right: 4px; +} + +.filter-heading { + margin-top: 16px !important; + color: #444; + font-size: 12px; + font-weight: bold !important; + text-transform: uppercase; +} + +.inline-inputs { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 4px; + align-items: center; +} + +.price-inputs { + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); +} + +.inline-inputs input { + width: 100%; + height: 24px; + padding: 2px 5px; +} + +.use-map { + display: block; + margin: 4px 0 12px; +} + +.price-histogram { + display: flex; + align-items: end; + gap: 2px; + height: 52px; + margin: 6px 0 12px 3px; + border-bottom: 2px solid #333; +} + +.price-histogram span { + display: block; + width: 9px; + background: #2374b7; +} + +.update-search { + margin-top: 10px; +} + +.modern-results { + min-width: 0; + padding: 0 8px; +} + +.result-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 344px)); + gap: 28px; + justify-content: start; + margin: 0; + padding: 0; + border: 0; + list-style: none; + background: transparent; +} + +.result-card { + position: relative; + display: block; + min-width: 0; + overflow: hidden; + border: 1px solid #ccc; + border-radius: 4px; + background: #fff; + box-shadow: 0 1px 5px rgba(0, 0, 0, .18); +} + +.card-photo { + position: relative; + display: block; + width: 100%; + height: 264px; + overflow: hidden; + background: #fff; +} + +.card-image-link { + display: block; + width: 100%; + height: 100%; +} + +.gallery-frame { + display: none; +} + +.gallery-frame.active { + display: block; +} + +.card-photo img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.card-photo .gallery-frame { + display: none; +} + +.card-photo .gallery-frame.active { + display: block; +} + +.card-photo .no-img { + display: grid; + width: 100%; + height: 100%; + place-items: center; + color: #777; + background: #eee; +} + +.card-price { + position: absolute; + left: 0; + top: 0; + z-index: 1; + padding: 2px 5px; + border-radius: 0 0 4px 0; + background: rgba(255, 255, 255, .9); + color: #008000; + font-size: 18px; +} + +.photo-dots { + display: flex; + justify-content: center; + gap: 12px; + height: 19px; + align-items: center; +} + +.photo-dots span, +.photo-dots button { + width: 4px; + height: 4px; + padding: 0; + border: 0; + border-radius: 50%; + background: #ccc; + cursor: pointer; +} + +.photo-dots .active, +.photo-dots button.active { + background: #008000; +} + +.card-gallery-nav { + position: absolute; + top: 50%; + z-index: 2; + display: none; + width: 28px; + height: 44px; + padding: 0; + border: 0; + border-radius: 0; + background: rgba(255, 255, 255, .78); + color: #333; + font-size: 32px; + line-height: 1; + transform: translateY(-50%); +} + +.card-gallery-nav.prev { + left: 0; +} + +.card-gallery-nav.next { + right: 0; +} + +.result-card:hover .card-gallery-nav, +.result-card:focus-within .card-gallery-nav { + display: block; +} + +.card-body { + position: relative; + min-height: 54px; + padding: 0 28px 5px 8px; +} + +.card-body .result-title { + display: block; + overflow: hidden; + margin-bottom: 2px; + font-size: 16px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.card-body .meta { + display: flex; + flex-wrap: wrap; + gap: 4px; + color: #666; + font-size: 12px; +} + +.card-actions { + position: absolute; + left: 7px; + right: 6px; + bottom: 3px; + display: flex; + justify-content: space-between; + pointer-events: none; +} + +.card-actions form { + pointer-events: auto; +} + +.view-list .result-list.result-grid { + display: block; +} + +.view-list .result-row.result-card { + display: grid; + grid-template-columns: 160px minmax(0, 1fr); + gap: 10px; + margin: 0; + border-radius: 0; + box-shadow: none; +} + +.view-list .card-photo { + width: 160px; + height: 118px; +} + +.view-list .photo-dots { + display: none; +} + +.view-list .card-body { + min-height: 118px; + padding: 12px 34px 12px 0; +} + +.view-list .card-body .result-title { + white-space: normal; +} + +.view-grid .result-list.result-grid { + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 18px; +} + +.view-grid .card-photo { + height: 185px; +} + +.view-grid .card-body { + min-height: 76px; +} + +.map-results-panel { + display: grid; + grid-template-columns: minmax(360px, 1fr) 280px; + min-height: 330px; + margin-bottom: 18px; + border: 1px solid #aaa; + background: #f5f5f5; +} + +.bay-map, +.detail-map { + position: relative; + overflow: hidden; + background: + radial-gradient(ellipse at 35% 35%, rgba(118, 178, 219, .58) 0 20%, transparent 21%), + radial-gradient(ellipse at 54% 48%, rgba(118, 178, 219, .65) 0 27%, transparent 28%), + linear-gradient(35deg, transparent 0 38%, rgba(255, 255, 255, .55) 39% 42%, transparent 43%), + linear-gradient(90deg, rgba(0, 0, 0, .06) 1px, transparent 1px), + linear-gradient(rgba(0, 0, 0, .06) 1px, transparent 1px), + #e7eadf; + background-size: auto, auto, auto, 32px 32px, 32px 32px, auto; +} + +.bay-map { + min-height: 330px; +} + +.map-water { + position: absolute; + left: 45%; + top: 38%; + color: rgba(35, 99, 145, .6); + font-size: 28px; + font-style: italic; +} + +.map-label { + position: absolute; + z-index: 1; + padding: 2px 5px; + border: 1px solid rgba(0, 0, 0, .12); + background: rgba(255, 255, 255, .72); + color: #555; + font-size: 12px; +} + +.map-label.sf { + left: 42%; + top: 43%; +} + +.map-label.oakland { + left: 61%; + top: 43%; +} + +.map-label.san-jose { + left: 68%; + top: 73%; +} + +.map-road { + position: absolute; + z-index: 1; + display: block; + height: 4px; + border-top: 1px solid rgba(180, 160, 100, .95); + border-bottom: 1px solid rgba(180, 160, 100, .95); + background: rgba(255, 255, 255, .72); + transform-origin: left center; +} + +.road-1 { + left: 30%; + top: 54%; + width: 48%; + transform: rotate(28deg); +} + +.road-2 { + left: 42%; + top: 36%; + width: 34%; + transform: rotate(-9deg); +} + +.map-pin { + position: absolute; + z-index: 3; + display: grid; + width: 25px; + height: 25px; + place-items: center; + border: 2px solid #fff; + border-radius: 50% 50% 50% 0; + background: #d42027; + color: #fff; + box-shadow: 0 1px 4px rgba(0, 0, 0, .35); + font-size: 11px; + font-weight: bold; + text-decoration: none; + transform: translate(-50%, -100%) rotate(-45deg); +} + +.map-pin span { + transform: rotate(45deg); +} + +.map-pin.single { + pointer-events: none; +} + +.map-result-index { + max-height: 330px; + margin: 0; + padding: 8px 10px 8px 28px; + overflow: auto; + border-left: 1px solid #bbb; + background: #fff; +} + +.map-result-index li { + margin-bottom: 9px; +} + +.map-result-index span { + display: block; + color: #666; + font-size: 12px; +} + +.view-map .result-list.result-grid { + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 18px; +} + +.view-map .card-photo { + height: 220px; +} + +.card-star, +.card-trash { + border: 0; + background: transparent; + color: #888; + padding: 0; + font-size: 21px; + line-height: 1; +} + +.card-trash { + font-size: 16px; +} + +/* Search pages */ +.search-layout { + display: grid; + grid-template-columns: 240px minmax(0, 1fr); + gap: 12px; + align-items: start; +} + +.filters { + border: 1px solid var(--border); + background: #f7f7f7; + padding: 10px; +} + +.filters h1 { + margin: 0 0 9px; + color: var(--visited); + font-size: 22px; +} + +.filters label, +.form-page label { + display: block; + margin: 8px 0 3px; + font-weight: bold; +} + +.filters input, +.filters select, +.form-page input, +.form-page select, +.form-page textarea { + width: 100%; +} + +.filters button, +.form-page button { + margin-top: 8px; +} + +.check { + font-weight: normal !important; +} + +.check input { + width: auto; +} + +.save-search-form { + margin-top: 16px; + padding-top: 10px; + border-top: 1px solid var(--border); +} + +.mini-cats { + margin-top: 16px; + column-count: 2; + column-gap: 12px; + font-size: 13px; +} + +.mini-cats h3 { + break-after: avoid; + margin: 0 0 3px; + padding: 3px 5px; + background: var(--panel); + border: 1px solid var(--border); + font-size: 13px; +} + +.mini-cats a { + display: block; + line-height: 1.45; +} + +.results { + min-width: 0; +} + +.results-bar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: baseline; + background: var(--panel); + border: 1px solid var(--border); + padding: 6px 9px; + margin-bottom: 0; +} + +.result-list { + list-style: none; + padding: 0; + margin: 0; + border-left: 1px solid var(--light-border); + border-right: 1px solid var(--light-border); + background: #fff; +} + +.result-row { + display: grid; + grid-template-columns: 118px minmax(0, 1fr); + gap: 10px; + border-bottom: 1px solid var(--light-border); + padding: 8px; +} + +.thumb { + display: block; + width: 108px; + height: 82px; + overflow: hidden; + border: 1px solid #ccc; + background: #eee; +} + +.thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.no-img, +.image-placeholder { + display: grid; + width: 100%; + height: 100%; + place-items: center; + color: #777; + font-size: 12px; +} + +.result-title-line { + display: flex; + align-items: baseline; + gap: 8px; +} + +.result-title { + font-size: 16px; +} + +.star { + padding: 1px 6px; + font-size: 12px; +} + +.meta { + display: flex; + flex-wrap: wrap; + gap: 9px; + margin-top: 3px; + color: #666; + font-size: 13px; +} + +.result-main p { + margin: 5px 0; + color: #333; +} + +.empty { + border: 1px solid var(--light-border); + background: #fff; + color: #666; + padding: 20px; + text-align: center; +} + +/* Posting detail */ +.detail-shell, +.narrow { + max-width: 1060px; + margin: 0 auto; +} + +.breadcrumb { + margin: 0 0 8px; + color: #666; + font-size: 13px; +} + +.posting { + border: 1px solid var(--border); + background: #fff; +} + +.posting-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 14px; + align-items: center; + padding: 8px 10px; + border-bottom: 1px solid var(--border); + background: var(--panel); +} + +.posting-title-line { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: baseline; +} + +.posting-title-line h1 { + margin: 0; + font-size: 22px; + font-weight: normal; +} + +.posting-price { + color: #008000; + font-size: 22px; +} + +.posting-hood { + color: #555; + font-size: 16px; +} + +.posting-meta { + display: flex; + flex-wrap: wrap; + gap: 9px; + margin-top: 3px; + color: #555; + font-size: 12px; +} + +.posting-actions { + display: flex; + gap: 6px; + align-items: center; +} + +.reply-button { + font-weight: bold; +} + +.posting-body { + display: grid; + grid-template-columns: minmax(360px, 700px) minmax(240px, 1fr); + gap: 12px; + padding: 12px 10px; +} + +.posting-image { + min-height: 300px; + border: 1px solid var(--light-border); + background: #f1f1f1; +} + +.posting-photo-stage { + position: relative; + overflow: hidden; + background: #f2f2f2; +} + +.posting-image img { + display: block; + width: 100%; + max-height: 560px; + object-fit: contain; + background: #f2f2f2; +} + +.posting-image .gallery-frame { + display: none; +} + +.posting-image .gallery-frame.active { + display: block; +} + +.gallery-arrow { + position: absolute; + top: 50%; + width: 42px; + height: 64px; + padding: 0; + border: 0; + border-radius: 0; + background: rgba(255, 255, 255, .8); + color: #333; + font-size: 44px; + line-height: 1; + transform: translateY(-50%); +} + +.gallery-arrow.prev { + left: 0; +} + +.gallery-arrow.next { + right: 0; +} + +.photo-strip { + display: flex; + justify-content: center; + gap: 11px; + padding: 8px 0 9px; +} + +.photo-strip span, +.photo-strip button { + width: 5px; + height: 5px; + padding: 0; + border: 0; + border-radius: 50%; + background: #ccc; + cursor: pointer; +} + +.photo-strip .active, +.photo-strip button.active { + background: #008a00; +} + +.facts { + display: grid; + grid-template-columns: 112px minmax(0, 1fr); + margin: 0; + border: 1px solid var(--light-border); + font-size: 13px; +} + +.facts dt, +.facts dd { + margin: 0; + padding: 5px 7px; + border-bottom: 1px solid #eee; +} + +.facts dt { + background: #f5f5f5; + color: #555; +} + +.detail-map { + position: relative; + min-height: 110px; + margin-top: 10px; + border: 1px solid var(--light-border); + color: #555; +} + +.detail-map .map-caption { + position: absolute; + left: 8px; + right: 8px; + bottom: 8px; + z-index: 2; + padding: 5px 7px; + border: 1px solid rgba(0, 0, 0, .15); + background: rgba(255, 255, 255, .82); + text-align: center; +} + +.detail-map .map-caption strong, +.detail-map .map-caption span { + display: block; +} + +.description { + max-width: 720px; + padding: 0 10px 15px; + line-height: 1.5; +} + +.description h2 { + margin: 4px 0 6px; + font-size: 16px; +} + +.nearby-results { + margin-top: 12px; + padding: 10px; + border: 1px solid var(--border); + background: #f7f7f7; +} + +.nearby-results h2 { + margin: 0 0 7px; + font-size: 16px; +} + +/* Forms and account pages */ +.form-page { + width: min(520px, 100%); + margin: 20px auto; + padding: 14px 16px 16px; + border: 1px solid var(--border); + background: #f5f5f5; +} + +.form-page.wide { + width: min(760px, 100%); +} + +.form-page h1 { + margin-top: 0; + margin-bottom: 12px; + color: var(--visited); + font-size: 24px; + font-weight: bold; +} + +.form-page textarea { + resize: vertical; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.form-back { + margin: 0 0 8px; + font-size: 13px; +} + +.reply-summary { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 10px; + color: #555; +} + +.reply-relay { + margin-bottom: 10px; + padding: 8px 10px; + border: 1px solid #d5d5d5; + background: #fff; + color: #444; + font-size: 13px; +} + +.account-shell { + max-width: 1060px; + margin: 0 auto; +} + +.account-tabs { + display: flex; + flex-wrap: wrap; + gap: 0; + margin: 0 0 12px; + border-bottom: 1px solid var(--border); +} + +.account-tabs a { + padding: 7px 12px; + border: 1px solid var(--border); + border-bottom: 0; + background: #eee; +} + +.account-tabs a.active { + background: #fff; + color: #000; + font-weight: bold; +} + +.account-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.panel { + padding: 13px; + border: 1px solid var(--border); + background: #fff; +} + +.panel h1, +.panel h2 { + margin-top: 0; + color: var(--visited); + font-size: 20px; +} + +.message-list { + list-style: none; + margin: 0; + padding: 0; +} + +.message-list li { + margin-bottom: 10px; + padding: 11px; + border: 1px solid var(--light-border); + background: #fff; +} + +.message-list li.inbound { + border-left: 4px solid var(--visited); +} + +.message-head { + display: flex; + justify-content: space-between; + gap: 10px; +} + +.saved-shell h1, +.messages-shell h1 { + margin: 0 0 12px; + color: var(--visited); + font-size: 24px; +} + +.compact-list { + list-style: none; + margin: 0; + padding: 0; +} + +.compact-list li { + padding: 5px 0; + border-bottom: 1px dotted #ccc; +} + +.date, +.muted, +.hint { + color: #666; +} + +.date { + display: inline-block; + min-width: 54px; +} + +.footer { + width: 100%; + margin: 25px auto 0; + padding: 12px 12px 9px; + border-top: 1px solid #ddd; + display: block; + color: #666; + background: var(--panel); + text-align: center; +} + +.footer-links { + display: flex; + justify-content: center; + gap: 22px; + margin-bottom: 12px; + font-size: 14px; +} + +.copyright { + color: #555; + font-size: 13px; +} + +.home-page .footer { + margin-top: 28px; +} + +.search-page .modern-filters { + min-height: calc(100vh - 88px); + padding: 0 6px 12px; + border: 0; + background: #fff; +} + +.search-page .result-list.result-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 344px)); + gap: 28px; + justify-content: start; + border: 0; + background: transparent; +} + +.search-page .result-row.result-card { + display: block; + gap: 0; + padding: 0; + border: 1px solid #ccc; + border-radius: 4px; + box-shadow: 0 1px 5px rgba(0, 0, 0, .18); +} + +/* Search-page fidelity pass: dimensions and icon font from craigslist's current assets. */ +.search-page .icon { + display: inline-block; + font-family: "icomoon" !important; + font-style: normal; + font-weight: normal; + line-height: 1; + text-transform: none; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.search-page .cl-search-topbar { + min-height: 45px; + padding: 3px 8px; +} + +.search-page .cl-mini-logo { + gap: 6px; + font-size: 17px; +} + +.search-page .cl-mini-logo .icon { + display: block; + width: 25px; + height: 25px; + border: 0; + border-radius: 0; + font-size: 25px; +} + +.search-page .cl-mini-logo .icon::before { + content: "\ed00"; +} + +.search-page .top-actions { + gap: 12px; +} + +.search-page .top-icon { + min-width: 32px; + line-height: 1; +} + +.search-page .top-icon .icon { + display: block; + width: 25px; + height: 25px; + border: 0; + border-radius: 0; + font-size: 25px; +} + +.search-page .top-icon .icon::before, +.search-page .top-icon .icon::after { + position: static; + display: inline; + width: auto; + height: auto; + border: 0; + background: transparent; + transform: none; +} + +.search-page .top-post .icon::before { + content: "\e916"; + color: #090; +} + +.search-page .top-account .icon::before { + content: "\eb09"; + color: #000; +} + +.search-page .top-icon small { + margin-top: 1px; + font-size: 12px; +} + +.search-page .top-selects select { + height: 34px; + padding: 2px 28px 2px 9px; + font-size: 15px; +} + +.search-page .search-command { + grid-template-columns: 192px minmax(260px, 1fr) auto; + gap: 8px; + min-height: 54px; + padding: 5px 8px; +} + +.search-title-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 26px; + align-items: center; + min-width: 0; +} + +.search-page .search-back { + font-size: 21px; +} + +.filter-toggle { + display: grid; + width: 26px; + height: 30px; + place-items: center; + border: 0; + background: transparent; + color: var(--link); + font-size: 18px; +} + +.filter-toggle .icon::before { + content: "\edc4"; +} + +.search-page .main-search-box { + grid-template-columns: minmax(0, 1fr) 38px 38px; + gap: 7px; +} + +.search-page .main-search-box input { + height: 34px; + padding: 3px 8px 3px 38px; + font-size: 16px; +} + +.search-page .search-glass { + left: 10px; + top: 6px; + width: auto; + height: auto; + border: 0; + border-radius: 0; + color: #bbb; + font-size: 23px; + z-index: 1; +} + +.search-page .search-glass::before { + content: "\e986"; +} + +.search-page .search-glass::after { + content: none; +} + +.search-page .main-search-box .cl-icon-button { + display: grid; + width: 38px; + height: 34px; + place-items: center; + padding: 0; + border: 1px solid #ccc; + border-radius: 4px; + background: #fff; + color: var(--link); +} + +.search-page .main-search-box .cl-icon-button::before, +.search-page .main-search-box .cl-icon-button::after { + content: none; +} + +.search-page .cl-exec-search .icon::before { + content: "\e986"; + font-size: 24px; +} + +.search-page .cl-save-search .icon::before { + content: "\ea58"; + font-size: 19px; +} + +.search-page .result-count { + justify-self: end; + font-size: 14px; +} + +.search-page .search-tools { + grid-template-columns: 192px 152px 128px minmax(300px, 1fr); + gap: 8px; + min-height: 52px; + padding: 0 8px 8px; +} + +.search-page .owner-tabs button { + min-width: 62px; +} + +.search-page .owner-tabs button, +.search-page .view-buttons a, +.search-page .quick-filter-buttons button, +.search-page .sort-form select { + height: 34px; + border-color: #ccc; + background: #fff; + color: #333; + font-size: 14.4px; +} + +.search-page .view-buttons a { + width: 38px; + font-size: 16px; +} + +.search-page .view-buttons .selected, +.search-page .owner-tabs .selected { + border: 2px solid var(--link); + background: #bacff2; + color: var(--link); +} + +.search-page .cl-search-view-mode-list .icon::before { + content: "\eec4"; +} + +.search-page .cl-search-view-mode-thumb .icon::before { + content: "\ec59"; +} + +.search-page .cl-search-view-mode-gallery .icon::before { + content: "\e93c"; +} + +.search-page .cl-search-view-mode-map .icon::before { + content: "\ea4a"; +} + +.search-page .sort-form { + margin-left: 36px; +} + +.search-page .sort-form select { + width: 128px; +} + +.search-page .quick-filter-buttons { + gap: 8px; + margin-left: 76px; +} + +.search-page .quick-filter-buttons button { + border-radius: 4px; + padding: 0 11px; +} + +.search-page .modern-search-layout { + grid-template-columns: 192px minmax(0, 1fr); + padding: 0 0 24px 8px; +} + +.search-page .modern-filters { + padding: 0 7px 12px 0; + color: #444; +} + +.search-page .modern-filters .check { + margin: 4px 0; + font-size: 14px; +} + +.search-page .modern-filters input[type="checkbox"] { + width: 13px; + height: 13px; + margin: 0 5px 0 0; + vertical-align: -1px; +} + +.search-page .filter-heading { + margin: 15px 0 5px !important; + font-size: 12px; + line-height: 1.1; + text-transform: uppercase; +} + +.search-page .inline-inputs { + gap: 4px; +} + +.search-page .inline-inputs input, +.search-page .modern-filters .full-input { + height: 24px; + padding: 2px 5px; + font-size: 14px; +} + +.search-page .price-inputs { + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); +} + +.search-page .price-histogram { + width: 178px; + height: 52px; + margin: 6px 0 12px 3px; +} + +.search-page .price-histogram span { + width: 9px; +} + +.filter-disclosure { + display: block; + width: 100%; + margin: 12px 0 0; + padding: 0; + border: 0; + background: transparent; + color: var(--link); + text-align: left; + font-size: 14px; +} + +.filter-disclosure span::before { + content: "\25b6"; + display: inline-block; + margin-right: 6px; + color: var(--link); + font-size: 10px; +} + +.search-page .modern-results { + padding: 0 8px; +} + +.search-page .result-list { + border: 0; + background: transparent; +} + +.search-page .result-list.result-grid { + grid-template-columns: repeat(auto-fill, 352px); + gap: 18px; +} + +.view-gallery .result-list.result-grid { + padding-top: 32px; +} + +.search-page .result-row.result-card { + width: 352px; + min-height: 345px; + overflow: hidden; +} + +.search-page .card-photo { + height: 264px; + background: #eee; +} + +.search-page .card-price { + padding: 2px 5px; + font-size: 16px; +} + +.search-page .photo-dots { + height: 18px; + gap: 13px; +} + +.search-page .photo-dots button { + width: 4px; + height: 4px; +} + +.search-page .card-body { + min-height: 62px; + padding: 0 28px 5px 8px; +} + +.search-page .card-body .result-title { + margin-bottom: 2px; + font-size: 16px; + line-height: 1.5; +} + +.search-page .card-body .meta { + gap: 4px; + margin-top: 0; + color: #666; + font-size: 14px; +} + +.icon-only { + display: inline-grid; + place-items: center; + padding: 0; + border: 0; + background: transparent; + color: #888; +} + +.card-star, +.card-trash, +.cl-favorite-button, +.cl-banish-button.icon-only { + width: 24px; + height: 24px; + border: 0; + background: transparent; + color: #888; + font-size: 16px; +} + +.card-star .icon::before, +.cl-favorite-button .icon::before { + content: "\ece0"; +} + +.card-trash .icon::before, +.cl-banish-button.icon-only .icon::before { + content: "\ebfd"; + color: #bbb; +} + +.view-list .list-results { + display: block; + padding-top: 32px; +} + +.view-list .cl-search-result { + margin: 0 0 16px; +} + +.view-list .result-node { + display: flex; + align-items: flex-start; + min-height: 21px; + overflow: hidden; + white-space: nowrap; +} + +.view-list .cl-favorite-button { + width: 32px; + height: 24px; + margin: -3px 0 0; + padding-right: 8px; +} + +.view-list .posting-title { + max-width: 46vw; + overflow: hidden; + padding-right: 4px; + font-size: 16px; + line-height: 1.15; + text-overflow: ellipsis; + white-space: nowrap; +} + +.view-list .list-meta { + color: #333; + font-size: 14px; + line-height: 1.15; +} + +.search-page .sep { + color: #666; +} + +.search-page .priceinfo { + color: #090; + font-weight: bold; +} + +.search-page .pic { + color: #f90; +} + +.cl-banish-text-button { + width: auto; + height: auto; + padding: 0; + border: 0; + background: transparent; + color: #777; + font-size: 14px; + line-height: 1.15; +} + +.view-thumb .thumb-results { + display: block; + padding-top: 32px; +} + +.view-thumb .cl-search-result { + height: 60px; + margin: 0 0 19px; + overflow: hidden; +} + +.view-thumb .thumb-node { + display: grid; + grid-template-columns: 64px minmax(0, 1fr); + align-items: start; +} + +.view-thumb .thumb-image { + display: flex; + width: 64px; + height: 60px; + padding: 3px; + align-items: flex-start; + justify-content: flex-start; + background: transparent; +} + +.view-thumb .thumb-image img, +.view-thumb .thumb-image .no-img { + width: 50px; + height: 50px; + margin: 4px 8px 0 0; + object-fit: cover; + font-size: 10px; +} + +.view-thumb .thumb-content { + min-width: 0; + padding-left: 14px; +} + +.view-thumb .thumb-location { + margin: 0 0 3px; + color: #333; + font-size: 16px; + line-height: 1.15; +} + +.view-thumb .thumb-title-line { + display: flex; + align-items: center; + height: 18px; + min-width: 0; +} + +.view-thumb .cl-favorite-button { + width: 28px; + height: 18px; + margin-right: 2px; +} + +.view-thumb .posting-title { + overflow: hidden; + font-size: 16px; + line-height: 1.15; + text-overflow: ellipsis; + white-space: nowrap; +} + +.view-thumb .thumb-meta { + display: flex; + align-items: center; + gap: 4px; + color: #333; + font-size: 14px; + line-height: 1; +} + +.view-thumb .thumb-meta form { + display: inline-flex; +} + +.view-thumb .cl-banish-button { + width: 18px; + height: 18px; + margin-left: 3px; +} + +.view-map .result-list.result-grid { + grid-template-columns: repeat(auto-fill, 352px); + gap: 18px; +} + +.view-map .card-photo { + height: 220px; +} + +@media (max-width: 950px) { + .global-header, + .homepage-content, + #topban, + .search-layout, + .posting-head, + .posting-body, + .account-grid { + grid-template-columns: 1fr; + } + + .homepage-content { + display: block; + width: 100%; + min-height: 0; + font-size: 16px; + grid-template-areas: + "topban" + "left" + "center" + "right"; + row-gap: 0; + } + + #topban { + display: flex; + flex-wrap: wrap; + gap: 6px 10px; + min-height: 0; + padding: 8px 10px; + } + + .regular-area { + min-width: 0; + gap: 8px; + } + + .pin-icon { + width: 21px; + height: 21px; + flex-basis: 21px; + } + + .pin-icon::after { + left: 6px; + top: 6px; + width: 9px; + height: 9px; + } + + .regular-area .area { + font-size: 28px; + } + + .sublinks { + gap: 5px; + font-size: 16px; + } + + .sublinks a { + padding: 4px 7px; + } + + .home-actions { + gap: 18px; + padding: 0 0 0 31px; + font-size: 12px; + } + + .action-symbol { + width: 27px; + height: 27px; + } + + .action-symbol.star::before { + font-size: 30px; + line-height: 27px; + } + + .action-symbol.post-icon::before { + left: 5px; + top: 6px; + width: 16px; + height: 16px; + border-width: 3px; + } + + .action-symbol.post-icon::after { + left: 15px; + top: 2px; + width: 4px; + height: 23px; + } + + .action-symbol.acct-icon::before { + left: 8px; + top: 2px; + width: 11px; + height: 11px; + border-width: 2px; + } + + .action-symbol.acct-icon::after { + left: 4px; + top: 18px; + width: 19px; + height: 9px; + border-width: 2px; + border-bottom: 0; + } + + .leftbar { + padding: 8px 10px 12px; + border-right: 0; + border-bottom: 1px solid var(--border); + } + + #logo, + #postlks, + #calban, + .cal, + .leftlinks, + .charity-links { + display: none; + } + + .home-search { + width: 100%; + margin: 0; + } + + .home-search input { + height: 36px; + padding-left: 31px; + font-size: 16px; + } + + .home-search::before { + left: 9px; + top: 8px; + width: 10px; + height: 10px; + border-width: 3px; + } + + .home-search::after { + left: 22px; + top: 22px; + width: 11px; + height: 3px; + } + + #center { + display: block; + padding: 10px; + } + + .center-col { + gap: 13px; + margin-bottom: 13px; + } + + .ban { + padding: 5px 6px; + font-size: 20px; + } + + .col ul { + font-size: 16px; + } + + .two-col, + .three-col, + .community-stack .two-col { + grid-template-columns: 1fr 1fr; + column-gap: 18px; + } + + #rightbar { + display: none; + } + + .site-search, + #center .community, + #center .housing, + #center .jobs, + .two-col, + .form-row { + grid-template-columns: 1fr; + } + + .userlinks, + .breadcrumbs-container { + text-align: left; + } + + .userlinks, + .sublinks { + justify-content: flex-start; + flex-wrap: wrap; + } + + .leftbar, + #rightbar { + min-height: 0; + } + + .mini-cats { + column-count: 1; + } + + .result-row { + grid-template-columns: 1fr; + } + + .search-page .result-list.result-grid { + grid-template-columns: 1fr; + } + + .search-page .card-photo { + height: 210px; + } + + .search-page .cl-search-topbar { + grid-template-columns: 1fr auto; + } + + .search-page .cl-mini-logo { + grid-column: 1 / 2; + } + + .search-page .top-selects { + grid-column: 1 / -1; + grid-template-columns: 1fr 1fr; + } + + .search-page .top-actions { + grid-column: 2 / 3; + grid-row: 1; + gap: 10px; + } + + .search-page .search-command { + grid-template-columns: 1fr; + padding: 7px 8px; + } + + .search-page .search-tools { + grid-template-columns: 1fr; + gap: 7px; + padding: 7px 8px; + } + + .search-page .modern-search-layout { + display: block; + padding: 0 8px 18px; + } + + .search-page .modern-results { + padding: 0; + } + + .search-page .modern-filters { + position: static; + min-height: 0; + margin-bottom: 10px; + padding: 8px; + border: 1px solid var(--border); + background: #f7f7f7; + } + + .search-page .modern-filters form { + display: grid; + grid-template-columns: 1fr 1fr; + column-gap: 8px; + } + + .search-page .modern-filters .filter-heading, + .search-page .modern-filters .inline-inputs, + .search-page .modern-filters .price-histogram, + .search-page .modern-filters .use-map, + .search-page .modern-filters .update-search { + display: none; + } + + .map-results-panel { + grid-template-columns: 1fr; + } + + .map-result-index { + max-height: 160px; + border-top: 1px solid #bbb; + border-left: 0; + } + + .search-layout { + display: flex; + flex-direction: column; + } + + .results { + order: 1; + } + + .filters { + order: 2; + } +} diff --git a/sites/craigslist/static/icons/.gitkeep b/sites/craigslist/static/icons/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/craigslist/static/js/.gitkeep b/sites/craigslist/static/js/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/craigslist/static/js/main.js b/sites/craigslist/static/js/main.js new file mode 100644 index 00000000..887485af --- /dev/null +++ b/sites/craigslist/static/js/main.js @@ -0,0 +1,57 @@ +(function () { + function setupGallery(root) { + var frames = Array.prototype.slice.call(root.querySelectorAll(".gallery-frame")); + if (frames.length <= 1) { + return; + } + var dots = Array.prototype.slice.call(root.querySelectorAll("[data-gallery-index]")); + var index = 0; + + function show(nextIndex) { + index = (nextIndex + frames.length) % frames.length; + frames.forEach(function (frame, frameIndex) { + frame.classList.toggle("active", frameIndex === index); + }); + dots.forEach(function (dot, dotIndex) { + dot.classList.toggle("active", dotIndex === index); + }); + } + + root.querySelectorAll("[data-gallery-prev]").forEach(function (button) { + button.addEventListener("click", function (event) { + event.preventDefault(); + event.stopPropagation(); + show(index - 1); + }); + }); + root.querySelectorAll("[data-gallery-next]").forEach(function (button) { + button.addEventListener("click", function (event) { + event.preventDefault(); + event.stopPropagation(); + show(index + 1); + }); + }); + dots.forEach(function (dot) { + dot.addEventListener("click", function (event) { + event.preventDefault(); + event.stopPropagation(); + show(Number(dot.getAttribute("data-gallery-index")) || 0); + }); + }); + } + + function submitTopSelects(form) { + form.querySelectorAll("select").forEach(function (select) { + select.addEventListener("change", function () { + if (select.name === "area") { + form.submit(); + } + }); + }); + } + + document.addEventListener("DOMContentLoaded", function () { + document.querySelectorAll("[data-gallery]").forEach(setupGallery); + document.querySelectorAll(".top-selects").forEach(submitTopSelects); + }); +}()); diff --git a/sites/craigslist/tasks.jsonl b/sites/craigslist/tasks.jsonl new file mode 100644 index 00000000..e99cc8db --- /dev/null +++ b/sites/craigslist/tasks.jsonl @@ -0,0 +1,20 @@ +{"web_name":"Craigslist","id":"Craigslist--0","ques":"Log in as alice.j@test.com with password TestPass123!, search furniture in the east bay for a chair under $100, open the result whose details say it has adjustable arms, and save it.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--1","ques":"Find an east bay apartment or studio under $2400 that has in-unit laundry. Open the listing and report its square footage and pet policy.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--2","ques":"Search bikes for a commuter bike under $500. Choose the listing whose detail table says it has hydraulic disc brakes, then send the seller a reply asking whether weekend pickup is possible.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--3","ques":"Log in as alice.j@test.com with password TestPass123!, find the 2006 Honda Accord EX sedan listing under $7000 with clean title status, open its detail page, and save it to Alice's account.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--4","ques":"Search for studio apartments and compare the Berkeley studio near BART with the quiet San Mateo studio. Which one has the lower rent per square foot?","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--5","ques":"Log in as alice.j@test.com with password TestPass123!, save a search named \"Gaming chair watch\" for furniture listings matching gaming chair with a maximum price of $200.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--6","ques":"Create a new posting in the bikes category for a blue commuter bike in Oakland priced at $325, with a short body mentioning a rear rack and recently replaced brake pads.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--7","ques":"Log in as alice.j@test.com with password TestPass123!, open Alice's messages, and identify what showing times the leasing office offered for the Berkeley studio.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--8","ques":"From the homepage, browse to community events and find the Saturday plant swap. Open it and report the time and what attendees should bring.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--9","ques":"Search free stuff in the east bay, find the listing for moving boxes near Oakland, and reply that you can pick them up after 6pm.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--10","ques":"Search jobs for speech language pathologist, open the school-year role, and report the compensation range, setting, and required license.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--11","ques":"Find the remote algebra and calculus tutoring service, open the detail page, and send a reply asking for two AP calculus time slots next week.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--12","ques":"Log in as alice.j@test.com with password TestPass123! and update the account area to peninsula and the phone number to (650) 555-0144.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--13","ques":"Log in as alice.j@test.com with password TestPass123!, open Alice's saved listings, and remove the saved item that is a Honda Accord, leaving the saved furniture and apartment items untouched.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--14","ques":"Search electronics for a USB-C monitor, open the matching detail page, and report its resolution and ports.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--15","ques":"Search moving services, find a two-person moving help listing, and report the hourly rate before replying with a request for a Saturday morning estimate.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--16","ques":"Hide the Honda Passport project SUV listing from search results, then search Honda again and confirm the Accord and Odyssey remain visible.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--17","ques":"Find the volunteer bike repair clinic in the east bay. Open its page and report the time and the listed skill requirement.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--18","ques":"Search furniture for a walnut writing desk in San Francisco under $150. Open the matching listing and report its width and depth.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--19","ques":"Search furniture for a standing desk frame in the peninsula. Open the white frame listing and report the height range and load rating.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} diff --git a/sites/craigslist/templates/.gitkeep b/sites/craigslist/templates/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/craigslist/templates/account.html b/sites/craigslist/templates/account.html new file mode 100644 index 00000000..e44fc6ad --- /dev/null +++ b/sites/craigslist/templates/account.html @@ -0,0 +1,69 @@ +{% extends "base.html" %} +{% block title %}account - craigslist{% endblock %} +{% block body_class %}account-page{% endblock %} +{% block content %} + +{% endblock %} diff --git a/sites/craigslist/templates/account_edit.html b/sites/craigslist/templates/account_edit.html new file mode 100644 index 00000000..2dd61f8a --- /dev/null +++ b/sites/craigslist/templates/account_edit.html @@ -0,0 +1,22 @@ +{% extends "base.html" %} +{% block title %}edit account - craigslist{% endblock %} +{% block body_class %}account-page account-edit-page{% endblock %} +{% block content %} +
+

< account

+

edit account

+
+ + + + + + + +
+
+{% endblock %} diff --git a/sites/craigslist/templates/base.html b/sites/craigslist/templates/base.html new file mode 100644 index 00000000..c951c605 --- /dev/null +++ b/sites/craigslist/templates/base.html @@ -0,0 +1,67 @@ + + + + + + {% block title %}craigslist{% endblock %} + + + + + + + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} + +
+ {% block content %}{% endblock %} +
+ + + + diff --git a/sites/craigslist/templates/index.html b/sites/craigslist/templates/index.html new file mode 100644 index 00000000..709504e6 --- /dev/null +++ b/sites/craigslist/templates/index.html @@ -0,0 +1,357 @@ +{% extends "base.html" %} +{% block title %}craigslist: SF bay area jobs, apartments, for sale, services, community{% endblock %} +{% block body_class %}home-page{% endblock %} +{% block content %} +{% set ns = namespace(community=None, services=None, housing=None, for_sale=None, jobs=None) %} +{% for group in category_groups() %} + {% if group.slug == "community" %}{% set ns.community = group %}{% endif %} + {% if group.slug == "services" %}{% set ns.services = group %}{% endif %} + {% if group.slug == "housing" %}{% set ns.housing = group %}{% endif %} + {% if group.slug == "for_sale" %}{% set ns.for_sale = group %}{% endif %} + {% if group.slug == "jobs" %}{% set ns.jobs = group %}{% endif %} +{% endfor %} + +{% macro cat_list(group) -%} +
    + {% for cat in group.categories %} +
  • {{ cat.name }}
  • + {% endfor %} +
+{%- endmacro %} + +
+ + + + +
+ + + + + +
+ + +
+{% endblock %} diff --git a/sites/craigslist/templates/listing_detail.html b/sites/craigslist/templates/listing_detail.html new file mode 100644 index 00000000..a3f3b2fc --- /dev/null +++ b/sites/craigslist/templates/listing_detail.html @@ -0,0 +1,110 @@ +{% extends "base.html" %} +{% block title %}{{ listing.title }} - craigslist{% endblock %} +{% block body_class %}detail-page{% endblock %} +{% block content %} +
+ + +
+
+
+
+ {% if listing.display_price %}{{ listing.display_price }}{% endif %} +

{{ listing.title }}

+ ({{ listing.neighborhood }}) +
+
+ posted {{ listing.posted_at.strftime("%Y-%m-%d %H:%M") }} + updated {{ listing.updated_at.strftime("%Y-%m-%d") }} + post id: {{ listing.id }} +
+
+
+ reply + {% if current_user.is_authenticated %} +
+ + +
+ {% endif %} +
+
+
+ +
+ {% set photos = listing_images(listing) %} +
+ {% if photos %} +
+ {% for photo in photos %} + + {% endfor %} + {% if photos|length > 1 %} + + + {% endif %} +
+ {% if photos|length > 1 %} +
+ {% for photo in photos %} + + {% endfor %} +
+ {% endif %} + {% else %} +
no image
+ {% endif %} +
+ +
+ +
+

posting body

+

{{ listing.description }}

+ {% if listing.compensation %}

compensation: {{ listing.compensation }}

{% endif %} +

do NOT contact this poster with unsolicited services or offers.

+
+
+ +
+

more from {{ listing.category.name }}

+
    + {% for item in nearby %} +
  • {{ item.title }} {{ item.display_price }} {{ item.neighborhood }}
  • + {% endfor %} +
+
+
+{% endblock %} diff --git a/sites/craigslist/templates/login.html b/sites/craigslist/templates/login.html new file mode 100644 index 00000000..609627fe --- /dev/null +++ b/sites/craigslist/templates/login.html @@ -0,0 +1,16 @@ +{% extends "base.html" %} +{% block title %}login - craigslist{% endblock %} +{% block body_class %}login-page{% endblock %} +{% block content %} +
+

log in

+
+ + + + + +
+

create an account

+
+{% endblock %} diff --git a/sites/craigslist/templates/messages.html b/sites/craigslist/templates/messages.html new file mode 100644 index 00000000..21a8c9a6 --- /dev/null +++ b/sites/craigslist/templates/messages.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block title %}messages - craigslist{% endblock %} +{% block body_class %}account-page messages-page{% endblock %} +{% block content %} +
+ +

messages

+ {% if rows %} +
    + {% for m in rows %} +
  • +
    + {{ m.listing.title }} + {{ m.direction }} {{ m.created_at.strftime("%Y-%m-%d %H:%M") }} +
    +

    {{ m.body }}

    + {{ m.sender_name }} <{{ m.sender_email }}> +
  • + {% endfor %} +
+ {% else %} +

no messages

+ {% endif %} +
+{% endblock %} diff --git a/sites/craigslist/templates/post.html b/sites/craigslist/templates/post.html new file mode 100644 index 00000000..bed9b913 --- /dev/null +++ b/sites/craigslist/templates/post.html @@ -0,0 +1,77 @@ +{% extends "base.html" %} +{% block title %}create posting - craigslist{% endblock %} +{% block body_class %}post-page account-page{% endblock %} +{% block content %} +
+

< account

+

create a posting

+
+ + + + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + + + + + + + +
+
+{% endblock %} diff --git a/sites/craigslist/templates/register.html b/sites/craigslist/templates/register.html new file mode 100644 index 00000000..217218ab --- /dev/null +++ b/sites/craigslist/templates/register.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} +{% block title %}register - craigslist{% endblock %} +{% block body_class %}login-page register-page{% endblock %} +{% block content %} +
+

create account

+
+ + + + + + + + + + + +
+
+{% endblock %} diff --git a/sites/craigslist/templates/reply.html b/sites/craigslist/templates/reply.html new file mode 100644 index 00000000..e95d5297 --- /dev/null +++ b/sites/craigslist/templates/reply.html @@ -0,0 +1,26 @@ +{% extends "base.html" %} +{% block title %}reply - {{ listing.title }}{% endblock %} +{% block body_class %}reply-page{% endblock %} +{% block content %} +
+

< back to posting

+

reply to: {{ listing.title }}

+
+ {{ listing.neighborhood }} + {% if listing.display_price %}{{ listing.display_price }}{% endif %} + {{ listing.category.name }} +
+
+ craigslist mail relay keeps your email address private unless you choose to include it in your message. +
+
+ + + + + + + +
+
+{% endblock %} diff --git a/sites/craigslist/templates/saved.html b/sites/craigslist/templates/saved.html new file mode 100644 index 00000000..7113eb88 --- /dev/null +++ b/sites/craigslist/templates/saved.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} +{% block title %}saved listings - craigslist{% endblock %} +{% block body_class %}account-page saved-page{% endblock %} +{% block content %} +
+ +

saved listings

+ {% if rows %} + + {% else %} +

nothing saved yet

+ {% endif %} +
+{% endblock %} diff --git a/sites/craigslist/templates/search.html b/sites/craigslist/templates/search.html new file mode 100644 index 00000000..5be36c08 --- /dev/null +++ b/sites/craigslist/templates/search.html @@ -0,0 +1,336 @@ +{% extends "base.html" %} +{% block title %}{% if category %}{{ category.name }}{% else %}search{% endif %} - craigslist{% endblock %} +{% block body_class %}search-page{% endblock %} +{% block content %} +{% set requested_view = request.args.get('view', 'gallery') %} +{% set active_view = 'thumb' if requested_view == 'grid' else requested_view %} +{% if active_view not in ['list', 'thumb', 'gallery', 'map'] %}{% set active_view = 'gallery' %}{% endif %} +{% set is_housing = category and category.group_slug == 'housing' %} +{% set is_for_sale = category and category.group_slug == 'for_sale' %} +{% set is_bikes = category and category.slug == 'bikes' %} +{% set purveyor_label = 'dealer' if is_for_sale else 'broker' %} +{% if is_housing %} + {% set quick_filters = ['price', 'beds', 'baths', 'type', 'furnished'] %} +{% elif is_bikes %} + {% set quick_filters = ['price', 'sold by', 'type', 'electric assist', 'condition'] %} +{% elif is_for_sale %} + {% set quick_filters = ['price', 'sold by', 'type', 'condition'] %} +{% else %} + {% set quick_filters = ['price', 'type', 'posted today'] %} +{% endif %} +{% macro view_url(view_mode) -%} + {% if category -%} + {{ url_for('category_search', category_slug=category.slug, q=request.args.get('q', ''), area=request.args.get('area', ''), min_price=request.args.get('min_price', ''), max_price=request.args.get('max_price', ''), sort=request.args.get('sort', 'relevance'), has_image=request.args.get('has_image', ''), view=view_mode) }} + {%- else -%} + {{ url_for('search', q=request.args.get('q', ''), area=request.args.get('area', ''), min_price=request.args.get('min_price', ''), max_price=request.args.get('max_price', ''), sort=request.args.get('sort', 'relevance'), has_image=request.args.get('has_image', ''), view=view_mode) }} + {%- endif %} +{%- endmacro %} +
+
+ +
+ + + + + + + + +
+
+ post + {% if current_user.is_authenticated %} + + {% else %} + + {% endif %} +
+
+ +
+ + + {% if listings %}1 - {{ listings|length }} of {{ listings|length }}{% else %}0 results{% endif %} +
+ +
+
+ + + +
+
+ + + + +
+
+ + + + + + +
+
+ {% for label in quick_filters %} + + {% endfor %} +
+
+ +
+ + +
+ {% if active_view == 'map' %} +
+
+ bay + SF + oakland + san jose + + + {% for listing in listings[:30] %} + {% set point = listing_map_point(listing) %} + + {{ loop.index }} + + {% endfor %} +
+
    + {% for listing in listings[:10] %} +
  1. {{ listing.title }}{{ listing.neighborhood }}
  2. + {% endfor %} +
+
+ {% endif %} + + {% if listings %} + {% if active_view == 'list' %} +
+ {% for listing in listings %} + {% set photos = listing_images(listing) %} +
+
+
+ + +
+ {{ listing.title }} + + + {{ listing.neighborhood }} + + {{ listing.age_label }} + {% if listing.price is not none %}{{ listing.display_price }}{% endif %} + {% if photos %}pic{% endif %} + + +
+ + +
+
+
+ {% endfor %} +
+ {% elif active_view == 'thumb' %} +
+ {% for listing in listings %} + {% set photos = listing_images(listing) %} +
+
+ + {% if photos %} + + {% else %} + no image + {% endif %} + +
+
{{ listing.neighborhood }}
+
+
+ + +
+ {{ listing.title }} +
+
+ {{ listing.age_label }} + {% if listing.price is not none %}{{ listing.display_price }}{% endif %} +
+ + +
+
+
+
+
+ {% endfor %} +
+ {% else %} + + {% endif %} + {% else %} +
no results matched this search
+ {% endif %} +
+
+
+{% endblock %} diff --git a/websyn_start.sh b/websyn_start.sh index 4d690d78..dd3aa8fd 100644 --- a/websyn_start.sh +++ b/websyn_start.sh @@ -1,12 +1,13 @@ #!/bin/bash -# WebSyn startup: launch all 16 mirror sites, then exec the original CMD. +# WebSyn startup: launch all mirror sites, then exec the original CMD. # This preserves the base image's browser env server (port 8100) as PID 1. set -e SITES=(allrecipes amazon apple arxiv bbc_news booking github google_flights google_map google_search huggingface wolfram_alpha - cambridge_dictionary coursera espn merriam_webster) + cambridge_dictionary coursera espn merriam_webster craigslist) BASE_PORT=40000 +SITE_COUNT=${#SITES[@]} PID_DIR=/tmp/websyn_pids mkdir -p "$PID_DIR" rm -f "$PID_DIR"/*.pid @@ -17,7 +18,7 @@ for d in "${SITES[@]}"; do cp -a "/opt/WebSyn/$d/instance_seed" "/opt/WebSyn/$d/instance" done -echo "[WebSyn] Starting 16 sites on ports ${BASE_PORT}-$((BASE_PORT + 15))..." +echo "[WebSyn] Starting ${SITE_COUNT} sites on ports ${BASE_PORT}-$((BASE_PORT + SITE_COUNT - 1))..." for i in "${!SITES[@]}"; do site="${SITES[$i]}" port=$((BASE_PORT + i)) @@ -51,8 +52,8 @@ except Exception: exit(1) ready=$((ready + 1)) fi done - echo " [${elapsed}/${max_wait}s] ${ready}/16 sites ready" - if [ $ready -eq 16 ]; then + echo " [${elapsed}/${max_wait}s] ${ready}/${SITE_COUNT} sites ready" + if [ $ready -eq $SITE_COUNT ]; then break fi done @@ -78,6 +79,6 @@ done echo "[WebSyn] Starting control server on :8101 (PID 1)..." # Control server becomes PID 1 — receives SIGTERM on `docker stop`, -# keeps the container alive as long as it's running. The 16 site +# keeps the container alive as long as it's running. The site # subprocesses are managed via /tmp/websyn_pids/.pid. exec python3 /opt/control_server.py --port 8101 From d13596777492f546cfcbeeeca563c45702196692 Mon Sep 17 00:00:00 2001 From: FlowRays <1581561246@qq.com> Date: Thu, 2 Jul 2026 11:06:22 +0800 Subject: [PATCH 2/4] fix(craigslist): address review feedback --- scripts/extract_assets.sh | 2 +- sites/craigslist/seed_data.py | 14 ++++++++++++-- sites/craigslist/tasks.jsonl | 8 ++++---- sites/craigslist/templates/index.html | 2 +- sites/craigslist/templates/search.html | 2 +- 5 files changed, 19 insertions(+), 9 deletions(-) diff --git a/scripts/extract_assets.sh b/scripts/extract_assets.sh index b9e58724..f731ec1c 100755 --- a/scripts/extract_assets.sh +++ b/scripts/extract_assets.sh @@ -42,7 +42,7 @@ for site_dir in sites/*/; do fi out="$TARGET/$site.tar.gz" - tar -czf "$out" -C sites "${members[@]}" + COPYFILE_DISABLE=1 tar --exclude='*/._*' --exclude='__MACOSX' -czf "$out" -C sites "${members[@]}" sz=$(du -sh "$out" 2>/dev/null | cut -f1) printf " %-22s -> %-30s %s\n" "$site" "$site.tar.gz" "$sz" count=$((count + 1)) diff --git a/sites/craigslist/seed_data.py b/sites/craigslist/seed_data.py index 89730b11..672833ba 100644 --- a/sites/craigslist/seed_data.py +++ b/sites/craigslist/seed_data.py @@ -608,7 +608,7 @@ def finalize_record(row, pools, idx): {"category_slug": "lessons", "title": "SAT math tutor", "area": "east bay", "neighborhood": "berkeley", "price": 60, "condition": "new", "description": "SAT math prep with practice tests.", "details": {"format": "in person", "rate": "$60 per hour", "subjects": "SAT math"}}, {"category_slug": "lessons", "title": "Middle school math tutoring", "area": "south bay", "neighborhood": "sunnyvale", "price": 50, "condition": "new", "description": "Patient math tutoring for middle school students.", "details": {"format": "online", "rate": "$50 per hour", "subjects": "pre-algebra"}}, - {"category_slug": "labor_move", "title": "Moving help for apartments", "area": "east bay", "neighborhood": "oakland", "price": 80, "condition": "new", "description": "Two helpers for local apartment moving jobs.", "details": {"crew": "2 people", "rate": "$80 per hour", "truck": "not included"}}, + {"category_slug": "labor_move", "title": "Two-person moving help, Oakland van crew", "area": "east bay", "neighborhood": "oakland", "price": 80, "condition": "new", "description": "Two helpers for local apartment moving jobs with a small van. Detail table lists crew and truck information.", "details": {"crew": "2 people", "rate": "$80 per hour", "truck": "cargo van included"}}, {"category_slug": "labor_move", "title": "Small moving crew with blankets", "area": "san francisco", "neighborhood": "mission district", "price": 110, "condition": "new", "description": "Moving crew for furniture, boxes, and studio apartments.", "details": {"crew": "2 people", "rate": "$110 per hour", "truck": "cargo van"}}, {"category_slug": "labor_move", "title": "Last minute moving labor", "area": "south bay", "neighborhood": "san jose", "price": 75, "condition": "new", "description": "Labor-only moving help for stairs and loading.", "details": {"crew": "1-2 people", "rate": "$75 per hour", "truck": "not included"}}, {"category_slug": "labor_move", "title": "Weekend moving and hauling", "area": "peninsula", "neighborhood": "san mateo", "price": 125, "condition": "new", "description": "Weekend moving and hauling for small households.", "details": {"crew": "2 people", "rate": "$125 per hour", "truck": "box truck"}}, @@ -630,6 +630,10 @@ def finalize_record(row, pools, idx): {"category_slug": "lessons", "title": "AP math tutoring online", "area": "north bay", "neighborhood": "remote", "price": 58, "condition": "new", "description": "Online tutoring for AP math courses and exam review.", "details": {"format": "Zoom", "rate": "$58 per hour", "subjects": "AP calculus pre-calculus"}}, {"category_slug": "labor_move", "title": "Moving help with pickup truck", "area": "north bay", "neighborhood": "san rafael", "price": 90, "condition": "new", "description": "Small moving jobs with one pickup truck and blankets.", "details": {"crew": "2 people", "rate": "$90 per hour", "truck": "pickup"}}, {"category_slug": "labor_move", "title": "Apartment moving and packing", "area": "east bay", "neighborhood": "berkeley", "price": 100, "condition": "new", "description": "Apartment moving, packing help, and loading support.", "details": {"crew": "2 people", "rate": "$100 per hour", "truck": "van available"}}, + {"category_slug": "furniture", "title": "Oak writing desk with walnut finish", "area": "san francisco", "neighborhood": "nob hill", "price": 125, "condition": "good", "description": "Small writing desk with walnut finish and one center drawer. Detail table lists dimensions.", "details": {"width": "38 in", "depth": "20 in", "material": "oak veneer", "delivery": "pickup only"}}, + {"category_slug": "furniture", "title": "Walnut side table for writing nook", "area": "san francisco", "neighborhood": "sunset", "price": 90, "condition": "fair", "description": "Compact table that can work as a writing surface. Detail table lists dimensions.", "details": {"width": "30 in", "depth": "18 in", "material": "walnut stain", "delivery": "curb pickup"}}, + {"category_slug": "furniture", "title": "Standing desk frame, black", "area": "peninsula", "neighborhood": "redwood city", "price": 180, "condition": "good", "description": "Electric standing desk frame in black. Detail table lists height and load specs.", "details": {"height_range": "27-46 in", "load_rating": "180 lb", "color": "black"}}, + {"category_slug": "furniture", "title": "Standing desk converter frame", "area": "peninsula", "neighborhood": "san mateo", "price": 95, "condition": "good", "description": "Desktop converter frame for sit-stand work. Detail table lists lift range.", "details": {"height_range": "5-17 in", "load_rating": "35 lb", "color": "gray"}}, {"category_slug": "volunteers", "title": "Bike repair table volunteers", "area": "east bay", "neighborhood": "emeryville", "price": 0, "condition": "new", "description": "Volunteers needed at a bike repair information table.", "details": {"date": "Sunday", "time": "12:00 PM", "skills": "friendly with cyclists"}}, {"category_slug": "volunteers", "title": "Community bike repair setup crew", "area": "east bay", "neighborhood": "berkeley", "price": 0, "condition": "new", "description": "Volunteers help set up stands and check in neighbors for a bike repair afternoon.", "details": {"date": "Saturday", "time": "11:00 AM", "skills": "event setup"}}, {"category_slug": "volunteers", "title": "Bike day repair station helpers", "area": "east bay", "neighborhood": "oakland", "price": 0, "condition": "new", "description": "Repair station seeks helpers for basic intake, tools, and sign-in during bike day.", "details": {"date": "Saturday", "time": "3:30 PM", "skills": "organized with tools"}}, @@ -674,11 +678,17 @@ def finalize_record(row, pools, idx): ("Registered nurse floater", None, "new", "clinic float role across East Bay"), ], "services": [ - ("Two-person moving help with van", 95, "new", "local moves, stairs ok"), + ("Handyman help by the hour", 95, "new", "small repairs, shelves, and door adjustments"), ("Guitar lessons for beginners", 45, "new", "weekly lessons in person or online"), ("House cleaning, green supplies", 120, "new", "apartments and small homes"), ("Logo design for small businesses", 250, "new", "brand kit and social avatars"), ], + "labor_move": [ + ("Two-person moving help, Alameda stairs crew", 95, "new", "local moves, stairs ok, cargo van available"), + ("Furniture moving crew, Mission district", 115, "new", "two helpers for apartments and offices"), + ("Labor-only moving helpers", 70, "new", "loading and unloading help, no vehicle"), + ("Small move crew with box truck", 135, "new", "weekend moves with blankets and dolly"), + ], "community": [ ("East Bay board game night", 0, "new", "weekly strategy games and snacks"), ("Lost gray tabby cat near Panhandle", 0, "new", "microchipped gray tabby"), diff --git a/sites/craigslist/tasks.jsonl b/sites/craigslist/tasks.jsonl index e99cc8db..6be4c28b 100644 --- a/sites/craigslist/tasks.jsonl +++ b/sites/craigslist/tasks.jsonl @@ -12,9 +12,9 @@ {"web_name":"Craigslist","id":"Craigslist--11","ques":"Find the remote algebra and calculus tutoring service, open the detail page, and send a reply asking for two AP calculus time slots next week.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} {"web_name":"Craigslist","id":"Craigslist--12","ques":"Log in as alice.j@test.com with password TestPass123! and update the account area to peninsula and the phone number to (650) 555-0144.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} {"web_name":"Craigslist","id":"Craigslist--13","ques":"Log in as alice.j@test.com with password TestPass123!, open Alice's saved listings, and remove the saved item that is a Honda Accord, leaving the saved furniture and apartment items untouched.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--14","ques":"Search electronics for a USB-C monitor, open the matching detail page, and report its resolution and ports.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--15","ques":"Search moving services, find a two-person moving help listing, and report the hourly rate before replying with a request for a Saturday morning estimate.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--14","ques":"Search electronics for the Dell 27 inch USB-C monitor in Sunnyvale, open its detail page, and report its resolution and ports.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--15","ques":"Search moving services, find the Oakland van crew listing for two-person moving help, open it, and report whether the truck is included before replying with a request for a Saturday morning estimate.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} {"web_name":"Craigslist","id":"Craigslist--16","ques":"Hide the Honda Passport project SUV listing from search results, then search Honda again and confirm the Accord and Odyssey remain visible.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} {"web_name":"Craigslist","id":"Craigslist--17","ques":"Find the volunteer bike repair clinic in the east bay. Open its page and report the time and the listed skill requirement.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--18","ques":"Search furniture for a walnut writing desk in San Francisco under $150. Open the matching listing and report its width and depth.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--19","ques":"Search furniture for a standing desk frame in the peninsula. Open the white frame listing and report the height range and load rating.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--18","ques":"Search furniture for a walnut writing desk in San Francisco under $150. Open the Mission District listing titled \"Walnut writing desk with two drawers\" and report its width and depth.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--19","ques":"Search furniture for a standing desk frame in the peninsula. Open the white frame listing in Palo Alto and report the height range and load rating.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} diff --git a/sites/craigslist/templates/index.html b/sites/craigslist/templates/index.html index 709504e6..7515b3a3 100644 --- a/sites/craigslist/templates/index.html +++ b/sites/craigslist/templates/index.html @@ -106,7 +106,7 @@

community

  • local news
    • -
    • lost+found
    • +
    • lost+found
    • missed connections
    • musicians
    • pets
    • diff --git a/sites/craigslist/templates/search.html b/sites/craigslist/templates/search.html index 5be36c08..d34fdc12 100644 --- a/sites/craigslist/templates/search.html +++ b/sites/craigslist/templates/search.html @@ -309,7 +309,7 @@
      {{ listing.age_label }} {% if listing.bedrooms %}{{ listing.bedrooms }}br{% endif %} - {% if listing.sqft %}{{ listing.sqft }}ft2{% endif %} + {% if listing.sqft and not is_housing %}{{ listing.sqft }}ft2{% endif %} {{ listing.neighborhood }}
      From 7d70d6e8730c5eff04cf48d9e63161c3a3dbfd53 Mon Sep 17 00:00:00 2001 From: FlowRays <1581561246@qq.com> Date: Thu, 2 Jul 2026 11:09:04 +0800 Subject: [PATCH 3/4] chore(craigslist): rebase onto latest site list --- sites/craigslist/tasks.jsonl | 40 ++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/sites/craigslist/tasks.jsonl b/sites/craigslist/tasks.jsonl index 6be4c28b..645dfddc 100644 --- a/sites/craigslist/tasks.jsonl +++ b/sites/craigslist/tasks.jsonl @@ -1,20 +1,20 @@ -{"web_name":"Craigslist","id":"Craigslist--0","ques":"Log in as alice.j@test.com with password TestPass123!, search furniture in the east bay for a chair under $100, open the result whose details say it has adjustable arms, and save it.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--1","ques":"Find an east bay apartment or studio under $2400 that has in-unit laundry. Open the listing and report its square footage and pet policy.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--2","ques":"Search bikes for a commuter bike under $500. Choose the listing whose detail table says it has hydraulic disc brakes, then send the seller a reply asking whether weekend pickup is possible.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--3","ques":"Log in as alice.j@test.com with password TestPass123!, find the 2006 Honda Accord EX sedan listing under $7000 with clean title status, open its detail page, and save it to Alice's account.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--4","ques":"Search for studio apartments and compare the Berkeley studio near BART with the quiet San Mateo studio. Which one has the lower rent per square foot?","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--5","ques":"Log in as alice.j@test.com with password TestPass123!, save a search named \"Gaming chair watch\" for furniture listings matching gaming chair with a maximum price of $200.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--6","ques":"Create a new posting in the bikes category for a blue commuter bike in Oakland priced at $325, with a short body mentioning a rear rack and recently replaced brake pads.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--7","ques":"Log in as alice.j@test.com with password TestPass123!, open Alice's messages, and identify what showing times the leasing office offered for the Berkeley studio.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--8","ques":"From the homepage, browse to community events and find the Saturday plant swap. Open it and report the time and what attendees should bring.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--9","ques":"Search free stuff in the east bay, find the listing for moving boxes near Oakland, and reply that you can pick them up after 6pm.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--10","ques":"Search jobs for speech language pathologist, open the school-year role, and report the compensation range, setting, and required license.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--11","ques":"Find the remote algebra and calculus tutoring service, open the detail page, and send a reply asking for two AP calculus time slots next week.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--12","ques":"Log in as alice.j@test.com with password TestPass123! and update the account area to peninsula and the phone number to (650) 555-0144.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--13","ques":"Log in as alice.j@test.com with password TestPass123!, open Alice's saved listings, and remove the saved item that is a Honda Accord, leaving the saved furniture and apartment items untouched.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--14","ques":"Search electronics for the Dell 27 inch USB-C monitor in Sunnyvale, open its detail page, and report its resolution and ports.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--15","ques":"Search moving services, find the Oakland van crew listing for two-person moving help, open it, and report whether the truck is included before replying with a request for a Saturday morning estimate.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--16","ques":"Hide the Honda Passport project SUV listing from search results, then search Honda again and confirm the Accord and Odyssey remain visible.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--17","ques":"Find the volunteer bike repair clinic in the east bay. Open its page and report the time and the listed skill requirement.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--18","ques":"Search furniture for a walnut writing desk in San Francisco under $150. Open the Mission District listing titled \"Walnut writing desk with two drawers\" and report its width and depth.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--19","ques":"Search furniture for a standing desk frame in the peninsula. Open the white frame listing in Palo Alto and report the height range and load rating.","web":"http://localhost:40015/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--0","ques":"Log in as alice.j@test.com with password TestPass123!, search furniture in the east bay for a chair under $100, open the result whose details say it has adjustable arms, and save it.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--1","ques":"Find an east bay apartment or studio under $2400 that has in-unit laundry. Open the listing and report its square footage and pet policy.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--2","ques":"Search bikes for a commuter bike under $500. Choose the listing whose detail table says it has hydraulic disc brakes, then send the seller a reply asking whether weekend pickup is possible.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--3","ques":"Log in as alice.j@test.com with password TestPass123!, find the 2006 Honda Accord EX sedan listing under $7000 with clean title status, open its detail page, and save it to Alice's account.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--4","ques":"Search for studio apartments and compare the Berkeley studio near BART with the quiet San Mateo studio. Which one has the lower rent per square foot?","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--5","ques":"Log in as alice.j@test.com with password TestPass123!, save a search named \"Gaming chair watch\" for furniture listings matching gaming chair with a maximum price of $200.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--6","ques":"Create a new posting in the bikes category for a blue commuter bike in Oakland priced at $325, with a short body mentioning a rear rack and recently replaced brake pads.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--7","ques":"Log in as alice.j@test.com with password TestPass123!, open Alice's messages, and identify what showing times the leasing office offered for the Berkeley studio.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--8","ques":"From the homepage, browse to community events and find the Saturday plant swap. Open it and report the time and what attendees should bring.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--9","ques":"Search free stuff in the east bay, find the listing for moving boxes near Oakland, and reply that you can pick them up after 6pm.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--10","ques":"Search jobs for speech language pathologist, open the school-year role, and report the compensation range, setting, and required license.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--11","ques":"Find the remote algebra and calculus tutoring service, open the detail page, and send a reply asking for two AP calculus time slots next week.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--12","ques":"Log in as alice.j@test.com with password TestPass123! and update the account area to peninsula and the phone number to (650) 555-0144.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--13","ques":"Log in as alice.j@test.com with password TestPass123!, open Alice's saved listings, and remove the saved item that is a Honda Accord, leaving the saved furniture and apartment items untouched.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--14","ques":"Search electronics for the Dell 27 inch USB-C monitor in Sunnyvale, open its detail page, and report its resolution and ports.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--15","ques":"Search moving services, find the Oakland van crew listing for two-person moving help, open it, and report whether the truck is included before replying with a request for a Saturday morning estimate.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--16","ques":"Hide the Honda Passport project SUV listing from search results, then search Honda again and confirm the Accord and Odyssey remain visible.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--17","ques":"Find the volunteer bike repair clinic in the east bay. Open its page and report the time and the listed skill requirement.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--18","ques":"Search furniture for a walnut writing desk in San Francisco under $150. Open the Mission District listing titled \"Walnut writing desk with two drawers\" and report its width and depth.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} +{"web_name":"Craigslist","id":"Craigslist--19","ques":"Search furniture for a standing desk frame in the peninsula. Open the white frame listing in Palo Alto and report the height range and load rating.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} From f645a593f13245f0aa1dc6554a7b56560de23ca2 Mon Sep 17 00:00:00 2001 From: DEM1TASSE Date: Sat, 4 Jul 2026 09:45:18 +0000 Subject: [PATCH 4/4] Add Craigslist task verifiers and judge rubrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer deliverable for the Craigslist mirror (site by @FlowRays, PR #5): one deterministic verifier per task under sites/craigslist/verify/, plus verifier_path + judge_rubric in every tasks.jsonl row. No answer key in tasks.jsonl — ground truth lives only inside the verifiers. Deterministic-first: listing-open navigation gate (anti knowledge-shortcut; listings are synthetic) + SQLite DB after-state for the stateful tasks (saved_listings / saved_searches / hidden_listings / messages / new postings / user profile) + answer vs frozen ground truth, LLM only as an anchored check. Validated against the official react agent (agent_demo/agent.py): a no-op run fails all 20 verifiers; on the full 20-task run verifier and LLM judge agree 14/20, and the divergences favor the deterministic verifier (it catches DB after-state and rejects blank/incomplete answers the judge false-positives). Note: verify_0 and verify_3 are limited by a task bug — their save-targets are already in the seed's saved-listings, so the save cannot be verified; the review asks the author to fix those tasks. Co-Authored-By: Claude Opus 4.8 --- sites/craigslist/tasks.jsonl | 40 ++--- sites/craigslist/verify/verify_0.py | 26 ++++ sites/craigslist/verify/verify_1.py | 20 +++ sites/craigslist/verify/verify_10.py | 20 +++ sites/craigslist/verify/verify_11.py | 21 +++ sites/craigslist/verify/verify_12.py | 20 +++ sites/craigslist/verify/verify_13.py | 24 +++ sites/craigslist/verify/verify_14.py | 21 +++ sites/craigslist/verify/verify_15.py | 25 +++ sites/craigslist/verify/verify_16.py | 24 +++ sites/craigslist/verify/verify_17.py | 20 +++ sites/craigslist/verify/verify_18.py | 19 +++ sites/craigslist/verify/verify_19.py | 20 +++ sites/craigslist/verify/verify_2.py | 22 +++ sites/craigslist/verify/verify_3.py | 26 ++++ sites/craigslist/verify/verify_4.py | 22 +++ sites/craigslist/verify/verify_5.py | 21 +++ sites/craigslist/verify/verify_6.py | 25 +++ sites/craigslist/verify/verify_7.py | 20 +++ sites/craigslist/verify/verify_8.py | 21 +++ sites/craigslist/verify/verify_9.py | 22 +++ sites/craigslist/verify/verify_lib.py | 214 ++++++++++++++++++++++++++ 22 files changed, 673 insertions(+), 20 deletions(-) create mode 100644 sites/craigslist/verify/verify_0.py create mode 100644 sites/craigslist/verify/verify_1.py create mode 100644 sites/craigslist/verify/verify_10.py create mode 100644 sites/craigslist/verify/verify_11.py create mode 100644 sites/craigslist/verify/verify_12.py create mode 100644 sites/craigslist/verify/verify_13.py create mode 100644 sites/craigslist/verify/verify_14.py create mode 100644 sites/craigslist/verify/verify_15.py create mode 100644 sites/craigslist/verify/verify_16.py create mode 100644 sites/craigslist/verify/verify_17.py create mode 100644 sites/craigslist/verify/verify_18.py create mode 100644 sites/craigslist/verify/verify_19.py create mode 100644 sites/craigslist/verify/verify_2.py create mode 100644 sites/craigslist/verify/verify_3.py create mode 100644 sites/craigslist/verify/verify_4.py create mode 100644 sites/craigslist/verify/verify_5.py create mode 100644 sites/craigslist/verify/verify_6.py create mode 100644 sites/craigslist/verify/verify_7.py create mode 100644 sites/craigslist/verify/verify_8.py create mode 100644 sites/craigslist/verify/verify_9.py create mode 100644 sites/craigslist/verify/verify_lib.py diff --git a/sites/craigslist/tasks.jsonl b/sites/craigslist/tasks.jsonl index 645dfddc..1b5a6cc8 100644 --- a/sites/craigslist/tasks.jsonl +++ b/sites/craigslist/tasks.jsonl @@ -1,20 +1,20 @@ -{"web_name":"Craigslist","id":"Craigslist--0","ques":"Log in as alice.j@test.com with password TestPass123!, search furniture in the east bay for a chair under $100, open the result whose details say it has adjustable arms, and save it.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--1","ques":"Find an east bay apartment or studio under $2400 that has in-unit laundry. Open the listing and report its square footage and pet policy.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--2","ques":"Search bikes for a commuter bike under $500. Choose the listing whose detail table says it has hydraulic disc brakes, then send the seller a reply asking whether weekend pickup is possible.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--3","ques":"Log in as alice.j@test.com with password TestPass123!, find the 2006 Honda Accord EX sedan listing under $7000 with clean title status, open its detail page, and save it to Alice's account.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--4","ques":"Search for studio apartments and compare the Berkeley studio near BART with the quiet San Mateo studio. Which one has the lower rent per square foot?","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--5","ques":"Log in as alice.j@test.com with password TestPass123!, save a search named \"Gaming chair watch\" for furniture listings matching gaming chair with a maximum price of $200.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--6","ques":"Create a new posting in the bikes category for a blue commuter bike in Oakland priced at $325, with a short body mentioning a rear rack and recently replaced brake pads.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--7","ques":"Log in as alice.j@test.com with password TestPass123!, open Alice's messages, and identify what showing times the leasing office offered for the Berkeley studio.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--8","ques":"From the homepage, browse to community events and find the Saturday plant swap. Open it and report the time and what attendees should bring.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--9","ques":"Search free stuff in the east bay, find the listing for moving boxes near Oakland, and reply that you can pick them up after 6pm.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--10","ques":"Search jobs for speech language pathologist, open the school-year role, and report the compensation range, setting, and required license.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--11","ques":"Find the remote algebra and calculus tutoring service, open the detail page, and send a reply asking for two AP calculus time slots next week.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--12","ques":"Log in as alice.j@test.com with password TestPass123! and update the account area to peninsula and the phone number to (650) 555-0144.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--13","ques":"Log in as alice.j@test.com with password TestPass123!, open Alice's saved listings, and remove the saved item that is a Honda Accord, leaving the saved furniture and apartment items untouched.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--14","ques":"Search electronics for the Dell 27 inch USB-C monitor in Sunnyvale, open its detail page, and report its resolution and ports.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--15","ques":"Search moving services, find the Oakland van crew listing for two-person moving help, open it, and report whether the truck is included before replying with a request for a Saturday morning estimate.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--16","ques":"Hide the Honda Passport project SUV listing from search results, then search Honda again and confirm the Accord and Odyssey remain visible.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--17","ques":"Find the volunteer bike repair clinic in the east bay. Open its page and report the time and the listed skill requirement.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--18","ques":"Search furniture for a walnut writing desk in San Francisco under $150. Open the Mission District listing titled \"Walnut writing desk with two drawers\" and report its width and depth.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} -{"web_name":"Craigslist","id":"Craigslist--19","ques":"Search furniture for a standing desk frame in the peninsula. Open the white frame listing in Palo Alto and report the height range and load rating.","web":"http://localhost:40016/","upstream_url":"https://craigslist.org/"} +{"web_name": "Craigslist", "id": "Craigslist--0", "ques": "Log in as alice.j@test.com with password TestPass123!, search furniture in the east bay for a chair under $100, open the result whose details say it has adjustable arms, and save it.", "web": "http://localhost:40016/", "upstream_url": "https://craigslist.org/", "verifier_path": "sites/craigslist/verify/verify_0.py", "judge_rubric": "MUST log in as alice, open the adjustable-arms chair (Ergonomic task chair), and save it. TASK BUG: this chair is already in alice's pre-seeded saved list, so the save can't be verified — the task should target a non-pre-saved chair."} +{"web_name": "Craigslist", "id": "Craigslist--1", "ques": "Find an east bay apartment or studio under $2400 that has in-unit laundry. Open the listing and report its square footage and pet policy.", "web": "http://localhost:40016/", "upstream_url": "https://craigslist.org/", "verifier_path": "sites/craigslist/verify/verify_1.py", "judge_rubric": "MUST open the east-bay sub-$2400 in-unit-laundry studio (Sunny studio near Berkeley BART). Answer = 510 sq ft; pet policy: cats ok."} +{"web_name": "Craigslist", "id": "Craigslist--2", "ques": "Search bikes for a commuter bike under $500. Choose the listing whose detail table says it has hydraulic disc brakes, then send the seller a reply asking whether weekend pickup is possible.", "web": "http://localhost:40016/", "upstream_url": "https://craigslist.org/", "verifier_path": "sites/craigslist/verify/verify_2.py", "judge_rubric": "MUST open the hydraulic-disc commuter bike (Marin commuter bike) and send the seller a reply about weekend pickup. A reply must be recorded on that listing."} +{"web_name": "Craigslist", "id": "Craigslist--3", "ques": "Log in as alice.j@test.com with password TestPass123!, find the 2006 Honda Accord EX sedan listing under $7000 with clean title status, open its detail page, and save it to Alice's account.", "web": "http://localhost:40016/", "upstream_url": "https://craigslist.org/", "verifier_path": "sites/craigslist/verify/verify_3.py", "judge_rubric": "MUST log in as alice, open the 2006 Honda Accord EX (under $7000, clean title), and save it. TASK BUG: this Accord is already pre-saved (it's the item T13 removes), so the save can't be verified — the task should use a non-pre-saved Accord."} +{"web_name": "Craigslist", "id": "Craigslist--4", "ques": "Search for studio apartments and compare the Berkeley studio near BART with the quiet San Mateo studio. Which one has the lower rent per square foot?", "web": "http://localhost:40016/", "upstream_url": "https://craigslist.org/", "verifier_path": "sites/craigslist/verify/verify_4.py", "judge_rubric": "MUST open BOTH the Berkeley BART studio and the San Mateo studio. Answer = the Berkeley studio has the lower rent per square foot ($4.30/sqft vs the San Mateo studio's $5.47/sqft)."} +{"web_name": "Craigslist", "id": "Craigslist--5", "ques": "Log in as alice.j@test.com with password TestPass123!, save a search named \"Gaming chair watch\" for furniture listings matching gaming chair with a maximum price of $200.", "web": "http://localhost:40016/", "upstream_url": "https://craigslist.org/", "verifier_path": "sites/craigslist/verify/verify_5.py", "judge_rubric": "MUST log in as alice and save a search named \"Gaming chair watch\" (furniture, gaming chair, max $200). The saved search must exist afterward."} +{"web_name": "Craigslist", "id": "Craigslist--6", "ques": "Create a new posting in the bikes category for a blue commuter bike in Oakland priced at $325, with a short body mentioning a rear rack and recently replaced brake pads.", "web": "http://localhost:40016/", "upstream_url": "https://craigslist.org/", "verifier_path": "sites/craigslist/verify/verify_6.py", "judge_rubric": "MUST create a new posting in the bikes category for a blue commuter bike in Oakland priced at $325. Afterward a new posting owned by alice (bikes / Oakland / $325) must exist."} +{"web_name": "Craigslist", "id": "Craigslist--7", "ques": "Log in as alice.j@test.com with password TestPass123!, open Alice's messages, and identify what showing times the leasing office offered for the Berkeley studio.", "web": "http://localhost:40016/", "upstream_url": "https://craigslist.org/", "verifier_path": "sites/craigslist/verify/verify_7.py", "judge_rubric": "MUST log in as alice and open Alice's messages. Answer = the leasing office offered showings Wednesday at 5:30pm or Thursday at noon."} +{"web_name": "Craigslist", "id": "Craigslist--8", "ques": "From the homepage, browse to community events and find the Saturday plant swap. Open it and report the time and what attendees should bring.", "web": "http://localhost:40016/", "upstream_url": "https://craigslist.org/", "verifier_path": "sites/craigslist/verify/verify_8.py", "judge_rubric": "MUST open the Saturday plant swap. Answer = 10:00 AM; attendees should bring labeled plants."} +{"web_name": "Craigslist", "id": "Craigslist--9", "ques": "Search free stuff in the east bay, find the listing for moving boxes near Oakland, and reply that you can pick them up after 6pm.", "web": "http://localhost:40016/", "upstream_url": "https://craigslist.org/", "verifier_path": "sites/craigslist/verify/verify_9.py", "judge_rubric": "MUST open the Oakland free moving-boxes listing and reply that you can pick them up after 6pm. A reply must be recorded on that listing."} +{"web_name": "Craigslist", "id": "Craigslist--10", "ques": "Search jobs for speech language pathologist, open the school-year role, and report the compensation range, setting, and required license.", "web": "http://localhost:40016/", "upstream_url": "https://craigslist.org/", "verifier_path": "sites/craigslist/verify/verify_10.py", "judge_rubric": "MUST open the speech-language-pathologist school-year role. Answer = $62-$70 per hour; setting K-8; license: CA SLP required."} +{"web_name": "Craigslist", "id": "Craigslist--11", "ques": "Find the remote algebra and calculus tutoring service, open the detail page, and send a reply asking for two AP calculus time slots next week.", "web": "http://localhost:40016/", "upstream_url": "https://craigslist.org/", "verifier_path": "sites/craigslist/verify/verify_11.py", "judge_rubric": "MUST open the remote algebra and calculus tutoring listing and send a reply asking for two AP calculus time slots. A reply must be recorded."} +{"web_name": "Craigslist", "id": "Craigslist--12", "ques": "Log in as alice.j@test.com with password TestPass123! and update the account area to peninsula and the phone number to (650) 555-0144.", "web": "http://localhost:40016/", "upstream_url": "https://craigslist.org/", "verifier_path": "sites/craigslist/verify/verify_12.py", "judge_rubric": "MUST log in as alice and update the account area to peninsula and the phone number to (650) 555-0144."} +{"web_name": "Craigslist", "id": "Craigslist--13", "ques": "Log in as alice.j@test.com with password TestPass123!, open Alice's saved listings, and remove the saved item that is a Honda Accord, leaving the saved furniture and apartment items untouched.", "web": "http://localhost:40016/", "upstream_url": "https://craigslist.org/", "verifier_path": "sites/craigslist/verify/verify_13.py", "judge_rubric": "MUST log in as alice and remove the saved Honda Accord from saved listings, leaving the saved chair and studio untouched."} +{"web_name": "Craigslist", "id": "Craigslist--14", "ques": "Search electronics for the Dell 27 inch USB-C monitor in Sunnyvale, open its detail page, and report its resolution and ports.", "web": "http://localhost:40016/", "upstream_url": "https://craigslist.org/", "verifier_path": "sites/craigslist/verify/verify_14.py", "judge_rubric": "MUST open the Dell 27 inch USB-C monitor (Sunnyvale). Answer = resolution 2560x1440; ports USB-C, HDMI, DP."} +{"web_name": "Craigslist", "id": "Craigslist--15", "ques": "Search moving services, find the Oakland van crew listing for two-person moving help, open it, and report whether the truck is included before replying with a request for a Saturday morning estimate.", "web": "http://localhost:40016/", "upstream_url": "https://craigslist.org/", "verifier_path": "sites/craigslist/verify/verify_15.py", "judge_rubric": "MUST open the Oakland van crew (two-person moving help) listing, note that a cargo van IS included, and reply requesting a Saturday-morning estimate. A reply must be recorded."} +{"web_name": "Craigslist", "id": "Craigslist--16", "ques": "Hide the Honda Passport project SUV listing from search results, then search Honda again and confirm the Accord and Odyssey remain visible.", "web": "http://localhost:40016/", "upstream_url": "https://craigslist.org/", "verifier_path": "sites/craigslist/verify/verify_16.py", "judge_rubric": "MUST hide the Honda Passport project SUV from search; the Accord and Odyssey must remain visible (not hidden)."} +{"web_name": "Craigslist", "id": "Craigslist--17", "ques": "Find the volunteer bike repair clinic in the east bay. Open its page and report the time and the listed skill requirement.", "web": "http://localhost:40016/", "upstream_url": "https://craigslist.org/", "verifier_path": "sites/craigslist/verify/verify_17.py", "judge_rubric": "MUST open the volunteer bike repair clinic (east bay). Answer = 1:00 PM; skill requirement: basic bike repair."} +{"web_name": "Craigslist", "id": "Craigslist--18", "ques": "Search furniture for a walnut writing desk in San Francisco under $150. Open the Mission District listing titled \"Walnut writing desk with two drawers\" and report its width and depth.", "web": "http://localhost:40016/", "upstream_url": "https://craigslist.org/", "verifier_path": "sites/craigslist/verify/verify_18.py", "judge_rubric": "MUST open the Mission District 'Walnut writing desk with two drawers'. Answer = width 42 in, depth 22 in."} +{"web_name": "Craigslist", "id": "Craigslist--19", "ques": "Search furniture for a standing desk frame in the peninsula. Open the white frame listing in Palo Alto and report the height range and load rating.", "web": "http://localhost:40016/", "upstream_url": "https://craigslist.org/", "verifier_path": "sites/craigslist/verify/verify_19.py", "judge_rubric": "MUST open the white Palo Alto standing desk frame. Answer = height range 25-50 in; load rating 220 lb."} diff --git a/sites/craigslist/verify/verify_0.py b/sites/craigslist/verify/verify_0.py new file mode 100644 index 00000000..b45de3c2 --- /dev/null +++ b/sites/craigslist/verify/verify_0.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""Craigslist--0: log in as alice, search furniture east bay for a chair under $100 with adjustable +arms, open it, and save it. Target = 'Ergonomic task chair' (id 1, $85, arms: adjustable). + +TASK BUG (see review): id 1 is ALREADY in alice's pre-seeded saved listings, so a genuine save is a +no-op and can't be distinguished from the initial state. This verifier checks navigation to the +listing + that it is saved after; it CANNOT confirm the agent added it. Fix the task (use a +non-pre-saved chair) to make it verifiable. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, opened_listing, navigated_any, resolve_db, saved_listing_ids, + Judge, parse_args) +EMAIL = "alice.j@test.com"; LID = 1 +def main(): + a = parse_args(); j = Judge('Craigslist--0', a.no_llm) + t = load_run(a.run_dir) + after = resolve_db(a.after_db, a.container, "instance") + j.check("nav_listing_or_saved", opened_listing(t, LID) or navigated_any(t, ["/saved", f"/listing/{LID}/save"]), + "expected the adjustable-arms chair (id 1) / a save action") + sa = saved_listing_ids(after, EMAIL) or [] + j.check("db_saved_present", LID in sa, + f"chair id {LID} must be in alice's saved listings (NOTE: pre-seeded; cannot confirm the add)") + j.emit() +if __name__ == "__main__": + main() diff --git a/sites/craigslist/verify/verify_1.py b/sites/craigslist/verify/verify_1.py new file mode 100644 index 00000000..8a837232 --- /dev/null +++ b/sites/craigslist/verify/verify_1.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +"""Craigslist--1: east bay apartment/studio under $2400 with in-unit laundry — report sqft + pet +policy. GT: 'Sunny studio near Berkeley BART' (id 21): 510 sqft, cats ok (the only east-bay +sub-$2400 listing with in-unit laundry).""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, opened_listing, number_mentioned, contains_any, + llm_text_match, Judge, parse_args) +def main(): + a = parse_args(); j = Judge('Craigslist--1', a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + j.check("nav_listing", opened_listing(t, 21), "expected the Berkeley BART studio (id 21)") + j.check("answer_sqft", number_mentioned(fa, 510), f"expected 510 sqft; final={fa!r}") + j.check("answer_pets", contains_any(fa, ["cats ok", "cat"]), "expected pet policy 'cats ok'") + ok, ev = llm_text_match(fa, "510 square feet; pet policy: cats ok", + "Report the studio's square footage and pet policy.") + j.check("answer_consistent", ok, ev, llm=True) + j.emit() +if __name__ == "__main__": + main() diff --git a/sites/craigslist/verify/verify_10.py b/sites/craigslist/verify/verify_10.py new file mode 100644 index 00000000..ce4c01e5 --- /dev/null +++ b/sites/craigslist/verify/verify_10.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +"""Craigslist--10: jobs — speech language pathologist school-year role; report compensation range, +setting, and required license. GT (id 30): $62-$70 per hour; setting K-8; license CA SLP required.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, opened_listing, contains_any, count_matches, + llm_text_match, Judge, parse_args) +def main(): + a = parse_args(); j = Judge('Craigslist--10', a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + j.check("nav_listing", opened_listing(t, 30), "expected the SLP school-year role (id 30)") + hits = count_matches(fa, ["62", "70", "K-8", "CA SLP", "SLP required", "license"]) + j.check("answer_facts", contains_any(fa, ["62", "70"]) and hits >= 2, + f"expected $62-$70/hr, K-8, CA SLP required; final={fa!r}") + ok, ev = llm_text_match(fa, "$62-$70 per hour; setting K-8; license CA SLP required", + "Report the compensation range, setting, and required license.") + j.check("answer_consistent", ok, ev, llm=True) + j.emit() +if __name__ == "__main__": + main() diff --git a/sites/craigslist/verify/verify_11.py b/sites/craigslist/verify/verify_11.py new file mode 100644 index 00000000..79b23df0 --- /dev/null +++ b/sites/craigslist/verify/verify_11.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Craigslist--11: find the remote algebra and calculus tutoring service (id 33), open it, and reply +asking for two AP calculus time slots next week. +Deterministic-first: nav | DB after-state: a NEW message exists on listing 33.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, opened_listing, navigated_to, resolve_db, messages_on_listing, + Judge, parse_args) +LID = 33 +def main(): + a = parse_args(); j = Judge('Craigslist--11', a.no_llm) + t = load_run(a.run_dir) + after = resolve_db(a.after_db, a.container, "instance") + init = resolve_db(a.initial_db, a.container, "instance_seed") + na = len(messages_on_listing(after, LID) or []); ni = len(messages_on_listing(init, LID) or []) + j.check("nav_listing", opened_listing(t, LID) or navigated_to(t, f"/reply/{LID}"), + "expected the remote algebra/calculus tutoring listing (id 33)") + j.check("db_reply_sent", na > ni, f"a reply must be sent on listing {LID}: after={na} initial={ni}") + j.emit() +if __name__ == "__main__": + main() diff --git a/sites/craigslist/verify/verify_12.py b/sites/craigslist/verify/verify_12.py new file mode 100644 index 00000000..d537eb89 --- /dev/null +++ b/sites/craigslist/verify/verify_12.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +"""Craigslist--12: log in as alice and update the account area to peninsula and the phone number to +(650) 555-0144. Deterministic-first: nav /account/edit | DB after-state: alice's area is 'peninsula' +and her phone contains 650 555 0144.""" +import os, sys, re +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, navigated_any, resolve_db, user_field, Judge, parse_args) +EMAIL = "alice.j@test.com" +def main(): + a = parse_args(); j = Judge('Craigslist--12', a.no_llm) + t = load_run(a.run_dir) + after = resolve_db(a.after_db, a.container, "instance") + area = (user_field(after, EMAIL, "area") or "").strip().lower() + phone_digits = re.sub(r"\D", "", user_field(after, EMAIL, "phone") or "") + j.check("nav_account_edit", navigated_any(t, ["/account/edit", "/account"]), "expected the account edit page") + j.check("db_area_peninsula", area == "peninsula", f"area must be 'peninsula'; got {area!r}") + j.check("db_phone_updated", "6505550144" in phone_digits, f"phone must be (650) 555-0144; digits={phone_digits}") + j.emit() +if __name__ == "__main__": + main() diff --git a/sites/craigslist/verify/verify_13.py b/sites/craigslist/verify/verify_13.py new file mode 100644 index 00000000..a9878565 --- /dev/null +++ b/sites/craigslist/verify/verify_13.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Craigslist--13: log in as alice, open saved listings, remove the saved Honda Accord (id 13), +leaving the saved furniture (chair id 1) and apartment (studio id 21) untouched. +Deterministic-first: nav /saved | DB after-state: id 13 was saved initially and is GONE after, while +id 1 and id 21 remain saved.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, navigated_any, resolve_db, saved_listing_ids, Judge, parse_args) +EMAIL = "alice.j@test.com"; REMOVE = 13; KEEP = [1, 21] +def main(): + a = parse_args(); j = Judge('Craigslist--13', a.no_llm) + t = load_run(a.run_dir) + after = resolve_db(a.after_db, a.container, "instance") + init = resolve_db(a.initial_db, a.container, "instance_seed") + sa = saved_listing_ids(after, EMAIL) or [] + si = saved_listing_ids(init, EMAIL) or [] + j.check("nav_saved", navigated_any(t, ["/saved", "/listing/13/unsave"]), "expected the saved listings page") + j.check("db_accord_removed", REMOVE in si and REMOVE not in sa, + f"Accord id {REMOVE} initial={REMOVE in si} after={REMOVE in sa} (must be removed)") + j.check("db_others_kept", all(k in sa for k in KEEP), + f"the chair (1) and studio (21) must remain saved; after={sa}") + j.emit() +if __name__ == "__main__": + main() diff --git a/sites/craigslist/verify/verify_14.py b/sites/craigslist/verify/verify_14.py new file mode 100644 index 00000000..ef97d778 --- /dev/null +++ b/sites/craigslist/verify/verify_14.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Craigslist--14: electronics — the Dell 27 inch USB-C monitor in Sunnyvale; report its resolution +and ports. GT (id 9): resolution 2560x1440; ports USB-C, HDMI, DP.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, opened_listing, contains_any, contains_all, + llm_text_match, Judge, parse_args) +def main(): + a = parse_args(); j = Judge('Craigslist--14', a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + j.check("nav_listing", opened_listing(t, 9), "expected the Dell 27\" USB-C monitor (id 9)") + j.check("answer_res", contains_any(fa, ["2560x1440", "2560 x 1440", "1440p", "qhd"]), + f"expected resolution 2560x1440; final={fa!r}") + j.check("answer_ports", contains_all(fa, ["usb-c"]) and contains_any(fa, ["hdmi", "dp", "displayport"]), + "expected ports USB-C / HDMI / DP") + ok, ev = llm_text_match(fa, "resolution 2560x1440; ports USB-C, HDMI, DP", + "Report the monitor's resolution and ports.") + j.check("answer_consistent", ok, ev, llm=True) + j.emit() +if __name__ == "__main__": + main() diff --git a/sites/craigslist/verify/verify_15.py b/sites/craigslist/verify/verify_15.py new file mode 100644 index 00000000..c394e13a --- /dev/null +++ b/sites/craigslist/verify/verify_15.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Craigslist--15: search moving services, find the Oakland van crew for two-person moving help +(id 61), open it, report whether the truck is included, then reply requesting a Saturday-morning +estimate. GT: yes — a cargo van is included. +Deterministic-first: nav | answer states the van is included | DB after-state: a NEW message on +listing 61.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, opened_listing, navigated_to, contains_any, + resolve_db, messages_on_listing, Judge, parse_args) +LID = 61 +def main(): + a = parse_args(); j = Judge('Craigslist--15', a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + after = resolve_db(a.after_db, a.container, "instance") + init = resolve_db(a.initial_db, a.container, "instance_seed") + na = len(messages_on_listing(after, LID) or []); ni = len(messages_on_listing(init, LID) or []) + j.check("nav_listing", opened_listing(t, LID) or navigated_to(t, f"/reply/{LID}"), + "expected the Oakland van crew listing (id 61)") + j.check("answer_truck_included", contains_any(fa, ["van", "included", "yes"]), + f"expected that the (cargo) van is included; final={fa!r}") + j.check("db_reply_sent", na > ni, f"a reply must be sent on listing {LID}: after={na} initial={ni}") + j.emit() +if __name__ == "__main__": + main() diff --git a/sites/craigslist/verify/verify_16.py b/sites/craigslist/verify/verify_16.py new file mode 100644 index 00000000..45921e1c --- /dev/null +++ b/sites/craigslist/verify/verify_16.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Craigslist--16: hide the Honda Passport project SUV listing (id 18) from search results, then +search Honda again and confirm the Accord and Odyssey remain visible. +Deterministic-first: nav | DB after-state: id 18 is in alice's hidden_listings after (and was not +initially), while the Accord (id 13) and any Odyssey remain un-hidden.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, navigated_any, resolve_db, hidden_listing_ids, Judge, parse_args) +EMAIL = "alice.j@test.com"; HIDE = 18 +def main(): + a = parse_args(); j = Judge('Craigslist--16', a.no_llm) + t = load_run(a.run_dir) + after = resolve_db(a.after_db, a.container, "instance") + init = resolve_db(a.initial_db, a.container, "instance_seed") + ha = hidden_listing_ids(after, EMAIL) or [] + hi = hidden_listing_ids(init, EMAIL) or [] + j.check("nav_hide", navigated_any(t, [f"/listing/{HIDE}/hide", "/search"]), "expected the hide action / search") + j.check("db_passport_hidden", HIDE in ha and HIDE not in hi, + f"Honda Passport id {HIDE} hidden after={HIDE in ha} initial={HIDE in hi}") + j.check("db_others_not_hidden", 13 not in ha, + "the Accord (id 13) must remain visible (not hidden)") + j.emit() +if __name__ == "__main__": + main() diff --git a/sites/craigslist/verify/verify_17.py b/sites/craigslist/verify/verify_17.py new file mode 100644 index 00000000..e1793db2 --- /dev/null +++ b/sites/craigslist/verify/verify_17.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +"""Craigslist--17: volunteer bike repair clinic in the east bay; report the time and skill +requirement. GT (id 36): 1:00 PM; skill: basic bike repair.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, opened_listing, contains_any, + llm_text_match, Judge, parse_args) +def main(): + a = parse_args(); j = Judge('Craigslist--17', a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + j.check("nav_listing", opened_listing(t, 36), "expected the volunteer bike repair clinic (id 36)") + j.check("answer_time", contains_any(fa, ["1:00 pm", "1 pm", "1pm"]), f"expected 1:00 PM; final={fa!r}") + j.check("answer_skill", contains_any(fa, ["basic bike repair", "bike repair"]), + "expected skill 'basic bike repair'") + ok, ev = llm_text_match(fa, "1:00 PM; skill requirement: basic bike repair", + "Report the clinic's time and skill requirement.") + j.check("answer_consistent", ok, ev, llm=True) + j.emit() +if __name__ == "__main__": + main() diff --git a/sites/craigslist/verify/verify_18.py b/sites/craigslist/verify/verify_18.py new file mode 100644 index 00000000..e2abb443 --- /dev/null +++ b/sites/craigslist/verify/verify_18.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +"""Craigslist--18: furniture — the Mission District 'Walnut writing desk with two drawers' in SF +under $150; report its width and depth. GT (id 7): width 42 in, depth 22 in.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, opened_listing, contains_any, + llm_text_match, Judge, parse_args) +def main(): + a = parse_args(); j = Judge('Craigslist--18', a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + j.check("nav_listing", opened_listing(t, 7), "expected the Walnut writing desk (id 7)") + j.check("answer_dims", contains_any(fa, ["42"]) and contains_any(fa, ["22"]), + f"expected width 42 in and depth 22 in; final={fa!r}") + ok, ev = llm_text_match(fa, "width 42 in, depth 22 in", + "Report the desk's width and depth.") + j.check("answer_consistent", ok, ev, llm=True) + j.emit() +if __name__ == "__main__": + main() diff --git a/sites/craigslist/verify/verify_19.py b/sites/craigslist/verify/verify_19.py new file mode 100644 index 00000000..66339390 --- /dev/null +++ b/sites/craigslist/verify/verify_19.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +"""Craigslist--19: furniture — the white standing desk frame in Palo Alto; report the height range +and load rating. GT (id 8): height range 25-50 in; load rating 220 lb.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, opened_listing, contains_any, + llm_text_match, Judge, parse_args) +def main(): + a = parse_args(); j = Judge('Craigslist--19', a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + j.check("nav_listing", opened_listing(t, 8), "expected the white standing desk frame (id 8)") + j.check("answer_height", contains_any(fa, ["25-50", "25 - 50", "25 to 50"]), + f"expected height range 25-50 in; final={fa!r}") + j.check("answer_load", contains_any(fa, ["220"]), "expected load rating 220 lb") + ok, ev = llm_text_match(fa, "height range 25-50 in; load rating 220 lb", + "Report the standing desk frame's height range and load rating.") + j.check("answer_consistent", ok, ev, llm=True) + j.emit() +if __name__ == "__main__": + main() diff --git a/sites/craigslist/verify/verify_2.py b/sites/craigslist/verify/verify_2.py new file mode 100644 index 00000000..dd8013eb --- /dev/null +++ b/sites/craigslist/verify/verify_2.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +"""Craigslist--2: search bikes for a commuter bike under $500, choose the one whose detail table +says hydraulic disc brakes (Marin commuter bike, id 11), then reply asking about weekend pickup. +Deterministic-first: nav to the listing | DB after-state: a NEW message exists on listing 11.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, opened_listing, navigated_to, resolve_db, messages_on_listing, + Judge, parse_args) +LID = 11 +def main(): + a = parse_args(); j = Judge('Craigslist--2', a.no_llm) + t = load_run(a.run_dir) + after = resolve_db(a.after_db, a.container, "instance") + init = resolve_db(a.initial_db, a.container, "instance_seed") + na = len(messages_on_listing(after, LID) or []) + ni = len(messages_on_listing(init, LID) or []) + j.check("nav_listing", opened_listing(t, LID) or navigated_to(t, f"/reply/{LID}"), + "expected the Marin hydraulic-disc bike (id 11)") + j.check("db_reply_sent", na > ni, f"a reply must be sent on listing {LID}: after={na} initial={ni}") + j.emit() +if __name__ == "__main__": + main() diff --git a/sites/craigslist/verify/verify_3.py b/sites/craigslist/verify/verify_3.py new file mode 100644 index 00000000..5f06d2cc --- /dev/null +++ b/sites/craigslist/verify/verify_3.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +"""Craigslist--3: log in as alice, find the 2006 Honda Accord EX sedan under $7000 with clean title +(id 13), open it, and save it. + +TASK BUG (see review): id 13 is ALREADY in alice's pre-seeded saved listings (it is the item task +T13 later removes), so a genuine save is a no-op here and cannot be distinguished from the initial +state. This verifier checks navigation + that it is saved after; it CANNOT confirm the add. Fix the +task (use a non-pre-saved Accord, or drop the pre-seed) to make it verifiable. +""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, opened_listing, navigated_any, resolve_db, saved_listing_ids, + Judge, parse_args) +EMAIL = "alice.j@test.com"; LID = 13 +def main(): + a = parse_args(); j = Judge('Craigslist--3', a.no_llm) + t = load_run(a.run_dir) + after = resolve_db(a.after_db, a.container, "instance") + j.check("nav_listing_or_save", opened_listing(t, LID) or navigated_any(t, ["/saved", f"/listing/{LID}/save"]), + "expected the 2006 Honda Accord EX (id 13) / a save action") + sa = saved_listing_ids(after, EMAIL) or [] + j.check("db_saved_present", LID in sa, + f"Accord id {LID} must be in alice's saved listings (NOTE: pre-seeded; cannot confirm the add)") + j.emit() +if __name__ == "__main__": + main() diff --git a/sites/craigslist/verify/verify_4.py b/sites/craigslist/verify/verify_4.py new file mode 100644 index 00000000..89db377a --- /dev/null +++ b/sites/craigslist/verify/verify_4.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +"""Craigslist--4: compare the Berkeley BART studio (id 21) with the quiet San Mateo studio (id 27); +which has the lower rent per square foot. GT: Berkeley ($2195/510 = $4.30/sqft) is lower than San +Mateo ($2350/430 = $5.47/sqft).""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, opened_listing, contains_any, + llm_text_match, Judge, parse_args) +def main(): + a = parse_args(); j = Judge('Craigslist--4', a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + j.check("nav_both_studios", opened_listing(t, 21) and opened_listing(t, 27), + "expected both the Berkeley (id 21) and San Mateo (id 27) studios") + j.check("answer_berkeley", contains_any(fa, ["berkeley"]), + f"expected the Berkeley studio as lower rent/sqft; final={fa!r}") + ok, ev = llm_text_match(fa, "the Berkeley BART studio has the lower rent per square foot " + "($4.30/sqft vs the San Mateo studio's $5.47/sqft)", + "Which studio has the lower rent per square foot?") + j.check("answer_consistent", ok, ev, llm=True) + j.emit() +if __name__ == "__main__": + main() diff --git a/sites/craigslist/verify/verify_5.py b/sites/craigslist/verify/verify_5.py new file mode 100644 index 00000000..688f49a8 --- /dev/null +++ b/sites/craigslist/verify/verify_5.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Craigslist--5: log in as alice, save a search named "Gaming chair watch" for furniture matching +'gaming chair' with a max price of $200. Deterministic-first: nav | DB after-state: a saved search +named 'Gaming chair watch' exists for alice (not in the initial seed).""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, navigated_any, resolve_db, saved_searches_for, Judge, parse_args) +EMAIL = "alice.j@test.com"; NAME = "gaming chair watch" +def main(): + a = parse_args(); j = Judge('Craigslist--5', a.no_llm) + t = load_run(a.run_dir) + after = resolve_db(a.after_db, a.container, "instance") + init = resolve_db(a.initial_db, a.container, "instance_seed") + names_a = [n.strip().lower() for n, _, _ in (saved_searches_for(after, EMAIL) or [])] + names_i = [n.strip().lower() for n, _, _ in (saved_searches_for(init, EMAIL) or [])] + j.check("nav_save_search", navigated_any(t, ["/save-search", "/search"]), "expected the save-search flow") + j.check("db_saved_search", NAME in names_a and NAME not in names_i, + f"saved search '{NAME}' after={NAME in names_a} initial={NAME in names_i}") + j.emit() +if __name__ == "__main__": + main() diff --git a/sites/craigslist/verify/verify_6.py b/sites/craigslist/verify/verify_6.py new file mode 100644 index 00000000..dd8dfc42 --- /dev/null +++ b/sites/craigslist/verify/verify_6.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 +"""Craigslist--6: create a new posting in the bikes category for a blue commuter bike in Oakland +priced at $325 (body mentioning a rear rack and recently replaced brake pads). +Deterministic-first: nav /post | DB after-state: a new listing owned by alice in the bikes category, +area east bay/oakland, price 325.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, navigated_to, resolve_db, listings_by_owner, Judge, parse_args) +EMAIL = "alice.j@test.com" +def main(): + a = parse_args(); j = Judge('Craigslist--6', a.no_llm) + t = load_run(a.run_dir) + after = resolve_db(a.after_db, a.container, "instance") + init = resolve_db(a.initial_db, a.container, "instance_seed") + la = listings_by_owner(after, EMAIL) or [] + li = listings_by_owner(init, EMAIL) or [] + j.check("nav_post", navigated_to(t, "/post"), "expected the posting form") + j.check("db_listing_created", len(la) > len(li), f"a new posting must exist: after={len(la)} initial={len(li)}") + match = any(cat == "bikes" and float(price or 0) == 325 and + ("oakland" in (area or "").lower() or "east bay" in (area or "").lower()) + for _, _, cat, area, price in la) + j.check("db_bike_oakland_325", match, "the new posting must be a bikes/Oakland/$325 listing") + j.emit() +if __name__ == "__main__": + main() diff --git a/sites/craigslist/verify/verify_7.py b/sites/craigslist/verify/verify_7.py new file mode 100644 index 00000000..ecb6bfb5 --- /dev/null +++ b/sites/craigslist/verify/verify_7.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +"""Craigslist--7: log in as alice, open Alice's messages, identify the showing times the leasing +office offered for the Berkeley studio. GT (pre-seeded inbound message on listing 21): "The +Berkeley studio can be shown Wednesday at 5:30pm or Thursday at noon." """ +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, navigated_to, contains_all, + llm_text_match, Judge, parse_args) +def main(): + a = parse_args(); j = Judge('Craigslist--7', a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + j.check("nav_messages", navigated_to(t, "/messages"), "expected Alice's messages") + j.check("answer_times", contains_all(fa, ["wednesday", "thursday"]), + f"expected both showing times (Wed 5:30pm / Thu noon); final={fa!r}") + ok, ev = llm_text_match(fa, "Wednesday at 5:30pm or Thursday at noon", + "What showing times did the leasing office offer for the Berkeley studio?") + j.check("answer_consistent", ok, ev, llm=True) + j.emit() +if __name__ == "__main__": + main() diff --git a/sites/craigslist/verify/verify_8.py b/sites/craigslist/verify/verify_8.py new file mode 100644 index 00000000..585861b7 --- /dev/null +++ b/sites/craigslist/verify/verify_8.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Craigslist--8: browse community events, find the Saturday plant swap, report the time and what +attendees should bring. GT: 'Saturday neighborhood plant swap' (id 35): 10:00 AM; bring labeled +plants.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, final_answer, opened_listing, contains_all, contains_any, + llm_text_match, Judge, parse_args) +def main(): + a = parse_args(); j = Judge('Craigslist--8', a.no_llm) + t = load_run(a.run_dir); fa = final_answer(t) + j.check("nav_listing", opened_listing(t, 35), "expected the Saturday plant swap (id 35)") + j.check("answer_time_bring", contains_any(fa, ["10:00", "10 am", "10am"]) and + contains_all(fa, ["labeled plants"]) or contains_any(fa, ["labeled plant"]), + f"expected time 10:00 AM and 'labeled plants'; final={fa!r}") + ok, ev = llm_text_match(fa, "10:00 AM; bring labeled plants", + "What time is the Saturday plant swap and what should attendees bring?") + j.check("answer_consistent", ok, ev, llm=True) + j.emit() +if __name__ == "__main__": + main() diff --git a/sites/craigslist/verify/verify_9.py b/sites/craigslist/verify/verify_9.py new file mode 100644 index 00000000..0fe8fa3c --- /dev/null +++ b/sites/craigslist/verify/verify_9.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +"""Craigslist--9: search free stuff in the east bay, find the moving boxes near Oakland, and reply +that you can pick them up after 6pm. Target = 'Free moving boxes and packing paper' (id 20, Oakland +Temescal); id 52 'Flattened moving boxes' (Oakland) is an acceptable alternate. +Deterministic-first: nav | DB after-state: a NEW message exists on the moving-boxes listing.""" +import os, sys +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from verify_lib import (load_run, opened_listing, navigated_any, resolve_db, messages_on_listing, + Judge, parse_args) +LIDS = [20, 52] +def main(): + a = parse_args(); j = Judge('Craigslist--9', a.no_llm) + t = load_run(a.run_dir) + after = resolve_db(a.after_db, a.container, "instance") + init = resolve_db(a.initial_db, a.container, "instance_seed") + replied = any(len(messages_on_listing(after, i) or []) > len(messages_on_listing(init, i) or []) for i in LIDS) + j.check("nav_listing", any(opened_listing(t, i) for i in LIDS) or navigated_any(t, [f"/reply/{i}" for i in LIDS]), + "expected an Oakland moving-boxes listing (id 20 or 52)") + j.check("db_reply_sent", replied, f"a reply must be sent on an Oakland moving-boxes listing {LIDS}") + j.emit() +if __name__ == "__main__": + main() diff --git a/sites/craigslist/verify/verify_lib.py b/sites/craigslist/verify/verify_lib.py new file mode 100644 index 00000000..ad5423b6 --- /dev/null +++ b/sites/craigslist/verify/verify_lib.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""verify_lib.py — shared deterministic + LLM utilities for the Craigslist mirror. + +Philosophy: DETERMINISTIC FIRST (mirrors sites/merriam_webster/verify/verify_lib.py). + 1. Trajectory navigation gate (anti knowledge-shortcut): the listings are synthetic, so the + agent MUST open the on-site page; a correct answer with no matching navigation is recall. + 2. Answer check against frozen ground truth (listing details / prices / attributes). + 3. DB after-state for stateful tasks: saved_listings / saved_searches / hidden_listings / + messages (replies) / listings (new postings) / users (account edits). + 4. LLM utilities ONLY where exact matching is brittle, ALWAYS anchored on ground truth. + +Input: --run_dir / --initial_db / --after_db / --container / --no_llm +Output: JSON {task_id, pass, reason, evidence[]}; exit 0 PASS / 1 FAIL. +""" +import base64, json, os, re, sqlite3, subprocess, sys, tempfile, urllib.request +from pathlib import Path +from dataclasses import dataclass +import simpleArgParser as sap + +SITE = "craigslist" + +# ---------------------------------------------------------------- trajectory +def load_run(run_dir): + d = Path(run_dir) + traj = json.loads((d / "trajectory.json").read_text()) + traj["_run_dir"] = d + traj["_shots"] = {p.name: p for p in sorted((d / "screenshots").glob("step_*.png"))} + return traj + +def step_urls(traj): + return [s.get("url", "") 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 navigated_re(traj, pattern): + rx = re.compile(pattern) + return any(rx.search(u or "") for u in step_urls(traj)) + +def opened_listing(traj, listing_id): + """True if the agent opened the listing detail page /d/-/.html.""" + return navigated_re(traj, rf"/d/[^/]*/{listing_id}\.html") or navigated_to(traj, f"/{listing_id}.html") + +def final_answer(traj): + return (traj.get("final_answer") or "").strip() + +def last_shot(traj): + for s in reversed(traj.get("steps", [])): + for key in ("screenshot_after", "screenshot_before"): + n = s.get(key) + p = traj["_shots"].get(Path(n).name) if n else None + if p and p.exists(): + return p + shots = sorted(traj["_shots"].values()) + return shots[-1] if shots else None + +# ---------------------------------------------------------------- deterministic answer match +def norm(s): + return re.sub(r"\s+", " ", (s or "").strip()).casefold() + +def contains_all(final, tokens): + f = norm(final); return all(norm(t) in f for t in tokens) + +def contains_any(final, tokens): + f = norm(final); return any(norm(t) in f for t in tokens) + +def count_matches(final, tokens): + f = norm(final); return sum(1 for t in tokens if norm(t) in f) + +def number_mentioned(final, amount): + f = norm(final); return str(amount) in f or f"{amount:,}" in f + +# ---------------------------------------------------------------- DB state +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 + try: + return fetch_db(container, kind) + except Exception: + return None + +def db_query(db_path, sql, params=()): + con = sqlite3.connect(db_path) + try: + return con.execute(sql, params).fetchall() + finally: + con.close() + +# --- Craigslist-specific state helpers (return None if db unavailable) ------- +def user_field(db_path, email, field): + if not db_path: return None + r = db_query(db_path, f"SELECT {field} FROM users WHERE email=?", (email,)) + return r[0][0] if r else None + +def listing_field(db_path, listing_id, field): + if not db_path: return None + r = db_query(db_path, f"SELECT {field} FROM listings WHERE id=?", (listing_id,)) + return r[0][0] if r else None + +def listing_details(db_path, listing_id): + dj = listing_field(db_path, listing_id, "details_json") + return json.loads(dj) if dj else {} + +def saved_listing_ids(db_path, email): + if not db_path: return None + return [r[0] for r in db_query(db_path, + "SELECT s.listing_id FROM saved_listings s JOIN users u ON u.id=s.user_id WHERE u.email=?", (email,))] + +def saved_searches_for(db_path, email): + """Return list of (name, query_text, max_price) for a user, or None.""" + if not db_path: return None + return [tuple(r) for r in db_query(db_path, + "SELECT s.name, s.query_text, s.max_price FROM saved_searches s JOIN users u ON u.id=s.user_id WHERE u.email=?", (email,))] + +def hidden_listing_ids(db_path, email): + if not db_path: return None + return [r[0] for r in db_query(db_path, + "SELECT h.listing_id FROM hidden_listings h JOIN users u ON u.id=h.user_id WHERE u.email=?", (email,))] + +def messages_on_listing(db_path, listing_id): + """Return list of message bodies on a listing (any direction), or None.""" + if not db_path: return None + return [r[0] for r in db_query(db_path, "SELECT body FROM messages WHERE listing_id=?", (listing_id,))] + +def listings_by_owner(db_path, email): + """Return list of (id, title, category_slug, area, price) for a user's postings, or None.""" + if not db_path: return None + return [tuple(r) for r in db_query(db_path, + "SELECT l.id, l.title, l.category_slug, l.area, l.price FROM listings l " + "JOIN users u ON u.id=l.owner_id WHERE u.email=?", (email,))] + +# ---------------------------------------------------------------- shared LLM utilities (anchored) +_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} + b = base.rstrip("/"); endpoint = b if b.endswith("/chat/completions") else b + "/chat/completions" + req = urllib.request.Request(endpoint, data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json", "Authorization": f"Bearer {key}"}) + try: + return json.loads(urllib.request.urlopen(req, timeout=180).read())["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]" + out = _chat([{"role": "user", "content": + f"You are a lenient-but-careful 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 if the agent's answer CONVEYS the ground-truth facts and answers the question. " + f"EXTRA detail or more-specific wording is FINE and must NOT cause a FAIL unless it " + f"CONTRADICTS the ground truth. FAIL only if a key fact is missing, empty, or contradicted.\n" + f"Line 1: PASS or FAIL. Line 2: one-sentence reason."}]) + return _verdict(out) + +# ---------------------------------------------------------------- judge harness + CLI +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 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(): + @dataclass + class VerifyArgs: + run_dir: str = "" + initial_db: str = "" + after_db: str = "" + container: str = os.environ.get("WH_CONTAINER", "wh-review") + no_llm: bool = False + def post_process(self): + if not self.run_dir: + raise SystemExit("--run_dir is required") + return sap.parse_args(VerifyArgs)