Compare commits

..
Author SHA1 Message Date
Typosaurus 35fb386be1 ticket #132 attempt 1 2026-07-24 14:01:15 +00:00
6 changed files with 204 additions and 119 deletions
File diff suppressed because one or more lines are too long
+31
View File
@@ -360,6 +360,37 @@ def create_comment_record(
comment_url, comment_url,
) )
if target_type == "post":
posts_table = get_table("posts")
post_record = posts_table.find_one(uid=target_uid)
if not post_record:
post_record = posts_table.find_one(slug=target_uid)
commenter_uids = set()
for c in get_table("comments").find(
target_type="post", target_uid=target_uid, deleted_at=None
):
commenter_uids.add(c["user_uid"])
commenter_uids.discard(user["uid"])
if post_record:
commenter_uids.discard(post_record["user_uid"])
if parent_uid:
parent_comment = get_table("comments").find_one(
uid=parent_uid, deleted_at=None
)
if parent_comment and parent_comment["user_uid"] != user["uid"]:
commenter_uids.discard(parent_comment["user_uid"])
for commenter_uid in commenter_uids:
create_notification(
commenter_uid,
"thread_comment",
f"{user['username']} also commented on a post you commented on",
user["uid"],
comment_url,
)
create_mention_notifications(content, user["uid"], comment_url) create_mention_notifications(content, user["uid"], comment_url)
schedule_correction(user, "comments", comment_uid, request) schedule_correction(user, "comments", comment_uid, request)
schedule_modification(user, "comments", comment_uid, request) schedule_modification(user, "comments", comment_uid, request)
+1
View File
@@ -19,6 +19,7 @@ NOTIFICATION_TYPES = [
{"key": "harvest_stolen", "label": "Farm raids", "description": "Someone steals a ready build from your Code Farm"}, {"key": "harvest_stolen", "label": "Farm raids", "description": "Someone steals a ready build from your Code Farm"},
{"key": "award", "label": "Awards", "description": "Someone gives you an award on your profile"}, {"key": "award", "label": "Awards", "description": "Someone gives you an award on your profile"},
{"key": "system", "label": "System alerts", "description": "Platform infrastructure alerts (e.g. the AI gateway going down)"}, {"key": "system", "label": "System alerts", "description": "Platform infrastructure alerts (e.g. the AI gateway going down)"},
{"key": "thread_comment", "label": "Thread comments", "description": "Someone else comments on a post you commented on"},
] ]
+111 -114
View File
@@ -22,8 +22,6 @@ from .pdf import MAX_PDF_BYTES, extract_pdf_text, is_pdf
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_pw_lock = asyncio.Lock()
RSEARCH_URL = "https://rsearch.app.molodetz.nl" RSEARCH_URL = "https://rsearch.app.molodetz.nl"
RSEARCH_TIMEOUT_SECONDS = 45.0 RSEARCH_TIMEOUT_SECONDS = 45.0
FETCH_TIMEOUT_SECONDS = 20.0 FETCH_TIMEOUT_SECONDS = 20.0
@@ -174,7 +172,13 @@ async def _render_with_playwright(
) -> tuple[str, str, int, list[tuple[str, str]]]: ) -> tuple[str, str, int, list[tuple[str, str]]]:
from playwright.async_api import async_playwright from playwright.async_api import async_playwright
async def _render(browser) -> tuple[str, str, int, list[tuple[str, str]]]: own_browser = browser is None
if own_browser:
pw = await async_playwright().__aenter__()
browser = await pw.chromium.launch(
headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"]
)
try:
context = await browser.new_context(user_agent=USER_AGENT) context = await browser.new_context(user_agent=USER_AGENT)
page = await context.new_page() page = await context.new_page()
response = await page.goto(url, wait_until="load", timeout=30000) response = await page.goto(url, wait_until="load", timeout=30000)
@@ -185,18 +189,10 @@ async def _render_with_playwright(
await context.close() await context.close()
extracted = extract_html(content, base_url=url) extracted = extract_html(content, base_url=url)
return extracted.title, extracted.text, status, extracted.links return extracted.title, extracted.text, status, extracted.links
finally:
if browser is None: if own_browser:
async with async_playwright() as pw: await browser.close()
browser = await pw.chromium.launch( await pw.__aexit__(None, None, None)
headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"]
)
try:
return await _render(browser)
finally:
await browser.close()
else:
return await _render(browser)
async def fetch_page(url: str, depth: int, browser=None) -> CrawledPage | None: async def fetch_page(url: str, depth: int, browser=None) -> CrawledPage | None:
@@ -246,20 +242,18 @@ async def fetch_page(url: str, depth: int, browser=None) -> CrawledPage | None:
except (LookupError, ValueError) as exc: except (LookupError, ValueError) as exc:
logger.info("deepsearch decode failed for %s: %s", url, exc) logger.info("deepsearch decode failed for %s: %s", url, exc)
if len(text) < MIN_PAGE_CHARS: if len(text) < MIN_PAGE_CHARS:
async with _pw_lock: try:
try: r_title, r_text, r_status, r_links = await _render_with_playwright(url, browser)
r_title, r_text, r_status, r_links = await _render_with_playwright(url, browser) if len(r_text) > len(text):
except Exception as exc: title, text, status, source, links = (
logger.info("deepsearch render failed for %s: %s", url, exc) r_title or title,
r_title, r_text, r_status, r_links = "", "", 0, [] r_text,
if len(r_text) > len(text): r_status or status,
title, text, status, source, links = ( "playwright",
r_title or title, r_links,
r_text, )
r_status or status, except Exception as exc:
"playwright", logger.info("deepsearch render failed for %s: %s", url, exc)
r_links,
)
if len(text) < MIN_PAGE_CHARS: if len(text) < MIN_PAGE_CHARS:
return None return None
return CrawledPage( return CrawledPage(
@@ -302,99 +296,102 @@ async def crawl(
total = min(len(level_candidates), max_pages) total = min(len(level_candidates), max_pages)
cancelled = False cancelled = False
pw = None
browser = None browser = None
async with async_playwright() as pw: try:
try: pw = await async_playwright().__aenter__()
browser = await pw.chromium.launch( browser = await pw.chromium.launch(
headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"] headless=True, args=["--no-sandbox", "--disable-dev-shm-usage"]
) )
except Exception as exc: except Exception as exc:
logger.warning("deepsearch playwright launch failed, pages will use httpx only: %s", exc) logger.warning("deepsearch playwright launch failed, pages will use httpx only: %s", exc)
try: try:
for level in range(max(1, depth)): for level in range(max(1, depth)):
if cancelled or fetched >= max_pages or not level_candidates: if cancelled or fetched >= max_pages or not level_candidates:
break
next_candidates: list[dict] = []
for start in range(0, len(level_candidates), CRAWL_CONCURRENCY):
if fetched >= max_pages:
break break
next_candidates: list[dict] = [] if await should_stop():
for start in range(0, len(level_candidates), CRAWL_CONCURRENCY): emit({"type": "stage", "stage": "cancelled", "message": "Crawl cancelled"})
cancelled = True
break
batch = level_candidates[start : start + CRAWL_CONCURRENCY][: max_pages - fetched]
for candidate in batch:
emit(
{
"type": "progress",
"done": fetched,
"total": total,
"url": candidate["url"],
"depth": level,
"message": f"Reading {candidate['url']}",
}
)
if is_cached(candidate["url"]):
emit({"type": "page_cached", "url": candidate["url"], "reason": "seen in a prior run"})
fetch_start = time.perf_counter()
results = await asyncio.gather(
*(_resolve_candidate(candidate, level, browser) for candidate in batch),
return_exceptions=True,
)
elapsed_ms = int((time.perf_counter() - fetch_start) * 1000)
for candidate, page in zip(batch, results):
url = candidate["url"]
if isinstance(page, BaseException):
logger.info("deepsearch fetch crashed for %s: %s", url, page)
page = None
if page is None:
emit(
{
"type": "page_skipped",
"url": url,
"reason": "no readable content",
"elapsed_ms": elapsed_ms,
}
)
continue
if fetched >= max_pages: if fetched >= max_pages:
break break
if await should_stop(): digest = content_hash(page.text)
emit({"type": "stage", "stage": "cancelled", "message": "Crawl cancelled"}) if digest in outcome.seen_hashes:
cancelled = True
break
batch = level_candidates[start : start + CRAWL_CONCURRENCY][: max_pages - fetched]
for candidate in batch:
emit( emit(
{ {
"type": "progress", "type": "page_duplicate",
"done": fetched, "url": url,
"total": total, "reason": "duplicate content",
"url": candidate["url"],
"depth": level,
"message": f"Reading {candidate['url']}",
}
)
if is_cached(candidate["url"]):
emit({"type": "page_cached", "url": candidate["url"], "reason": "seen in a prior run"})
fetch_start = time.perf_counter()
results = await asyncio.gather(
*(_resolve_candidate(candidate, level, browser) for candidate in batch),
return_exceptions=True,
)
elapsed_ms = int((time.perf_counter() - fetch_start) * 1000)
for candidate, page in zip(batch, results):
url = candidate["url"]
if isinstance(page, BaseException):
logger.info("deepsearch fetch crashed for %s: %s", url, page)
page = None
if page is None:
emit(
{
"type": "page_skipped",
"url": url,
"reason": "no readable content",
"elapsed_ms": elapsed_ms,
}
)
continue
if fetched >= max_pages:
break
digest = content_hash(page.text)
if digest in outcome.seen_hashes:
emit(
{
"type": "page_duplicate",
"url": url,
"reason": "duplicate content",
"elapsed_ms": elapsed_ms,
}
)
continue
outcome.seen_hashes.add(digest)
outcome.pages.append(page)
fetched += 1
emit(
{
"type": "page_loaded",
"url": page.url,
"title": page.title,
"source": page.source,
"depth": level,
"render": page.source == "playwright",
"elapsed_ms": elapsed_ms, "elapsed_ms": elapsed_ms,
"done": fetched,
"total": total,
} }
) )
if level + 1 < depth: continue
for link in relevant_links(page.links, query, LINKS_PER_PAGE): outcome.seen_hashes.add(digest)
if link not in seen_urls: outcome.pages.append(page)
seen_urls.add(link) fetched += 1
next_candidates.append({"url": link}) emit(
level_candidates = next_candidates {
total = min(total + len(next_candidates), max_pages) "type": "page_loaded",
finally: "url": page.url,
if browser is not None: "title": page.title,
await browser.close() "source": page.source,
"depth": level,
"render": page.source == "playwright",
"elapsed_ms": elapsed_ms,
"done": fetched,
"total": total,
}
)
if level + 1 < depth:
for link in relevant_links(page.links, query, LINKS_PER_PAGE):
if link not in seen_urls:
seen_urls.add(link)
next_candidates.append({"url": link})
level_candidates = next_candidates
total = min(total + len(next_candidates), max_pages)
finally:
if browser is not None:
await browser.close()
if pw is not None:
await pw.__aexit__(None, None, None)
return outcome return outcome
-4
View File
@@ -620,10 +620,6 @@ img {
.topnav-logo span { color: var(--accent); } .topnav-logo span { color: var(--accent); }
.topnav-links { display: flex; gap: 0.25rem; } .topnav-links { display: flex; gap: 0.25rem; }
.topnav-link { .topnav-link {
display: inline-flex;
align-items: center;
gap: 0.375rem;
white-space: nowrap;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border-radius: var(--radius); border-radius: var(--radius);
font-size: 0.875rem; font-size: 0.875rem;
+60
View File
@@ -384,6 +384,66 @@ def test_comment_notification_on_post(app_server, browser, seeded_db):
ctx_b.close() ctx_b.close()
def test_thread_comment_notification(app_server, browser, seeded_db):
from tests.conftest import login_user
ctx_a = browser.new_context(viewport={"width": 1400, "height": 900})
ctx_b = browser.new_context(viewport={"width": 1400, "height": 900})
pa = ctx_a.new_page()
pb = ctx_b.new_page()
pa.set_default_timeout(15000)
pb.set_default_timeout(15000)
login_user(pa, seeded_db["alice"])
login_user(pb, seeded_db["bob"])
pa.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
pa.locator(".feed-fab").first.wait_for(state="visible", timeout=10000)
pa.locator(".feed-fab").first.click()
pa.fill("#post-content", "Post for thread comment notification test")
pa.locator("#create-post-modal button.btn-primary:has-text('Post')").click()
pa.wait_for_url("**/posts/*", timeout=10000, wait_until="domcontentloaded")
post_url = pa.url
pb.goto(post_url, wait_until="domcontentloaded")
pb.wait_for_timeout(1000)
comment_textarea = pb.locator("form.comment-form textarea[name='content']").first
comment_textarea.wait_for(state="visible", timeout=10000)
comment_textarea.fill("Bob's top-level comment")
pb.locator("button.comment-form-submit").first.click()
pb.wait_for_timeout(1500)
pb_body = pb.locator("body").text_content()
assert "Internal Server Error" not in pb_body, f"Bob got 500: {pb_body[:300]}"
pa.goto(post_url, wait_until="domcontentloaded")
pa.wait_for_timeout(1000)
comment_textarea = pa.locator("form.comment-form textarea[name='content']").first
comment_textarea.wait_for(state="visible", timeout=10000)
comment_textarea.fill("Alice also comments on her own post")
pa.locator("button.comment-form-submit").first.click()
pa.wait_for_timeout(1500)
pa_body = pa.locator("body").text_content()
assert "Internal Server Error" not in pa_body, f"Alice got 500: {pa_body[:300]}"
pb.goto(f"{BASE_URL}/notifications", wait_until="domcontentloaded")
pb.wait_for_timeout(2000)
body = pb.locator("body").text_content()
assert "Internal Server Error" not in body, (
f"Got 500 error on notifications: {body[:500]}"
)
assert "alice_test" in body, (
f"Expected 'alice_test' in notifications, got: {body[:500]}"
)
assert "also commented" in body, (
f"Expected 'also commented' in notifications, got: {body[:500]}"
)
ctx_a.close()
ctx_b.close()
def test_follow_notification(app_server, browser, seeded_db): def test_follow_notification(app_server, browser, seeded_db):
from tests.conftest import login_user from tests.conftest import login_user