diff --git a/Dockerfile b/Dockerfile index 86c17615..490014d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -72,6 +72,6 @@ os.makedirs('instance_seed', exist_ok=True); \ shutil.copy2('instance/rotten_tomatoes.db', 'instance_seed/rotten_tomatoes.db'); \ print('Rotten Tomatoes seed DB generated at build time.')" && rm -rf /opt/WebSyn/rotten_tomatoes/instance -EXPOSE 8101 40000-40023 +EXPOSE 8101 40000-40024 CMD ["/opt/websyn_start.sh"] diff --git a/control_server.py b/control_server.py index 7df0d9ee..f62bae08 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', 'ikea', 'phys_org', 'target', 'ted', 'osu', 'rotten_tomatoes', 'compass', 'walmart_careers', + 'coursera', 'espn', 'merriam_webster', 'ikea', 'phys_org', 'target', 'ted', 'osu', 'rotten_tomatoes', 'compass', 'walmart_careers', '9gag', ] BASE_PORT = 40000 WEBSYN_DIR = '/opt/WebSyn' diff --git a/review-reports/9GAG-FINAL-AUDIT.md b/review-reports/9GAG-FINAL-AUDIT.md new file mode 100644 index 00000000..4b0f3d13 --- /dev/null +++ b/review-reports/9GAG-FINAL-AUDIT.md @@ -0,0 +1,62 @@ +# 9GAG contributor-side final audit + +## Scope + +- Mirror: https://9gag.com/ +- Local site: `http://localhost:40024/` (host verification port `41024`) +- Branch: `add-9gag-mirror` +- Base: upstream `main` at `3600493` +- Docker image: `sha256:0cf5649d5cb058fa05d1887a3559f78b327e84e5a78320f2aa6f795a2ab31dc6` + +## Results + +- All 25 registered WebHarbor sites started successfully. +- `/reset-all` returned every site as ready. +- All 20 9GAG tasks passed from the homepage through visible Playwright controls, with a fresh browser context and site reset for each task. +- 27 representative responsive checks passed across 1440, 390, and 320 px. +- No broken images, stretched images, horizontal document overflow, or out-of-bounds controls were detected. +- Five hardened search families returned 9–10 visible results; targets appeared at positions 2, 2, 4, 5, and 7. +- None of the task-specific detail facts appeared on search-result cards. +- Runtime and seed database MD5 after the final task run and `/reset-all`: `44768940829bf4a8bec07d7bdf072d97`. + +## Seed inventory + +- Posts: 80 +- Users: 4 +- Comments: 4 +- Saved-post rows: 16 +- Vote rows: 12 +- Genuine captured post images: 38 unique files +- Additional captured 9GAG brand/interest references: 27 files + +All four benchmark accounts use `@test.com` addresses and password `TestPass123!`. Both `seed_database()` and `seed_benchmark_users()` gate the entire function; repeated application left database bytes unchanged. + +## Asset package prepared for Hugging Face + +- Archive: `9gag.tar.gz` +- Managed members: 74 +- SHA-256: `6ba9ca464d23bcae76316efff234548f75e428a7570f1d08f004c38a1123841c` +- The archive was validated locally and was not uploaded. + +## Screenshots + +### Desktop — 1440 px + +![9GAG desktop homepage](assets/9gag-homepage-1440.png) + +### Mobile — 390 px + +![9GAG mobile homepage](assets/9gag-homepage-390.png) + +### Narrow mobile — 320 px + +![9GAG narrow mobile homepage](assets/9gag-homepage-320.png) + +## Human integration steps + +1. Upload the prepared `9gag.tar.gz` to the contributor's Hugging Face dataset fork and open an assets PR against `ChilleD/WebHarbor`. +2. After the HF PR merges, update `.assets-revision` to the merged asset commit. +3. Rebuild and rerun the 25-site health, reset-all, task, and MD5 checks. +4. Push `add-9gag-mirror` and open the GitHub PR, linking the merged HF asset commit and this report. + +No Hugging Face or GitHub PR was opened during preparation. diff --git a/review-reports/assets/9gag-homepage-1440.png b/review-reports/assets/9gag-homepage-1440.png new file mode 100644 index 00000000..8ca5077e Binary files /dev/null and b/review-reports/assets/9gag-homepage-1440.png differ diff --git a/review-reports/assets/9gag-homepage-320.png b/review-reports/assets/9gag-homepage-320.png new file mode 100644 index 00000000..4725f596 Binary files /dev/null and b/review-reports/assets/9gag-homepage-320.png differ diff --git a/review-reports/assets/9gag-homepage-390.png b/review-reports/assets/9gag-homepage-390.png new file mode 100644 index 00000000..004721d3 Binary files /dev/null and b/review-reports/assets/9gag-homepage-390.png differ diff --git a/sites/9gag/ASSET_SOURCES.md b/sites/9gag/ASSET_SOURCES.md new file mode 100644 index 00000000..175341f4 --- /dev/null +++ b/sites/9gag/ASSET_SOURCES.md @@ -0,0 +1,24 @@ +# 9GAG asset provenance + +All media in `static/images/` was captured from the rendered public 9GAG homepage on 2026-09-09 with Playwright. The browser exposed the resolved CDN URLs after hydration; only then were the image bytes downloaded. No placeholders, generated images, private data, cookies, or browser-profile material are included. + +## Post media + +- `posts/aD2BwPZ-451efa65.jpg` — https://img-9gag-fun.9cache.com/photo/aD2BwPZ_460s.jpg +- `posts/aVvGONd-41d662a3.jpg` — https://miscmedia-9gag-fun.9cache.com/images/thumbnail-facebook/71134346_1788942069.8925_rYNuRA_700b.jpg +- `posts/aO86yA2-87d68ca6.jpg` — https://img-9gag-fun.9cache.com/photo/aO86yA2_460s.jpg +- `posts/aryPpR7-3497fabc.jpg` — https://img-9gag-fun.9cache.com/photo/aryPpR7_460s.jpg +- `posts/amoDNzv-6cbe8b39.jpg` — https://img-9gag-fun.9cache.com/photo/amoDNzv_700b.jpg +- `posts/aQzYqZq-9c4605da.jpg` — https://img-9gag-fun.9cache.com/photo/aQzYqZq_460s.jpg +- `posts/aLnqKLv-bea354c8.jpg` — https://img-9gag-fun.9cache.com/photo/aLnqKLv_700b.jpg +- `posts/adB3Nj9-f56e7b44.jpg` — https://img-9gag-fun.9cache.com/photo/adB3Nj9_460s.jpg +- `posts/aYQzNgV-f6067981.jpg` — https://img-9gag-fun.9cache.com/photo/aYQzNgV_460s.jpg +- `posts/aQzYqK8-ec9e7408.jpg` — https://img-9gag-fun.9cache.com/photo/aQzYqK8_460s.jpg + +## Brand and interest references + +The files under `static/images/reference/` are the 9GAG logo and interest thumbnails resolved from `miscmedia-9gag-fun.9cache.com` by the same rendered page. Their original URLs and dimensions are retained in the ignored reconnaissance record `scraped_data/capture.json`. + +An additional 28 unique feed images under `static/images/posts/` were captured through Chromium from the public `/v1/group-posts/group/default/type/{hot,trending,fresh}?c=10` responses. Their source post IDs remain in every filename, and the resolved source URLs are retained in the ignored reconnaissance record `scraped_data/feed-posts.json`. + +The 80-row seeded catalog rotates across 38 genuine 9GAG post images while keeping titles, descriptions, authors, tags, and task facts distinct. This avoids blank, generated, or placeholder imagery and limits repetition across feed pages. diff --git a/sites/9gag/_health.py b/sites/9gag/_health.py new file mode 100644 index 00000000..13420bb9 --- /dev/null +++ b/sites/9gag/_health.py @@ -0,0 +1,3 @@ +"""Per-site health probe (optional, called by control_server).""" +def health(): + return {"ok": True, "site": "9gag"} diff --git a/sites/9gag/app.py b/sites/9gag/app.py new file mode 100644 index 00000000..d7daec5f --- /dev/null +++ b/sites/9gag/app.py @@ -0,0 +1,435 @@ +"""A deterministic, task-complete local mirror of 9GAG.""" + +from __future__ import annotations + +import os +import re + +from flask import Flask, flash, redirect, render_template, request, url_for +from flask_login import LoginManager, UserMixin, current_user, login_required, login_user, logout_user +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy import UniqueConstraint +from werkzeug.security import check_password_hash, generate_password_hash + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +INSTANCE_DIR = os.path.join(BASE_DIR, "instance") +os.makedirs(INSTANCE_DIR, exist_ok=True) + +app = Flask(__name__, instance_path=INSTANCE_DIR) +app.config.update( + SECRET_KEY="webharbor-9gag-deterministic-key", + SQLALCHEMY_DATABASE_URI=f"sqlite:///{os.path.join(INSTANCE_DIR, '9gag.db')}", + SQLALCHEMY_TRACK_MODIFICATIONS=False, +) +db = SQLAlchemy(app) +login_manager = LoginManager(app) +login_manager.login_view = "login" + + +class User(UserMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(80), unique=True, nullable=False) + email = db.Column(db.String(160), unique=True, nullable=False) + display_name = db.Column(db.String(120), nullable=False) + password_hash = db.Column(db.String(255), nullable=False) + bio = db.Column(db.String(500), default="") + location = db.Column(db.String(120), default="") + joined_at = db.Column(db.String(30), default="September 2026") + + def set_password(self, password): + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + return check_password_hash(self.password_hash, password) + + +class Post(db.Model): + id = db.Column(db.Integer, primary_key=True) + source_id = db.Column(db.String(20), unique=True, nullable=False) + slug = db.Column(db.String(180), unique=True, nullable=False) + title = db.Column(db.String(300), nullable=False) + description = db.Column(db.Text, nullable=False) + image = db.Column(db.String(255), nullable=False) + section = db.Column(db.String(80), nullable=False) + post_type = db.Column(db.String(30), default="Photo") + tags = db.Column(db.String(400), default="") + author_name = db.Column(db.String(80), nullable=False) + up_votes = db.Column(db.Integer, default=0) + down_votes = db.Column(db.Integer, default=0) + comment_count = db.Column(db.Integer, default=0) + created_rank = db.Column(db.Integer, nullable=False) + featured = db.Column(db.Boolean, default=False) + + @property + def tag_list(self): + return [tag.strip() for tag in self.tags.split("|") if tag.strip()] + + +class Comment(db.Model): + id = db.Column(db.Integer, primary_key=True) + post_id = db.Column(db.Integer, db.ForeignKey("post.id"), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + body = db.Column(db.String(1000), nullable=False) + created_at = db.Column(db.String(40), nullable=False) + post = db.relationship("Post", backref="comments") + user = db.relationship("User") + + +class Vote(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + post_id = db.Column(db.Integer, db.ForeignKey("post.id"), nullable=False) + value = db.Column(db.Integer, nullable=False) + __table_args__ = (UniqueConstraint("user_id", "post_id", name="uq_vote_user_post"),) + + +class SavedPost(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + post_id = db.Column(db.Integer, db.ForeignKey("post.id"), nullable=False) + post = db.relationship("Post") + __table_args__ = (UniqueConstraint("user_id", "post_id", name="uq_saved_user_post"),) + + +class HiddenPost(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + post_id = db.Column(db.Integer, db.ForeignKey("post.id"), nullable=False) + __table_args__ = (UniqueConstraint("user_id", "post_id", name="uq_hidden_user_post"),) + + +class Report(db.Model): + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) + post_id = db.Column(db.Integer, db.ForeignKey("post.id"), nullable=False) + reason = db.Column(db.String(120), nullable=False) + + +@login_manager.user_loader +def load_user(user_id): + return db.session.get(User, int(user_id)) + + +def slugify(value): + return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")[:160] or "post" + + +def scored_posts(query, posts): + stop = {"the", "a", "an", "in", "on", "at", "to", "for", "of", "and", "or", "is", "it", "by", "with"} + tokens = [token for token in re.split(r"\W+", query.lower()) if len(token) > 1 and token not in stop] + if not tokens: + return list(posts) + ranked = [] + for post in posts: + haystack = f"{post.title} {post.description} {post.section} {post.tags} {post.author_name}".lower() + score = sum(3 if token in post.title.lower() else 1 for token in tokens if token in haystack) + if score: + ranked.append((post, score)) + return [post for post, _ in sorted(ranked, key=lambda item: (-item[1], -item[0].up_votes, item[0].id))] + + +def visible_posts(): + query = Post.query + if current_user.is_authenticated: + hidden_ids = [row.post_id for row in HiddenPost.query.filter_by(user_id=current_user.id)] + if hidden_ids: + query = query.filter(~Post.id.in_(hidden_ids)) + return query + + +def render_feed(title="Hot", posts=None, active="home", query=""): + posts = posts if posts is not None else visible_posts().order_by(Post.featured.desc(), Post.up_votes.desc()).all() + page_number = max(request.args.get("page", 1, type=int), 1) + total = len(posts) + page_size = 10 + posts = posts[(page_number - 1) * page_size:page_number * page_size] + saved_ids, votes = set(), {} + if current_user.is_authenticated: + saved_ids = {row.post_id for row in SavedPost.query.filter_by(user_id=current_user.id)} + votes = {row.post_id: row.value for row in Vote.query.filter_by(user_id=current_user.id)} + return render_template("feed.html", title=title, posts=posts, active=active, query=query, + saved_ids=saved_ids, votes=votes, page_number=page_number, + has_previous=page_number > 1, has_next=page_number * page_size < total) + + +@app.route("/") +@app.route("/home") +def index(): + return render_feed() + + +@app.route("/top") +def top(): + return render_feed("Top", visible_posts().order_by(Post.up_votes.desc()).all(), "top") + + +@app.route("/trending") +def trending(): + posts = sorted(visible_posts().all(), key=lambda post: post.up_votes + post.comment_count * 4, reverse=True) + return render_feed("Trending", posts, "trending") + + +@app.route("/fresh") +def fresh(): + return render_feed("Fresh", visible_posts().order_by(Post.created_rank.desc()).all(), "fresh") + + +@app.route("/news") +def news(): + posts = visible_posts().filter(Post.section.in_(["news", "politics", "science"])).order_by(Post.created_rank.desc()).all() + return render_feed("News", posts, "news") + + +@app.route("/interest/
") +def interest(section): + posts = visible_posts().filter(db.func.lower(Post.section) == section.lower()).order_by(Post.up_votes.desc()).all() + return render_feed(section.replace("-", " ").title(), posts, section) + + +@app.route("/tag/") +def tag(tag): + needle = tag.replace("-", " ").lower() + posts = visible_posts().filter(db.func.lower(Post.tags).contains(needle)).all() + return render_feed(f"#{needle}", posts, "", needle) + + +@app.route("/search") +def search(): + query = request.args.get("q", "").strip() + return render_feed("Search", scored_posts(query, visible_posts().all()), "", query) + + +@app.route("/shuffle") +def shuffle(): + return redirect(url_for("post_detail", slug=Post.query.order_by(db.func.random()).first_or_404().slug)) + + +@app.route("/gag/") +def post_detail(slug): + post = Post.query.filter_by(slug=slug).first_or_404() + saved = current_user.is_authenticated and SavedPost.query.filter_by(user_id=current_user.id, post_id=post.id).first() is not None + vote = Vote.query.filter_by(user_id=current_user.id, post_id=post.id).first() if current_user.is_authenticated else None + related = Post.query.filter(Post.section == post.section, Post.id != post.id).order_by(Post.up_votes.desc()).limit(5).all() + return render_template("post_detail.html", post=post, saved=saved, vote=vote, related=related) + + +@app.post("/gag//vote") +@login_required +def vote(slug): + post = Post.query.filter_by(slug=slug).first_or_404() + value = 1 if request.form.get("value") == "1" else -1 + existing = Vote.query.filter_by(user_id=current_user.id, post_id=post.id).first() + if existing and existing.value == value: + db.session.delete(existing) + post.up_votes -= int(value == 1) + post.down_votes -= int(value == -1) + elif existing: + post.up_votes += 1 if value == 1 else -1 + post.down_votes += 1 if value == -1 else -1 + existing.value = value + else: + db.session.add(Vote(user_id=current_user.id, post_id=post.id, value=value)) + post.up_votes += int(value == 1) + post.down_votes += int(value == -1) + db.session.commit() + return redirect(request.referrer or url_for("post_detail", slug=slug)) + + +@app.post("/gag//save") +@login_required +def save_post(slug): + post = Post.query.filter_by(slug=slug).first_or_404() + row = SavedPost.query.filter_by(user_id=current_user.id, post_id=post.id).first() + if row: + db.session.delete(row) + flash("Removed from saved posts.") + else: + db.session.add(SavedPost(user_id=current_user.id, post_id=post.id)) + flash("Saved to your collection.") + db.session.commit() + return redirect(request.referrer or url_for("post_detail", slug=slug)) + + +@app.post("/gag//comment") +@login_required +def add_comment(slug): + post = Post.query.filter_by(slug=slug).first_or_404() + body = request.form.get("body", "").strip() + if len(body) < 2: + flash("Comment must contain at least 2 characters.", "error") + else: + db.session.add(Comment(post_id=post.id, user_id=current_user.id, body=body, created_at="just now")) + post.comment_count += 1 + db.session.commit() + flash("Comment posted.") + return redirect(url_for("post_detail", slug=slug) + "#comments") + + +@app.post("/gag//hide") +@login_required +def hide_post(slug): + post = Post.query.filter_by(slug=slug).first_or_404() + if not HiddenPost.query.filter_by(user_id=current_user.id, post_id=post.id).first(): + db.session.add(HiddenPost(user_id=current_user.id, post_id=post.id)) + db.session.commit() + flash("Post hidden from your feeds.") + return redirect(url_for("index")) + + +@app.route("/gag//report", methods=["GET", "POST"]) +@login_required +def report_post(slug): + post = Post.query.filter_by(slug=slug).first_or_404() + if request.method == "POST": + reason = request.form.get("reason", "").strip() + if reason: + db.session.add(Report(user_id=current_user.id, post_id=post.id, reason=reason)) + db.session.commit() + flash("Thanks. Your report was submitted.") + return redirect(url_for("post_detail", slug=slug)) + flash("Choose a reason.", "error") + return render_template("report.html", post=post) + + +@app.route("/submit", methods=["GET", "POST"]) +@login_required +def submit(): + if request.method == "POST": + title = request.form.get("title", "").strip() + if len(title) < 4: + flash("A title of at least 4 characters is required.", "error") + else: + base, slug, suffix = slugify(title), slugify(title), 2 + while Post.query.filter_by(slug=slug).first(): + slug, suffix = f"{base}-{suffix}", suffix + 1 + post = Post(source_id=f"local-{Post.query.count() + 1}", slug=slug, title=title, + description=request.form.get("description", "").strip() or "Shared with the 9GAG community.", + image=request.form.get("image", "posts/aVvGONd-41d662a3.jpg"), section=request.form.get("section", "humor"), + post_type="Photo", tags=request.form.get("tags", ""), author_name=current_user.username, + up_votes=0, down_votes=0, comment_count=0, created_rank=Post.query.count() + 100) + db.session.add(post) + db.session.commit() + flash("Your post is live.") + return redirect(url_for("post_detail", slug=post.slug)) + return render_template("submit.html") + + +@app.route("/login", methods=["GET", "POST"]) +def login(): + if request.method == "POST": + identity = request.form.get("identity", "").strip() + user = User.query.filter((User.email == identity) | (User.username == identity)).first() + if user and user.check_password(request.form.get("password", "")): + login_user(user) + return redirect(request.args.get("next") or url_for("index")) + flash("Incorrect email, username, or password.", "error") + return render_template("login.html") + + +@app.route("/register", methods=["GET", "POST"]) +def register(): + if request.method == "POST": + email, username, password = request.form.get("email", "").strip().lower(), request.form.get("username", "").strip(), request.form.get("password", "") + if "@" not in email or len(username) < 3 or len(password) < 8: + flash("Enter a valid email, username, and password of at least 8 characters.", "error") + elif User.query.filter((User.email == email) | (User.username == username)).first(): + flash("That email or username is already registered.", "error") + else: + user = User(email=email, username=username, display_name=username, password_hash="") + user.set_password(password) + db.session.add(user) + db.session.commit() + login_user(user) + return redirect(url_for("index")) + return render_template("register.html") + + +@app.route("/logout") +@login_required +def logout(): + logout_user() + return redirect(url_for("index")) + + +@app.route("/account") +@login_required +def account(): + posts = Post.query.filter_by(author_name=current_user.username).order_by(Post.created_rank.desc()).all() + return render_template("account.html", profile=current_user, posts=posts) + + +@app.route("/u/") +def profile(username): + profile_user = User.query.filter_by(username=username).first_or_404() + posts = Post.query.filter_by(author_name=username).order_by(Post.created_rank.desc()).all() + return render_template("account.html", profile=profile_user, posts=posts) + + +@app.route("/settings", methods=["GET", "POST"]) +@login_required +def settings(): + if request.method == "POST": + display_name = request.form.get("display_name", "").strip() + if not display_name: + flash("Display name is required.", "error") + else: + current_user.display_name = display_name + current_user.bio = request.form.get("bio", "").strip()[:500] + current_user.location = request.form.get("location", "").strip()[:120] + db.session.commit() + flash("Profile updated.") + return render_template("settings.html") + + +@app.route("/saved") +@login_required +def saved(): + rows = SavedPost.query.filter_by(user_id=current_user.id).order_by(SavedPost.id.desc()).all() + return render_feed("Saved", [row.post for row in rows], "saved") + + +@app.route("/notifications") +@login_required +def notifications(): + return render_template("notifications.html") + + +@app.route("/about") +def about(): + return render_template("static_page.html", heading="About 9GAG", body="9GAG is a community-powered entertainment platform where people discover and discuss funny, surprising, and timely posts.") + + +@app.route("/rules") +def rules(): + return render_template("static_page.html", heading="Community rules", body="Be respectful, post original context, use accurate interests and tags, and report content that breaks community standards.") + + +@app.route("/apps") +def apps(): + return render_template("apps.html") + + +@app.route("/help") +def help_page(): + return render_template("help.html") + + +@app.route("/terms") +def terms(): + return render_template("terms.html") + + +@app.route("/_health") +def health(): + return {"ok": True, "site": "9gag", "posts": Post.query.count()} + + +with app.app_context(): + db.create_all() + from seed_data import seed_benchmark_users, seed_database + seed_database(db, User, Post, Comment, SavedPost, Vote) + seed_benchmark_users(db, User, Post, Comment, SavedPost, Vote) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=False) diff --git a/sites/9gag/requirements.txt b/sites/9gag/requirements.txt new file mode 100644 index 00000000..a4d756f4 --- /dev/null +++ b/sites/9gag/requirements.txt @@ -0,0 +1,3 @@ +Flask +Flask-Login +Flask-SQLAlchemy diff --git a/sites/9gag/seed_data.py b/sites/9gag/seed_data.py new file mode 100644 index 00000000..38d8015f --- /dev/null +++ b/sites/9gag/seed_data.py @@ -0,0 +1,117 @@ +"""Deterministic seed data for the 9GAG mirror.""" + +CAPTURED = [ + ("aD2BwPZ", "Boys and girls solve the same problem differently", "A split-panel joke contrasts two wildly different approaches to the same everyday problem.", "posts/aD2BwPZ-451efa65.jpg", "humor", "boys and girls|funny", "womb69raider"), + ("aVvGONd", "Meet the fan who went viral during the football game", "A worried fan in the stadium became an unexpected reaction-image favorite.", "posts/aVvGONd-41d662a3.jpg", "sports", "football|reaction|9gag news", "gamedayframe"), + ("aO86yA2", "Life is good?", "An oddly placed LG sign turns an ordinary street scene into accidental comedy.", "posts/aO86yA2-87d68ca6.jpg", "wtf", "LG|unexpected|street", "bradleypits11"), + ("aryPpR7", "A happy dude meets his new exchange-student friend", "A cheerful travel vlog follows two new friends exploring the city together.", "posts/aryPpR7-3497fabc.jpg", "wholesome", "friends|travel|vlog", "cityhellos"), + ("amoDNzv", "Chess 1 — tech billionaire 0", "A chessboard joke shows why confidence is not the same thing as strategy.", "posts/amoDNzv-6cbe8b39.jpg", "humor", "chess|technology|funny", "knightshift"), + ("aQzYqZq", "A mysterious press-conference moment from 2009", "Archive footage prompts a long discussion about media, context, and unanswered questions.", "posts/aQzYqZq-9c4605da.jpg", "news", "archive|media|history", "waitforme"), + ("aLnqKLv", "Trust me, I have not bookmarked any of these sites", "A browser-bookmark confession that every procrastinator will recognize.", "posts/aLnqKLv-bea354c8.jpg", "random", "browser|bookmarks|internet", "tabhoarder"), + ("adB3Nj9", "How helpful are you? Yes", "A determined cat insists on helping with a household repair, whether invited or not.", "posts/adB3Nj9-f56e7b44.jpg", "animals", "cat|helping|pets", "realjarmando"), + ("aYQzNgV", "The self-driving cab became a cybercop", "A satirical sketch imagines an automated taxi making one very unexpected detour.", "posts/aYQzNgV-f6067981.jpg", "science", "technology|cars|satire", "sodablob"), + ("aQzYqK8", "A remarkably polite diplomatic rebuttal", "A defense-panel exchange demonstrates how calm delivery can sharpen a pointed response.", "posts/aQzYqK8-ec9e7408.jpg", "politics", "diplomacy|philippines|conference", "freebie3"), +] + +EXTRA_IMAGES = [ + "posts/axyp2eK-a40174af.jpg", "posts/adB36rd-63e493a8.jpg", "posts/a4PM9AZ-99be8d3d.jpg", + "posts/a9y3MD1-aac2d674.jpg", "posts/aqyvZ6L-cb566fb0.jpg", "posts/aXP8E72-b35cc332.jpg", + "posts/ayNypPp-29b03ce9.jpg", "posts/aXP8EQP-fb62f010.jpg", "posts/a9y3M80-383eb696.jpg", + "posts/aW4V0rZ-e6ecd2ab.jpg", "posts/agmR0zg-12384a06.jpg", "posts/aRBxgq7-4df2c5ea.jpg", + "posts/aByDQeZ-5b7e3094.jpg", "posts/aE0eG4e-99ba422d.jpg", "posts/a87rbN1-f9e54d20.jpg", + "posts/a2vQPXd-73b4bdec.jpg", "posts/a6ZWdgA-c2528fb7.jpg", "posts/aGyPVQZ-85dd3bcf.jpg", + "posts/aByDQAN-58fb68ce.jpg", "posts/azx6LWB-1ea1705b.jpg", "posts/a87rbRV-75ef0018.jpg", + "posts/aAynV8Z-f0fe61f7.jpg", "posts/aD2BwKO-9ef53069.jpg", "posts/aVvGQp2-63e493a8.jpg", + "posts/an7AXmE-d74017c9.jpg", "posts/aGyPVmG-0bf5e5f6.jpg", "posts/a1m4ZyD-ff8359c0.jpg", + "posts/aNDyo7w-71005b4e.jpg", +] + +CURATED = [ + ("Solar-powered camping setup survives a rainy weekend", "The builder used a 120-watt folding panel and stored the controller inside a waterproof lunch box.", "science", "camping|solar|diy", "trailpixel"), + ("Tiny lighthouse office with the best ocean view", "The circular room is 4.2 meters wide and the desk was built from reclaimed oak.", "awesome", "lighthouse|office|ocean", "northcoastcam"), + ("Rescue cat learns the sound of the treat drawer", "Miso arrived at Harbor Paws in February and learned the routine after just eleven days.", "animals", "cat|rescue|wholesome", "pawsandpixels"), + ("Mechanical keyboard made entirely from transparent parts", "The board uses silent tactile switches and a hand-polished acrylic case.", "gaming", "keyboard|pc|design", "switchcraft"), + ("Grandmother finishes her first marathon at seventy-two", "Her family met her at kilometer 38 with handmade orange flags.", "sports", "running|marathon|wholesome", "milemarker"), + ("A baker recreates a city skyline in sourdough", "The edible skyline took three attempts and nearly fourteen hours to shape.", "food", "bread|baking|art", "crumbarchitect"), + ("The museum guard who quietly sketches every visitor", "After five years, the guard has filled nineteen pocket notebooks with portraits.", "art", "drawing|museum|people", "softpencil"), + ("Neighborhood builds a miniature library for night-shift workers", "The blue cabinet is restocked every Thursday at 6 a.m. by local volunteers.", "wholesome", "books|community|library", "earlybirdreads"), + ("An engineer explains why this bridge hums in the wind", "The note is produced by evenly spaced railings resonating near 440 hertz.", "science", "engineering|bridge|sound", "civilnerd"), + ("Dog refuses to leave the kayak after the trip ends", "Pepper waited for a second lap around the lake and ignored every promised snack.", "animals", "dog|kayak|lake", "paddlingpup"), + ("Hand-painted arcade cabinet celebrates classic space games", "The cabinet runs twelve restored titles and uses original 1980s-style controls.", "gaming", "arcade|retro|space", "quartermaster"), + ("Street musician turns a rain delay into a concert", "Commuters joined the chorus under platform seven while the storm passed.", "music", "concert|rain|train", "platformbeats"), + ("A fox naps on the same garden wall every afternoon", "Residents named the visitor Copper and keep a respectful distance from the wall.", "animals", "fox|garden|wildlife", "windowwatcher"), + ("Prototype bicycle folds small enough for a train locker", "Its designer says the steel frame weighs 10.8 kilograms without accessories.", "motorvehicles", "bicycle|design|commute", "foldandroll"), + ("Students launch a weather balloon with a tiny rubber duck", "The camera reached 27 kilometers before landing safely in a cornfield.", "science", "balloon|school|weather", "skyclassroom"), + ("The office plant gets an employee badge", "Kevin the pothos has perfect attendance and a surprisingly active calendar.", "humor", "office|plant|work", "meetingescape"), + ("Restored train carriage becomes a community cinema", "Volunteers kept the original green seats and installed a retractable screen.", "movies", "cinema|train|restoration", "reeljourney"), + ("Chef makes a five-course dinner using one camp stove", "The final course was a skillet peach cake shared beside the river.", "food", "cooking|camping|challenge", "oneburner"), + ("A ten-second drawing trick that changes every cartoon face", "Moving the eyebrows two millimeters completely changes the character's mood.", "comic", "drawing|tutorial|faces", "inkminute"), + ("Local team celebrates its first championship in 34 years", "The deciding goal came with 18 seconds left in the final period.", "sports", "championship|team|celebration", "finalwhistle"), + ("Fog wraps the old harbor lighthouse at sunrise", "The keeper photographed the lantern room before the morning ferry arrived.", "awesome", "lighthouse|harbor|photography", "beaconwatch"), + ("Miniature lighthouse built for a seaside bookshop", "The model stands beside a reading desk made from pine and brass.", "art", "lighthouse|books|craft", "tinyharbor"), + ("Remote lighthouse receives a compact radio desk", "A volunteer radio operator tested the new equipment during a winter storm.", "science", "lighthouse|office|radio", "signalkeeper"), + ("Solar lantern comparison for a week of camping", "Six compact lanterns were tested for charge time and nighttime brightness.", "science", "solar|camping|gear", "campbench"), + ("Rainy campsite kitchen stays dry under one clever tarp", "The angled ridgeline moved runoff away from the stove and food boxes.", "awesome", "camping|rain|diy", "drycamp"), + ("Foldable solar mat powers a photographer's trail camera", "The lightweight kit was carried between three mountain camps.", "science", "solar|camera|camping", "trailcurrent"), + ("Rescue dog recognizes the volunteer who found him", "Basil greeted the volunteer at the shelter's spring open house.", "animals", "dog|rescue|shelter", "secondchance"), + ("Shelter cat chooses the quietest reading chair", "The senior tabby now naps beside visitors during weekend adoption events.", "animals", "cat|shelter|books", "quietpaws"), + ("Foster kitten discovers the automatic treat puzzle", "Juniper solved the final compartment after several patient attempts.", "animals", "cat|rescue|treats", "fosterframes"), + ("Suspension bridge whistles only during winter gusts", "Engineers traced the sound to a narrow seam beneath the eastern walkway.", "science", "bridge|wind|sound", "spanlistener"), + ("Footbridge becomes a percussion instrument in heavy rain", "Different deck panels produce distinct notes as drops strike the surface.", "music", "bridge|sound|rain", "river rhythm"), + ("City bridge lights react to nearby music", "Microphones beneath the deck map bass notes to slow blue pulses.", "science", "bridge|music|engineering", "nightspan"), + ("Community library adds a shelf for night-bus drivers", "Volunteers stock travel books and warm drinks beside the depot.", "wholesome", "library|community|night", "lastbusbooks"), + ("Tiny red library appears outside the fire station", "Neighbors exchange mysteries, cookbooks, and children's stories each weekend.", "books", "library|community|books", "cornerreader"), + ("Hospital staff open a midnight reading cart", "The rolling library visits three floors during the overnight shift.", "wholesome", "library|night shift|volunteers", "roundsreader"), +] + + +def seed_database(db, User, Post, Comment, SavedPost, Vote): + if Post.query.count() > 0: + return + rows = CAPTURED + CURATED + [ + (f"Community remix {index}: {title.lower()}", f"A different community member adds context: {description}", section, f"{tags}|community", f"user{index:02d}") + for index, (title, description, section, tags, _author) in enumerate(CURATED, start=1) + ] + images = [row[3] for row in CAPTURED] + EXTRA_IMAGES + for index, row in enumerate(rows): + if len(row) == 7: + source_id, title, description, image, section, tags, author = row + else: + title, description, section, tags, author = row + source_id = f"seed{index:03d}" + image = images[index % len(images)] + slug = "-".join("".join(ch.lower() if ch.isalnum() else " " for ch in title).split())[:150] + db.session.add(Post(source_id=source_id, slug=f"{slug}-{index + 1}", title=title, + description=description, image=image, section=section, post_type="Photo", + tags=tags, author_name=author, up_votes=430 + ((index * 173) % 8400), + down_votes=5 + ((index * 7) % 90), comment_count=12 + ((index * 19) % 430), + created_rank=index + 1, featured=index in {2, 11, 24})) + db.session.commit() + + +def seed_benchmark_users(db, User, Post, Comment, SavedPost, Vote): + if User.query.filter_by(email="alice.j@test.com").first(): + return + people = [ + ("alice_j", "alice.j@test.com", "Alice Johnson", "Seattle, WA", "Collector of wholesome posts and clever design."), + ("bob_c", "bob.c@test.com", "Bob Chen", "Austin, TX", "Science, games, and weekend cooking."), + ("carol_d", "carol.d@test.com", "Carol Davis", "Boston, MA", "Here for animals and community stories."), + ("david_k", "david.k@test.com", "David Kim", "Portland, OR", "Cyclist, illustrator, and serial commenter."), + ] + users = [] + for username, email, display, location, bio in people: + user = User(username=username, email=email, display_name=display, location=location, bio=bio, password_hash="") + user.set_password("TestPass123!") + db.session.add(user) + users.append(user) + db.session.flush() + posts = Post.query.order_by(Post.id).all() + for user_index, user in enumerate(users): + for offset in range(4): + db.session.add(SavedPost(user_id=user.id, post_id=posts[(user_index * 7 + offset * 5) % len(posts)].id)) + for offset in range(3): + db.session.add(Vote(user_id=user.id, post_id=posts[(user_index * 9 + offset * 4 + 2) % len(posts)].id, value=1)) + comment_post = posts[(user_index * 11 + 6) % len(posts)] + db.session.add(Comment(post_id=comment_post.id, user_id=user.id, + body=f"Saved this one for later — {user.display_name}", created_at=f"{user_index + 2} days ago")) + db.session.commit() diff --git a/sites/9gag/static/css/.gitkeep b/sites/9gag/static/css/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/9gag/static/css/site.css b/sites/9gag/static/css/site.css new file mode 100644 index 00000000..0adbcb1a --- /dev/null +++ b/sites/9gag/static/css/site.css @@ -0,0 +1,6 @@ +:root{--blue:#087bf5;--ink:#111;--muted:#777;--line:#e4e4e4}*{box-sizing:border-box}body{margin:0;color:var(--ink);font-family:Arial,Helvetica,sans-serif;background:#fff}a{color:inherit;text-decoration:none}button,input,textarea,select{font:inherit}.topbar{height:49px;border-top:3px solid var(--blue);border-bottom:1px solid var(--line);display:flex;align-items:center;gap:17px;padding:0 max(20px,calc((100vw - 1280px)/2));position:sticky;top:0;background:#fff;z-index:10}.menu{font-size:23px}.brand{font-size:25px;font-weight:900}.toplinks{display:flex;gap:18px;font-weight:700}.searchbar{margin-left:auto;display:flex;border-bottom:1px solid #bbb}.searchbar input{width:170px;border:0;padding:8px}.searchbar button{border:0;background:#fff;font-size:20px}.muted-link{color:#777;font-weight:700;white-space:nowrap}.post-button,.primary{background:var(--blue);color:#fff;border:0;border-radius:22px;padding:10px 20px;font-weight:700}.layout{max-width:1300px;margin:18px auto;display:grid;grid-template-columns:240px minmax(430px,640px) 260px;gap:34px;padding:0 20px}.sidebar{position:sticky;top:68px;align-self:start;max-height:calc(100vh - 80px);overflow:auto}.signup-card{border:1px solid #ddd;border-radius:8px;text-align:center;padding:14px 16px;margin-bottom:6px}.signup-card h2{font-size:20px;margin:2px}.signup-card a{display:block;background:var(--blue);color:white;border-radius:22px;padding:10px;margin-top:16px;font-weight:700}.side-nav,.interests{display:flex;flex-direction:column}.side-nav a,.interests a{display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:3px}.side-nav a{font-weight:700}.side-nav a.active,.interests a.active{background:#eee}.sidebar h3{color:#888;font-size:14px;margin:18px 12px 7px}.interests b{width:24px;text-align:center}.account-links{border-top:1px solid #eee;margin-top:8px;padding-top:8px}.feed-heading{border-bottom:1px solid var(--line);margin-bottom:14px}.feed-heading h1{font-size:24px;margin:6px 0}.feed-heading p{color:var(--muted)}.post-card{border-bottom:1px solid var(--line);padding:4px 0 24px;margin-bottom:20px}.post-card h2,.post-card h1{font-size:22px;line-height:1.28;margin:8px 0 13px}.post-meta{font-size:13px;color:#777;display:flex;gap:6px}.post-meta a:first-child{font-weight:700;color:#333}.post-image{display:block;background:#f2f2f2;overflow:hidden}.post-image img,.detail-image{display:block;width:100%;max-height:620px;object-fit:contain;background:#f5f5f5}.stats{font-size:13px;color:#666;margin:10px 0}.actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.actions form{margin:0}.actions button,.actions a{border:1px solid #ddd;background:#fff;border-radius:4px;padding:8px 11px;color:#555;font-weight:700;cursor:pointer}.actions button.selected{color:var(--blue);border-color:var(--blue)}.right-rail{padding-top:35px}.rail-card{border:1px solid #ddd;border-radius:7px;padding:16px;margin-bottom:16px}.rail-card h3{margin-top:0}.rail-card>a{display:block;padding:7px 0;color:#555}.related{border-bottom:1px solid #eee}.description{font-size:16px;line-height:1.55}.tag-row{display:flex;gap:7px;flex-wrap:wrap}.tag-row a{background:#eee;border-radius:3px;padding:6px 9px;font-size:13px}.comments{padding-bottom:50px}.comments textarea{width:100%;min-height:90px;padding:12px}.comment{border-top:1px solid #eee;padding:14px 0}.comment small{color:#888;margin-left:8px}.auth,.form-page,.profile{width:min(620px,calc(100% - 32px));margin:60px auto}.auth{width:min(420px,calc(100% - 32px));border:1px solid #ddd;border-radius:8px;padding:28px}.auth form,.form-page form{display:grid;gap:16px}.auth label,.form-page label{display:grid;gap:7px;font-weight:700}.auth input,.form-page input,.form-page textarea,.form-page select{border:1px solid #bbb;border-radius:5px;padding:11px}.profile{width:min(760px,calc(100% - 32px))}.profile-head{display:flex;gap:20px;align-items:flex-start;border-bottom:1px solid #ddd;padding-bottom:24px}.avatar{height:90px;width:90px;background:#eee;border-radius:50%;display:grid;place-items:center;font-size:38px;font-weight:800}.profile-head h1{margin:4px 0}.profile-head .outline{margin-left:auto}.outline{border:1px solid #aaa;border-radius:5px;padding:9px 14px}.profile-post{display:flex;justify-content:space-between;padding:14px 0;border-bottom:1px solid #eee}.profile-post span{color:#777}.notification,.empty{border:1px solid #ddd;border-radius:7px;padding:18px;margin:12px 0}.store-buttons{display:flex;gap:12px}.store-buttons span{background:#111;color:#fff;border-radius:8px;padding:13px}.flashes{position:fixed;top:62px;left:50%;transform:translateX(-50%);z-index:20}.flash{background:#edf8ee;border:1px solid #9bc89e;border-radius:5px;padding:10px 20px}.flash.error{background:#fff0f0;border-color:#e5a0a0}footer{border-top:1px solid #ddd;padding:30px;display:flex;justify-content:center;gap:22px;color:#777;font-size:13px} +@media(max-width:1000px){.right-rail{display:none}.layout{grid-template-columns:220px minmax(0,640px);justify-content:center}.toplinks{display:none}}@media(max-width:680px){.topbar{padding:0 10px;gap:11px}.menu{display:none}.brand{font-size:22px}.searchbar{flex:1}.searchbar input{width:100%}.muted-link{display:none}.post-button{padding:8px 12px}.layout{display:block;margin-top:10px;padding:0 12px}.sidebar{position:static;max-height:none}.signup-card,.sidebar h3,.interests,.account-links{display:none}.side-nav{flex-direction:row;overflow-x:auto;border-bottom:1px solid #ddd;margin-bottom:14px}.side-nav a{white-space:nowrap}.feed-heading{display:none}.post-card h2,.post-card h1{font-size:19px}.post-image img,.detail-image{max-height:520px}.actions{gap:5px}.actions button,.actions a{padding:7px 8px;font-size:13px}.auth,.form-page,.profile{margin:30px auto}.profile-head{flex-wrap:wrap}.profile-head .outline{margin-left:0}footer{flex-wrap:wrap}.detail .description{font-size:15px}}@media(max-width:340px){.topbar{gap:7px}.brand{font-size:20px}.post-button{font-size:13px}.layout{padding:0 8px}.side-nav a{padding:8px}.actions button,.actions a{font-size:12px}.profile-post{gap:8px}.profile-post span{white-space:nowrap}} +.post-image{display:flex;justify-content:center}.post-image img,.detail-image{width:auto;max-width:100%;height:auto;max-height:620px;object-fit:contain}.detail-image{margin-left:auto;margin-right:auto}.pagination{display:flex;justify-content:space-between;margin:24px 0 50px}.pagination a{border:1px solid #ccc;border-radius:5px;padding:10px 15px;font-weight:700}@media(max-width:680px){.post-image img,.detail-image{max-height:520px}} +@media(max-width:680px){.side-nav{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));overflow:visible}.side-nav a{min-width:0;padding:7px 2px;gap:2px;justify-content:center;flex-direction:column;font-size:12px;text-align:center}} +.topbar{border-top:0;height:48px}.layout{margin-top:18px}.recent-heading{display:flex;justify-content:space-between}.recent-heading span{font-weight:400}.recents{border-bottom:1px solid #eee;padding-bottom:8px}.feed-tabs{height:38px;display:flex;gap:24px;border-bottom:1px solid #ccc;margin-bottom:24px}.feed-tabs a{padding:5px 16px 13px;color:#999;font-weight:700}.feed-tabs a:first-child{padding-left:16px}.feed-tabs a.active{color:#111;border-bottom:3px solid #111}.topic-row{display:flex;gap:10px;overflow:hidden;margin-bottom:14px}.topic-row a{white-space:nowrap;border:1px solid #ddd;color:#777;border-radius:18px;padding:6px 13px}.content-notice{height:41px;border-left:4px solid #ffa800;background:#fff2c7;display:flex;align-items:center;gap:22px;padding:0 16px;margin-bottom:14px;font-size:14px}.content-notice a{font-weight:700}.content-notice span{margin-left:auto;font-size:26px;color:#999}.right-rail{padding-top:0}.captured-ad{display:block;border:1px solid #222;padding:34px 32px 18px;position:relative;margin-bottom:16px}.captured-ad img{display:block;width:100%;height:176px;object-fit:cover}.captured-ad span{position:absolute;right:8px;top:7px;color:#888;font-size:9px;letter-spacing:.08em}.post-card{padding-top:0}.post-card h2,.post-card h1{font-size:20px}.post-meta:before{content:'✦';display:inline-grid;place-items:center;background:#f5a623;color:#fff;width:25px;height:25px;border-radius:4px;margin-right:2px}.post-meta{align-items:center}.stats{color:#999}.actions button,.actions a{border:0;padding-left:8px;padding-right:8px;color:#999;font-size:14px} +@media(max-width:680px){.feed-tabs{gap:0;justify-content:space-between;margin-bottom:12px}.feed-tabs a{padding:6px 5px 11px;font-size:12px}.feed-tabs a:nth-last-child(-n+2){display:none}.topic-row,.content-notice{display:none}.recent-heading,.recents{display:none}.post-meta:before{width:22px;height:22px}.topbar{height:46px}} diff --git a/sites/9gag/static/icons/.gitkeep b/sites/9gag/static/icons/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/9gag/static/js/.gitkeep b/sites/9gag/static/js/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/9gag/tasks.jsonl b/sites/9gag/tasks.jsonl new file mode 100644 index 00000000..6920e8ab --- /dev/null +++ b/sites/9gag/tasks.jsonl @@ -0,0 +1,20 @@ +{"web_name":"9GAG","id":"9GAG--0","ques":"Search for posts about lighthouse offices. Open the post whose title mentions the best ocean view and report the room's width and the material used for the desk.","web":"http://localhost:40024/","upstream_url":"https://9gag.com/"} +{"web_name":"9GAG","id":"9GAG--1","ques":"Search for rescue cat posts. Open the post about a cat learning the treat-drawer sound and report the month Miso arrived at Harbor Paws and how many days the routine took to learn.","web":"http://localhost:40024/","upstream_url":"https://9gag.com/"} +{"web_name":"9GAG","id":"9GAG--2","ques":"Search for solar camping posts and open the rainy-weekend setup. What wattage was its folding panel, and what protected the controller from water?","web":"http://localhost:40024/","upstream_url":"https://9gag.com/"} +{"web_name":"9GAG","id":"9GAG--3","ques":"Browse Science & Tech and find the post explaining a bridge that hums in the wind. Report the frequency of the note and the bridge feature that produces it.","web":"http://localhost:40024/","upstream_url":"https://9gag.com/"} +{"web_name":"9GAG","id":"9GAG--4","ques":"Search the community library posts and open the one created for night-shift workers. What color is the cabinet, and exactly when is it restocked?","web":"http://localhost:40024/","upstream_url":"https://9gag.com/"} +{"web_name":"9GAG","id":"9GAG--5","ques":"Browse Sports and open the post about a grandmother finishing her first marathon. At which kilometer did her family meet her, and what color were their flags?","web":"http://localhost:40024/","upstream_url":"https://9gag.com/"} +{"web_name":"9GAG","id":"9GAG--6","ques":"Search for sourdough skyline posts. Open the original baker's post and report both the number of attempts and the approximate shaping time.","web":"http://localhost:40024/","upstream_url":"https://9gag.com/"} +{"web_name":"9GAG","id":"9GAG--7","ques":"Browse Gaming and find the transparent mechanical keyboard. What kind of switches and what case material does it use?","web":"http://localhost:40024/","upstream_url":"https://9gag.com/"} +{"web_name":"9GAG","id":"9GAG--8","ques":"Search for the rain-delay concert, open the original street-musician post, and report the platform number where commuters joined the chorus.","web":"http://localhost:40024/","upstream_url":"https://9gag.com/"} +{"web_name":"9GAG","id":"9GAG--9","ques":"Browse Animals & Pets and compare the original rescue-cat, kayaking-dog, and garden-fox posts. Which has the most points? Open that post and report the animal's name.","web":"http://localhost:40024/","upstream_url":"https://9gag.com/"} +{"web_name":"9GAG","id":"9GAG--10","ques":"Log in with alice.j@test.com and TestPass123!, search for the tiny lighthouse office with the best ocean view, open it, and save it to your collection.","web":"http://localhost:40024/","upstream_url":"https://9gag.com/"} +{"web_name":"9GAG","id":"9GAG--11","ques":"Log in with carol.d@test.com and TestPass123!, browse Animals & Pets, open the original post about the dog refusing to leave a kayak, and upvote it.","web":"http://localhost:40024/","upstream_url":"https://9gag.com/"} +{"web_name":"9GAG","id":"9GAG--12","ques":"Log in with bob.c@test.com and TestPass123!, find the original bridge-humming explanation in Science & Tech, and comment exactly: Resonance makes ordinary structures fascinating.","web":"http://localhost:40024/","upstream_url":"https://9gag.com/"} +{"web_name":"9GAG","id":"9GAG--13","ques":"Log in with carol.d@test.com and TestPass123!, search for the office plant with an employee badge, open the original post, and hide it from your feeds.","web":"http://localhost:40024/","upstream_url":"https://9gag.com/"} +{"web_name":"9GAG","id":"9GAG--14","ques":"Log in with david.k@test.com and TestPass123!, open News, find the mysterious 2009 press-conference post, and report it for Misinformation.","web":"http://localhost:40024/","upstream_url":"https://9gag.com/"} +{"web_name":"9GAG","id":"9GAG--15","ques":"Log in with alice.j@test.com and TestPass123! and create a post titled 'Quiet victories deserve confetti' in Wholesome. Use description 'My neighbor finished night school after six years.' and tags 'community|education|wholesome', then publish it.","web":"http://localhost:40024/","upstream_url":"https://9gag.com/"} +{"web_name":"9GAG","id":"9GAG--16","ques":"Log in with alice.j@test.com and TestPass123! and update the profile: display name 'Alice J.', bio 'Memes, trail photos, and excellent tiny libraries.', and location 'Tacoma, WA'. Save the changes.","web":"http://localhost:40024/","upstream_url":"https://9gag.com/"} +{"web_name":"9GAG","id":"9GAG--17","ques":"Create a new account with email river.reader@test.com, username river_reader, and password RiverRead123!. Confirm that the signed-in username appears in the header.","web":"http://localhost:40024/","upstream_url":"https://9gag.com/"} +{"web_name":"9GAG","id":"9GAG--18","ques":"Log in with alice.j@test.com and TestPass123!, open Saved, locate the original solar-powered rainy-weekend camping setup, and remove it from the saved collection.","web":"http://localhost:40024/","upstream_url":"https://9gag.com/"} +{"web_name":"9GAG","id":"9GAG--19","ques":"Log in with david.k@test.com and TestPass123!. In Science & Tech, compare the original solar camping setup, humming bridge, and weather-balloon posts by the largest number stated in each detail description. Save the post whose stated number is greatest.","web":"http://localhost:40024/","upstream_url":"https://9gag.com/"} diff --git a/sites/9gag/templates/.gitkeep b/sites/9gag/templates/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/sites/9gag/templates/_post_card.html b/sites/9gag/templates/_post_card.html new file mode 100644 index 00000000..0e3a7514 --- /dev/null +++ b/sites/9gag/templates/_post_card.html @@ -0,0 +1 @@ + diff --git a/sites/9gag/templates/_sidebar.html b/sites/9gag/templates/_sidebar.html new file mode 100644 index 00000000..d5a3e7af --- /dev/null +++ b/sites/9gag/templates/_sidebar.html @@ -0,0 +1,24 @@ + diff --git a/sites/9gag/templates/account.html b/sites/9gag/templates/account.html new file mode 100644 index 00000000..5a0d1b8d --- /dev/null +++ b/sites/9gag/templates/account.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}{{ profile.display_name }} - 9GAG{% endblock %}{% block body %}
{{ profile.display_name[0] }}

{{ profile.display_name }}

@{{ profile.username }}

{{ profile.bio }}

{{ profile.location }} · Joined {{ profile.joined_at }}
{% if current_user.is_authenticated and current_user.id==profile.id %}Edit profile{% endif %}

Posts

{% if posts %}{% for post in posts %}{{ post.title }} {{ post.up_votes }} points{% endfor %}{% else %}

No posts yet.

{% endif %}
{% endblock %} diff --git a/sites/9gag/templates/apps.html b/sites/9gag/templates/apps.html new file mode 100644 index 00000000..b96d2c1f --- /dev/null +++ b/sites/9gag/templates/apps.html @@ -0,0 +1 @@ +{% extends 'static_page.html' %}{% set heading='Get the 9GAG app' %}{% set body='Carry the community with you and browse interests, vote, comment, and save posts from your phone.' %}{% block page_content %}
Download on the App StoreGet it on Google Play
{% endblock %} diff --git a/sites/9gag/templates/base.html b/sites/9gag/templates/base.html new file mode 100644 index 00000000..9689033b --- /dev/null +++ b/sites/9gag/templates/base.html @@ -0,0 +1,3 @@ +{% block title %}9GAG - Best Funny Memes and Breaking News{% endblock %} +
9GAG{% if current_user.is_authenticated %}{{ current_user.username }}{% else %}Sign Up/Log In{% endif %}✎ Post
+{% with messages=get_flashed_messages(with_categories=true) %}{% if messages %}
{% for kind,message in messages %}
{{ message }}
{% endfor %}
{% endif %}{% endwith %}{% block body %}{% endblock %} diff --git a/sites/9gag/templates/feed.html b/sites/9gag/templates/feed.html new file mode 100644 index 00000000..9e5eb21f --- /dev/null +++ b/sites/9gag/templates/feed.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}{{ title }} - 9GAG{% endblock %}{% block body %}
{% include '_sidebar.html' %}
{% for tag in ['spider man','usa','trump','elon musk','politics','latest news'] %}{{tag}}{% endfor %}
Want fewer political content? ⊘   Block tags×
{% if query %}

{{ title }}

Posts related to “{{ query }}”

{% endif %}{% if posts %}{% for post in posts %}{% include '_post_card.html' %}{% endfor %}{% else %}

No posts found

Try another search or explore an interest.

{% endif %}
{% endblock %} diff --git a/sites/9gag/templates/help.html b/sites/9gag/templates/help.html new file mode 100644 index 00000000..41d4a5bc --- /dev/null +++ b/sites/9gag/templates/help.html @@ -0,0 +1 @@ +{% extends 'static_page.html' %}{% set heading='Help center' %}{% set body='Find answers about accounts, posting, voting, saved posts, and community safety.' %}{% block page_content %}

Popular topics

  • Managing your profile
  • Posting and choosing an interest
  • Reporting content
{% endblock %} diff --git a/sites/9gag/templates/index.html b/sites/9gag/templates/index.html new file mode 100644 index 00000000..0c02ac31 --- /dev/null +++ b/sites/9gag/templates/index.html @@ -0,0 +1 @@ +{% extends 'feed.html' %} diff --git a/sites/9gag/templates/login.html b/sites/9gag/templates/login.html new file mode 100644 index 00000000..bb1eb861 --- /dev/null +++ b/sites/9gag/templates/login.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}Log in - 9GAG{% endblock %}{% block body %}

Log in

Welcome back to the fun side.

New here? Create an account

{% endblock %} diff --git a/sites/9gag/templates/notifications.html b/sites/9gag/templates/notifications.html new file mode 100644 index 00000000..f0a9c3e9 --- /dev/null +++ b/sites/9gag/templates/notifications.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}Notifications - 9GAG{% endblock %}{% block body %}

Notifications

Your saved collection is ready

Everything you save stays available from the sidebar.

Welcome back, {{ current_user.display_name }}

Fresh posts were added to Science & Tech and Animals & Pets.

{% endblock %} diff --git a/sites/9gag/templates/post_detail.html b/sites/9gag/templates/post_detail.html new file mode 100644 index 00000000..cffa6b36 --- /dev/null +++ b/sites/9gag/templates/post_detail.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}{{ post.title }} - 9GAG{% endblock %}{% block body %}
{% set active=post.section %}{% include '_sidebar.html' %}

{{ post.title }}

{{ post.title }}

{{ post.description }}

{% for tag in post.tag_list %}#{{ tag }}{% endfor %}
{{ post.up_votes }} points · {{ post.down_votes }} downvotes · {{ post.comment_count }} comments
Report

Comments

{% if current_user.is_authenticated %}
{% else %}

Log in to comment.

{% endif %}{% for comment in post.comments %}
{{ comment.user.display_name }}{{ comment.created_at }}

{{ comment.body }}

{% endfor %}
{% endblock %} diff --git a/sites/9gag/templates/register.html b/sites/9gag/templates/register.html new file mode 100644 index 00000000..e4b49715 --- /dev/null +++ b/sites/9gag/templates/register.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}Sign up - 9GAG{% endblock %}{% block body %}

Join 9GAG

Already a member? Log in

{% endblock %} diff --git a/sites/9gag/templates/report.html b/sites/9gag/templates/report.html new file mode 100644 index 00000000..a09708d0 --- /dev/null +++ b/sites/9gag/templates/report.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}Report post - 9GAG{% endblock %}{% block body %}

Report post

{{ post.title }}

{% endblock %} diff --git a/sites/9gag/templates/settings.html b/sites/9gag/templates/settings.html new file mode 100644 index 00000000..277ee2d2 --- /dev/null +++ b/sites/9gag/templates/settings.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}Settings - 9GAG{% endblock %}{% block body %}

Edit profile

Log out
{% endblock %} diff --git a/sites/9gag/templates/static_page.html b/sites/9gag/templates/static_page.html new file mode 100644 index 00000000..a16e64bd --- /dev/null +++ b/sites/9gag/templates/static_page.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}{{ heading }} - 9GAG{% endblock %}{% block body %}

{{ heading }}

{{ body }}

{% block page_content %}{% endblock %}
{% endblock %} diff --git a/sites/9gag/templates/submit.html b/sites/9gag/templates/submit.html new file mode 100644 index 00000000..66bc341c --- /dev/null +++ b/sites/9gag/templates/submit.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block title %}Create a post - 9GAG{% endblock %}{% block body %}

Create a post

Share something the community will enjoy.

{% endblock %} diff --git a/sites/9gag/templates/terms.html b/sites/9gag/templates/terms.html new file mode 100644 index 00000000..02f04321 --- /dev/null +++ b/sites/9gag/templates/terms.html @@ -0,0 +1 @@ +{% extends 'static_page.html' %}{% set heading='Terms and privacy' %}{% set body='Use the service responsibly and respect the rights and privacy of other community members.' %} diff --git a/websyn_start.sh b/websyn_start.sh index 9539b4f9..8d0c4442 100644 --- a/websyn_start.sh +++ b/websyn_start.sh @@ -5,7 +5,7 @@ set -e SITES=(allrecipes amazon apple arxiv bbc_news booking github google_flights google_map google_search huggingface wolfram_alpha - cambridge_dictionary coursera espn merriam_webster ikea phys_org target ted osu rotten_tomatoes compass walmart_careers) + cambridge_dictionary coursera espn merriam_webster ikea phys_org target ted osu rotten_tomatoes compass walmart_careers 9gag) BASE_PORT=40000 PID_DIR=/tmp/websyn_pids mkdir -p "$PID_DIR"