Files
unyo-web/scripts/generate_extension_health_report.py

291 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Generate a Docusaurus documentation page from an Aniyomi extension batch test JSON report.
Usage:
python3 scripts/generate_extension_health_report.py <path/to/report.json>
The script reads the report JSON and writes a formatted Markdown file to:
website/docs/extensions-compatibility.md
Example:
python3 scripts/generate_extension_health_report.py reports/2026-05-04_01-59-09/report.json
"""
import json
import sys
import os
from collections import Counter, defaultdict
import statistics
def fmt_duration(ms):
"""Format a duration in milliseconds to a human-readable string."""
if ms < 0:
return '—'
if ms < 1000:
return f"{ms}ms"
return f"{ms / 1000:.1f}s"
def extract_short_error(error_msg):
"""Extract a concise error description from a full stack trace."""
if not error_msg:
return '—'
lines = error_msg.strip().split('\n')
for line in lines:
line = line.strip()
if 'UnknownHostException' in line:
return 'UnknownHostException'
if 'HttpException: HTTP error' in line:
return line.split('HttpException: ')[1][:30]
if 'ConnectException: Connection refused' in line:
return 'Connection refused'
if 'ConnectException' in line:
return 'ConnectException'
if 'CloudflareBypassException' in line or 'Failed to bypass Cloudflare' in line:
return 'Cloudflare bypass failed'
if 'SSL' in line or 'Certificate' in line:
return 'SSL/TLS error'
if 'SocketTimeoutException' in line or 'timeout' in line.lower():
return 'Timeout'
if 'IllegalArgumentException' in line:
if 'extension settings' in line.lower():
return 'Missing settings'
return 'IllegalArgumentException'
if 'NullPointerException' in line:
return 'NullPointerException'
if 'NoSuchMethodError' in line:
return 'NoSuchMethodError'
if 'AbstractMethodError' in line:
return 'AbstractMethodError'
if 'Episode list not found' in line:
return 'Episode list not found'
if 'JSON token' in line or 'JSON input' in line:
return 'JSON parsing error'
if 'IndexOutOfBoundsException' in line:
return 'IndexOutOfBoundsException'
if 'IOException' in line:
return 'IOException'
# Fallback: first meaningful line
for line in lines:
if line and not line.startswith('at ') and not line.startswith('\tat '):
return line[:60]
return 'See error details'
def generate_report(json_path, output_path):
"""Generate the Markdown report from a JSON batch test result."""
with open(json_path) as f:
data = json.load(f)
meta = data['meta']
summary = data['summary']
results = data['results']
# Repository stats
repo_pass = Counter(r['repoId'] for r in results if r['overallStatus'] == 'PASS')
repo_fail = Counter(r['repoId'] for r in results if r['overallStatus'] == 'FAIL')
# Stage stats
stage_stats = {}
for stage in ['load', 'search', 'episodes', 'videos']:
passes = sum(1 for r in results if r[stage]['status'] == 'PASS')
fails = sum(1 for r in results if r[stage]['status'] == 'FAIL')
pendings = sum(1 for r in results if r[stage]['status'] == 'PENDING')
durations = [r[stage]['durationMs'] for r in results if r[stage]['durationMs'] >= 0]
stage_stats[stage] = {
'pass': passes,
'fail': fails,
'pending': pendings,
'median': statistics.median(durations) if durations else 0,
'max': max(durations) if durations else 0,
'min': min(durations) if durations else 0,
}
# Working extensions (all that passed every stage)
working_extensions = []
for r in results:
if r['overallStatus'] == 'PASS':
total = sum(r[stage]['durationMs'] for stage in ['load', 'search', 'episodes', 'videos'])
working_extensions.append({
'name': r['name'],
'repo': r['repoId'],
'lang': r['lang'],
'total': total,
'load': r['load']['durationMs'],
'search': r['search']['durationMs'],
'episodes': r['episodes']['durationMs'],
'videos': r['videos']['durationMs'],
'search_results': r['searchResultCount'],
'episode_results': r['episodeResultCount'],
'video_results': r['videoResultCount'],
})
working_extensions.sort(key=lambda x: x['total'], reverse=True)
# Failures grouped by stage
failures_by_stage = defaultdict(list)
for r in results:
if r['overallStatus'] == 'FAIL':
failures_by_stage[r['failedAtStage']].append(r)
# Build markdown
md = f"""---
title: Extension Compatibility
description: Automated batch test results for Aniyomi extensions ({meta['timestamp'][:10]})
---
# Extension Compatibility
This report presents the results of an automated batch test run against **{summary['total']}** Aniyomi extensions across the **yuzono** and **kohiden** repositories. The test validates each extension through four stages: loading, searching, episode listing, and video extraction.
:::info Test Methodology
Each extension is tested with the query **`{meta['query']}`** using **{meta['concurrency']}** concurrent threads. A failure at any stage prevents subsequent stages from running. Results depend on network conditions, geo-location, and the current state of third-party sites.
:::
## Test Configuration
| Parameter | Value |
|-----------|-------|
| **Timestamp** | `{meta['timestamp']}` |
| **Query** | `{meta['query']}` |
| **Concurrency** | {meta['concurrency']} threads |
| **Duration** | {fmt_duration(meta['totalDurationMs'])} |
| **Repositories** | {', '.join(meta['repos'])} |
| **Total Extensions** | {summary['total']} |
## Overall Summary
| Metric | Count | Percentage |
|--------|-------|------------|
| **Pass** | {summary['pass']} | {summary['pass'] / summary['total'] * 100:.1f}% |
| **Fail** | {summary['fail']} | {summary['fail'] / summary['total'] * 100:.1f}% |
| **Skip** | {summary['skip']} | {summary['skip'] / summary['total'] * 100:.1f}% |
| **Total** | {summary['total']} | 100.0% |
### By Repository
| Repository | Total | Pass | Fail | Pass Rate |
|------------|-------|------|------|-----------|
| yuzono | {repo_pass['yuzono'] + repo_fail['yuzono']} | {repo_pass['yuzono']} | {repo_fail['yuzono']} | {repo_pass['yuzono'] / (repo_pass['yuzono'] + repo_fail['yuzono']) * 100:.1f}% |
| kohiden | {repo_pass['kohiden'] + repo_fail['kohiden']} | {repo_pass['kohiden']} | {repo_fail['kohiden']} | {repo_pass['kohiden'] / (repo_pass['kohiden'] + repo_fail['kohiden']) * 100:.1f}% |
## Working Extensions
The {len(working_extensions)} extensions that passed all four stages, sorted by total test duration:
| Extension | Repository | Language | Load | Search | Episodes | Videos | Total | Results (S/E/V) |
|-----------|------------|----------|------|--------|----------|--------|-------|-----------------|
"""
for ext in working_extensions:
md += (f"| {ext['name']} | {ext['repo']} | {ext['lang']} | {fmt_duration(ext['load'])} | "
f"{fmt_duration(ext['search'])} | {fmt_duration(ext['episodes'])} | "
f"{fmt_duration(ext['videos'])} | {fmt_duration(ext['total'])} | "
f"{ext['search_results']}/{ext['episode_results']}/{ext['video_results']} |\n")
md += f"""
## Stage Breakdown
Each extension progresses through four stages. The tables below show how many extensions passed, failed, or never reached each stage.
| Stage | Pass | Fail | Pending | Median Time | Max Time |
|-------|------|------|---------|-------------|----------|
| Load | {stage_stats['load']['pass']} | {stage_stats['load']['fail']} | {stage_stats['load']['pending']} | {fmt_duration(int(stage_stats['load']['median']))} | {fmt_duration(stage_stats['load']['max'])} |
| Search | {stage_stats['search']['pass']} | {stage_stats['search']['fail']} | {stage_stats['search']['pending']} | {fmt_duration(int(stage_stats['search']['median']))} | {fmt_duration(stage_stats['search']['max'])} |
| Episodes | {stage_stats['episodes']['pass']} | {stage_stats['episodes']['fail']} | {stage_stats['episodes']['pending']} | {fmt_duration(int(stage_stats['episodes']['median']))} | {fmt_duration(stage_stats['episodes']['max'])} |
| Videos | {stage_stats['videos']['pass']} | {stage_stats['videos']['fail']} | {stage_stats['videos']['pending']} | {fmt_duration(int(stage_stats['videos']['median']))} | {fmt_duration(stage_stats['videos']['max'])} |
### Failure Distribution
| Stage | Failures | Percentage of Total |
|-------|----------|---------------------|
| Load | {summary['failAtLoad']} | {summary['failAtLoad'] / summary['total'] * 100:.1f}% |
| Search | {summary['failAtSearch']} | {summary['failAtSearch'] / summary['total'] * 100:.1f}% |
| Episodes | {summary['failAtEpisodes']} | {summary['failAtEpisodes'] / summary['total'] * 100:.1f}% |
| Videos | {summary['failAtVideos']} | {summary['failAtVideos'] / summary['total'] * 100:.1f}% |
## Failures by Stage
:::danger Load Failures ({len(failures_by_stage.get('load', []))})
Extensions that failed to initialize.
:::
| Extension | Repository | Error |
|-----------|------------|-------|
"""
for r in failures_by_stage.get('load', []):
md += f"| {r['name']} | {r['repoId']} | {extract_short_error(r.get('errorMessage', ''))} |\n"
md += f"""
:::danger Search Failures ({len(failures_by_stage.get('search', []))})
Extensions that loaded successfully but failed to return search results.
:::
| Extension | Repository | Error |
|-----------|------------|-------|
"""
for r in failures_by_stage.get('search', [])[:50]:
md += f"| {r['name']} | {r['repoId']} | {extract_short_error(r.get('errorMessage', ''))} |\n"
if len(failures_by_stage.get('search', [])) > 50:
md += f"| ... | ... | *and {len(failures_by_stage['search']) - 50} more* |\n"
md += f"""
:::caution Episode Failures ({len(failures_by_stage.get('episodes', []))})
Extensions that passed search but failed to retrieve episode lists.
:::
| Extension | Repository | Error |
|-----------|------------|-------|
"""
for r in failures_by_stage.get('episodes', []):
md += f"| {r['name']} | {r['repoId']} | {extract_short_error(r.get('errorMessage', ''))} |\n"
md += f"""
:::caution Video Failures ({len(failures_by_stage.get('videos', []))})
Extensions that passed episodes but failed to extract video URLs.
:::
| Extension | Repository | Error |
|-----------|------------|-------|
"""
for r in failures_by_stage.get('videos', []):
md += f"| {r['name']} | {r['repoId']} | {extract_short_error(r.get('errorMessage', ''))} |\n"
md += """
## Notes
- **Test Environment**: Results are highly dependent on the test runner's network conditions, DNS resolution, and geographic location.
- **Cloudflare**: Many failures are due to Cloudflare protection. These extensions may work fine in the actual Unyo app with proper WebView support.
- **Configuration Required**: Some extensions (e.g., Google Drive, Jellyfin) require user settings that were not provided during the automated test.
- **Site Changes**: Extensions relying on third-party websites may break when those sites change their layout or domain.
- **API Mismatches**: Errors like `NoSuchMethodError` or `AbstractMethodError` indicate the extension was built against a different version of the Aniyomi/Tachiyomi API than what the bridge provides.
*Report generated automatically from batch test results.*
"""
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, 'w') as f:
f.write(md)
print(f"Generated {output_path}")
print(f"Total lines: {len(md.splitlines())}")
def main():
if len(sys.argv) < 2:
print("Usage: python3 generate_extension_health_report.py <path/to/report.json>")
sys.exit(1)
json_path = sys.argv[1]
if not os.path.exists(json_path):
print(f"Error: File not found: {json_path}")
sys.exit(1)
# Default output path: website/docs/extensions-compatibility.md
output_path = os.path.join('website', 'docs', 'extensions-compatibility.md')
generate_report(json_path, output_path)
if __name__ == "__main__":
main()