mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-08 22:22:17 +02:00
213 lines
7.6 KiB
Python
213 lines
7.6 KiB
Python
"""Manual functional e2e for the Reddit scraper (app/proprietary/scrapers/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/scrapers/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.scrapers.reddit import ( # noqa: E402
|
|
RedditScrapeInput,
|
|
scrape_reddit,
|
|
)
|
|
from app.proprietary.scrapers.reddit.fetch import ( # noqa: E402
|
|
fetch_json,
|
|
proxy_session,
|
|
warm_session,
|
|
)
|
|
from app.proprietary.scrapers.reddit.parsers import children # noqa: E402
|
|
|
|
_SUBREDDIT = "python"
|
|
_SEARCH_TERM = "async web scraping"
|
|
_USER = "spez"
|
|
|
|
_FIXTURE_DIR = _BACKEND_ROOT / "tests" / "unit" / "scrapers" / "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()))
|