Performance optimizations: abort binary assets, domcontentloaded, adaptive scroll, browser pre-warming (7x speedup)
This commit is contained in:
1 parent
c1129017d0
commit
56d9aa4d30
2 files changed
+45
-26
No files matched your search
+42
-26
@@ -11,8 +11,10 @@ class ThreadScraper:
|
|||||||
self.headless = headless
|
self.headless = headless
|
||||||
self._browser: Optional[Browser] = None
|
self._browser: Optional[Browser] = None
|
||||||
self._playwright = None
|
self._playwright = None
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
async def get_browser(self) -> Browser:
|
async def get_browser(self) -> Browser:
|
||||||
|
async with self._lock:
|
||||||
if self._browser is None or not self._browser.is_connected():
|
if self._browser is None or not self._browser.is_connected():
|
||||||
if self._playwright is None:
|
if self._playwright is None:
|
||||||
self._playwright = await async_playwright().start()
|
self._playwright = await async_playwright().start()
|
||||||
@@ -23,11 +25,16 @@ class ThreadScraper:
|
|||||||
"--disable-setuid-sandbox",
|
"--disable-setuid-sandbox",
|
||||||
"--disable-dev-shm-usage",
|
"--disable-dev-shm-usage",
|
||||||
"--disable-gpu",
|
"--disable-gpu",
|
||||||
|
"--disable-extensions",
|
||||||
|
"--disable-component-update",
|
||||||
|
"--disable-background-networking",
|
||||||
|
"--mute-audio"
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
return self._browser
|
return self._browser
|
||||||
|
|
||||||
async def close(self):
|
async def close(self):
|
||||||
|
async with self._lock:
|
||||||
if self._browser:
|
if self._browser:
|
||||||
await self._browser.close()
|
await self._browser.close()
|
||||||
self._browser = None
|
self._browser = None
|
||||||
@@ -35,8 +42,8 @@ class ThreadScraper:
|
|||||||
await self._playwright.stop()
|
await self._playwright.stop()
|
||||||
self._playwright = None
|
self._playwright = None
|
||||||
|
|
||||||
async def rip(self, url: str, mode: str = "author_only", max_scrolls: int = 5) -> RippedThread:
|
async def rip(self, url: str, mode: str = "author_only", max_scrolls: int = 4) -> RippedThread:
|
||||||
"""Scrapes and rips a Threads post and its chained continuation posts."""
|
"""Scrapes and rips a Threads post and chained continuation posts with optimized speed."""
|
||||||
browser = await self.get_browser()
|
browser = await self.get_browser()
|
||||||
context: BrowserContext = await browser.new_context(
|
context: BrowserContext = await browser.new_context(
|
||||||
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||||
@@ -45,14 +52,26 @@ class ThreadScraper:
|
|||||||
page: Page = await context.new_page()
|
page: Page = await context.new_page()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Navigate and follow redirects
|
# 1. Performance optimization: Block unnecessary binary media downloads
|
||||||
await page.goto(url, wait_until="networkidle", timeout=30000)
|
# We only need the DOM attributes (src, alt, text) which are in the HTML payload
|
||||||
|
await page.route(
|
||||||
|
"**/*",
|
||||||
|
lambda route: route.abort()
|
||||||
|
if route.request.resource_type in ["image", "media", "font"]
|
||||||
|
else route.continue_()
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Fast navigation using domcontentloaded instead of networkidle
|
||||||
|
await page.goto(url, wait_until="domcontentloaded", timeout=20000)
|
||||||
canonical_url = page.url
|
canonical_url = page.url
|
||||||
|
|
||||||
# Small delay for hydration
|
# 3. Wait for the primary post container to render
|
||||||
await page.wait_for_timeout(2500)
|
try:
|
||||||
|
await page.wait_for_selector("div[data-pressable-container='true']", timeout=7000)
|
||||||
|
except Exception:
|
||||||
|
pass # Fall back to evaluating whatever rendered
|
||||||
|
|
||||||
# Extract Open Graph metadata
|
# 4. Extract Open Graph metadata
|
||||||
og_title = await page.evaluate("() => document.querySelector('meta[property=\"og:title\"]')?.content || ''")
|
og_title = await page.evaluate("() => document.querySelector('meta[property=\"og:title\"]')?.content || ''")
|
||||||
og_image = await page.evaluate("() => document.querySelector('meta[property=\"og:image\"]')?.content || ''")
|
og_image = await page.evaluate("() => document.querySelector('meta[property=\"og:image\"]')?.content || ''")
|
||||||
og_desc = await page.evaluate("() => document.querySelector('meta[property=\"og:description\"]')?.content || ''")
|
og_desc = await page.evaluate("() => document.querySelector('meta[property=\"og:description\"]')?.content || ''")
|
||||||
@@ -61,12 +80,10 @@ class ThreadScraper:
|
|||||||
author_handle = ""
|
author_handle = ""
|
||||||
author_name = ""
|
author_name = ""
|
||||||
|
|
||||||
# Check canonical URL for /@username
|
|
||||||
match_handle = re.search(r"/@([a-zA-Z0-9_\.]+)", canonical_url)
|
match_handle = re.search(r"/@([a-zA-Z0-9_\.]+)", canonical_url)
|
||||||
if match_handle:
|
if match_handle:
|
||||||
author_handle = match_handle.group(1)
|
author_handle = match_handle.group(1)
|
||||||
|
|
||||||
# Check og:title e.g. "Beth Parent McMillian (@bethmcmilliandesigns) on Threads"
|
|
||||||
if og_title:
|
if og_title:
|
||||||
m_title = re.search(r"^(.*?)\s*\(@?([a-zA-Z0-9_\.]+)\)", og_title)
|
m_title = re.search(r"^(.*?)\s*\(@?([a-zA-Z0-9_\.]+)\)", og_title)
|
||||||
if m_title:
|
if m_title:
|
||||||
@@ -77,13 +94,19 @@ class ThreadScraper:
|
|||||||
if not author_name:
|
if not author_name:
|
||||||
author_name = author_handle or "Threads Author"
|
author_name = author_handle or "Threads Author"
|
||||||
|
|
||||||
# Author profile avatar
|
|
||||||
author_avatar = og_image if og_image else None
|
author_avatar = og_image if og_image else None
|
||||||
|
|
||||||
# Progressively scroll to load dynamic replies in the thread
|
# 5. Smart adaptive scrolling for thread continuation replies
|
||||||
|
prev_containers_count = 0
|
||||||
for _ in range(max_scrolls):
|
for _ in range(max_scrolls):
|
||||||
await page.evaluate("window.scrollBy(0, 1200)")
|
containers = await page.query_selector_all("div[data-pressable-container='true']")
|
||||||
await page.wait_for_timeout(1000)
|
curr_count = len(containers)
|
||||||
|
# If we already have multiple containers and count didn't change on last scroll, settle early
|
||||||
|
if curr_count > 1 and curr_count == prev_containers_count:
|
||||||
|
break
|
||||||
|
prev_containers_count = curr_count
|
||||||
|
await page.evaluate("window.scrollBy(0, 1400)")
|
||||||
|
await page.wait_for_timeout(350)
|
||||||
|
|
||||||
# Query all pressable post containers
|
# Query all pressable post containers
|
||||||
containers = await page.query_selector_all("div[data-pressable-container='true']")
|
containers = await page.query_selector_all("div[data-pressable-container='true']")
|
||||||
@@ -92,7 +115,6 @@ class ThreadScraper:
|
|||||||
seen_texts = set()
|
seen_texts = set()
|
||||||
|
|
||||||
for idx, c in enumerate(containers):
|
for idx, c in enumerate(containers):
|
||||||
# 1. Identify post author handle
|
|
||||||
user_links = await c.query_selector_all("a[href*='/@']")
|
user_links = await c.query_selector_all("a[href*='/@']")
|
||||||
post_handle = None
|
post_handle = None
|
||||||
post_url = None
|
post_url = None
|
||||||
@@ -113,7 +135,7 @@ class ThreadScraper:
|
|||||||
if badge:
|
if badge:
|
||||||
is_author_badge = True
|
is_author_badge = True
|
||||||
|
|
||||||
# Determine if this segment belongs to the original author
|
# Determine if this segment belongs to original author
|
||||||
is_author_post = False
|
is_author_post = False
|
||||||
if idx == 0:
|
if idx == 0:
|
||||||
is_author_post = True
|
is_author_post = True
|
||||||
@@ -124,21 +146,18 @@ class ThreadScraper:
|
|||||||
elif post_handle and author_handle and (post_handle.lower() == author_handle.lower()):
|
elif post_handle and author_handle and (post_handle.lower() == author_handle.lower()):
|
||||||
is_author_post = True
|
is_author_post = True
|
||||||
|
|
||||||
# If author_only mode is on and this is not by the author, skip
|
|
||||||
if mode == "author_only" and not is_author_post:
|
if mode == "author_only" and not is_author_post:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 2. Extract text with dir='auto'
|
# Extract text lines
|
||||||
dir_els = await c.query_selector_all("[dir='auto']")
|
dir_els = await c.query_selector_all("[dir='auto']")
|
||||||
segment_text_parts = []
|
segment_text_parts = []
|
||||||
for del_el in dir_els:
|
for del_el in dir_els:
|
||||||
txt = (await del_el.inner_text()).strip()
|
txt = (await del_el.inner_text()).strip()
|
||||||
if not txt:
|
if not txt:
|
||||||
continue
|
continue
|
||||||
# Ignore handle, author badge, bullet dots, stats
|
|
||||||
if txt == post_handle or txt == "Author" or txt == "·" or txt == author_handle:
|
if txt == post_handle or txt == "Author" or txt == "·" or txt == author_handle:
|
||||||
continue
|
continue
|
||||||
# Ignore pure stat numbers (e.g., '16.2K', '5d', '2K', '711')
|
|
||||||
if re.match(r"^(\d+(\.\d+)?[KMkmdh]?|\d+)$", txt):
|
if re.match(r"^(\d+(\.\d+)?[KMkmdh]?|\d+)$", txt):
|
||||||
continue
|
continue
|
||||||
if txt not in segment_text_parts:
|
if txt not in segment_text_parts:
|
||||||
@@ -148,21 +167,19 @@ class ThreadScraper:
|
|||||||
if not combined_text and not is_author_post:
|
if not combined_text and not is_author_post:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Deduplicate identical consecutive segments
|
|
||||||
text_hash = hashlib.md5(combined_text.encode("utf-8")).hexdigest()
|
text_hash = hashlib.md5(combined_text.encode("utf-8")).hexdigest()
|
||||||
if text_hash in seen_texts and combined_text:
|
if text_hash in seen_texts and combined_text:
|
||||||
continue
|
continue
|
||||||
seen_texts.add(text_hash)
|
seen_texts.add(text_hash)
|
||||||
|
|
||||||
# 3. Extract media images inside post (exclude profile pictures)
|
# Extract post images
|
||||||
imgs = await c.query_selector_all("img")
|
imgs = await c.query_selector_all("img")
|
||||||
post_images: List[MediaItem] = []
|
post_images: List[MediaItem] = []
|
||||||
for img in imgs:
|
for img in imgs:
|
||||||
src = await img.get_attribute("src")
|
src = await img.get_attribute("src")
|
||||||
alt = await img.get_attribute("alt") or ""
|
alt = await img.get_attribute("alt") or ""
|
||||||
if src:
|
if src:
|
||||||
# Exclude small avatars or generic SVG icons
|
if "profile_pic" in src or ("scontent" in src and "206x206" in src):
|
||||||
if "profile_pic" in src or "scontent" in src and "206x206" in src:
|
|
||||||
continue
|
continue
|
||||||
post_images.append(MediaItem(type="image", url=src, alt_text=alt))
|
post_images.append(MediaItem(type="image", url=src, alt_text=alt))
|
||||||
|
|
||||||
@@ -179,7 +196,7 @@ class ThreadScraper:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# If no segments could be extracted from containers, fallback to OG description
|
# Fallback to OG description if empty
|
||||||
if not raw_segments and og_desc:
|
if not raw_segments and og_desc:
|
||||||
raw_segments.append(
|
raw_segments.append(
|
||||||
PostSegment(
|
PostSegment(
|
||||||
@@ -194,12 +211,11 @@ class ThreadScraper:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Construct stitched outputs
|
# Build stitched outputs
|
||||||
markdown_out = build_markdown(author_name, author_handle, canonical_url, raw_segments)
|
markdown_out = build_markdown(author_name, author_handle, canonical_url, raw_segments)
|
||||||
plain_text_out = build_plain_text(author_name, author_handle, canonical_url, raw_segments)
|
plain_text_out = build_plain_text(author_name, author_handle, canonical_url, raw_segments)
|
||||||
html_out = build_standalone_html(author_name, author_handle, author_avatar, canonical_url, raw_segments)
|
html_out = build_standalone_html(author_name, author_handle, author_avatar, canonical_url, raw_segments)
|
||||||
|
|
||||||
# Generate unique ID for this ripped thread
|
|
||||||
thread_id = hashlib.md5(canonical_url.split("?")[0].encode("utf-8")).hexdigest()[:12]
|
thread_id = hashlib.md5(canonical_url.split("?")[0].encode("utf-8")).hexdigest()[:12]
|
||||||
title = raw_segments[0].text.split("\n")[0] if raw_segments and raw_segments[0].text else f"Thread by @{author_handle}"
|
title = raw_segments[0].text.split("\n")[0] if raw_segments and raw_segments[0].text else f"Thread by @{author_handle}"
|
||||||
if len(title) > 90:
|
if len(title) > 90:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import asyncio
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI, HTTPException, Response
|
from fastapi import FastAPI, HTTPException, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
@@ -14,6 +15,8 @@ scraper = ThreadScraper(headless=True)
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
# Pre-warm Chromium in background so initial user requests are instant
|
||||||
|
asyncio.create_task(scraper.get_browser())
|
||||||
yield
|
yield
|
||||||
await scraper.close()
|
await scraper.close()
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user