chore: import upstream snapshot with attribution
Build and Push Docker Images / create_manifest (web, surfsense-web, , cpu) (push) Has been cancelled
Build and Push Docker Images / finalize_release (push) Has been cancelled
Obsidian Plugin Lint / lint (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64, , runner, false, cpu) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64, , runner, false, cpu) (push) Has been cancelled
Build and Push Docker Images / compute_version (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, , production, false, cpu) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, , production, false, cpu) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda126, production, true, cuda126) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda126, production, true, cuda126) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda, production, true, cuda) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda, production, true, cuda) (push) Has been cancelled
Build and Push Docker Images / verify_digests (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend, , cpu) (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda, cuda) (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda126, cuda126) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:33:44 +08:00
commit 6ede33ccdb
3841 changed files with 594049 additions and 0 deletions
@@ -0,0 +1,142 @@
"""Self-check for the alembic fast-forward/adoption flow in alembic/env.py.
Verifies ``alembic upgrade head`` succeeds on the three DB states it must
handle without replaying pre-workspace-rename history against a
workspace-shape schema:
1. fresh -- empty database (fast-forward: create_all + stamp head)
2. bootstrap -- create_all-created schema + zero_publication, no alembic
history (adoption: stamp head)
3. midcrash -- bootstrap schema whose alembic_version is stuck at a
pre-rename revision from a failed replay (adoption: stamp head)
Run: python scripts/check_migration_flow.py
"""
import asyncio
import os
import subprocess
import sys
from pathlib import Path
BACKEND_DIR = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(BACKEND_DIR))
ADMIN_URL = os.getenv(
"ADMIN_DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/postgres"
)
SCRATCH_DB = "surfsense_check_migration_flow"
SCRATCH_URL = ADMIN_URL.rsplit("/", 1)[0] + f"/{SCRATCH_DB}"
SCRATCH_URL_ASYNC = SCRATCH_URL.replace("postgresql://", "postgresql+asyncpg://")
async def recreate_scratch_db() -> None:
import asyncpg
admin = await asyncpg.connect(ADMIN_URL)
await admin.execute(f'DROP DATABASE IF EXISTS "{SCRATCH_DB}" WITH (FORCE)')
await admin.execute(f'CREATE DATABASE "{SCRATCH_DB}"')
await admin.close()
async def drop_scratch_db() -> None:
import asyncpg
admin = await asyncpg.connect(ADMIN_URL)
await admin.execute(f'DROP DATABASE IF EXISTS "{SCRATCH_DB}" WITH (FORCE)')
await admin.close()
def run_alembic_upgrade() -> None:
env = dict(os.environ, DATABASE_URL=SCRATCH_URL_ASYNC)
subprocess.run(
[sys.executable, "-m", "alembic", "upgrade", "head"],
cwd=BACKEND_DIR,
env=env,
check=True,
)
async def bootstrap_schema() -> None:
"""Mimic app startup bootstrap: create_all + ensure_publication, no stamp."""
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from app.db import Base
from app.zero_publication import ensure_publication
engine = create_async_engine(SCRATCH_URL_ASYNC)
async with engine.begin() as conn:
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
await conn.run_sync(Base.metadata.create_all)
await conn.run_sync(ensure_publication)
await engine.dispose()
async def set_version(version: str | None) -> None:
import asyncpg
conn = await asyncpg.connect(SCRATCH_URL)
if version is None:
await conn.execute("DROP TABLE IF EXISTS alembic_version")
else:
await conn.execute(
"CREATE TABLE IF NOT EXISTS alembic_version ("
"version_num VARCHAR(32) NOT NULL PRIMARY KEY)"
)
await conn.execute("DELETE FROM alembic_version")
await conn.execute(
"INSERT INTO alembic_version (version_num) VALUES ($1)", version
)
await conn.close()
async def assert_at_head() -> None:
import asyncpg
from alembic.script import ScriptDirectory
head = ScriptDirectory(str(BACKEND_DIR / "alembic")).get_current_head()
conn = await asyncpg.connect(SCRATCH_URL)
version = await conn.fetchval("SELECT version_num FROM alembic_version")
workspaces = await conn.fetchval("SELECT to_regclass('workspaces')")
publication = await conn.fetchval(
"SELECT 1 FROM pg_publication WHERE pubname = 'zero_publication'"
)
await conn.close()
assert version == head, f"expected version {head}, got {version}"
assert workspaces, "workspaces table missing"
assert publication, "zero_publication missing"
async def main() -> None:
try:
# 1. Fresh empty DB -> fast-forward.
await recreate_scratch_db()
run_alembic_upgrade()
await assert_at_head()
print("OK: fresh DB fast-forwards to head")
# 2. Bootstrap-created schema, no alembic history -> adoption.
await recreate_scratch_db()
await bootstrap_schema()
await set_version(None)
run_alembic_upgrade()
await assert_at_head()
print("OK: bootstrap-created schema adopted (stamped head)")
# 3. Bootstrap schema stuck at a pre-rename revision -> adoption.
await set_version("4")
run_alembic_upgrade()
await assert_at_head()
print("OK: pre-rename stuck revision adopted (stamped head)")
# Re-run must be a clean no-op.
run_alembic_upgrade()
await assert_at_head()
print("OK: repeat upgrade is a no-op")
finally:
await drop_scratch_db()
asyncio.run(main())
@@ -0,0 +1,48 @@
"""Self-check for ensure_publication on a create_all-bootstrapped scratch DB."""
import asyncio
import asyncpg
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
SCRATCH_DB = "surfsense_zero_pub_check"
ADMIN_DSN = "postgresql://postgres:postgres@localhost:5432/postgres"
SCRATCH_URL = f"postgresql+asyncpg://postgres:postgres@localhost:5432/{SCRATCH_DB}"
async def main() -> None:
admin = await asyncpg.connect(ADMIN_DSN)
await admin.execute(f'DROP DATABASE IF EXISTS "{SCRATCH_DB}" WITH (FORCE)')
await admin.execute(f'CREATE DATABASE "{SCRATCH_DB}"')
await admin.close()
from app.db import Base
from app.zero_publication import ensure_publication, verify_publication
engine = create_async_engine(SCRATCH_URL)
try:
async with engine.begin() as conn:
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
await conn.run_sync(Base.metadata.create_all)
await conn.run_sync(ensure_publication)
mismatches = await conn.run_sync(verify_publication)
assert not mismatches, f"shape wrong after ensure: {mismatches}"
# Second call must be a no-op that leaves a verified shape.
await conn.run_sync(ensure_publication)
mismatches = await conn.run_sync(verify_publication)
assert not mismatches, f"shape wrong after re-ensure: {mismatches}"
finally:
await engine.dispose()
admin = await asyncpg.connect(ADMIN_DSN)
await admin.execute(f'DROP DATABASE IF EXISTS "{SCRATCH_DB}" WITH (FORCE)')
await admin.close()
print(
"OK: ensure_publication creates and verifies on a create_all DB, idempotently."
)
asyncio.run(main())
@@ -0,0 +1,105 @@
"""Create the Daytona snapshot used by SurfSense code-execution sandboxes.
Run from the backend directory:
cd surfsense_backend
uv run python scripts/create_sandbox_snapshot.py
Prerequisites:
- DAYTONA_API_KEY set in surfsense_backend/.env (or exported in shell)
- DAYTONA_API_URL=https://app.daytona.io/api
- DAYTONA_TARGET=us (or eu)
After this script succeeds, add to surfsense_backend/.env:
DAYTONA_SNAPSHOT_ID=surfsense-sandbox
"""
import os
import sys
import time
from pathlib import Path
from dotenv import load_dotenv
_here = Path(__file__).parent
for candidate in [
_here / "../surfsense_backend/.env",
_here / ".env",
_here / "../.env",
]:
if candidate.exists():
load_dotenv(candidate)
break
from daytona import CreateSnapshotParams, Daytona, Image # noqa: E402
SNAPSHOT_NAME = "surfsense-sandbox"
PACKAGES = [
"pandas",
"numpy",
"matplotlib",
"scipy",
"scikit-learn",
]
def build_image() -> Image:
"""Build the sandbox image with data-science packages and a /documents symlink."""
return (
Image.debian_slim("3.12")
.pip_install(*PACKAGES)
# Symlink /documents → /home/daytona/documents so the LLM can use
# the same /documents/ path it sees in the virtual filesystem.
.run_commands(
"mkdir -p /home/daytona/documents",
"ln -sfn /home/daytona/documents /documents",
)
)
def main() -> None:
api_key = os.environ.get("DAYTONA_API_KEY")
if not api_key:
print("ERROR: DAYTONA_API_KEY is not set.", file=sys.stderr)
print(
"Add it to surfsense_backend/.env or export it in your shell.",
file=sys.stderr,
)
sys.exit(1)
daytona = Daytona()
try:
existing = daytona.snapshot.get(SNAPSHOT_NAME)
print(f"Deleting existing snapshot '{SNAPSHOT_NAME}'")
daytona.snapshot.delete(existing)
print(f"Deleted '{SNAPSHOT_NAME}'. Waiting for removal to propagate …")
for _attempt in range(30):
time.sleep(2)
try:
daytona.snapshot.get(SNAPSHOT_NAME)
except Exception:
print(f"Confirmed '{SNAPSHOT_NAME}' is gone.\n")
break
else:
print(
f"WARNING: '{SNAPSHOT_NAME}' may still exist after 60s. Proceeding anyway.\n"
)
except Exception:
pass
print(f"Building snapshot '{SNAPSHOT_NAME}'")
print(f"Packages: {', '.join(PACKAGES)}\n")
daytona.snapshot.create(
CreateSnapshotParams(name=SNAPSHOT_NAME, image=build_image()),
on_logs=lambda chunk: print(chunk, end="", flush=True),
)
print(f"\n\nSnapshot '{SNAPSHOT_NAME}' is ready.")
print("\nAdd this to surfsense_backend/.env:")
print(f" DAYTONA_SNAPSHOT_ID={SNAPSHOT_NAME}")
if __name__ == "__main__":
main()
+53
View File
@@ -0,0 +1,53 @@
#!/bin/bash
# =============================================================================
# E2E entrypoint for the multi-stage Dockerfile's `e2e` target.
#
# Dispatches on SERVICE_ROLE to the test-only entrypoints under tests/e2e/.
# Those scripts apply sys.modules hijacks and LLM/embedding patches BEFORE
# importing production app code (see tests/e2e/run_backend.py for rationale).
#
# Production never sees this file: tests/ is excluded from the production
# stage, and the production stage uses scripts/docker/entrypoint.sh.
# =============================================================================
set -euo pipefail
SERVICE_ROLE="${SERVICE_ROLE:-api}"
echo "[e2e-entrypoint] starting role=${SERVICE_ROLE}"
wait_for_db() {
# Block until the database is reachable. We don't loop forever — Compose
# depends_on/healthchecks already gate on db readiness, this is just
# belt-and-suspenders so a slow first connection doesn't race migrations.
for i in {1..60}; do
echo "[e2e-entrypoint] db check attempt ${i}/60"
if python -c "from app.db import engine; import asyncio; asyncio.run(engine.dispose())"; then
echo "[e2e-entrypoint] db reachable after ${i} attempts"
return 0
fi
sleep 1
done
echo "[e2e-entrypoint] ERROR: db not reachable after 60s" >&2
return 1
}
case "${SERVICE_ROLE}" in
api)
wait_for_db
echo "[e2e-entrypoint] running alembic upgrade head"
alembic upgrade head
# `exec` so SIGTERM from `docker stop` reaches Python directly,
# without a shell wrapper interposing.
exec python tests/e2e/run_backend.py
;;
worker)
# Worker doesn't run migrations — the api role does that exactly once.
# We still wait for db so Celery's broker connection check doesn't
# race against an unready Postgres on cold start.
wait_for_db
exec python tests/e2e/run_celery.py
;;
*)
echo "[e2e-entrypoint] ERROR: unknown SERVICE_ROLE='${SERVICE_ROLE}' (expected: api | worker)" >&2
exit 1
;;
esac
@@ -0,0 +1,160 @@
#!/bin/bash
set -e
# ─────────────────────────────────────────────────────────────
# SERVICE_ROLE controls which process(es) this container runs.
#
# migrate Run `alembic upgrade head`, verify zero_publication,
# then exit 0. Used by the dedicated `migrations` service
# in docker-compose.yml so downstream services can gate
# on `condition: service_completed_successfully`.
# api FastAPI backend only (does NOT run migrations)
# worker Celery worker only
# beat Celery beat scheduler only
# all migrations + api + worker + beat in one container
# (legacy / dev default; fails fast on migration error)
#
# Set SERVICE_ROLE as an environment variable in Coolify for
# each service deployment.
# ─────────────────────────────────────────────────────────────
SERVICE_ROLE="${SERVICE_ROLE:-all}"
echo "Starting SurfSense with SERVICE_ROLE=${SERVICE_ROLE}"
# ── Autoscale defaults (override via env) ────────────────────
# CELERY_MAX_WORKERS max concurrent worker processes
# CELERY_MIN_WORKERS min workers kept warm
# CELERY_QUEUES comma-separated queues to consume
# (empty = all queues for backward compat)
CELERY_MAX_WORKERS="${CELERY_MAX_WORKERS:-10}"
CELERY_MIN_WORKERS="${CELERY_MIN_WORKERS:-2}"
CELERY_MAX_TASKS_PER_CHILD="${CELERY_MAX_TASKS_PER_CHILD:-50}"
CELERY_QUEUES="${CELERY_QUEUES:-}"
# ── Graceful shutdown ────────────────────────────────────────
PIDS=()
cleanup() {
echo "Shutting down services..."
for pid in "${PIDS[@]}"; do
kill -TERM "$pid" 2>/dev/null || true
done
for pid in "${PIDS[@]}"; do
wait "$pid" 2>/dev/null || true
done
exit 0
}
trap cleanup SIGTERM SIGINT
# ── Database migrations (only for migrate / all) ─────────────
# Fail-fast contract:
# - alembic upgrade head must succeed within ${MIGRATION_TIMEOUT:-900}s
# - zero_publication must match the canonical app.zero_publication shape
# Either failure exits non-zero so the dedicated `migrations` compose
# service exits non-zero, halting the rest of the stack instead of
# silently producing a drifted Zero publication.
run_migrations() {
echo "Running database migrations..."
for i in {1..30}; do
if python -c "from app.db import engine; import asyncio; asyncio.run(engine.dispose())" 2>/dev/null; then
echo "Database is ready."
break
fi
echo "Waiting for database... ($i/30)"
sleep 1
done
local timeout_secs="${MIGRATION_TIMEOUT:-900}"
echo "Running alembic upgrade head (timeout=${timeout_secs}s)..."
if ! timeout "${timeout_secs}" alembic upgrade head; then
echo "ERROR: alembic upgrade head failed (or exceeded ${timeout_secs}s timeout)." >&2
echo "Refusing to start. Inspect the error above and re-run." >&2
exit 1
fi
echo "Migrations completed successfully."
echo "Verifying zero_publication matches the canonical shape..."
if ! python -m app.zero_publication --verify; then
echo "ERROR: zero_publication does not match the canonical shape." >&2
echo "Inspect alembic state with:" >&2
echo " docker compose exec db psql -U \"\$DB_USER\" -d \"\$DB_NAME\" -c 'SELECT * FROM alembic_version;'" >&2
exit 1
fi
}
# ── Service starters ─────────────────────────────────────────
start_api() {
echo "Starting FastAPI Backend..."
python main.py &
PIDS+=($!)
echo " FastAPI PID=${PIDS[-1]}"
}
start_worker() {
QUEUE_ARGS=""
if [ -n "${CELERY_QUEUES}" ]; then
QUEUE_ARGS="--queues=${CELERY_QUEUES}"
else
# When no queues specified, consume from the default, connectors, and
# gateway maintenance queues. Without --queues, Celery only consumes
# from the default queue, leaving connector/gateway maintenance tasks stuck.
DEFAULT_Q="${CELERY_TASK_DEFAULT_QUEUE:-surfsense}"
QUEUE_ARGS="--queues=${DEFAULT_Q},${DEFAULT_Q}.connectors,${DEFAULT_Q}.gateway"
fi
echo "Starting Celery Worker (autoscale=${CELERY_MAX_WORKERS},${CELERY_MIN_WORKERS}, max-tasks-per-child=${CELERY_MAX_TASKS_PER_CHILD}, queues=${CELERY_QUEUES:-all})..."
celery -A app.celery_app worker \
--loglevel=info \
--autoscale="${CELERY_MAX_WORKERS},${CELERY_MIN_WORKERS}" \
--max-tasks-per-child="${CELERY_MAX_TASKS_PER_CHILD}" \
--prefetch-multiplier=1 \
-Ofair \
${QUEUE_ARGS} &
PIDS+=($!)
echo " Celery Worker PID=${PIDS[-1]}"
}
start_beat() {
echo "Starting Celery Beat..."
celery -A app.celery_app beat --loglevel=info &
PIDS+=($!)
echo " Celery Beat PID=${PIDS[-1]}"
}
# ── Main: run based on role ──────────────────────────────────
case "${SERVICE_ROLE}" in
migrate)
run_migrations
echo "Migrations complete; exiting cleanly."
exit 0
;;
api)
start_api
;;
worker)
start_worker
;;
beat)
start_beat
;;
all)
run_migrations
start_api
sleep 5
start_worker
sleep 3
start_beat
;;
*)
echo "ERROR: Unknown SERVICE_ROLE '${SERVICE_ROLE}'. Use: migrate, api, worker, beat, or all"
exit 1
;;
esac
echo "All requested services started. PIDs: ${PIDS[*]}"
# Wait for any process to exit
wait -n
# If we get here, one process exited unexpectedly
exit $?
@@ -0,0 +1,824 @@
"""Deep functional verification for the Google Maps scraper (live network).
Complements the fast smoke e2e (e2e_google_maps_scraper.py) with breadth:
diverse places (countries, scripts, categories), URL-kind coverage (name-only
URLs, CID), and review semantics (sort order, date cutoff, pagination
uniqueness, personal-data stripping, localization).
Verification style: ground-truth invariants instead of screenshots — known
coordinates/websites/address keywords for world-famous places, and internal
consistency rules (newest sort is monotonically non-increasing, cutoff dates
hold, review IDs are unique across pages, etc.).
Run from the backend directory:
.\\.venv\\Scripts\\python.exe scripts/e2e_google_maps_deep.py
"""
import asyncio
import itertools
import sys
from datetime import datetime
from pathlib import Path
from dotenv import load_dotenv
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
_BACKEND_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(_BACKEND_ROOT))
for _candidate in (_BACKEND_ROOT / ".env", _BACKEND_ROOT.parent / ".env"):
if _candidate.exists():
load_dotenv(_candidate)
break
from app.proprietary.platforms.google_maps import ( # noqa: E402
GoogleMapsReviewsInput,
GoogleMapsScrapeInput,
scrape_places,
scrape_reviews,
)
_CHECKS: list[tuple[str, bool, str]] = []
def _check(label: str, ok: bool, detail: str = "") -> bool:
_CHECKS.append((label, ok, detail))
print(f" [{'PASS' if ok else 'FAIL'}] {label}{f'{detail}' if detail else ''}")
return ok
def _hr(title: str) -> None:
print(f"\n{'=' * 70}\n{title}\n{'=' * 70}")
def _near(actual: float | None, expected: float, tol: float = 0.05) -> bool:
return actual is not None and abs(actual - expected) <= tol
def _iso(s: str | None) -> datetime | None:
if not s:
return None
try:
return datetime.fromisoformat(s.replace("Z", "+00:00"))
except ValueError:
return None
# Ground truth: world-famous places whose facts don't drift. Name-only URLs
# exercise the HTML -> fid resolution path for every one of them.
_PLACES = [
{
"label": "Eiffel Tower (FR landmark)",
"url": "https://www.google.com/maps/place/Eiffel+Tower/",
"title_contains": "eiffel",
"lat": 48.8584,
"lng": 2.2945,
"address_contains": ["paris", "75007", "france"],
"website_contains": "toureiffel",
},
{
"label": "Tokyo Tower (JP, non-Latin locale)",
"url": "https://www.google.com/maps/place/Tokyo+Tower/",
"title_contains": "tokyo tower",
"lat": 35.6586,
"lng": 139.7454,
"address_contains": ["tokyo", "japan", "minato"],
},
{
"label": "Sydney Opera House (AU)",
"url": "https://www.google.com/maps/place/Sydney+Opera+House/",
"title_contains": "opera house",
"lat": -33.8568,
"lng": 151.2153,
"address_contains": ["sydney", "nsw", "australia"],
},
{
"label": "The Plaza Hotel (US hotel category)",
"url": "https://www.google.com/maps/place/The+Plaza+Hotel+New+York/",
"title_contains": "plaza",
"lat": 40.7646,
"lng": -73.9744,
"address_contains": ["new york", "ny"],
"category_contains": "hotel",
},
]
_KIMS_CID_URL = "https://maps.google.com/?cid=7838756667406262025" # Kim's Island
_KIMS_PLACE_ID = "ChIJJQz5EZzKw4kRCZ95UajbyGw"
_LOUVRE_URL = "https://www.google.com/maps/place/Louvre+Museum/"
async def scrape_one(url: str, **kwargs) -> dict | None:
items = await scrape_places(
GoogleMapsScrapeInput(startUrls=[{"url": url}], **kwargs)
)
return items[0] if items else None
async def step_diverse_places() -> None:
_hr("A — diverse places via name-only URLs (HTML -> fid path)")
for spec in _PLACES:
it = await scrape_one(spec["url"])
if it is None:
_check(spec["label"], False, "no item returned")
continue
title = (it.get("title") or "").lower()
loc = it.get("location") or {}
addr = (it.get("address") or "").lower()
problems = []
if spec["title_contains"] not in title:
problems.append(f"title={it.get('title')!r}")
if not _near(loc.get("lat"), spec["lat"]) or not _near(
loc.get("lng"), spec["lng"]
):
problems.append(f"loc={loc}")
if not any(k in addr for k in spec["address_contains"]):
problems.append(f"address={it.get('address')!r}")
if not it.get("placeId"):
problems.append("no placeId")
if not it.get("categories"):
problems.append("no categories")
if spec.get("website_contains") and spec["website_contains"] not in (
it.get("website") or ""
):
problems.append(f"website={it.get('website')!r}")
if (
spec.get("category_contains")
and spec["category_contains"]
not in (
(it.get("categoryName") or "") + " ".join(it.get("categories") or [])
).lower()
):
problems.append(f"categories={it.get('categories')}")
_check(
spec["label"],
not problems,
"; ".join(problems)
or f"{it.get('title')!r} @ ({loc.get('lat'):.4f},{loc.get('lng'):.4f}) "
f"cat={it.get('categoryName')!r} score={it.get('totalScore')}",
)
async def step_cid_url() -> None:
_hr("B — CID URL dispatch")
it = await scrape_one(_KIMS_CID_URL)
ok = it is not None and it.get("title") == "Kim's Island"
_check(
"?cid=... resolves to the right place",
ok,
f"title={it.get('title')!r}" if it else "no item",
)
if it:
_check(
"cid place has full detail (phone+hours)",
bool(it.get("phone")) and bool(it.get("openingHours")),
f"phone={it.get('phone')!r}, hours={len(it.get('openingHours') or [])} days",
)
async def step_review_sorts() -> None:
_hr("C — review sort semantics (Kim's Island)")
newest = await scrape_reviews(
GoogleMapsReviewsInput(placeIds=[_KIMS_PLACE_ID], maxReviews=10)
)
dates = [_iso(r.get("publishedAtDate")) for r in newest]
dated = [d for d in dates if d]
_check(
"newest: publishedAtDate non-increasing",
len(dated) >= 5 and all(a >= b for a, b in itertools.pairwise(dated)),
f"{len(newest)} reviews, first={newest[0].get('publishAt') if newest else None}",
)
lowest = await scrape_reviews(
GoogleMapsReviewsInput(
placeIds=[_KIMS_PLACE_ID], maxReviews=10, reviewsSort="lowestRanking"
)
)
highest = await scrape_reviews(
GoogleMapsReviewsInput(
placeIds=[_KIMS_PLACE_ID], maxReviews=10, reviewsSort="highestRanking"
)
)
lo = [r["stars"] for r in lowest if r.get("stars")]
hi = [r["stars"] for r in highest if r.get("stars")]
lo_avg = sum(lo) / len(lo) if lo else 0
hi_avg = sum(hi) / len(hi) if hi else 0
_check(
"lowestRanking avg < highestRanking avg",
bool(lo and hi) and lo_avg < hi_avg,
f"lowest avg={lo_avg:.2f} (first={lo[:3]}), highest avg={hi_avg:.2f} (first={hi[:3]})",
)
_check(
"highestRanking page is all 5 stars",
bool(hi) and all(s == 5 for s in hi),
f"stars={hi}",
)
async def step_start_date_cutoff() -> None:
_hr("D — reviewsStartDate cutoff")
baseline = await scrape_reviews(
GoogleMapsReviewsInput(placeIds=[_KIMS_PLACE_ID], maxReviews=10)
)
dated = [(_iso(r.get("publishedAtDate")), r) for r in baseline]
dated = [(d, r) for d, r in dated if d]
if len(dated) < 6:
_check("cutoff test has enough dated reviews", False, f"only {len(dated)}")
return
# Cut between the 4th and 5th newest review -> expect exactly 4 back.
cutoff_dt = dated[4][0]
cutoff = (
dated[3][0].strftime("%Y-%m-%dT%H:%M:%S.000Z")
if dated[3][0] == cutoff_dt
else cutoff_dt.strftime("%Y-%m-%dT%H:%M:%S.000Z")
)
got = await scrape_reviews(
GoogleMapsReviewsInput(
placeIds=[_KIMS_PLACE_ID], maxReviews=100, reviewsStartDate=cutoff
)
)
got_dates = [_iso(r.get("publishedAtDate")) for r in got]
cutoff_parsed = _iso(cutoff)
_check(
"all returned reviews >= cutoff",
bool(got) and all(d is None or d >= cutoff_parsed for d in got_dates),
f"cutoff={cutoff}, returned={len(got)} (expected ~4)",
)
_check(
"cutoff actually limits the result",
0 < len(got) < len(baseline) + 1 and len(got) <= 6,
f"{len(got)} vs baseline {len(baseline)}",
)
async def step_personal_data() -> None:
_hr("E — personalData=false stripping")
items = await scrape_reviews(
GoogleMapsReviewsInput(
placeIds=[_KIMS_PLACE_ID], maxReviews=3, personalData=False
)
)
if not items:
_check("reviews returned", False)
return
leaked = [
k
for k in ("name", "reviewerId", "reviewerUrl", "reviewerPhotoUrl")
if any(r.get(k) for r in items)
]
_check(
"reviewer identity fields are absent",
not leaked,
f"leaked={leaked}" if leaked else f"{len(items)} reviews, ids+stars kept",
)
_check(
"non-personal fields survive",
all(r.get("reviewId") and r.get("stars") for r in items),
)
async def step_localization() -> None:
_hr("F — localization (language=fr)")
items = await scrape_reviews(
GoogleMapsReviewsInput(
startUrls=[{"url": "https://www.google.com/maps/place/Eiffel+Tower/"}],
maxReviews=5,
language="fr",
)
)
if not items:
_check("french reviews returned", False)
return
rel = [r.get("publishAt") or "" for r in items]
french = [s for s in rel if "il y a" in s or "mois" in s or "semaine" in s]
_check(
"relative dates come back in French",
len(french) >= 3,
f"publishAt={rel}",
)
_check(
"items stamped language=fr",
all(r.get("language") == "fr" for r in items),
)
async def step_big_place_pagination() -> None:
_hr("G — big place, 30 reviews across >=3 pages (Louvre)")
items = await scrape_reviews(
GoogleMapsReviewsInput(startUrls=[{"url": _LOUVRE_URL}], maxReviews=30)
)
ids = [r.get("reviewId") for r in items]
_check(
"30 reviews with unique IDs",
len(items) == 30 and len(set(ids)) == 30,
f"{len(items)} reviews, {len(set(ids))} unique",
)
ok_fields = all(
r.get("name") and r.get("stars") is not None and r.get("publishedAtDate")
for r in items
)
_check("every review has author/stars/date", ok_fields)
_check(
"place header stamped on all (Louvre)",
all("louvre" in (r.get("title") or "").lower() for r in items),
f"title={items[0].get('title')!r}" if items else "",
)
async def step_search_discovery() -> None:
_hr("I — search discovery: paging, rank, dedupe (pizza in New York)")
items = await scrape_places(
GoogleMapsScrapeInput(
searchStringsArray=["pizza"],
locationQuery="New York, NY",
maxCrawledPlacesPerSearch=25,
)
)
fids = [i.get("fid") for i in items]
_check(
"25 places across >1 page, all unique fids",
len(items) == 25 and len(set(fids)) == 25,
f"{len(items)} items, {len(set(fids))} unique",
)
_check(
"ranks are 1..25 in order",
[i.get("rank") for i in items] == list(range(1, 26)),
)
ny = sum(
1
for i in items
if i.get("location")
and 40.4 < (i["location"]["lat"] or 0) < 41.1
and -74.3 < (i["location"]["lng"] or 0) < -73.6
)
_check(
"locationQuery scopes results to NYC",
ny >= 23,
f"{ny}/25 within NYC bounds",
)
with_core = sum(
1 for i in items if i.get("title") and i.get("placeId") and i.get("address")
)
_check("all items have title/placeId/address", with_core == 25, f"{with_core}/25")
async def step_search_filters() -> None:
_hr("J — search filters (stars, website, matching)")
starred = await scrape_places(
GoogleMapsScrapeInput(
searchStringsArray=["restaurant"],
locationQuery="Chicago, IL",
maxCrawledPlacesPerSearch=10,
placeMinimumStars="fourAndHalf",
)
)
scores = [i.get("totalScore") for i in starred]
_check(
"placeMinimumStars=fourAndHalf holds",
bool(scores) and all(s is not None and s >= 4.5 for s in scores),
f"scores={scores}",
)
no_web = await scrape_places(
GoogleMapsScrapeInput(
searchStringsArray=["restaurant"],
locationQuery="Chicago, IL",
maxCrawledPlacesPerSearch=5,
website="withoutWebsite",
)
)
_check(
"website=withoutWebsite holds",
bool(no_web) and all(not i.get("website") for i in no_web),
f"{len(no_web)} items, websites={[i.get('website') for i in no_web]}",
)
matching = await scrape_places(
GoogleMapsScrapeInput(
searchStringsArray=["pizza"],
locationQuery="Boston, MA",
maxCrawledPlacesPerSearch=5,
searchMatching="only_includes",
)
)
titles = [i.get("title") for i in matching]
_check(
"searchMatching=only_includes keeps 'pizza' in titles",
bool(titles) and all("pizza" in (t or "").lower() for t in titles),
f"titles={titles}",
)
async def step_search_closed() -> None:
_hr("K — closed-place detection + skipClosedPlaces (Dean & DeLuca)")
q = "Dean DeLuca 560 Broadway New York"
found = await scrape_places(
GoogleMapsScrapeInput(searchStringsArray=[q], maxCrawledPlacesPerSearch=3)
)
dean = next((i for i in found if "DeLuca" in (i.get("title") or "")), None)
_check(
"permanently closed place flagged",
dean is not None and dean.get("permanentlyClosed") is True,
f"title={dean.get('title') if dean else None}, closed={dean.get('permanentlyClosed') if dean else None}",
)
skipped = await scrape_places(
GoogleMapsScrapeInput(
searchStringsArray=[q],
maxCrawledPlacesPerSearch=3,
skipClosedPlaces=True,
)
)
_check(
"skipClosedPlaces filters it out",
not any("DeLuca" in (i.get("title") or "") for i in skipped),
f"{len(skipped)} items after skip",
)
async def step_search_url_and_geo() -> None:
_hr("L — /maps/search/ URL routing + customGeolocation")
via_url = await scrape_places(
GoogleMapsScrapeInput(
startUrls=[
{"url": "https://www.google.com/maps/search/ramen+in+Osaka+Japan/"}
],
maxCrawledPlacesPerSearch=5,
)
)
osaka = sum(
1
for i in via_url
if i.get("location") and 34.4 < (i["location"]["lat"] or 0) < 35.0
)
_check(
"/maps/search/ startUrl yields Osaka ramen places",
len(via_url) == 5 and osaka >= 4,
f"{len(via_url)} items, {osaka} in Osaka",
)
geo = await scrape_places(
GoogleMapsScrapeInput(
searchStringsArray=["museum"],
customGeolocation={
"type": "Point",
"coordinates": [2.3522, 48.8566], # [lng, lat] Paris
"radiusKm": 10,
},
maxCrawledPlacesPerSearch=5,
)
)
paris = sum(
1
for i in geo
if i.get("location")
and 48.5 < (i["location"]["lat"] or 0) < 49.2
and 1.9 < (i["location"]["lng"] or 0) < 2.8
)
_check(
"customGeolocation Point scopes to Paris",
len(geo) >= 3 and paris >= 3,
f"{len(geo)} items, {paris} in Paris: {[i.get('title') for i in geo]}",
)
async def step_search_pagination_stress() -> None:
_hr("M — search pagination stress (60 results / 3+ pages, exhaustion)")
items = await scrape_places(
GoogleMapsScrapeInput(
searchStringsArray=["restaurant"],
locationQuery="Manhattan, New York",
maxCrawledPlacesPerSearch=60,
)
)
fids = [i.get("fid") for i in items]
_check(
"60 places, all fids unique (offset paging + dedupe)",
len(items) == 60 and len(set(fids)) == 60,
f"{len(items)} items, {len(set(fids))} unique",
)
_check(
"ranks strictly sequential 1..60",
[i.get("rank") for i in items] == list(range(1, 61)),
)
manhattan = sum(
1
for i in items
if i.get("location") and 40.68 < (i["location"]["lat"] or 0) < 40.9
)
_check(
"results stay in Manhattan across pages",
manhattan >= 55,
f"{manhattan}/60 in bounds",
)
# A hyper-specific query has few results: paging must terminate on its
# own (no infinite loop, no error) well before the requested cap.
sparse = await scrape_places(
GoogleMapsScrapeInput(
searchStringsArray=["Kim's Island Staten Island"],
maxCrawledPlacesPerSearch=100,
)
)
_check(
"sparse query exhausts naturally below the cap",
0 < len(sparse) < 25,
f"{len(sparse)} results",
)
async def step_detail_extras() -> None:
_hr("N — detail-page extras (kgmid/cid/additionalInfo/links)")
# pin the exact place by fid — a name search returns a different Joe's
# branch run to run, which makes magnitude assertions flaky
items = await scrape_places(
GoogleMapsScrapeInput(
startUrls=[
{
"url": "https://www.google.com/maps/place/Joe's+Pizza+Broadway/"
"data=!4m2!3m1!1s0x89c259ab3c1ef289:0x3b67a41175949f55"
}
],
maxImages=5,
)
)
if not items:
_check("place returned", False)
return
it = items[0]
dist = it.get("reviewsDistribution") or {}
_check(
"reviewsCount + distribution (NID-session fields)",
(it.get("reviewsCount") or 0) > 20_000
and (it.get("reviewsCount") == sum(dist.values())),
f"count={it.get('reviewsCount')}, dist={dist}",
)
hist = it.get("popularTimesHistogram") or {}
_check(
"popular times histogram covers the week",
set(hist) == {"Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"}
and all(
{"hour", "occupancyPercent"} <= set(slot)
for d in hist.values()
for slot in d
),
f"days={sorted(hist)}",
)
_check(
"image gallery fields (count/categories/urls capped at maxImages)",
(it.get("imagesCount") or 0) > 1000
and "All" in (it.get("imageCategories") or [])
and len(it.get("imageUrls") or []) == 5,
f"count={it.get('imagesCount')}, cats={len(it.get('imageCategories') or [])}, "
f"urls={len(it.get('imageUrls') or [])}",
)
tags = it.get("reviewsTags") or []
_check(
"reviewsTags with counts",
len(tags) >= 5 and all(t.get("title") and t.get("count") for t in tags),
f"{tags[:2]}",
)
_check(
"additionalInfo has full section set (not just Accessibility)",
len(it.get("additionalInfo") or {}) >= 8,
f"sections={list((it.get('additionalInfo') or {}).keys())}",
)
# scrapePlaceDetailPage on the SEARCH flow: search darrays lack the
# session-gated fields, so each hit must get enriched via a detail RPC.
enriched = await scrape_places(
GoogleMapsScrapeInput(
searchStringsArray=["ramen"],
locationQuery="Chicago, IL",
maxCrawledPlacesPerSearch=2,
scrapePlaceDetailPage=True,
)
)
_check(
"search flow + scrapePlaceDetailPage enriches every hit",
len(enriched) == 2
and all(
(e.get("reviewsCount") or 0) > 0 and e.get("reviewsDistribution")
for e in enriched
),
f"counts={[e.get('reviewsCount') for e in enriched]}",
)
fid = it.get("fid") or ""
cid_ok = bool(fid) and it.get("cid") == str(int(fid.split(":")[1], 16))
_check(
"kgmid + cid derived from fid",
bool(it.get("kgmid", "").startswith("/g/")) and cid_ok,
f"kgmid={it.get('kgmid')}, cid={it.get('cid')}",
)
info = it.get("additionalInfo") or {}
_check(
"additionalInfo has sections with boolean options",
bool(info)
and all(
isinstance(v, list) and all(isinstance(e, dict) for e in v)
for v in info.values()
),
f"sections={list(info.keys())}",
)
async def step_hotel_fields() -> None:
_hr("O — hotel fields (The Plaza: stars, dates, similar, ads)")
items = await scrape_places(
GoogleMapsScrapeInput(
startUrls=[
{
"url": "https://www.google.com/maps/place/The+Plaza/"
"data=!4m2!3m1!1s0x89c258f07d5da561:0x61f6aa300ba8339d"
}
],
)
)
if not items:
_check("hotel returned", False)
return
it = items[0]
_check(
"hotelStars + check-in/out dates",
it.get("hotelStars") == "5 stars"
and (it.get("checkInDate") or "") < (it.get("checkOutDate") or ""),
f"stars={it.get('hotelStars')}, {it.get('checkInDate')}..{it.get('checkOutDate')}",
)
similar = it.get("similarHotelsNearby") or []
_check(
"similarHotelsNearby with fid/score",
len(similar) >= 3 and all(h.get("title") and h.get("fid") for h in similar),
f"{len(similar)} hotels, first={similar[0].get('title') if similar else None}",
)
ads = it.get("hotelAds") or []
_check(
"hotelAds with booking links",
bool(ads) and all(a.get("url", "").startswith("https://") for a in ads),
f"{len(ads)} ads",
)
async def step_all_places_scan() -> None:
_hr("P — allPlacesNoSearchAction area scan (Times Square 400m)")
items = await scrape_places(
GoogleMapsScrapeInput(
allPlacesNoSearchAction="all_places_no_search_mouse",
customGeolocation={
"type": "Point",
"coordinates": [-73.9855, 40.758],
"radiusKm": 0.4,
},
maxCrawledPlacesPerSearch=25,
)
)
fids = [i.get("fid") for i in items]
_check(
"25 unique places without any search term",
len(items) == 25 and len(set(fids)) == 25,
f"{len(items)} items",
)
cats = {i.get("categoryName") for i in items if i.get("categoryName")}
_check(
"multiple categories represented (sweep, not one query)",
len(cats) >= 5,
f"{len(cats)} categories",
)
in_view = sum(
1 for i in items if i.get("location") and 40.74 < i["location"]["lat"] < 40.78
)
_check("scan respects the viewport", in_view >= 23, f"{in_view}/25 in bounds")
async def step_short_url() -> None:
_hr("Q — short link (maps.app.goo.gl Firebase redirect -> place)")
# A real shared short link -> "Aux Merveilleux de Fred" (NYC). These are
# Firebase Dynamic Links (JS interstitial), so this exercises the browser-
# render redirect path in resolve_fid. fid is the ground-truth invariant.
it = await scrape_one("https://maps.app.goo.gl/8YUvDPbQPrasqC528")
_check(
"maps.app.goo.gl short link resolves to the right place",
it is not None
and it.get("fid") == "0x89c259957da502cd:0xed3eb58a4ca08a95"
and "merveilleux" in (it.get("title") or "").lower(),
f"title={it.get('title') if it else None!r}, fid={it.get('fid') if it else None}",
)
async def step_filter_variants() -> None:
_hr("R — search filter variants (only_exact, categoryFilterWords)")
# only_exact: the parsed title must equal the query exactly (Seattle
# Starbucks are titled "Starbucks Coffee Company", not "Starbucks").
exact = await scrape_places(
GoogleMapsScrapeInput(
searchStringsArray=["Starbucks Coffee Company"],
locationQuery="Seattle, WA",
maxCrawledPlacesPerSearch=8,
searchMatching="only_exact",
)
)
titles = [i.get("title") for i in exact]
_check(
"searchMatching=only_exact keeps only exact-title matches",
bool(titles)
and all((t or "").lower() == "starbucks coffee company" for t in titles),
f"{len(titles)} items, titles={titles[:3]}",
)
# categoryFilterWords: drop places whose categories don't include a word.
coffee = await scrape_places(
GoogleMapsScrapeInput(
searchStringsArray=["food"],
locationQuery="Seattle, WA",
maxCrawledPlacesPerSearch=6,
categoryFilterWords=["coffee"],
)
)
_check(
"categoryFilterWords keeps only matching categories",
bool(coffee)
and all(
any("coffee" in c.lower() for c in (i.get("categories") or []))
for i in coffee
),
f"{len(coffee)} items, cats={[i.get('categories') for i in coffee][:2]}",
)
async def step_reviews_origin() -> None:
_hr("S — reviewsOrigin=google filter")
# The public BOQ feed only carries Google-origin reviews (partner reviews
# aren't exposed anonymously), so the invariant is: nothing non-Google
# leaks through when origin is pinned to google.
items = await scrape_reviews(
GoogleMapsReviewsInput(
placeIds=[_KIMS_PLACE_ID], maxReviews=10, reviewsOrigin="google"
)
)
origins = {(r.get("reviewOrigin") or "Google") for r in items}
_check(
"reviewsOrigin=google -> only Google-origin reviews",
bool(items) and origins <= {"Google"},
f"{len(items)} reviews, origins={origins}",
)
async def step_inline_consistency() -> None:
_hr("H — inline reviews[] match the standalone reviews endpoint")
place = await scrape_one(
f"https://www.google.com/maps/place/?q=place_id:{_KIMS_PLACE_ID}",
maxReviews=5,
)
standalone = await scrape_reviews(
GoogleMapsReviewsInput(placeIds=[_KIMS_PLACE_ID], maxReviews=5)
)
if not place or not standalone:
_check("both sources returned data", False)
return
inline_ids = {r.get("reviewId") for r in (place.get("reviews") or [])}
standalone_ids = {r.get("reviewId") for r in standalone}
overlap = len(inline_ids & standalone_ids)
_check(
"inline and standalone reviews overlap (same feed)",
overlap >= 3, # feed ordering can shift slightly between calls
f"{overlap}/5 shared review IDs",
)
async def main() -> int:
steps = [
step_diverse_places(),
step_cid_url(),
step_review_sorts(),
step_start_date_cutoff(),
step_personal_data(),
step_localization(),
step_big_place_pagination(),
step_inline_consistency(),
step_search_discovery(),
step_search_filters(),
step_search_closed(),
step_search_url_and_geo(),
step_search_pagination_stress(),
step_detail_extras(),
step_hotel_fields(),
step_all_places_scan(),
step_short_url(),
step_filter_variants(),
step_reviews_origin(),
]
for coro in steps:
try:
await coro
except Exception as e: # keep going; report the step as failed
_check(f"step crashed: {coro}", False, repr(e))
_hr("SUMMARY")
passed = sum(1 for _, ok, _ in _CHECKS if ok)
for label, ok, detail in _CHECKS:
if not ok:
print(f" FAILED: {label}{detail}")
print(f" {passed}/{len(_CHECKS)} checks passed")
return 0 if passed == len(_CHECKS) else 1
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
@@ -0,0 +1,241 @@
"""Manual functional e2e for the Google Maps scraper (app/proprietary/platforms/google_maps).
Run from the backend directory:
cd surfsense_backend
uv run python scripts/e2e_google_maps_scraper.py
# or: .\\.venv\\Scripts\\python.exe scripts/e2e_google_maps_scraper.py
NOT a pytest test (needs live network + optional proxy creds). It:
Step 1 — scrapes a known place URL and prints the core fields.
Step 2 — scrapes the same place by bare placeId (HTML -> fid -> RPC path).
Step 3 — dumps the raw place darray (jd[6] of /maps/preview/place) to
tests/unit/platforms/google_maps/fixtures/ for the offline parser test.
Step 4 — scrapes reviews via the Reviews endpoint (BOQ feed), checks fields.
Step 5 — paginates past one page (maxReviews=15) and checks the count.
Step 6 — place scrape with maxReviews>0 attaches inline reviews[].
Step 7 — dumps a raw BOQ reviews page fixture for the offline parser test.
Step 8 — search discovery (searchStringsArray + locationQuery), checks items.
Step 9 — dumps a raw map-search response fixture for the offline parser test.
"""
import asyncio
import json
import sys
from pathlib import Path
from dotenv import load_dotenv
# Windows consoles default to cp1252; reviews/ratings contain non-latin chars.
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
_BACKEND_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(_BACKEND_ROOT))
for _candidate in (_BACKEND_ROOT / ".env", _BACKEND_ROOT.parent / ".env"):
if _candidate.exists():
load_dotenv(_candidate)
break
from app.proprietary.platforms.google_maps import ( # noqa: E402
GoogleMapsReviewsInput,
GoogleMapsScrapeInput,
scrape_places,
scrape_reviews,
)
from app.proprietary.platforms.google_maps.fetch import ( # noqa: E402
build_search_url,
fetch_place_darray,
fetch_rpc_json,
iter_reviews_pages,
)
from app.proprietary.platforms.google_maps.url_resolver import extract_fid # noqa: E402
# A well-known, stable place (the restaurant used in the Apify output example).
_PLACE_URL = (
"https://www.google.com/maps/place/Kim's+Island/"
"@40.5107736,-74.2482624,17z/data=!4m6!3m5!1s0x89c3ca9c11f90c25:"
"0x6cc8dba851799f09!8m2!3d40.5107736!4d-74.2482624!16s%2Fg%2F1tmgdcj8?hl=en"
)
_FIXTURE_DIR = (
_BACKEND_ROOT / "tests" / "unit" / "platforms" / "google_maps" / "fixtures"
)
def _hr(title: str) -> None:
print(f"\n{'=' * 70}\n{title}\n{'=' * 70}")
def _check(label: str, ok: bool, detail: str = "") -> bool:
print(f" [{'PASS' if ok else 'FAIL'}] {label}{f'{detail}' if detail else ''}")
return ok
async def step1_place() -> bool:
_hr("STEP 1 — scrape a known place URL")
items = await scrape_places(GoogleMapsScrapeInput(startUrls=[{"url": _PLACE_URL}]))
if not items:
return _check("place scraped", False, "no items returned")
it = items[0]
print(json.dumps(it, indent=2, ensure_ascii=False)[:2500])
ok = bool(it.get("title")) and it.get("placeId") is not None
return _check(
"place has title + placeId",
ok,
f"{it.get('title')!r} / {it.get('totalScore')}★ / {it.get('reviewsCount')} reviews",
)
async def step2_place_id() -> bool:
_hr("STEP 2 — scrape by bare placeId (HTML -> fid -> RPC path)")
items = await scrape_places(
GoogleMapsScrapeInput(placeIds=["ChIJJQz5EZzKw4kRCZ95UajbyGw"])
)
if not items:
return _check("placeId scraped", False, "no items returned")
it = items[0]
return _check(
"placeId resolves to same place",
it.get("title") == "Kim's Island",
f"{it.get('title')!r}",
)
async def step3_dump_fixture() -> bool:
_hr("STEP 3 — dump raw place darray fixture for offline test")
fid = extract_fid(_PLACE_URL)
if not fid:
return _check("extracted fid from URL", False)
darray = await fetch_place_darray(fid)
if not darray:
return _check("fetched place darray via RPC", False)
_FIXTURE_DIR.mkdir(parents=True, exist_ok=True)
(_FIXTURE_DIR / "place_darray.json").write_text(
json.dumps(darray), encoding="utf-8"
)
return _check("dumped fixture", True, f"-> {_FIXTURE_DIR / 'place_darray.json'}")
async def step4_reviews() -> bool:
_hr("STEP 4 — reviews endpoint (one page, newest first)")
items = await scrape_reviews(
GoogleMapsReviewsInput(startUrls=[{"url": _PLACE_URL}], maxReviews=5)
)
if not items:
return _check("reviews returned", False, "no items")
it = items[0]
print(json.dumps(it, indent=2, ensure_ascii=False)[:1800])
ok = (
bool(it.get("name"))
and it.get("stars") is not None
and bool(it.get("reviewId"))
and it.get("title") == "Kim's Island" # place header stamped on
and bool(it.get("publishedAtDate"))
)
return _check(
"review has author/stars/id/place header",
ok and len(items) == 5,
f"{len(items)} reviews, first by {it.get('name')!r} ({it.get('stars')}★)",
)
async def step5_pagination() -> bool:
_hr("STEP 5 — pagination past one page (maxReviews=15)")
items = await scrape_reviews(
GoogleMapsReviewsInput(placeIds=["ChIJJQz5EZzKw4kRCZ95UajbyGw"], maxReviews=15)
)
dates = [i.get("publishedAtDate") for i in items[:3]]
return _check(
"got 15 reviews across >1 page",
len(items) == 15,
f"{len(items)} reviews; newest: {dates}",
)
async def step6_inline_reviews() -> bool:
_hr("STEP 6 — place scrape with maxReviews=3 attaches inline reviews[]")
items = await scrape_places(
GoogleMapsScrapeInput(startUrls=[{"url": _PLACE_URL}], maxReviews=3)
)
if not items:
return _check("place scraped", False, "no items")
reviews = items[0].get("reviews") or []
return _check(
"place item carries 3 inline reviews",
len(reviews) == 3 and bool(reviews[0].get("name")),
f"{len(reviews)} reviews inline",
)
async def step7_dump_reviews_fixture() -> bool:
_hr("STEP 7 — dump raw BOQ reviews page fixture for offline test")
fid = extract_fid(_PLACE_URL)
async for raw_page in iter_reviews_pages(fid, sort="newest", max_pages=1):
_FIXTURE_DIR.mkdir(parents=True, exist_ok=True)
(_FIXTURE_DIR / "boq_reviews_page.json").write_text(
json.dumps(raw_page), encoding="utf-8"
)
return _check(
"dumped fixture",
len(raw_page) > 0,
f"{len(raw_page)} reviews -> {_FIXTURE_DIR / 'boq_reviews_page.json'}",
)
return _check("fetched a reviews page", False)
async def step8_search() -> bool:
_hr("STEP 8 — search discovery (query + locationQuery)")
items = await scrape_places(
GoogleMapsScrapeInput(
searchStringsArray=["coffee shop"],
locationQuery="Seattle, WA",
maxCrawledPlacesPerSearch=5,
)
)
if not items:
return _check("search returned items", False, "no items")
it = items[0]
print(json.dumps(it, indent=2, ensure_ascii=False)[:1500])
ok = (
len(items) == 5
and all(i.get("title") and i.get("placeId") and i.get("fid") for i in items)
and [i.get("rank") for i in items] == [1, 2, 3, 4, 5]
and it.get("searchString") == "coffee shop"
)
return _check(
"5 ranked places with title/placeId/fid",
ok,
f"first: {it.get('title')!r} ({it.get('totalScore')}★, {it.get('city')})",
)
async def step9_dump_search_fixture() -> bool:
_hr("STEP 9 — dump raw map-search response fixture for offline test")
url = build_search_url("pizza new york")
jd = await fetch_rpc_json(url)
if not isinstance(jd, list):
return _check("fetched search response", False)
_FIXTURE_DIR.mkdir(parents=True, exist_ok=True)
(_FIXTURE_DIR / "search_response.json").write_text(json.dumps(jd), encoding="utf-8")
return _check("dumped fixture", True, f"-> {_FIXTURE_DIR / 'search_response.json'}")
async def main() -> int:
results = [
await step1_place(),
await step2_place_id(),
await step3_dump_fixture(),
await step4_reviews(),
await step5_pagination(),
await step6_inline_reviews(),
await step7_dump_reviews_fixture(),
await step8_search(),
await step9_dump_search_fixture(),
]
_hr("SUMMARY")
print(f" {sum(results)}/{len(results)} steps passed")
return 0 if all(results) else 1
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
@@ -0,0 +1,206 @@
"""Live end-to-end checks for the Google Search scraper (needs proxy + browser).
.venv/Scripts/python.exe scripts/e2e_google_search.py
Covers: a plain query, a site: filter, text ads, product ads, the
focusOnPaidAds retry (commercial = ads found; non-commercial = retries capped,
organic still returned), People-Also-Ask answer expansion, sitelinks, the AI
Overview, the mobile layout, filter=0, base64 icons, and Google AI Mode.
Pass case names as args to run a subset, e.g.:
.venv/Scripts/python.exe scripts/e2e_google_search.py paa
"""
import asyncio
import logging
import sys
import time
from pathlib import Path
from dotenv import load_dotenv
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(_ROOT))
load_dotenv(_ROOT / ".env")
logging.basicConfig(level=logging.WARNING)
logging.getLogger("app.proprietary.platforms.google_search.scraper").setLevel(
logging.INFO
)
from app.proprietary.platforms.google_search import ( # noqa: E402
GoogleSearchScrapeInput,
scrape_serps,
)
from app.proprietary.platforms.google_search.fetch import close_sessions # noqa: E402
async def run_ai_mode(label: str, *, queries: str) -> None:
print(f"\n=== {label} ===")
t0 = time.perf_counter()
inp = GoogleSearchScrapeInput(
queries=queries,
countryCode="us",
languageCode="en",
aiModeSearch={"enableAiMode": True},
)
items = await scrape_serps(inp, limit=2)
ai_items = [i for i in items if i["aiModeResult"]]
assert ai_items, f"{label}: no aiModeResult item emitted"
res = ai_items[0]["aiModeResult"]
print(
f" text={len(res['text'])} chars, sources={len(res['sources'])} "
f"({time.perf_counter() - t0:.0f}s)"
)
print(f" {res['text'][:130]!r}")
for s in res["sources"][:3]:
print(f" src: {(s['title'] or '')[:60]!r}")
assert res["text"] and len(res["text"]) > 100, f"{label}: answer too short"
assert res["sources"], f"{label}: no cited sources"
assert "udm=50" in ai_items[0]["searchQuery"]["url"]
async def run(
label: str,
*,
expect_ads=False,
expect_products=False,
expect_paa_answers=False,
expect_sitelinks=False,
expect_aio=False,
expect_device=None,
expect_icons=False,
**kwargs,
) -> None:
print(f"\n=== {label} ===")
t0 = time.perf_counter()
inp = GoogleSearchScrapeInput(countryCode="us", languageCode="en", **kwargs)
items = await scrape_serps(inp, limit=1)
assert items, f"{label}: no SERP item"
it = items[0]
paa_answered = [p for p in it["peopleAlsoAsk"] if p["answer"]]
sitelinked = [o for o in it["organicResults"] if o["siteLinks"]]
print(f" term={it['searchQuery']['term']!r} resultsTotal={it['resultsTotal']}")
print(
f" organic={len(it['organicResults'])} paidResults={len(it['paidResults'])} "
f"paidProducts={len(it['paidProducts'])} related={len(it['relatedQueries'])} "
f"suggested={len(it['suggestedResults'])} "
f"paa={len(it['peopleAlsoAsk'])} (answered={len(paa_answered)}) "
f"({time.perf_counter() - t0:.0f}s)"
)
for o in sitelinked[:2]:
print(
f" [sitelinks on #{o['position']}] "
+ ", ".join(s["title"] for s in o["siteLinks"][:5])
)
aio = it["aiOverview"]
if aio:
print(
f" [aiOverview] content={len(aio['content'])} chars, "
f"sources={len(aio['sources'])}"
)
print(f" {aio['content'][:110]!r}")
for s in aio["sources"][:3]:
print(f" src: {(s['title'] or '')[:55]!r}")
for a in it["paidResults"][:3]:
print(f" [ad {a['adPosition']}] {a['title'][:44]!r} {(a['url'] or '')[:45]}")
for p in it["paidProducts"][:3]:
print(f" [pla] {p['title'][:40]!r} {p['prices']} {p['displayedUrl']}")
for p in paa_answered[:3]:
print(f" [paa] {p['question'][:48]!r}")
print(f" A: {p['answer'][:90]!r}")
print(f" src: {p['url'] or '-'} | {(p['title'] or '-')[:45]}")
assert it["organicResults"], f"{label}: no organic results"
if expect_ads:
assert it["paidResults"], f"{label}: expected text ads, got none"
if expect_products:
assert it["paidProducts"], f"{label}: expected product ads, got none"
if expect_paa_answers:
assert paa_answered, f"{label}: expected PAA answers, got none"
if expect_sitelinks:
assert sitelinked, f"{label}: expected sitelinks, got none"
assert it["suggestedResults"], f"{label}: expected suggestedResults"
if expect_aio:
assert aio and aio["content"], f"{label}: expected an AI Overview"
assert aio["sources"], f"{label}: expected AI Overview sources"
if expect_device:
assert it["searchQuery"]["device"] == expect_device, (
f"{label}: device={it['searchQuery']['device']}"
)
if expect_icons:
iconed = [
o
for o in it["organicResults"]
if (o["icon"] or "").startswith("data:image")
]
print(
f" [icons] {len(iconed)}/{len(it['organicResults'])} organic "
f"carry a base64 favicon"
)
assert iconed, f"{label}: expected base64 icons on organic results"
_CASES = {
"plain": lambda: run("plain query", queries="python asyncio tutorial"),
"site": lambda: run("site: filter", queries="machine learning", site="arxiv.org"),
"ads": lambda: run("text ads", queries="car insurance quotes", expect_ads=True),
"products": lambda: run(
"product ads", queries="buy running shoes", expect_products=True
),
"focus": lambda: run(
"focusOnPaidAds (commercial)",
queries="car insurance quotes",
focusOnPaidAds=True,
expect_ads=True,
),
"focus-neg": lambda: run(
"focusOnPaidAds (non-commercial, retries capped)",
queries="python asyncio tutorial",
focusOnPaidAds=True,
),
"paa": lambda: run(
"people also ask", queries="what is seo", expect_paa_answers=True
),
"sitelinks": lambda: run(
"sitelinks + suggested (brand query)", queries="amazon", expect_sitelinks=True
),
"aio": lambda: run("AI Overview", queries="benefits of green tea", expect_aio=True),
"mobile": lambda: run(
"mobile layout (mobileResults)",
queries="best seo tools",
mobileResults=True,
expect_device="MOBILE",
),
"unfiltered": lambda: run(
"includeUnfilteredResults (filter=0)",
queries="python asyncio tutorial",
includeUnfilteredResults=True,
),
"icons": lambda: run(
"includeIcons (base64 favicons)",
queries="github",
includeIcons=True,
expect_icons=True,
),
"aimode": lambda: run_ai_mode(
"Google AI Mode (udm=50)", queries="what is quantum computing"
),
}
async def main() -> None:
names = sys.argv[1:] or list(_CASES)
try:
for name in names:
await _CASES[name]()
finally:
await close_sessions()
print("\nALL E2E OK")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,140 @@
"""Manual functional e2e for Phase 3 crawler core (3a / 3b).
Run from the backend directory:
cd surfsense_backend
uv run python scripts/e2e_phase3_crawl_billing.py
# or: .\\.venv\\Scripts\\python.exe scripts/e2e_phase3_crawl_billing.py
What it exercises (everything REAL — live network, live proxy, live DB reads):
Stage 1 (3a + 3b) — direct fetch + proxy egress-IP proof + crawl_url ladder.
Crawl billing now lives entirely in the ``web.crawl`` capability (charged
directly on the wallet via ``charge_capability``); there is no longer a
chat-turn "fold" surface to exercise here.
This is NOT a pytest test (it needs a live stack + proxy creds + network). It
is the manual functional counterpart to the unit suites; the undetectability /
anti-bot scorecard is a separate deliverable (03f), after 03d/03e.
"""
import asyncio
import sys
from pathlib import Path
from urllib.parse import urlsplit
from dotenv import load_dotenv
# --- bootstrap: load .env and put the backend root on sys.path before app.* ---
_BACKEND_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(_BACKEND_ROOT))
for _candidate in (_BACKEND_ROOT / ".env", _BACKEND_ROOT.parent / ".env"):
if _candidate.exists():
load_dotenv(_candidate)
break
# Content-rich, generally crawl-friendly targets (real extraction expected).
_ARTICLE_URLS = [
"https://en.wikipedia.org/wiki/Competitive_intelligence",
"https://en.wikipedia.org/wiki/Market_research",
]
_IP_ECHO = "https://api.ipify.org?format=json"
def _mask(url: str | None) -> str:
if not url:
return "<none>"
p = urlsplit(url)
host = p.hostname or "?"
port = f":{p.port}" if p.port else ""
creds = "***@" if p.username else ""
return f"{p.scheme}://{creds}{host}{port}"
def _hr(title: str) -> None:
print(f"\n{'=' * 70}\n{title}\n{'=' * 70}")
def _check(label: str, ok: bool, detail: str = "") -> bool:
print(f" [{'PASS' if ok else 'FAIL'}] {label}{f'{detail}' if detail else ''}")
return ok
# ===========================================================================
# Stage 1 — crawl core (3a) + proxy routing (3b)
# ===========================================================================
async def stage1_crawl_and_proxy() -> bool:
_hr("STAGE 1 — crawl_url ladder (3a) + proxy egress (3b)")
from scrapling.fetchers import AsyncFetcher
from app.proprietary.web_crawler import CrawlOutcomeStatus, WebCrawlerConnector
from app.utils.proxy import get_active_provider, get_proxy_url, is_pool_backed
ok = True
provider = get_active_provider()
proxy_url = get_proxy_url()
print(f" active proxy provider : {provider.name}")
print(f" proxy url : {_mask(proxy_url)}")
print(f" pool-backed (rotates) : {is_pool_backed()}")
# Proxy egress-IP proof: direct IP vs proxied IP should differ.
direct_ip = proxied_ip = None
try:
direct = await AsyncFetcher.get(_IP_ECHO, impersonate="chrome", timeout=30)
direct_ip = direct.json().get("ip")
except Exception as exc:
print(f" [INFO] direct IP fetch failed: {exc}")
if proxy_url:
try:
via = await AsyncFetcher.get(
_IP_ECHO, impersonate="chrome", proxy=proxy_url, timeout=45
)
proxied_ip = via.json().get("ip")
except Exception as exc:
print(f" [INFO] proxied IP fetch failed: {exc}")
print(f" egress IP (direct) : {direct_ip}")
print(f" egress IP (via proxy) : {proxied_ip}")
if proxy_url:
ok &= _check(
"proxy changes egress IP",
bool(proxied_ip) and proxied_ip != direct_ip,
f"{direct_ip} -> {proxied_ip}",
)
else:
print(" [INFO] no proxy configured — skipping egress-IP assertion")
# crawl_url end-to-end on a content-rich page.
crawler = WebCrawlerConnector()
outcome = await crawler.crawl_url(_ARTICLE_URLS[0])
content = (outcome.result or {}).get("content", "") if outcome.result else ""
tier = (outcome.result or {}).get("crawler_type", "?") if outcome.result else "?"
ok &= _check(
"crawl_url returns SUCCESS with content",
outcome.status is CrawlOutcomeStatus.SUCCESS and len(content) > 200,
f"status={outcome.status.value} tier={tier} chars={len(content)}",
)
return ok
async def main() -> int:
print("Phase 3 functional e2e (3a/3b) — live network + proxy, DB rolled back")
results: dict[str, bool] = {}
for name, coro in (("Stage 1 crawl+proxy", stage1_crawl_and_proxy),):
try:
results[name] = await coro()
except Exception as exc:
import traceback
traceback.print_exc()
print(f" [ERROR] {name} raised: {exc}")
results[name] = False
_hr("SUMMARY")
for name, ok in results.items():
print(f" {'PASS' if ok else 'FAIL/SKIP'}{name}")
return 0 if all(results.values()) else 1
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
@@ -0,0 +1,211 @@
"""Manual functional e2e for the Reddit scraper (app/proprietary/platforms/reddit).
Run from the backend directory:
cd surfsense_backend
uv run python scripts/e2e_reddit_scraper.py
# or: .venv/bin/python scripts/e2e_reddit_scraper.py
This is NOT a pytest test (it needs live network + a residential/custom proxy).
It:
Step 0 — go/no-go probe (folds in the old scripts/reddit_probe.py): open a
proxy session, warm a ``loid`` (svc/shreddit first, old.reddit fallback),
then do sequential ``.json`` fetches on the SAME sticky IP and assert each
returns a Reddit Listing. If this fails the whole approach is invalid —
later steps are skipped.
Step 1 — scrape a discovered post URL (post + a few comments).
Step 2 — scrape a subreddit listing.
Step 3 — run a search query.
Step 4 — scrape a user profile.
Step 5 — dump trimmed raw ``.json`` fixtures into
tests/unit/platforms/reddit/fixtures/ for the offline parser tests.
"""
import asyncio
import json
import sys
from pathlib import Path
from dotenv import load_dotenv
# --- bootstrap: load .env and put the backend root on sys.path before app.* ---
_BACKEND_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(_BACKEND_ROOT))
for _candidate in (_BACKEND_ROOT / ".env", _BACKEND_ROOT.parent / ".env"):
if _candidate.exists():
load_dotenv(_candidate)
break
from app.proprietary.platforms.reddit import ( # noqa: E402
RedditScrapeInput,
scrape_reddit,
)
from app.proprietary.platforms.reddit.fetch import ( # noqa: E402
fetch_json,
proxy_session,
warm_session,
)
from app.proprietary.platforms.reddit.parsers import children # noqa: E402
_SUBREDDIT = "python"
_SEARCH_TERM = "async web scraping"
_USER = "spez"
_FIXTURE_DIR = _BACKEND_ROOT / "tests" / "unit" / "platforms" / "reddit" / "fixtures"
def _hr(title: str) -> None:
print(f"\n{'=' * 70}\n{title}\n{'=' * 70}")
def _check(label: str, ok: bool, detail: str = "") -> bool:
print(f" [{'PASS' if ok else 'FAIL'}] {label}{f'{detail}' if detail else ''}")
return ok
def _listing_count(data) -> int | None:
"""Child count if ``data`` looks like a Reddit Listing (or post+comments)."""
try:
if isinstance(data, dict) and data.get("kind") == "Listing":
return len(data["data"]["children"])
if isinstance(data, list):
return sum(
len(x["data"]["children"])
for x in data
if isinstance(x, dict) and x.get("kind") == "Listing"
)
except Exception:
return None
return None
async def _first_post_permalink() -> str | None:
"""Discover a live post URL from the subreddit's hot listing."""
listing = await fetch_json(f"r/{_SUBREDDIT}/hot", {"limit": 5})
kids = children(listing)
if not kids:
return None
permalink = (kids[0].get("data") or {}).get("permalink")
return f"https://www.reddit.com{permalink}" if permalink else None
async def step0_probe() -> bool:
_hr("STEP 0 — go/no-go: loid warm-up + sticky .json")
async with proxy_session() as holder:
if holder.session is None:
return _check(
"proxy configured", False, "no proxy -> set PROXY_PROVIDER + creds"
)
minted = await warm_session(holder.session)
holder.warmed = True # don't let fetch_json re-warm; we just warmed it
_check("loid warm-up minted a session", minted)
oks: list[bool] = []
for path in (f"r/{_SUBREDDIT}/hot", "r/programming/new", f"r/{_SUBREDDIT}/hot"):
data = await fetch_json(path, {"limit": 5})
n = _listing_count(data)
print(f" {path} -> listing_count={n}")
oks.append(n is not None and n > 0)
await asyncio.sleep(1.0)
return _check("sequential .json on sticky IP", minted and all(oks))
async def step1_post() -> bool:
_hr("STEP 1 — scrape a discovered post (post + comments)")
url = await _first_post_permalink()
if not url:
return _check("discovered a post URL", False)
items = await scrape_reddit(
RedditScrapeInput(startUrls=[{"url": url}], maxComments=5)
)
posts = [i for i in items if i.get("dataType") == "post"]
comments = [i for i in items if i.get("dataType") == "comment"]
print(f" posts={len(posts)} comments={len(comments)} url={url}")
return _check("post scraped", bool(posts) and bool(posts[0].get("id")))
async def step2_subreddit() -> bool:
_hr("STEP 2 — scrape a subreddit listing")
items = await scrape_reddit(
RedditScrapeInput(
startUrls=[{"url": f"https://www.reddit.com/r/{_SUBREDDIT}/hot"}],
maxPostCount=5,
skipComments=True,
)
)
posts = [i for i in items if i.get("dataType") == "post"]
for it in posts[:5]:
print(f" - {it.get('id')} | {it.get('title')}")
return _check("subreddit returned posts", len(posts) > 0, f"{len(posts)} posts")
async def step3_search() -> bool:
_hr("STEP 3 — search query")
items = await scrape_reddit(
RedditScrapeInput(searches=[_SEARCH_TERM], sort="relevance", maxItems=5)
)
for it in items[:5]:
print(f" - {it.get('id')} | {it.get('title')}")
return _check("search returned results", len(items) > 0, f"{len(items)} items")
async def step4_user() -> bool:
_hr("STEP 4 — user profile")
items = await scrape_reddit(
RedditScrapeInput(
startUrls=[{"url": f"https://www.reddit.com/user/{_USER}"}], maxItems=5
)
)
print(f" {len(items)} items for u/{_USER}")
return _check("user returned items", len(items) > 0, f"{len(items)} items")
async def step5_dump_fixtures() -> bool:
_hr("STEP 5 — dump trimmed .json fixtures for offline tests")
listing = await fetch_json(f"r/{_SUBREDDIT}/hot", {"limit": 25})
url = await _first_post_permalink()
post = None
if url:
path = url.split("reddit.com/")[-1].strip("/")
post = await fetch_json(path, {"limit": 20})
_FIXTURE_DIR.mkdir(parents=True, exist_ok=True)
wrote = []
if _listing_count(listing):
(_FIXTURE_DIR / "sample_listing.json").write_text(
json.dumps(listing), encoding="utf-8"
)
wrote.append("sample_listing.json")
if isinstance(post, list) and post:
(_FIXTURE_DIR / "sample_post.json").write_text(
json.dumps(post), encoding="utf-8"
)
wrote.append("sample_post.json")
# A single comment thing, for the comment-mapping fixture.
comment_kids = children(post[1]) if len(post) > 1 else []
first_comment = next((c for c in comment_kids if c.get("kind") == "t1"), None)
if first_comment:
(_FIXTURE_DIR / "sample_comment.json").write_text(
json.dumps(first_comment), encoding="utf-8"
)
wrote.append("sample_comment.json")
return _check("dumped fixtures", bool(wrote), f"{wrote} -> {_FIXTURE_DIR}")
async def main() -> int:
results = [await step0_probe()]
if not results[-1]:
print("\nloid probe failed — the approach is invalid on this IP/proxy.")
print("Aborting remaining steps.")
return 1
results.append(await step1_post())
results.append(await step2_subreddit())
results.append(await step3_search())
results.append(await step4_user())
results.append(await step5_dump_fixtures())
_hr("SUMMARY")
print(f" {sum(results)}/{len(results)} steps passed")
return 0 if all(results) else 1
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
@@ -0,0 +1,205 @@
"""Manual functional e2e for the YouTube scraper (app/proprietary/platforms/youtube).
Run from the backend directory:
cd surfsense_backend
uv run python scripts/e2e_youtube_scraper.py
# or: .\\.venv\\Scripts\\python.exe scripts/e2e_youtube_scraper.py
This is NOT a pytest test (it needs live network + optional proxy creds). It:
Step 0 — validates the keyless InnerTube ``search`` POST works; if it 400s the
scraper transparently retries with the public web key (proven here).
Step 1 — scrapes a known video URL (metadata + optional subtitles).
Step 2 — runs a search query and prints the first few results.
Step 3 — scrapes a small channel's latest videos.
Step 4 — dumps trimmed raw ytInitialData / ytInitialPlayerResponse fixtures to
tests/unit/platforms/youtube/fixtures/ for the offline parser test.
"""
import asyncio
import json
import sys
from pathlib import Path
from dotenv import load_dotenv
# --- bootstrap: load .env and put the backend root on sys.path before app.* ---
_BACKEND_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(_BACKEND_ROOT))
for _candidate in (_BACKEND_ROOT / ".env", _BACKEND_ROOT.parent / ".env"):
if _candidate.exists():
load_dotenv(_candidate)
break
from app.proprietary.platforms.youtube import ( # noqa: E402
YouTubeCommentsInput,
YouTubeScrapeInput,
scrape_comments,
scrape_youtube,
)
from app.proprietary.platforms.youtube.innertube import ( # noqa: E402
INNERTUBE_PUBLIC_API_KEY,
INNERTUBE_SEARCH_URL,
build_innertube_payload,
fetch_html,
post_innertube,
)
from app.proprietary.platforms.youtube.parsers import ( # noqa: E402
extract_yt_initial_data,
extract_yt_initial_player_response,
)
_VIDEO_URL = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
_SEARCH_TERM = "web scraping tutorials"
_CHANNEL_URL = "https://www.youtube.com/@YouTube"
# A geo-tagged walking tour + a multi-owner collaboration video (long-tail fields).
_LOCATION_VIDEO_URL = "https://www.youtube.com/watch?v=bhJU_fVHMmY"
_COLLAB_VIDEO_URL = "https://www.youtube.com/watch?v=AI2BwwLX_7s"
# MrBeast localizes titles/descriptions into many languages (translation flow).
_TRANSLATED_VIDEO_URL = "https://www.youtube.com/watch?v=iYlODtkyw_I"
_FIXTURE_DIR = _BACKEND_ROOT / "tests" / "unit" / "platforms" / "youtube" / "fixtures"
def _hr(title: str) -> None:
print(f"\n{'=' * 70}\n{title}\n{'=' * 70}")
def _check(label: str, ok: bool, detail: str = "") -> bool:
print(f" [{'PASS' if ok else 'FAIL'}] {label}{f'{detail}' if detail else ''}")
return ok
async def step0_validate_innertube() -> bool:
_hr("STEP 0 — InnerTube search POST (keyless, then public-key fallback)")
payload = build_innertube_payload(search_query=_SEARCH_TERM)
keyless = await post_innertube(INNERTUBE_SEARCH_URL, payload)
if keyless is not None:
return _check("keyless search POST", True, "keyless works")
keyed = await post_innertube(
INNERTUBE_SEARCH_URL, payload, api_key=INNERTUBE_PUBLIC_API_KEY
)
return _check("public-key search POST", keyed is not None, "keyless 400 -> key")
async def step1_video() -> bool:
_hr("STEP 1 — scrape a known video")
inp = YouTubeScrapeInput(startUrls=[{"url": _VIDEO_URL}], downloadSubtitles=True)
items = await scrape_youtube(inp)
print(json.dumps(items[:1], indent=2)[:2000])
ok = bool(items) and items[0].get("id") == "dQw4w9WgXcQ"
return _check("video scraped with id + title", ok and bool(items[0].get("title")))
async def step2_search() -> bool:
_hr("STEP 2 — search query")
inp = YouTubeScrapeInput(searchQueries=[_SEARCH_TERM], maxResults=5)
items = await scrape_youtube(inp)
for it in items[:5]:
print(f" - {it.get('id')} | {it.get('title')}")
return _check("search returned results", len(items) > 0, f"{len(items)} items")
async def step3_channel() -> bool:
_hr("STEP 3 — channel latest videos")
inp = YouTubeScrapeInput(startUrls=[{"url": _CHANNEL_URL}], maxResults=3)
items = await scrape_youtube(inp)
for it in items[:3]:
print(f" - {it.get('id')} | {it.get('title')}")
return _check("channel returned videos", len(items) > 0, f"{len(items)} items")
async def step4_dump_fixtures() -> bool:
_hr("STEP 4 — dump raw fixtures for offline test")
html = await fetch_html(_VIDEO_URL)
if not html:
return _check("fetched video HTML", False)
initial = extract_yt_initial_data(html)
player = extract_yt_initial_player_response(html)
_FIXTURE_DIR.mkdir(parents=True, exist_ok=True)
if player:
(_FIXTURE_DIR / "video_player_response.json").write_text(
json.dumps(player), encoding="utf-8"
)
if initial:
(_FIXTURE_DIR / "video_initial_data.json").write_text(
json.dumps(initial), encoding="utf-8"
)
return _check("dumped fixtures", bool(player), f"-> {_FIXTURE_DIR}")
async def step5_comments() -> bool:
_hr("STEP 5 — comments (+ replies) for a video")
inp = YouTubeCommentsInput(
startUrls=[{"url": _VIDEO_URL}], maxComments=6, sortCommentsBy="NEWEST_FIRST"
)
items = await scrape_comments(inp)
for it in items[:6]:
print(
f" - [{it.get('type')}] {it.get('author')} | {it.get('voteCount')} votes"
)
ok = bool(items) and all(it.get("cid") and it.get("videoId") for it in items)
has_reply = any(it.get("type") == "reply" and it.get("replyToCid") for it in items)
return _check(
"comments scraped (cid+videoId, reply linkage)",
ok and has_reply,
f"{len(items)} items",
)
async def step6_location_collaborators() -> bool:
_hr("STEP 6 — long-tail fields: location + collaborators")
loc_items = await scrape_youtube(
YouTubeScrapeInput(startUrls=[{"url": _LOCATION_VIDEO_URL}])
)
location = loc_items[0].get("location") if loc_items else None
print(f" location: {location!r}")
collab_items = await scrape_youtube(
YouTubeScrapeInput(startUrls=[{"url": _COLLAB_VIDEO_URL}])
)
collaborators = collab_items[0].get("collaborators") if collab_items else None
print(f" collaborators: {collaborators}")
return _check(
"location + collaborators populated",
bool(location) and bool(collaborators) and len(collaborators) >= 2,
)
async def step7_translation() -> bool:
_hr("STEP 7 — translatedTitle/translatedText (subtitlesLanguage=es)")
items = await scrape_youtube(
YouTubeScrapeInput(
startUrls=[{"url": _TRANSLATED_VIDEO_URL}], subtitlesLanguage="es"
)
)
it = items[0] if items else {}
print(f" title : {it.get('title')}")
print(f" translatedTitle: {it.get('translatedTitle')}")
# A localized video's translated title differs from the canonical English one.
ok = bool(it.get("translatedTitle")) and it.get("translatedTitle") != it.get(
"title"
)
return _check("translatedTitle differs from original", ok)
async def main() -> int:
results = []
results.append(await step0_validate_innertube())
if not results[-1]:
print("\nInnerTube unreachable — aborting remaining steps.")
return 1
results.append(await step1_video())
results.append(await step2_search())
results.append(await step3_channel())
results.append(await step4_dump_fixtures())
results.append(await step5_comments())
results.append(await step6_location_collaborators())
results.append(await step7_translation())
_hr("SUMMARY")
print(f" {sum(results)}/{len(results)} steps passed")
return 0 if all(results) else 1
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
@@ -0,0 +1,62 @@
"""Register the SurfSense Telegram webhook."""
from __future__ import annotations
import asyncio
import os
import re
import sys
from dotenv import load_dotenv
from telegram import Bot
from app.db import async_session_maker
from app.gateway.accounts import get_or_create_system_telegram_account
load_dotenv()
WEBHOOK_SECRET_RE = re.compile(r"^[A-Za-z0-9_-]{1,256}$")
async def main() -> int:
token = os.getenv("TELEGRAM_SHARED_BOT_TOKEN")
secret = os.getenv("TELEGRAM_WEBHOOK_SECRET")
base_url = os.getenv("GATEWAY_BASE_URL") or os.getenv("BACKEND_URL")
if not token or not secret or not base_url:
print(
"Missing TELEGRAM_SHARED_BOT_TOKEN, TELEGRAM_WEBHOOK_SECRET, or GATEWAY_BASE_URL/BACKEND_URL",
file=sys.stderr,
)
return 1
if not WEBHOOK_SECRET_RE.fullmatch(secret):
print(
"TELEGRAM_WEBHOOK_SECRET must be 1-256 chars and contain only A-Z, a-z, 0-9, '_' or '-'",
file=sys.stderr,
)
return 1
async with async_session_maker() as session:
account = await get_or_create_system_telegram_account(session)
account.webhook_secret = secret
await session.commit()
account_id = int(account.id)
webhook_url = (
f"{base_url.rstrip('/')}/api/v1/gateway/webhooks/telegram/{account_id}"
)
bot = Bot(token=token)
ok = await bot.set_webhook(
url=webhook_url,
secret_token=secret,
allowed_updates=["message", "edited_message"],
drop_pending_updates=True,
)
if not ok:
print("Telegram rejected webhook registration", file=sys.stderr)
return 1
print(f"Registered Telegram webhook: {webhook_url}")
return 0
if __name__ == "__main__":
raise SystemExit(asyncio.run(main()))
@@ -0,0 +1,69 @@
"""One-shot cutover helper to revoke every refresh token.
Run with --yes during the auth-hardening cutover, alongside setting
MIN_ISSUED_AT to the deploy epoch.
"""
from __future__ import annotations
import argparse
import asyncio
from sqlalchemy import text
from app.db import async_session_maker
async def _count_active_tokens() -> int:
async with async_session_maker() as session:
result = await session.execute(
text(
"""
SELECT count(*)
FROM refresh_tokens
WHERE revoked_at IS NULL
AND expires_at > NOW()
"""
)
)
return int(result.scalar_one())
async def _revoke_all_tokens() -> int:
async with async_session_maker() as session:
result = await session.execute(
text(
"""
UPDATE refresh_tokens
SET revoked_at = NOW(),
expires_at = NOW()
WHERE revoked_at IS NULL
OR expires_at > NOW()
"""
)
)
await session.commit()
return int(result.rowcount or 0)
async def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"--yes",
action="store_true",
help="Actually revoke tokens. Without this flag the command is a dry run.",
)
args = parser.parse_args()
active_count = await _count_active_tokens()
if not args.yes:
print(f"Dry run: {active_count} active refresh token(s) would be revoked.")
print("Re-run with --yes during the auth-hardening cutover to revoke them.")
return
updated_count = await _revoke_all_tokens()
print(f"Revoked {updated_count} refresh token row(s).")
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,502 @@
"""End-to-end smoke test for vision / image config wiring.
Loads the live ``global_llm_config.yaml`` (no mocking, no fixtures) and
exercises every chat / vision / image-generation config + the OpenRouter
dynamic catalog. For each config the script:
1. Reports the resolver classification (catalog-allow vs strict-block).
2. Optionally fires a tiny live API call against the provider:
- Chat configs: ``litellm.acompletion`` with a 1x1 PNG and the prompt
``"reply with one word: ok"``.
- Vision configs: same, against the dedicated vision router pool.
- Image-gen configs: ``litellm.aimage_generation`` with a single tiny
prompt and ``n=1``.
- OpenRouter integration: samples one chat, one vision, one image-gen
model from the dynamically fetched catalog.
Usage::
python -m scripts.verify_chat_image_capability # capability + connectivity
python -m scripts.verify_chat_image_capability --no-live # capability resolver only
The script is meant to be runnable from the repository root or from
``surfsense_backend/`` and prints a short PASS/FAIL/SKIP summary at the
end so it's usable as a CI smoke check too.
Live-mode caveat: each successful call costs a small amount of provider
credit (a few tokens or one tiny generated image per config). The
default size for image generation is ``1024x1024`` because Azure
GPT-image deployments reject smaller sizes; OpenRouter image-gen models
generally accept the same size.
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import os
import sys
import time
from dataclasses import dataclass, field
from typing import Any
# Bootstrap the surfsense_backend package on sys.path so the script runs
# from the repo root or from `surfsense_backend/` interchangeably.
_HERE = os.path.dirname(os.path.abspath(__file__))
_BACKEND_ROOT = os.path.dirname(_HERE)
if _BACKEND_ROOT not in sys.path:
sys.path.insert(0, _BACKEND_ROOT)
import litellm # noqa: E402
from app.config import config # noqa: E402
from app.services.openrouter_integration_service import ( # noqa: E402
_OPENROUTER_DYNAMIC_MARKER,
OpenRouterIntegrationService,
)
from app.services.provider_capabilities import ( # noqa: E402
derive_supports_image_input,
is_known_text_only_chat_model,
)
logging.basicConfig(
level=logging.WARNING,
format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
)
# Quiet down LiteLLM's verbose router/cost logs so the script output is
# scannable.
logging.getLogger("LiteLLM").setLevel(logging.ERROR)
logging.getLogger("litellm").setLevel(logging.ERROR)
logging.getLogger("httpx").setLevel(logging.ERROR)
# 1x1 transparent PNG — used as the cheapest possible vision payload.
_TINY_PNG_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
_TINY_PNG_DATA_URL = f"data:image/png;base64,{_TINY_PNG_B64}"
# ---------------------------------------------------------------------------
# Result accounting
# ---------------------------------------------------------------------------
@dataclass
class ProbeResult:
label: str
surface: str
config_id: int | str
capability_ok: bool | None = None
capability_note: str = ""
live_ok: bool | None = None
live_note: str = ""
duration_s: float = 0.0
@dataclass
class Report:
results: list[ProbeResult] = field(default_factory=list)
def add(self, r: ProbeResult) -> None:
self.results.append(r)
def render(self) -> int:
passed = failed = skipped = 0
print()
print("=" * 92)
print(
f"{'Surface':<14}{'ID':>8} {'Cap':>5} {'Live':>5} {'Time':>6} Label / notes"
)
print("-" * 92)
for r in self.results:
def _flag(value: bool | None) -> str:
if value is None:
return "skip"
return "ok" if value else "fail"
cap = _flag(r.capability_ok)
live = _flag(r.live_ok)
if r.capability_ok is False or r.live_ok is False:
failed += 1
elif r.capability_ok is None and r.live_ok is None:
skipped += 1
else:
passed += 1
print(
f"{r.surface:<14}{r.config_id!s:>8} {cap:>5} {live:>5} "
f"{r.duration_s:>5.2f}s {r.label}"
)
if r.capability_note:
print(f" cap: {r.capability_note}")
if r.live_note:
print(f" live: {r.live_note}")
print("-" * 92)
print(
f"Total: {passed} ok / {failed} fail / {skipped} skip "
f"(of {len(self.results)} probes)"
)
print("=" * 92)
return failed
# ---------------------------------------------------------------------------
# Capability probes (no network)
# ---------------------------------------------------------------------------
def _probe_chat_capability(cfg: dict) -> tuple[bool, str]:
"""For chat configs the catalog flag is *expected* True (vision-capable
pool). The probe reports both the resolver value and the strict
safety-net value to surface any drift between them."""
litellm_params = cfg.get("litellm_params") or {}
base_model = (
litellm_params.get("base_model") if isinstance(litellm_params, dict) else None
)
cap = derive_supports_image_input(
provider=cfg.get("litellm_provider"),
model_name=cfg.get("model_name"),
base_model=base_model,
custom_provider=cfg.get("custom_provider"),
)
block = is_known_text_only_chat_model(
provider=cfg.get("litellm_provider"),
model_name=cfg.get("model_name"),
base_model=base_model,
custom_provider=cfg.get("custom_provider"),
)
note = f"derive={cap} strict_block={block}"
if not cap and not block:
# Resolver said False but strict gate is also False — that means
# OR modalities published [text] explicitly. Surface it.
note += " (OR modality says text-only)"
# We accept a True derive *or* (False derive AND False block) as
# 'capability ok' — either way, the streaming task will flow through.
ok = cap or not block
return ok, note
def _build_chat_model_string(cfg: dict) -> str:
if cfg.get("custom_provider"):
return f"{cfg['custom_provider']}/{cfg['model_name']}"
prefix = cfg.get("litellm_provider") or "openai"
return f"{prefix}/{cfg['model_name']}"
# ---------------------------------------------------------------------------
# Live probes (network calls)
# ---------------------------------------------------------------------------
async def _live_chat_image_call(cfg: dict) -> tuple[bool, str]:
"""Send a 1x1 PNG + `reply with one word: ok` to the chat config."""
model_string = _build_chat_model_string(cfg)
kwargs: dict[str, Any] = {
"model": model_string,
"api_key": cfg.get("api_key"),
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "reply with one word: ok"},
{
"type": "image_url",
"image_url": {"url": _TINY_PNG_DATA_URL},
},
],
}
],
"max_tokens": 16,
"timeout": 60,
}
if cfg.get("api_base"):
kwargs["api_base"] = cfg["api_base"]
if cfg.get("litellm_params"):
# Strip pricing keys — they're tracking-only and confuse some
# provider validators (e.g. azure/openai reject unknown kwargs
# in strict mode).
merged = {
k: v
for k, v in dict(cfg["litellm_params"]).items()
if k
not in {
"input_cost_per_token",
"output_cost_per_token",
"input_cost_per_pixel",
"output_cost_per_pixel",
}
}
kwargs.update(merged)
try:
resp = await litellm.acompletion(**kwargs)
except Exception as exc:
return False, f"{type(exc).__name__}: {exc}"
text = resp.choices[0].message.content if resp.choices else ""
return True, f"got reply ({(text or '').strip()[:40]!r})"
# Gemini image models occasionally return zero-length ``data`` for the
# minimal "red dot on white" prompt (provider-side safety / empty-output
# quirk reproducible against ``google/gemini-2.5-flash-image`` even when
# the request itself succeeds). Use a more naturalistic prompt and
# retry once with a different one before giving up.
_IMAGE_GEN_PROMPTS: tuple[str, ...] = (
"A simple icon of a coffee cup, flat illustration",
"A small green leaf on a white background",
)
async def _live_image_gen_call(cfg: dict) -> tuple[bool, str]:
"""Generate one tiny image to verify the deployment is reachable."""
if cfg.get("custom_provider"):
prefix = cfg["custom_provider"]
else:
prefix = cfg.get("litellm_provider") or "openai"
model_string = f"{prefix}/{cfg['model_name']}"
base_kwargs: dict[str, Any] = {
"model": model_string,
"api_key": cfg.get("api_key"),
"n": 1,
"size": "1024x1024",
"timeout": 120,
}
if cfg.get("api_base"):
base_kwargs["api_base"] = cfg["api_base"]
if cfg.get("api_version"):
base_kwargs["api_version"] = cfg["api_version"]
if cfg.get("litellm_params"):
base_kwargs.update(
{
k: v
for k, v in dict(cfg["litellm_params"]).items()
if k
not in {
"input_cost_per_token",
"output_cost_per_token",
"input_cost_per_pixel",
"output_cost_per_pixel",
}
}
)
last_note = ""
for attempt, prompt in enumerate(_IMAGE_GEN_PROMPTS, start=1):
try:
resp = await litellm.aimage_generation(prompt=prompt, **base_kwargs)
except Exception as exc:
last_note = f"{type(exc).__name__}: {exc}"
continue
data_count = len(getattr(resp, "data", None) or [])
if data_count > 0:
return True, (
f"received {data_count} image(s) on attempt {attempt} "
f"(prompt={prompt!r})"
)
last_note = (
f"call ok but received 0 images on attempt {attempt} (prompt={prompt!r})"
)
return False, last_note
# ---------------------------------------------------------------------------
# Probe drivers
# ---------------------------------------------------------------------------
def _is_or_dynamic(cfg: dict) -> bool:
return bool(cfg.get(_OPENROUTER_DYNAMIC_MARKER))
async def probe_chat_configs(report: Report, *, live: bool) -> None:
print("\n[chat configs from global_llm_configs (YAML-static)]")
for cfg in config.GLOBAL_LLM_CONFIGS:
# Skip OR dynamic entries here — handled in the OR section so
# the YAML / OR split stays clear in the report.
if _is_or_dynamic(cfg):
continue
result = ProbeResult(
label=str(cfg.get("name") or cfg.get("model_name")),
surface="chat-yaml",
config_id=cfg.get("id"),
)
cap_ok, cap_note = _probe_chat_capability(cfg)
result.capability_ok = cap_ok
result.capability_note = cap_note
if live:
t0 = time.perf_counter()
ok, note = await _live_chat_image_call(cfg)
result.live_ok = ok
result.live_note = note
result.duration_s = time.perf_counter() - t0
report.add(result)
async def probe_image_gen_configs(report: Report, *, live: bool) -> None:
print(
"\n[image generation configs from global_image_generation_configs (YAML-static)]"
)
for cfg in config.GLOBAL_IMAGE_GEN_CONFIGS:
if _is_or_dynamic(cfg):
continue
result = ProbeResult(
label=str(cfg.get("name") or cfg.get("model_name")),
surface="image-gen",
config_id=cfg.get("id"),
)
# Image gen configs don't have a "supports_image_input" flag;
# the catalog tracks output, not input. Mark capability as None
# (skip) for the report.
if live:
t0 = time.perf_counter()
ok, note = await _live_image_gen_call(cfg)
result.live_ok = ok
result.live_note = note
result.duration_s = time.perf_counter() - t0
report.add(result)
async def probe_openrouter_catalog(report: Report, *, live: bool) -> None:
"""Sample chat/vision-capable and image-gen models
from the live OpenRouter catalogue. Doesn't iterate the full pool
(would be hundreds of probes); just validates the integration end-
to-end on a representative model from each surface."""
print("\n[OpenRouter integration: sampled probes]")
settings = config.OPENROUTER_INTEGRATION_SETTINGS
if not settings:
report.add(
ProbeResult(
label="OpenRouter integration",
surface="openrouter",
config_id="settings",
capability_ok=None,
capability_note="openrouter_integration disabled in YAML — skipping",
live_ok=None,
)
)
return
service = OpenRouterIntegrationService.get_instance()
or_chat = [
c
for c in config.GLOBAL_LLM_CONFIGS
if c.get("provider") == "OPENROUTER" and c.get("supports_image_input")
]
or_image_gen = [
c for c in config.GLOBAL_IMAGE_GEN_CONFIGS if c.get("provider") == "OPENROUTER"
]
# Pick one representative per provider family per surface so a single
# broken vendor (e.g. Anthropic key revoked, Google quota exceeded)
# surfaces independently of the others. Each needle matches the
# OpenRouter ``model_name`` prefix; the first match wins.
def _pick_first(pool: list[dict], needle: str) -> dict | None:
for c in pool:
if (c.get("model_name") or "").lower().startswith(needle):
return c
return None
chat_picks = [
("or-chat", _pick_first(or_chat, "openai/gpt-4o")),
("or-chat", _pick_first(or_chat, "anthropic/claude")),
("or-chat", _pick_first(or_chat, "google/gemini-2.5-flash")),
]
image_picks = [
("or-image", _pick_first(or_image_gen, "google/gemini-2.5-flash-image")),
# OpenRouter publishes OpenAI image models as ``openai/gpt-5-image*``
# / ``openai/gpt-5.4-image-2`` (no ``gpt-image`` literal). Match
# the actual prefix.
("or-image", _pick_first(or_image_gen, "openai/gpt-5-image")),
]
print(
f" catalog: chat_vision={len(or_chat)} image_gen={len(or_image_gen)} "
f"(service initialized={service.is_initialized() if hasattr(service, 'is_initialized') else 'n/a'})"
)
for surface, picked in chat_picks + image_picks:
if not picked:
report.add(
ProbeResult(
label=f"<no candidate for {surface}>",
surface=surface,
config_id="-",
capability_ok=None,
capability_note="no candidate found in OR catalog",
)
)
continue
runner = (
_live_image_gen_call if surface == "or-image" else _live_chat_image_call
)
result = ProbeResult(
label=str(picked.get("model_name")),
surface=surface,
config_id=picked.get("id"),
)
if surface != "or-image":
cap_ok, cap_note = _probe_chat_capability(picked)
result.capability_ok = cap_ok
result.capability_note = cap_note
if live:
t0 = time.perf_counter()
ok, note = await runner(picked)
result.live_ok = ok
result.live_note = note
result.duration_s = time.perf_counter() - t0
report.add(result)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
async def main(args: argparse.Namespace) -> int:
print("Loaded global configs:")
print(f" chat: {len(config.GLOBAL_LLM_CONFIGS)} entries")
print(f" image-gen: {len(config.GLOBAL_IMAGE_GEN_CONFIGS)} entries")
print(f" OR settings present: {bool(config.OPENROUTER_INTEGRATION_SETTINGS)}")
# Initialize the OpenRouter integration so the catalog is populated
# (this is what main.py does at startup). It's idempotent.
if config.OPENROUTER_INTEGRATION_SETTINGS:
try:
from app.config import initialize_openrouter_integration
initialize_openrouter_integration()
except Exception as exc:
print(f" WARNING: OpenRouter integration init failed: {exc}")
print(
f"\nMode: {'LIVE (will hit providers)' if args.live else 'DRY (capability only)'}"
)
report = Report()
if not args.skip_chat:
await probe_chat_configs(report, live=args.live)
if not args.skip_image_gen:
await probe_image_gen_configs(report, live=args.live)
if not args.skip_openrouter:
await probe_openrouter_catalog(report, live=args.live)
failed = report.render()
return 1 if failed else 0
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--no-live",
dest="live",
action="store_false",
help="Skip live API calls — capability resolver only.",
)
parser.set_defaults(live=True)
parser.add_argument("--skip-chat", action="store_true")
parser.add_argument("--skip-image-gen", action="store_true")
parser.add_argument("--skip-openrouter", action="store_true")
return parser.parse_args()
if __name__ == "__main__":
args = _parse_args()
sys.exit(asyncio.run(main(args)))
@@ -0,0 +1,15 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --silent
COPY . .
ENV WHATSAPP_SESSION_DIR=/data/sessions
EXPOSE 9929
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1:9929/health || exit 1
CMD ["node", "bridge.js"]
@@ -0,0 +1,343 @@
#!/usr/bin/env node
import {
DisconnectReason,
fetchLatestBaileysVersion,
makeWASocket,
useMultiFileAuthState,
} from "@whiskeysockets/baileys";
import { Boom } from "@hapi/boom";
import express from "express";
import { mkdirSync, readdirSync, rmSync } from "node:fs";
import path from "node:path";
import pino from "pino";
import qrcode from "qrcode-terminal";
const PORT = Number(process.env.PORT || "9929");
const SESSION_DIR = process.env.WHATSAPP_SESSION_DIR || "/data/sessions";
const SEND_TIMEOUT_MS = Number(process.env.WHATSAPP_SEND_TIMEOUT_MS || "60000");
const PAIRING_TIMEOUT_MS = Number(process.env.WHATSAPP_PAIRING_TIMEOUT_MS || "30000");
const MAX_QUEUE_SIZE = Number(process.env.WHATSAPP_MAX_QUEUE_SIZE || "100");
const WHATSAPP_MODE = process.env.WHATSAPP_MODE || "self-chat";
const SENT_ECHO_TTL_MS = 60_000;
mkdirSync(SESSION_DIR, { recursive: true });
const app = express();
app.use(express.json({ limit: "2mb" }));
const logger = pino({ level: process.env.WHATSAPP_DEBUG ? "debug" : "warn" });
const messageQueue = [];
const sentKeys = new Map();
const recentlySentIds = new Set();
let sock = null;
let connectionState = "disconnected";
let latestQr = null;
let starting = null;
let pendingPairing = null;
function resetSessionState() {
sock = null;
latestQr = null;
sentKeys.clear();
recentlySentIds.clear();
mkdirSync(SESSION_DIR, { recursive: true });
for (const entry of readdirSync(SESSION_DIR)) {
rmSync(path.join(SESSION_DIR, entry), { recursive: true, force: true });
}
}
function resolvePendingPairing(payload) {
if (!pendingPairing) return;
clearTimeout(pendingPairing.timer);
pendingPairing.resolve(payload);
pendingPairing = null;
}
function rejectPendingPairing(error) {
if (!pendingPairing) return;
clearTimeout(pendingPairing.timer);
pendingPairing.reject(error);
pendingPairing = null;
}
async function maybeRequestPairingCode(update = {}) {
if (!pendingPairing || pendingPairing.inFlight || !sock) return;
const canRequestPairingCode =
update.connection === "connecting" ||
Boolean(update.qr) ||
Boolean(latestQr);
if (!canRequestPairingCode) return;
pendingPairing.inFlight = true;
connectionState = "pairing";
try {
const code = await sock.requestPairingCode(pendingPairing.phoneNumber);
resolvePendingPairing({ status: "pairing", pairing_code: code, expires_in: 60 });
} catch (error) {
rejectPendingPairing(error);
}
}
function requestPairingCodeWhenReady(phoneNumber) {
if (connectionState === "connected") {
return Promise.resolve({ status: "connected", pairing_code: null, expires_in: 0 });
}
if (pendingPairing) {
return Promise.reject(new Error("A WhatsApp pairing request is already in progress"));
}
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pendingPairing = null;
reject(new Error("Timed out waiting for WhatsApp to become ready for pairing"));
}, PAIRING_TIMEOUT_MS);
pendingPairing = {
phoneNumber,
resolve,
reject,
timer,
inFlight: false,
};
void startSocket()
.then(() => maybeRequestPairingCode())
.catch((error) => rejectPendingPairing(error));
void maybeRequestPairingCode();
});
}
function normalizeText(message) {
const content = message?.message || {};
return (
content.conversation ||
content.extendedTextMessage?.text ||
content.imageMessage?.caption ||
content.videoMessage?.caption ||
content.documentMessage?.caption ||
""
);
}
function enqueueMessage(message) {
const remoteJid = message?.key?.remoteJid;
const id = message?.key?.id;
if (!remoteJid || !id || !message?.message) return;
if (messageQueue.length >= MAX_QUEUE_SIZE) messageQueue.shift();
messageQueue.push({
event: "messages.upsert",
key: message.key,
chatId: remoteJid,
senderId: message.key.participant || remoteJid,
messageId: id,
fromMe: Boolean(message.key.fromMe),
isGroup: remoteJid.endsWith("@g.us"),
body: normalizeText(message),
timestamp: Number(message.messageTimestamp || Date.now() / 1000),
raw: message,
});
}
function rememberSentMessage(sent) {
const sentId = sent?.key?.id;
if (!sentId) return;
sentKeys.set(sentId, sent.key);
recentlySentIds.add(sentId);
setTimeout(() => {
recentlySentIds.delete(sentId);
}, SENT_ECHO_TTL_MS).unref?.();
}
function withTimeout(promise, timeoutMs) {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(
() => reject(new Error(`sendMessage timed out after ${timeoutMs}ms`)),
timeoutMs,
);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
}
async function startSocket() {
if (starting) return starting;
starting = (async () => {
connectionState = "connecting";
const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR);
const { version } = await fetchLatestBaileysVersion();
sock = makeWASocket({
version,
auth: state,
logger,
printQRInTerminal: false,
browser: ["SurfSense", "Chrome", "120.0"],
syncFullHistory: false,
markOnlineOnConnect: false,
getMessage: async () => ({ conversation: "" }),
});
sock.ev.on("creds.update", saveCreds);
sock.ev.on("connection.update", (update) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
latestQr = qr;
connectionState = "qr";
qrcode.generate(qr, { small: true });
void maybeRequestPairingCode(update);
}
if (connection === "open") {
latestQr = null;
connectionState = "connected";
console.log("WhatsApp connected");
resolvePendingPairing({ status: "connected", pairing_code: null, expires_in: 0 });
}
if (connection === "close") {
const reason = new Boom(lastDisconnect?.error)?.output?.statusCode;
connectionState = "disconnected";
if (reason === DisconnectReason.loggedOut) {
console.error("WhatsApp logged out; clearing session and waiting for pairing.");
connectionState = "logged_out";
resetSessionState();
setTimeout(() => {
starting = null;
void startSocket();
}, 1000);
return;
}
setTimeout(() => {
starting = null;
void startSocket();
}, reason === 515 ? 1000 : 3000);
}
void maybeRequestPairingCode(update);
});
sock.ev.on("messages.upsert", ({ messages, type }) => {
if (type !== "notify" && type !== "append") return;
for (const message of messages || []) {
const chatId = message?.key?.remoteJid;
if (!chatId) continue;
if (chatId.endsWith("@g.us") || chatId.includes("status@broadcast")) continue;
if (message?.key?.fromMe) {
if (WHATSAPP_MODE !== "self-chat") continue;
if (recentlySentIds.has(message.key.id)) continue;
const myNumber = (sock.user?.id || "").replace(/:.*@/, "@").replace(/@.*/, "");
const myLid = (sock.user?.lid || "").replace(/:.*@/, "@").replace(/@.*/, "");
const chatNumber = chatId.replace(/@.*/, "");
const isSelfChat =
(myNumber && chatNumber === myNumber) || (myLid && chatNumber === myLid);
if (!isSelfChat) continue;
} else if (WHATSAPP_MODE === "self-chat") {
continue;
}
enqueueMessage(message);
}
});
})();
try {
await starting;
} finally {
starting = null;
}
}
app.get("/health", (_req, res) => {
res.json({
status: connectionState,
hasQr: Boolean(latestQr),
qr: latestQr,
queueDepth: messageQueue.length,
user: sock?.user || null,
});
});
app.get("/messages", (_req, res) => {
const messages = messageQueue.splice(0, messageQueue.length);
res.json(messages);
});
app.post("/send", async (req, res) => {
try {
if (!sock || connectionState !== "connected") {
return res.status(503).json({ error: "WhatsApp is not connected" });
}
const { chatId, message, replyTo } = req.body || {};
if (!chatId || !message) {
return res.status(400).json({ error: "chatId and message are required" });
}
const payload = { text: String(message) };
if (replyTo) {
payload.contextInfo = { stanzaId: String(replyTo) };
}
const sent = await withTimeout(sock.sendMessage(chatId, payload), SEND_TIMEOUT_MS);
rememberSentMessage(sent);
res.json({ messageId: sent?.key?.id || null, raw: sent });
} catch (error) {
res.status(500).json({ error: error?.message || "send failed" });
}
});
app.post("/edit", async (req, res) => {
try {
if (!sock || connectionState !== "connected") {
return res.status(503).json({ error: "WhatsApp is not connected" });
}
const { chatId, messageId, message } = req.body || {};
if (!chatId || !messageId || !message) {
return res.status(400).json({ error: "chatId, messageId and message are required" });
}
const key = sentKeys.get(String(messageId)) || {
remoteJid: chatId,
id: String(messageId),
fromMe: true,
};
const sent = await withTimeout(
sock.sendMessage(chatId, { text: String(message), edit: key }),
SEND_TIMEOUT_MS,
);
rememberSentMessage(sent);
res.json({ messageId: sent?.key?.id || messageId, raw: sent });
} catch (error) {
res.status(500).json({ error: error?.message || "edit failed" });
}
});
app.post("/typing", async (req, res) => {
try {
if (!sock || connectionState !== "connected") return res.status(204).end();
const { chatId } = req.body || {};
if (chatId) {
await sock.sendPresenceUpdate("composing", chatId);
}
res.status(204).end();
} catch {
res.status(204).end();
}
});
app.post("/pair", async (req, res) => {
try {
const phoneNumber = String(req.body?.phoneNumber || req.body?.phone_number || "").replace(/\D/g, "");
if (!phoneNumber) {
return res.status(400).json({ error: "phoneNumber is required for pairing code" });
}
res.json(await requestPairingCodeWhenReady(phoneNumber));
} catch (error) {
res.status(500).json({ error: error?.message || "pairing failed" });
}
});
app.listen(PORT, "0.0.0.0", () => {
console.log(
`SurfSense WhatsApp bridge listening on ${PORT}; session=${path.resolve(SESSION_DIR)}; mode=${WHATSAPP_MODE}`,
);
void startSocket();
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
{
"name": "surfsense-whatsapp-bridge",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node bridge.js"
},
"dependencies": {
"@hapi/boom": "latest",
"@whiskeysockets/baileys": "latest",
"express": "latest",
"pino": "latest",
"qrcode-terminal": "latest"
}
}