Add Playwright-based URL resolution for download links

- Add scripts/resolve-download-url.py to resolve Cloudflare-protected
  redirect URLs using Playwright browser automation
- Update check-claude-version.yml workflow to use the new script
- Automatically update build.sh with resolved URLs when changes detected
- Support both amd64 and arm64 architectures with fallback derivation
- Verify derived URLs exist before committing changes

This replaces the static Google Cloud Storage URLs which are no longer
being updated by Anthropic. The new approach resolves the official
redirect endpoints to get current download URLs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
aaddrick
2026-01-05 17:34:16 -05:00
parent 4de560b8d4
commit 3e813c5d5f
2 changed files with 353 additions and 49 deletions

View File

@@ -18,87 +18,151 @@ jobs:
fetch-depth: 0
token: ${{ secrets.GH_PAT }}
- name: Set up environment
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y p7zip-full wget
pip install playwright requests
playwright install chromium
- name: Download and check Claude version
id: check_version
- name: Resolve download URLs
id: resolve_urls
run: |
CLAUDE_DOWNLOAD_URL="https://storage.googleapis.com/osprey-downloads-c02f6a0d-347c-492b-a752-3e0651722e97/nest-win-x64/Claude-Setup-x64.exe"
echo "Resolving Claude Desktop download URLs..."
WORK_DIR=$(mktemp -d)
cd "$WORK_DIR"
# Run the resolver script - stdout has KEY=VALUE pairs, stderr has status messages
python scripts/resolve-download-url.py all --format both > resolved_urls.txt || true
if ! wget -q -O Claude-Setup-x64.exe "$CLAUDE_DOWNLOAD_URL"; then
echo "Failed to download Claude Desktop installer"
echo "Resolved URLs:"
cat resolved_urls.txt
# Parse the output
AMD64_URL=$(grep "^AMD64_URL=" resolved_urls.txt | cut -d= -f2-)
ARM64_URL=$(grep "^ARM64_URL=" resolved_urls.txt | cut -d= -f2-)
AMD64_VERSION=$(grep "^AMD64_VERSION=" resolved_urls.txt | cut -d= -f2-)
ARM64_VERSION=$(grep "^ARM64_VERSION=" resolved_urls.txt | cut -d= -f2-)
echo "AMD64 URL: $AMD64_URL"
echo "ARM64 URL: $ARM64_URL"
echo "AMD64 Version: $AMD64_VERSION"
echo "ARM64 Version: $ARM64_VERSION"
# Use AMD64 version as the canonical version (they should match)
CLAUDE_VERSION="${AMD64_VERSION:-$ARM64_VERSION}"
if [ -z "$AMD64_URL" ]; then
echo "::error::Failed to resolve AMD64 download URL"
exit 1
fi
EXTRACT_DIR="$WORK_DIR/extract"
mkdir -p "$EXTRACT_DIR"
if ! 7z x -y Claude-Setup-x64.exe -o"$EXTRACT_DIR" > /dev/null 2>&1; then
echo "Failed to extract installer"
exit 1
fi
cd "$EXTRACT_DIR"
NUPKG_FILE=$(find . -maxdepth 1 -name "AnthropicClaude-*.nupkg" | head -1)
if [ -z "$NUPKG_FILE" ]; then
echo "Could not find AnthropicClaude nupkg file"
exit 1
fi
# Extract version from nupkg filename
CLAUDE_VERSION=$(echo "$NUPKG_FILE" | grep -oP 'AnthropicClaude-\K[0-9]+\.[0-9]+\.[0-9]+(?=-full|-arm64-full)')
if [ -z "$CLAUDE_VERSION" ]; then
echo "Could not extract version from nupkg filename"
exit 1
fi
echo "Detected Claude Desktop version: $CLAUDE_VERSION"
echo "amd64_url=$AMD64_URL" >> $GITHUB_OUTPUT
echo "arm64_url=$ARM64_URL" >> $GITHUB_OUTPUT
echo "claude_version=$CLAUDE_VERSION" >> $GITHUB_OUTPUT
# Clean up
cd /
rm -rf "$WORK_DIR"
- name: Get current URLs from build.sh
id: current_urls
run: |
# Extract current URLs from build.sh
CURRENT_AMD64_URL=$(grep -E 'HOST_ARCH.*=.*"amd64"' -A3 build.sh | grep 'CLAUDE_DOWNLOAD_URL=' | head -1 | sed 's/.*CLAUDE_DOWNLOAD_URL="\([^"]*\)".*/\1/')
CURRENT_ARM64_URL=$(grep -E 'HOST_ARCH.*=.*"arm64"' -A3 build.sh | grep 'CLAUDE_DOWNLOAD_URL=' | head -1 | sed 's/.*CLAUDE_DOWNLOAD_URL="\([^"]*\)".*/\1/')
echo "Current AMD64 URL: $CURRENT_AMD64_URL"
echo "Current ARM64 URL: $CURRENT_ARM64_URL"
echo "current_amd64_url=$CURRENT_AMD64_URL" >> $GITHUB_OUTPUT
echo "current_arm64_url=$CURRENT_ARM64_URL" >> $GITHUB_OUTPUT
- name: Check if update needed
id: check_update
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
CLAUDE_VERSION="${{ steps.check_version.outputs.claude_version }}"
REPO_VERSION="${{ vars.REPO_VERSION }}"
NEW_AMD64_URL="${{ steps.resolve_urls.outputs.amd64_url }}"
NEW_ARM64_URL="${{ steps.resolve_urls.outputs.arm64_url }}"
CURRENT_AMD64_URL="${{ steps.current_urls.outputs.current_amd64_url }}"
CURRENT_ARM64_URL="${{ steps.current_urls.outputs.current_arm64_url }}"
CLAUDE_VERSION="${{ steps.resolve_urls.outputs.claude_version }}"
STORED_CLAUDE_VERSION="${{ vars.CLAUDE_DESKTOP_VERSION }}"
REPO_VERSION="${{ vars.REPO_VERSION }}"
echo "Current stored Claude version: $STORED_CLAUDE_VERSION"
echo "Detected Claude version: $CLAUDE_VERSION"
echo "Repository version: $REPO_VERSION"
if [ "$CLAUDE_VERSION" != "$STORED_CLAUDE_VERSION" ]; then
echo "New Claude Desktop version detected!"
UPDATE_NEEDED=false
# Check if AMD64 URL changed
if [ "$NEW_AMD64_URL" != "$CURRENT_AMD64_URL" ]; then
echo "AMD64 URL has changed"
UPDATE_NEEDED=true
fi
# Check if ARM64 URL changed (only if we got a new one)
if [ -n "$NEW_ARM64_URL" ] && [ "$NEW_ARM64_URL" != "$CURRENT_ARM64_URL" ]; then
echo "ARM64 URL has changed"
UPDATE_NEEDED=true
fi
# Check if version changed
if [ -n "$CLAUDE_VERSION" ] && [ "$CLAUDE_VERSION" != "$STORED_CLAUDE_VERSION" ]; then
echo "Claude version has changed"
UPDATE_NEEDED=true
fi
if [ "$UPDATE_NEEDED" = "true" ]; then
echo "Update needed!"
echo "update_needed=true" >> $GITHUB_OUTPUT
# Construct the new tag
NEW_TAG="v${REPO_VERSION}+claude${CLAUDE_VERSION}"
echo "new_tag=$NEW_TAG" >> $GITHUB_OUTPUT
echo "New tag will be: $NEW_TAG"
else
echo "Claude Desktop version unchanged"
echo "No updates needed"
echo "update_needed=false" >> $GITHUB_OUTPUT
fi
- name: Update version variable and create tag
- name: Update build.sh with new URLs
if: steps.check_update.outputs.update_needed == 'true'
run: |
NEW_AMD64_URL="${{ steps.resolve_urls.outputs.amd64_url }}"
NEW_ARM64_URL="${{ steps.resolve_urls.outputs.arm64_url }}"
CURRENT_AMD64_URL="${{ steps.current_urls.outputs.current_amd64_url }}"
CURRENT_ARM64_URL="${{ steps.current_urls.outputs.current_arm64_url }}"
echo "Updating build.sh with new URLs..."
# Update AMD64 URL
if [ -n "$NEW_AMD64_URL" ] && [ "$NEW_AMD64_URL" != "$CURRENT_AMD64_URL" ]; then
echo "Updating AMD64 URL..."
# Escape special characters for sed
ESCAPED_CURRENT=$(printf '%s\n' "$CURRENT_AMD64_URL" | sed 's/[[\.*^$()+?{|]/\\&/g')
ESCAPED_NEW=$(printf '%s\n' "$NEW_AMD64_URL" | sed 's/[&/\]/\\&/g')
sed -i "s|$ESCAPED_CURRENT|$ESCAPED_NEW|g" build.sh
fi
# Update ARM64 URL (if we have a new one)
if [ -n "$NEW_ARM64_URL" ] && [ "$NEW_ARM64_URL" != "$CURRENT_ARM64_URL" ]; then
echo "Updating ARM64 URL..."
ESCAPED_CURRENT=$(printf '%s\n' "$CURRENT_ARM64_URL" | sed 's/[[\.*^$()+?{|]/\\&/g')
ESCAPED_NEW=$(printf '%s\n' "$NEW_ARM64_URL" | sed 's/[&/\]/\\&/g')
sed -i "s|$ESCAPED_CURRENT|$ESCAPED_NEW|g" build.sh
fi
echo "Updated build.sh:"
grep "CLAUDE_DOWNLOAD_URL=" build.sh
- name: Commit and push changes
if: steps.check_update.outputs.update_needed == 'true'
env:
GH_TOKEN: ${{ secrets.GH_PAT }}
run: |
CLAUDE_VERSION="${{ steps.check_version.outputs.claude_version }}"
CLAUDE_VERSION="${{ steps.resolve_urls.outputs.claude_version }}"
NEW_TAG="${{ steps.check_update.outputs.new_tag }}"
# Check if we have a PAT
@@ -107,14 +171,29 @@ jobs:
exit 1
fi
gh variable set CLAUDE_DESKTOP_VERSION --body "$CLAUDE_VERSION"
echo "Creating and pushing new tag: $NEW_TAG"
# Configure git
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Check if there are changes to commit
if git diff --quiet build.sh; then
echo "No changes to build.sh"
else
git add build.sh
git commit -m "Update Claude Desktop download URLs to version $CLAUDE_VERSION
Updated download URLs resolved from official redirect endpoints.
🤖 Generated with [Claude Code](https://claude.com/claude-code)"
git push
echo "Changes committed and pushed"
fi
# Update the version variable
gh variable set CLAUDE_DESKTOP_VERSION --body "$CLAUDE_VERSION"
echo "Creating and pushing new tag: $NEW_TAG"
# Create annotated tag
git tag -a "$NEW_TAG" -m "Update to Claude Desktop $CLAUDE_VERSION"
@@ -129,7 +208,7 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
NEW_TAG="${{ steps.check_update.outputs.new_tag }}"
CLAUDE_VERSION="${{ steps.check_version.outputs.claude_version }}"
CLAUDE_VERSION="${{ steps.resolve_urls.outputs.claude_version }}"
# Create release notes using heredoc
RELEASE_NOTES=$(cat <<EOF

225
scripts/resolve-download-url.py Executable file
View File

@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""
Resolve Claude Desktop download URLs by bypassing Cloudflare protection.
Uses Playwright to navigate to the redirect URL and capture the final
download URL from network requests.
"""
import argparse
import re
import sys
import requests
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeout
# Redirect URLs for each architecture
REDIRECT_URLS = {
"amd64": "https://claude.ai/redirect/claudedotcom.v1.290130bf-1c36-4eb0-9a93-2410ca43ae53/api/desktop/win32/x64/exe/latest/redirect",
"arm64": "https://claude.ai/redirect/claudedotcom.v1.290130bf-1c36-4eb0-9a93-2410ca43ae53/api/desktop/win32/arm64/exe/latest/redirect",
}
# User agent to appear as a regular browser
USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
def resolve_download_url(arch: str, timeout: int = 30000) -> str | None:
"""
Resolve the actual download URL for the given architecture.
Args:
arch: Architecture to resolve ('amd64' or 'arm64')
timeout: Timeout in milliseconds
Returns:
The resolved download URL, or None if resolution failed
"""
if arch not in REDIRECT_URLS:
print(f"Error: Unknown architecture '{arch}'", file=sys.stderr)
return None
redirect_url = REDIRECT_URLS[arch]
resolved_url = None
def handle_request(request):
nonlocal resolved_url
url = request.url
# Look for the final Google Cloud Storage URL
if "storage.googleapis.com" in url and url.endswith(".exe"):
resolved_url = url
def handle_download(download):
nonlocal resolved_url
resolved_url = download.url
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
context = browser.new_context(
user_agent=USER_AGENT,
viewport={"width": 1920, "height": 1080},
accept_downloads=True,
)
# Apply stealth settings
context.add_init_script("""
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
Object.defineProperty(navigator, 'platform', { get: () => 'Linux x86_64' });
Object.defineProperty(navigator, 'vendor', { get: () => 'Google Inc.' });
""")
page = context.new_page()
page.on("request", handle_request)
page.on("download", handle_download)
try:
# Navigate to the redirect URL - this will trigger a download
page.goto(redirect_url, timeout=timeout, wait_until="commit")
# Give it a moment to capture the download URL
page.wait_for_timeout(2000)
except PlaywrightTimeout:
# Timeout is expected - we just need to capture the redirect
pass
except Exception as e:
# "Download is starting" error is expected and means we captured the URL
if "Download is starting" not in str(e):
print(f"Error navigating to {redirect_url}: {e}", file=sys.stderr)
finally:
browser.close()
return resolved_url
def extract_version_from_url(url: str) -> str | None:
"""
Extract version number from a download URL.
The URL typically contains a path like /1.0.1234/ with the version.
"""
match = re.search(r"/(\d+\.\d+\.\d+)/", url)
if match:
return match.group(1)
return None
def verify_url_exists(url: str, timeout: int = 10) -> bool:
"""
Verify that a URL exists by making a HEAD request.
Returns True if the URL returns a 200 status code.
"""
try:
response = requests.head(url, timeout=timeout, allow_redirects=True)
return response.status_code == 200
except requests.RequestException:
return False
def derive_arm64_url_from_amd64(amd64_url: str) -> str | None:
"""
Derive the ARM64 download URL from an AMD64 URL by pattern substitution.
Handles both old and new URL patterns:
Old: https://storage.googleapis.com/.../nest-win-x64/Claude-Setup-x64.exe
New: https://downloads.claude.ai/releases/win32/x64/1.0.xxx/Claude-xxx.exe
"""
if not amd64_url:
return None
arm64_url = amd64_url
# New URL pattern: downloads.claude.ai/releases/win32/x64/ -> /arm64/
arm64_url = arm64_url.replace("/win32/x64/", "/win32/arm64/")
# Old URL pattern: storage.googleapis.com
arm64_url = arm64_url.replace("nest-win-x64", "nest-win-arm64")
arm64_url = arm64_url.replace("Claude-Setup-x64.exe", "Claude-Setup-arm64.exe")
arm64_url = arm64_url.replace("-x64.exe", "-arm64.exe")
# Only return if we actually made changes
if arm64_url != amd64_url:
return arm64_url
return None
def main():
parser = argparse.ArgumentParser(
description="Resolve Claude Desktop download URLs"
)
parser.add_argument(
"arch",
choices=["amd64", "arm64", "all"],
help="Architecture to resolve (amd64, arm64, or all)",
)
parser.add_argument(
"--timeout",
type=int,
default=30000,
help="Timeout in milliseconds (default: 30000)",
)
parser.add_argument(
"--format",
choices=["url", "version", "both"],
default="url",
help="Output format (default: url)",
)
args = parser.parse_args()
architectures = ["amd64", "arm64"] if args.arch == "all" else [args.arch]
results = {}
for arch in architectures:
print(f"Resolving {arch} download URL...", file=sys.stderr)
url = resolve_download_url(arch, args.timeout)
if url:
version = extract_version_from_url(url)
results[arch] = {"url": url, "version": version, "derived": False}
print(f" Resolved: {url}", file=sys.stderr)
if version:
print(f" Version: {version}", file=sys.stderr)
else:
print(f" Failed to resolve {arch} URL via redirect", file=sys.stderr)
results[arch] = None
# If ARM64 failed but AMD64 succeeded, try to derive ARM64 URL
if args.arch == "all" and results.get("arm64") is None and results.get("amd64"):
print("Attempting to derive ARM64 URL from AMD64 URL...", file=sys.stderr)
derived_url = derive_arm64_url_from_amd64(results["amd64"]["url"])
if derived_url:
print(f" Derived URL: {derived_url}", file=sys.stderr)
print(" Verifying URL exists...", file=sys.stderr)
if verify_url_exists(derived_url):
version = extract_version_from_url(derived_url)
results["arm64"] = {"url": derived_url, "version": version, "derived": True}
print(f" ✓ Verified ARM64 URL exists", file=sys.stderr)
else:
print(" ✗ Derived URL does not exist (404)", file=sys.stderr)
else:
print(" Could not derive ARM64 URL pattern", file=sys.stderr)
# Output results based on format
for arch, result in results.items():
if result is None:
continue
prefix = f"{arch.upper()}_" if len(architectures) > 1 else ""
if args.format == "url":
print(f"{prefix}URL={result['url']}")
elif args.format == "version":
if result["version"]:
print(f"{prefix}VERSION={result['version']}")
elif args.format == "both":
print(f"{prefix}URL={result['url']}")
if result["version"]:
print(f"{prefix}VERSION={result['version']}")
# Exit with error if any resolution failed
if any(r is None for r in results.values()):
sys.exit(1)
if __name__ == "__main__":
main()