367 lines
13 KiB
Python
367 lines
13 KiB
Python
|
|
"""
|
|||
|
|
Example of using the virtual scroll feature to capture content from pages
|
|||
|
|
with virtualized scrolling (like Twitter, Instagram, or other infinite scroll feeds).
|
|||
|
|
|
|||
|
|
This example demonstrates virtual scroll with a local test server serving
|
|||
|
|
different types of scrolling behaviors from HTML files in the assets directory.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import asyncio
|
|||
|
|
import os
|
|||
|
|
import http.server
|
|||
|
|
import socketserver
|
|||
|
|
import threading
|
|||
|
|
from pathlib import Path
|
|||
|
|
from crawl4ai import AsyncWebCrawler, CrawlerRunConfig, VirtualScrollConfig, CacheMode, BrowserConfig
|
|||
|
|
|
|||
|
|
# Get the assets directory path
|
|||
|
|
ASSETS_DIR = Path(__file__).parent / "assets"
|
|||
|
|
|
|||
|
|
class TestServer:
|
|||
|
|
"""Simple HTTP server to serve our test HTML files"""
|
|||
|
|
|
|||
|
|
def __init__(self, port=8080):
|
|||
|
|
self.port = port
|
|||
|
|
self.httpd = None
|
|||
|
|
self.server_thread = None
|
|||
|
|
|
|||
|
|
async def start(self):
|
|||
|
|
"""Start the test server"""
|
|||
|
|
Handler = http.server.SimpleHTTPRequestHandler
|
|||
|
|
|
|||
|
|
# Save current directory and change to assets directory
|
|||
|
|
self.original_cwd = os.getcwd()
|
|||
|
|
os.chdir(ASSETS_DIR)
|
|||
|
|
|
|||
|
|
# Try to find an available port
|
|||
|
|
for _ in range(10):
|
|||
|
|
try:
|
|||
|
|
self.httpd = socketserver.TCPServer(("", self.port), Handler)
|
|||
|
|
break
|
|||
|
|
except OSError:
|
|||
|
|
self.port += 1
|
|||
|
|
|
|||
|
|
if self.httpd is None:
|
|||
|
|
raise RuntimeError("Could not find available port")
|
|||
|
|
|
|||
|
|
self.server_thread = threading.Thread(target=self.httpd.serve_forever)
|
|||
|
|
self.server_thread.daemon = True
|
|||
|
|
self.server_thread.start()
|
|||
|
|
|
|||
|
|
# Give server time to start
|
|||
|
|
await asyncio.sleep(0.5)
|
|||
|
|
|
|||
|
|
print(f"Test server started on http://localhost:{self.port}")
|
|||
|
|
return self.port
|
|||
|
|
|
|||
|
|
def stop(self):
|
|||
|
|
"""Stop the test server"""
|
|||
|
|
if self.httpd:
|
|||
|
|
self.httpd.shutdown()
|
|||
|
|
# Restore original directory
|
|||
|
|
if hasattr(self, 'original_cwd'):
|
|||
|
|
os.chdir(self.original_cwd)
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def example_twitter_like_virtual_scroll():
|
|||
|
|
"""
|
|||
|
|
Example 1: Twitter-like virtual scroll where content is REPLACED.
|
|||
|
|
This is the classic virtual scroll use case - only visible items exist in DOM.
|
|||
|
|
"""
|
|||
|
|
print("\n" + "="*60)
|
|||
|
|
print("EXAMPLE 1: Twitter-like Virtual Scroll")
|
|||
|
|
print("="*60)
|
|||
|
|
|
|||
|
|
server = TestServer()
|
|||
|
|
port = await server.start()
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# Configure virtual scroll for Twitter-like timeline
|
|||
|
|
virtual_config = VirtualScrollConfig(
|
|||
|
|
container_selector="#timeline", # The scrollable container
|
|||
|
|
scroll_count=50, # Scroll up to 50 times to get all content
|
|||
|
|
scroll_by="container_height", # Scroll by container's height
|
|||
|
|
wait_after_scroll=0.3 # Wait 300ms after each scroll
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
config = CrawlerRunConfig(
|
|||
|
|
virtual_scroll_config=virtual_config,
|
|||
|
|
cache_mode=CacheMode.BYPASS
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# TIP: Set headless=False to watch the scrolling happen!
|
|||
|
|
browser_config = BrowserConfig(
|
|||
|
|
headless=False,
|
|||
|
|
viewport={"width": 1280, "height": 800}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
async with AsyncWebCrawler(config=browser_config) as crawler:
|
|||
|
|
result = await crawler.arun(
|
|||
|
|
url=f"http://localhost:{port}/virtual_scroll_twitter_like.html",
|
|||
|
|
config=config
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Count tweets captured
|
|||
|
|
import re
|
|||
|
|
tweets = re.findall(r'data-tweet-id="(\d+)"', result.html)
|
|||
|
|
unique_tweets = sorted(set(int(id) for id in tweets))
|
|||
|
|
|
|||
|
|
print(f"\n📊 Results:")
|
|||
|
|
print(f" Total HTML length: {len(result.html):,} characters")
|
|||
|
|
print(f" Tweets captured: {len(unique_tweets)} unique tweets")
|
|||
|
|
if unique_tweets:
|
|||
|
|
print(f" Tweet IDs range: {min(unique_tweets)} to {max(unique_tweets)}")
|
|||
|
|
print(f" Expected range: 0 to 499 (500 tweets total)")
|
|||
|
|
|
|||
|
|
if len(unique_tweets) == 500:
|
|||
|
|
print(f" ✅ SUCCESS! All tweets captured!")
|
|||
|
|
else:
|
|||
|
|
print(f" ⚠️ Captured {len(unique_tweets)}/500 tweets")
|
|||
|
|
|
|||
|
|
finally:
|
|||
|
|
server.stop()
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def example_traditional_append_scroll():
|
|||
|
|
"""
|
|||
|
|
Example 2: Traditional infinite scroll where content is APPENDED.
|
|||
|
|
No virtual scroll needed - all content stays in DOM.
|
|||
|
|
"""
|
|||
|
|
print("\n" + "="*60)
|
|||
|
|
print("EXAMPLE 2: Traditional Append-Only Scroll")
|
|||
|
|
print("="*60)
|
|||
|
|
|
|||
|
|
server = TestServer()
|
|||
|
|
port = await server.start()
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# Configure virtual scroll
|
|||
|
|
virtual_config = VirtualScrollConfig(
|
|||
|
|
container_selector=".posts-container",
|
|||
|
|
scroll_count=15, # Less scrolls needed since content accumulates
|
|||
|
|
scroll_by=500, # Scroll by 500 pixels
|
|||
|
|
wait_after_scroll=0.4
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
config = CrawlerRunConfig(
|
|||
|
|
virtual_scroll_config=virtual_config,
|
|||
|
|
cache_mode=CacheMode.BYPASS
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
async with AsyncWebCrawler() as crawler:
|
|||
|
|
result = await crawler.arun(
|
|||
|
|
url=f"http://localhost:{port}/virtual_scroll_append_only.html",
|
|||
|
|
config=config
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Count posts
|
|||
|
|
import re
|
|||
|
|
posts = re.findall(r'data-post-id="(\d+)"', result.html)
|
|||
|
|
unique_posts = sorted(set(int(id) for id in posts))
|
|||
|
|
|
|||
|
|
print(f"\n📊 Results:")
|
|||
|
|
print(f" Total HTML length: {len(result.html):,} characters")
|
|||
|
|
print(f" Posts captured: {len(unique_posts)} unique posts")
|
|||
|
|
|
|||
|
|
if unique_posts:
|
|||
|
|
print(f" Post IDs range: {min(unique_posts)} to {max(unique_posts)}")
|
|||
|
|
print(f" ℹ️ Note: This page appends content, so virtual scroll")
|
|||
|
|
print(f" just helps trigger more loads. All content stays in DOM.")
|
|||
|
|
|
|||
|
|
finally:
|
|||
|
|
server.stop()
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def example_instagram_grid():
|
|||
|
|
"""
|
|||
|
|
Example 3: Instagram-like grid with virtual scroll.
|
|||
|
|
Grid layout where only visible rows are rendered.
|
|||
|
|
"""
|
|||
|
|
print("\n" + "="*60)
|
|||
|
|
print("EXAMPLE 3: Instagram Grid Virtual Scroll")
|
|||
|
|
print("="*60)
|
|||
|
|
|
|||
|
|
server = TestServer()
|
|||
|
|
port = await server.start()
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# Configure for grid layout
|
|||
|
|
virtual_config = VirtualScrollConfig(
|
|||
|
|
container_selector=".feed-container", # Container with the grid
|
|||
|
|
scroll_count=100, # Many scrolls for 999 posts
|
|||
|
|
scroll_by="container_height",
|
|||
|
|
wait_after_scroll=0.2 # Faster scrolling for grid
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
config = CrawlerRunConfig(
|
|||
|
|
virtual_scroll_config=virtual_config,
|
|||
|
|
cache_mode=CacheMode.BYPASS,
|
|||
|
|
screenshot=True # Take a screenshot of the final grid
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Show browser for this visual example
|
|||
|
|
browser_config = BrowserConfig(
|
|||
|
|
headless=False,
|
|||
|
|
viewport={"width": 1200, "height": 900}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
async with AsyncWebCrawler(config=browser_config) as crawler:
|
|||
|
|
result = await crawler.arun(
|
|||
|
|
url=f"http://localhost:{port}/virtual_scroll_instagram_grid.html",
|
|||
|
|
config=config
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Count posts in grid
|
|||
|
|
import re
|
|||
|
|
posts = re.findall(r'data-post-id="(\d+)"', result.html)
|
|||
|
|
unique_posts = sorted(set(int(id) for id in posts))
|
|||
|
|
|
|||
|
|
print(f"\n📊 Results:")
|
|||
|
|
print(f" Posts in grid: {len(unique_posts)} unique posts")
|
|||
|
|
if unique_posts:
|
|||
|
|
print(f" Post IDs range: {min(unique_posts)} to {max(unique_posts)}")
|
|||
|
|
print(f" Expected: 0 to 998 (999 posts total)")
|
|||
|
|
|
|||
|
|
# Save screenshot
|
|||
|
|
if result.screenshot:
|
|||
|
|
import base64
|
|||
|
|
with open("instagram_grid_result.png", "wb") as f:
|
|||
|
|
f.write(base64.b64decode(result.screenshot))
|
|||
|
|
print(f" 📸 Screenshot saved as instagram_grid_result.png")
|
|||
|
|
|
|||
|
|
finally:
|
|||
|
|
server.stop()
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def example_mixed_content():
|
|||
|
|
"""
|
|||
|
|
Example 4: News feed with mixed behavior.
|
|||
|
|
Featured articles stay (no virtual scroll), regular articles are virtualized.
|
|||
|
|
"""
|
|||
|
|
print("\n" + "="*60)
|
|||
|
|
print("EXAMPLE 4: News Feed with Mixed Behavior")
|
|||
|
|
print("="*60)
|
|||
|
|
|
|||
|
|
server = TestServer()
|
|||
|
|
port = await server.start()
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# Configure virtual scroll
|
|||
|
|
virtual_config = VirtualScrollConfig(
|
|||
|
|
container_selector="#newsContainer",
|
|||
|
|
scroll_count=25,
|
|||
|
|
scroll_by="container_height",
|
|||
|
|
wait_after_scroll=0.3
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
config = CrawlerRunConfig(
|
|||
|
|
virtual_scroll_config=virtual_config,
|
|||
|
|
cache_mode=CacheMode.BYPASS
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
async with AsyncWebCrawler() as crawler:
|
|||
|
|
result = await crawler.arun(
|
|||
|
|
url=f"http://localhost:{port}/virtual_scroll_news_feed.html",
|
|||
|
|
config=config
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Count different types of articles
|
|||
|
|
import re
|
|||
|
|
featured = re.findall(r'data-article-id="featured-\d+"', result.html)
|
|||
|
|
regular = re.findall(r'data-article-id="article-(\d+)"', result.html)
|
|||
|
|
|
|||
|
|
print(f"\n📊 Results:")
|
|||
|
|
print(f" Featured articles: {len(set(featured))} (always visible)")
|
|||
|
|
print(f" Regular articles: {len(set(regular))} unique articles")
|
|||
|
|
|
|||
|
|
if regular:
|
|||
|
|
regular_ids = sorted(set(int(id) for id in regular))
|
|||
|
|
print(f" Regular article IDs: {min(regular_ids)} to {max(regular_ids)}")
|
|||
|
|
print(f" ℹ️ Note: Featured articles stay in DOM, only regular")
|
|||
|
|
print(f" articles are replaced during virtual scroll")
|
|||
|
|
|
|||
|
|
finally:
|
|||
|
|
server.stop()
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def compare_with_without_virtual_scroll():
|
|||
|
|
"""
|
|||
|
|
Comparison: Show the difference between crawling with and without virtual scroll.
|
|||
|
|
"""
|
|||
|
|
print("\n" + "="*60)
|
|||
|
|
print("COMPARISON: With vs Without Virtual Scroll")
|
|||
|
|
print("="*60)
|
|||
|
|
|
|||
|
|
server = TestServer()
|
|||
|
|
port = await server.start()
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
url = f"http://localhost:{port}/virtual_scroll_twitter_like.html"
|
|||
|
|
|
|||
|
|
# First, crawl WITHOUT virtual scroll
|
|||
|
|
print("\n1️⃣ Crawling WITHOUT virtual scroll...")
|
|||
|
|
async with AsyncWebCrawler() as crawler:
|
|||
|
|
config_normal = CrawlerRunConfig(cache_mode=CacheMode.BYPASS)
|
|||
|
|
result_normal = await crawler.arun(url=url, config=config_normal)
|
|||
|
|
|
|||
|
|
# Count items
|
|||
|
|
import re
|
|||
|
|
tweets_normal = len(set(re.findall(r'data-tweet-id="(\d+)"', result_normal.html)))
|
|||
|
|
|
|||
|
|
# Then, crawl WITH virtual scroll
|
|||
|
|
print("2️⃣ Crawling WITH virtual scroll...")
|
|||
|
|
virtual_config = VirtualScrollConfig(
|
|||
|
|
container_selector="#timeline",
|
|||
|
|
scroll_count=50,
|
|||
|
|
scroll_by="container_height",
|
|||
|
|
wait_after_scroll=0.2
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
config_virtual = CrawlerRunConfig(
|
|||
|
|
virtual_scroll_config=virtual_config,
|
|||
|
|
cache_mode=CacheMode.BYPASS
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
async with AsyncWebCrawler() as crawler:
|
|||
|
|
result_virtual = await crawler.arun(url=url, config=config_virtual)
|
|||
|
|
|
|||
|
|
# Count items
|
|||
|
|
tweets_virtual = len(set(re.findall(r'data-tweet-id="(\d+)"', result_virtual.html)))
|
|||
|
|
|
|||
|
|
# Compare results
|
|||
|
|
print(f"\n📊 Comparison Results:")
|
|||
|
|
print(f" Without virtual scroll: {tweets_normal} tweets (only initial visible)")
|
|||
|
|
print(f" With virtual scroll: {tweets_virtual} tweets (all content captured)")
|
|||
|
|
print(f" Improvement: {tweets_virtual / tweets_normal if tweets_normal > 0 else 'N/A':.1f}x more content!")
|
|||
|
|
|
|||
|
|
print(f"\n HTML size without: {len(result_normal.html):,} characters")
|
|||
|
|
print(f" HTML size with: {len(result_virtual.html):,} characters")
|
|||
|
|
|
|||
|
|
finally:
|
|||
|
|
server.stop()
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
print("""
|
|||
|
|
╔════════════════════════════════════════════════════════════╗
|
|||
|
|
║ Virtual Scroll Examples for Crawl4AI ║
|
|||
|
|
╚════════════════════════════════════════════════════════════╝
|
|||
|
|
|
|||
|
|
These examples demonstrate different virtual scroll scenarios:
|
|||
|
|
1. Twitter-like (content replaced) - Classic virtual scroll
|
|||
|
|
2. Traditional append - Content accumulates
|
|||
|
|
3. Instagram grid - Visual grid layout
|
|||
|
|
4. Mixed behavior - Some content stays, some virtualizes
|
|||
|
|
|
|||
|
|
Starting examples...
|
|||
|
|
""")
|
|||
|
|
|
|||
|
|
# Run all examples
|
|||
|
|
asyncio.run(example_twitter_like_virtual_scroll())
|
|||
|
|
asyncio.run(example_traditional_append_scroll())
|
|||
|
|
asyncio.run(example_instagram_grid())
|
|||
|
|
asyncio.run(example_mixed_content())
|
|||
|
|
asyncio.run(compare_with_without_virtual_scroll())
|
|||
|
|
|
|||
|
|
print("\n✅ All examples completed!")
|
|||
|
|
print("\nTIP: Set headless=False in BrowserConfig to watch the scrolling in action!")
|