diff --git a/.github/workflows/sync-extension.yml b/.github/workflows/sync-extension.yml index 3aae02a..d64f72b 100644 --- a/.github/workflows/sync-extension.yml +++ b/.github/workflows/sync-extension.yml @@ -25,6 +25,7 @@ jobs: uses: actions/checkout@v4 with: token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 # Fetch all history for proper branching - name: Set up Python uses: actions/setup-python@v5 @@ -62,17 +63,38 @@ jobs: mkdir -p extension-temp cd extension-temp - # Download each file from release - curl -L -H "Authorization: token ${{ secrets.SENTIENCE_CHROME_TOKEN }}" \ + # First, try to download the zip archive if available + ZIP_URL=$(curl -s -H "Authorization: token ${{ secrets.SENTIENCE_CHROME_TOKEN }}" \ "https://api.github.com/repos/$REPO/releases/tags/$TAG" | \ - jq -r '.assets[] | select(.name | endswith(".js") or endswith(".wasm") or endswith(".json") or endswith(".d.ts")) | .browser_download_url' | \ - while read url; do - filename=$(basename "$url") - curl -L -H "Authorization: token ${{ secrets.SENTIENCE_CHROME_TOKEN }}" "$url" -o "$filename" - done + jq -r '.assets[] | select(.name == "extension-package.zip") | .browser_download_url') - # Alternative: Download from release archive if available - # Or use the extension-package artifact + if [ -n "$ZIP_URL" ] && [ "$ZIP_URL" != "null" ]; then + echo "📦 Downloading extension-package.zip..." + curl -L -H "Authorization: token ${{ secrets.SENTIENCE_CHROME_TOKEN }}" "$ZIP_URL" -o extension-package.zip + unzip -q extension-package.zip -d . + # Files should now be in extension-temp/extension-package/ or extension-temp/ + if [ -d "extension-package" ]; then + mv extension-package/* . 2>/dev/null || true + rmdir extension-package 2>/dev/null || true + fi + else + echo "📁 Downloading individual files from release..." + # Download each file from release + curl -s -H "Authorization: token ${{ secrets.SENTIENCE_CHROME_TOKEN }}" \ + "https://api.github.com/repos/$REPO/releases/tags/$TAG" | \ + jq -r '.assets[] | select(.name | endswith(".js") or endswith(".wasm") or endswith(".json") or endswith(".d.ts")) | .browser_download_url' | \ + while read url; do + if [ -n "$url" ] && [ "$url" != "null" ]; then + filename=$(basename "$url") + echo " Downloading $filename..." + curl -L -H "Authorization: token ${{ secrets.SENTIENCE_CHROME_TOKEN }}" "$url" -o "$filename" + fi + done + fi + + # Verify files were downloaded + echo "📋 Downloaded files:" + ls -la - name: Copy extension files if: steps.release.outputs.skip != 'true' @@ -80,16 +102,40 @@ jobs: # Create extension directory structure mkdir -p sentience/extension/pkg - # Copy extension files - cp extension-temp/manifest.json sentience/extension/ 2>/dev/null || echo "manifest.json not found in release" - cp extension-temp/content.js sentience/extension/ 2>/dev/null || echo "content.js not found in release" - cp extension-temp/background.js sentience/extension/ 2>/dev/null || echo "background.js not found in release" - cp extension-temp/injected_api.js sentience/extension/ 2>/dev/null || echo "injected_api.js not found in release" + # Copy extension files (check both root and pkg subdirectory) + cp extension-temp/manifest.json sentience/extension/ 2>/dev/null || echo "⚠️ manifest.json not found in release" + cp extension-temp/content.js sentience/extension/ 2>/dev/null || echo "⚠️ content.js not found in release" + cp extension-temp/background.js sentience/extension/ 2>/dev/null || echo "⚠️ background.js not found in release" + cp extension-temp/injected_api.js sentience/extension/ 2>/dev/null || echo "⚠️ injected_api.js not found in release" + + # Copy WASM files (check both root and pkg subdirectory) + if [ -f "extension-temp/pkg/sentience_core.js" ]; then + cp extension-temp/pkg/sentience_core.js sentience/extension/pkg/ + elif [ -f "extension-temp/sentience_core.js" ]; then + cp extension-temp/sentience_core.js sentience/extension/pkg/ + else + echo "⚠️ sentience_core.js not found" + fi + + if [ -f "extension-temp/pkg/sentience_core_bg.wasm" ]; then + cp extension-temp/pkg/sentience_core_bg.wasm sentience/extension/pkg/ + elif [ -f "extension-temp/sentience_core_bg.wasm" ]; then + cp extension-temp/sentience_core_bg.wasm sentience/extension/pkg/ + else + echo "⚠️ sentience_core_bg.wasm not found" + fi + + # Copy TypeScript definitions + if [ -d "extension-temp/pkg" ]; then + cp extension-temp/pkg/*.d.ts sentience/extension/pkg/ 2>/dev/null || echo "⚠️ Type definitions not found" + elif [ -d "extension-temp" ]; then + cp extension-temp/*.d.ts sentience/extension/pkg/ 2>/dev/null || echo "⚠️ Type definitions not found" + fi - # Copy WASM files - cp extension-temp/pkg/sentience_core.js sentience/extension/pkg/ 2>/dev/null || echo "sentience_core.js not found" - cp extension-temp/pkg/sentience_core_bg.wasm sentience/extension/pkg/ 2>/dev/null || echo "sentience_core_bg.wasm not found" - cp extension-temp/pkg/*.d.ts sentience/extension/pkg/ 2>/dev/null || echo "Type definitions not found" + # Verify copied files + echo "📋 Copied files:" + ls -la sentience/extension/ + ls -la sentience/extension/pkg/ 2>/dev/null || echo "⚠️ pkg directory not created" - name: Check for changes if: steps.release.outputs.skip != 'true' @@ -110,7 +156,9 @@ jobs: if: steps.release.outputs.skip != 'true' && steps.changes.outputs.changed == 'true' uses: peter-evans/create-pull-request@v5 with: - token: ${{ secrets.GITHUB_TOKEN }} + # Use GITHUB_TOKEN (built-in) if repository allows PR creation, otherwise use PR_TOKEN (PAT) + # To use PAT: create secret named PR_TOKEN with a Personal Access Token that has 'repo' scope + token: ${{ secrets.PR_TOKEN || secrets.GITHUB_TOKEN }} commit-message: "chore: sync extension files from sentience-chrome ${{ steps.release.outputs.tag }}" title: "Sync Extension: ${{ steps.release.outputs.tag }}" body: | @@ -120,7 +168,10 @@ jobs: - Extension manifest and scripts - WASM binary and bindings - **Source:** [sentience-chrome release ${{ steps.release.outputs.tag }}](${{ secrets.SENTIENCE_CHROME_REPO }}/releases/tag/${{ steps.release.outputs.tag }}) + **Source:** [sentience-chrome release ${{ steps.release.outputs.tag }}](https://github.com/${{ secrets.SENTIENCE_CHROME_REPO }}/releases/tag/${{ steps.release.outputs.tag }}) branch: sync-extension-${{ steps.release.outputs.tag }} delete-branch: true + labels: | + automated + extension-sync diff --git a/README.md b/README.md index 2e8dd94..52dadf7 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,14 @@ with SentienceBrowser(headless=False) as browser: - Pydantic models for type safety - `snapshot.save(filepath)` - Save to JSON +### Content Reading & Screenshots +- `read(browser, format="text|markdown")` - Read page content as text or markdown + - Enhanced markdown conversion using `markdownify` (better than extension's lightweight conversion) + - Supports `enhance_markdown=True` to use improved conversion +- `screenshot(browser, format="png|jpeg", quality=80)` - Capture standalone screenshot + - Returns base64-encoded data URL + - Supports PNG and JPEG formats with quality control + ### Day 4: Query Engine - `query(snapshot, selector)` - Find elements matching selector - `find(snapshot, selector)` - Find single best match @@ -73,6 +81,39 @@ See `examples/` directory: - `query_demo.py` - Query engine - `wait_and_click.py` - Wait and actions +### Content Reading Example + +```python +from sentience import SentienceBrowser, read + +with SentienceBrowser() as browser: + browser.page.goto("https://example.com") + browser.page.wait_for_load_state("networkidle") + + # Read as enhanced markdown (better quality) + result = read(browser, format="markdown", enhance_markdown=True) + print(result["content"]) # High-quality markdown +``` + +### Screenshot Example + +```python +from sentience import SentienceBrowser, screenshot +import base64 + +with SentienceBrowser() as browser: + browser.page.goto("https://example.com") + browser.page.wait_for_load_state("networkidle") + + # Capture PNG screenshot + data_url = screenshot(browser, format="png") + + # Save to file + image_data = base64.b64decode(data_url.split(",")[1]) + with open("screenshot.png", "wb") as f: + f.write(image_data) +``` + ## Testing ```bash diff --git a/pyproject.toml b/pyproject.toml index e7bdcdd..c89fa87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dependencies = [ "jsonschema>=4.0.0", "requests>=2.31.0", # For server-side API calls "playwright-stealth>=1.0.6", # Bot evasion and stealth mode + "markdownify>=0.11.6", # Enhanced HTML to Markdown conversion ] [project.urls] diff --git a/sentience/__init__.py b/sentience/__init__.py index b5a6330..3d02d09 100644 --- a/sentience/__init__.py +++ b/sentience/__init__.py @@ -12,6 +12,8 @@ from .inspector import Inspector, inspect from .recorder import Recorder, Trace, TraceStep, record from .generator import ScriptGenerator, generate +from .read import read +from .screenshot import screenshot __version__ = "0.1.0" @@ -39,5 +41,7 @@ "record", "ScriptGenerator", "generate", + "read", + "screenshot", ] diff --git a/sentience/read.py b/sentience/read.py new file mode 100644 index 0000000..91425f7 --- /dev/null +++ b/sentience/read.py @@ -0,0 +1,70 @@ +""" +Read page content - enhanced markdown conversion +""" + +from typing import Optional, Literal +from .browser import SentienceBrowser + + +def read( + browser: SentienceBrowser, + format: Literal["text", "markdown"] = "text", + enhance_markdown: bool = True, +) -> dict: + """ + Read page content as text or markdown + + Args: + browser: SentienceBrowser instance + format: Output format - "text" or "markdown" + enhance_markdown: If True and format="markdown", use markdownify for better conversion + + Returns: + dict with: + - status: "success" or "error" + - url: Current page URL + - format: "text" or "markdown" + - content: Page content as string + - length: Content length in characters + - error: Error message if status is "error" + """ + if not browser.page: + raise RuntimeError("Browser not started. Call browser.start() first.") + + # Get basic content from extension + result = browser.page.evaluate( + """ + (options) => { + return window.sentience.read(options); + } + """, + {"format": format}, + ) + + # Enhance markdown if requested and format is markdown + if format == "markdown" and enhance_markdown and result.get("status") == "success": + try: + # Get full HTML from page + html_content = browser.page.evaluate("() => document.documentElement.outerHTML") + + # Use markdownify for better conversion + from markdownify import markdownify as md + enhanced_markdown = md( + html_content, + heading_style="ATX", # Use # for headings + bullets="-", # Use - for lists + strip=['script', 'style', 'nav', 'footer', 'header', 'noscript'], # Strip unwanted tags + ) + result["content"] = enhanced_markdown + result["length"] = len(enhanced_markdown) + except ImportError: + # Fallback to extension's lightweight conversion if markdownify not installed + # This shouldn't happen if dependencies are installed, but handle gracefully + pass + except Exception as e: + # If enhancement fails, use extension's result + # Don't overwrite result["error"] - keep extension's result + pass + + return result + diff --git a/sentience/screenshot.py b/sentience/screenshot.py new file mode 100644 index 0000000..4a34d26 --- /dev/null +++ b/sentience/screenshot.py @@ -0,0 +1,53 @@ +""" +Screenshot functionality - standalone screenshot capture +""" + +from typing import Optional, Literal, Dict, Any +from .browser import SentienceBrowser + + +def screenshot( + browser: SentienceBrowser, + format: Literal["png", "jpeg"] = "png", + quality: Optional[int] = None, +) -> str: + """ + Capture screenshot of current page + + Args: + browser: SentienceBrowser instance + format: Image format - "png" or "jpeg" + quality: JPEG quality (1-100), only used for JPEG format + + Returns: + Base64-encoded screenshot data URL (e.g., "data:image/png;base64,...") + + Raises: + RuntimeError: If browser not started + ValueError: If quality is invalid for JPEG + """ + if not browser.page: + raise RuntimeError("Browser not started. Call browser.start() first.") + + if format == "jpeg" and quality is not None: + if not (1 <= quality <= 100): + raise ValueError("Quality must be between 1 and 100 for JPEG format") + + # Use Playwright's screenshot with base64 encoding + screenshot_options: Dict[str, Any] = { + "type": format, + } + + if format == "jpeg" and quality is not None: + screenshot_options["quality"] = quality + + # Capture screenshot as base64 + # Playwright returns bytes when encoding is not specified, so we encode manually + import base64 + image_bytes = browser.page.screenshot(**screenshot_options) + base64_data = base64.b64encode(image_bytes).decode('utf-8') + + # Return as data URL + mime_type = "image/png" if format == "png" else "image/jpeg" + return f"data:{mime_type};base64,{base64_data}" + diff --git a/tests/test_read.py b/tests/test_read.py new file mode 100644 index 0000000..2fde9c8 --- /dev/null +++ b/tests/test_read.py @@ -0,0 +1,64 @@ +""" +Tests for read functionality +""" + +from sentience import SentienceBrowser, read + + +def test_read_text(): + """Test reading page as text""" + with SentienceBrowser(headless=True) as browser: + browser.page.goto("https://example.com") + browser.page.wait_for_load_state("networkidle") + + result = read(browser, format="text") + + assert result["status"] == "success" + assert result["format"] == "text" + assert "content" in result + assert "length" in result + assert len(result["content"]) > 0 + assert result["url"] == "https://example.com" + + +def test_read_markdown(): + """Test reading page as markdown""" + with SentienceBrowser(headless=True) as browser: + browser.page.goto("https://example.com") + browser.page.wait_for_load_state("networkidle") + + result = read(browser, format="markdown") + + assert result["status"] == "success" + assert result["format"] == "markdown" + assert "content" in result + assert "length" in result + assert len(result["content"]) > 0 + assert result["url"] == "https://example.com" + + +def test_read_markdown_enhanced(): + """Test reading page as markdown with enhancement""" + with SentienceBrowser(headless=True) as browser: + browser.page.goto("https://example.com") + browser.page.wait_for_load_state("networkidle") + + # Test with enhancement (default) + result_enhanced = read(browser, format="markdown", enhance_markdown=True) + + assert result_enhanced["status"] == "success" + assert result_enhanced["format"] == "markdown" + assert len(result_enhanced["content"]) > 0 + + # Test without enhancement + result_basic = read(browser, format="markdown", enhance_markdown=False) + + assert result_basic["status"] == "success" + assert result_basic["format"] == "markdown" + assert len(result_basic["content"]) > 0 + + # Enhanced markdown should be different (and likely better formatted) + # Note: They might be similar for simple pages, but enhanced should handle more cases + assert isinstance(result_enhanced["content"], str) + assert isinstance(result_basic["content"], str) + diff --git a/tests/test_screenshot.py b/tests/test_screenshot.py new file mode 100644 index 0000000..cea8521 --- /dev/null +++ b/tests/test_screenshot.py @@ -0,0 +1,70 @@ +""" +Tests for screenshot functionality +""" + +import base64 +from sentience import SentienceBrowser, screenshot + + +def test_screenshot_png(): + """Test capturing PNG screenshot""" + with SentienceBrowser(headless=True) as browser: + browser.page.goto("https://example.com") + browser.page.wait_for_load_state("networkidle") + + data_url = screenshot(browser, format="png") + + assert data_url.startswith("data:image/png;base64,") + + # Decode and verify it's valid base64 + base64_data = data_url.split(",")[1] + image_data = base64.b64decode(base64_data) + assert len(image_data) > 0 + + +def test_screenshot_jpeg(): + """Test capturing JPEG screenshot""" + with SentienceBrowser(headless=True) as browser: + browser.page.goto("https://example.com") + browser.page.wait_for_load_state("networkidle") + + data_url = screenshot(browser, format="jpeg", quality=80) + + assert data_url.startswith("data:image/jpeg;base64,") + + # Decode and verify it's valid base64 + base64_data = data_url.split(",")[1] + image_data = base64.b64decode(base64_data) + assert len(image_data) > 0 + + +def test_screenshot_default(): + """Test default screenshot (PNG)""" + with SentienceBrowser(headless=True) as browser: + browser.page.goto("https://example.com") + browser.page.wait_for_load_state("networkidle") + + data_url = screenshot(browser) + + assert data_url.startswith("data:image/png;base64,") + + +def test_screenshot_quality_validation(): + """Test JPEG quality validation""" + import pytest + + with SentienceBrowser(headless=True) as browser: + browser.page.goto("https://example.com") + browser.page.wait_for_load_state("networkidle") + + # Valid quality + screenshot(browser, format="jpeg", quality=50) # Should not raise + + # Invalid quality - too low + with pytest.raises(ValueError, match="Quality must be between 1 and 100"): + screenshot(browser, format="jpeg", quality=0) + + # Invalid quality - too high + with pytest.raises(ValueError, match="Quality must be between 1 and 100"): + screenshot(browser, format="jpeg", quality=101) +