Spaces:
Running
Running
prince1604 commited on
Commit ·
b458f3d
0
Parent(s):
Initial commit for deployment
Browse files- .idea/.gitignore +3 -0
- .idea/inspectionProfiles/profiles_settings.xml +6 -0
- .idea/misc.xml +7 -0
- .idea/modules.xml +8 -0
- .idea/vcs.xml +6 -0
- .idea/webscreper.iml +14 -0
- DEPLOYMENT_GUIDE.md +102 -0
- Procfile +1 -0
- api.py +94 -0
- build.sh +6 -0
- current_reqs.txt +0 -0
- debug_http.html +1 -0
- main.py +63 -0
- qodana.yaml +23 -0
- requirements.txt +10 -0
- seo_report.json +227 -0
- src/__init__.py +0 -0
- src/__pycache__/__init__.cpython-313.pyc +0 -0
- src/__pycache__/__init__.cpython-314.pyc +0 -0
- src/__pycache__/analyzer.cpython-313.pyc +0 -0
- src/__pycache__/analyzer.cpython-314.pyc +0 -0
- src/__pycache__/crawler.cpython-313.pyc +0 -0
- src/__pycache__/crawler.cpython-314.pyc +0 -0
- src/__pycache__/reporter.cpython-313.pyc +0 -0
- src/__pycache__/reporter.cpython-314.pyc +0 -0
- src/analyzer.py +41 -0
- src/crawler.py +276 -0
- src/reporter.py +115 -0
- tests/__init__.py +0 -0
.idea/.gitignore
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Default ignored files
|
| 2 |
+
/shelf/
|
| 3 |
+
/workspace.xml
|
.idea/inspectionProfiles/profiles_settings.xml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<component name="InspectionProjectProfileManager">
|
| 2 |
+
<settings>
|
| 3 |
+
<option name="USE_PROJECT_PROFILE" value="false" />
|
| 4 |
+
<version value="1.0" />
|
| 5 |
+
</settings>
|
| 6 |
+
</component>
|
.idea/misc.xml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
| 2 |
+
<project version="4">
|
| 3 |
+
<component name="Black">
|
| 4 |
+
<option name="sdkName" value="Python 3.10 (webscreper)" />
|
| 5 |
+
</component>
|
| 6 |
+
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.10 (webscreper)" project-jdk-type="Python SDK" />
|
| 7 |
+
</project>
|
.idea/modules.xml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
| 2 |
+
<project version="4">
|
| 3 |
+
<component name="ProjectModuleManager">
|
| 4 |
+
<modules>
|
| 5 |
+
<module fileurl="file://$PROJECT_DIR$/.idea/webscreper.iml" filepath="$PROJECT_DIR$/.idea/webscreper.iml" />
|
| 6 |
+
</modules>
|
| 7 |
+
</component>
|
| 8 |
+
</project>
|
.idea/vcs.xml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
| 2 |
+
<project version="4">
|
| 3 |
+
<component name="VcsDirectoryMappings">
|
| 4 |
+
<mapping directory="" vcs="Git" />
|
| 5 |
+
</component>
|
| 6 |
+
</project>
|
.idea/webscreper.iml
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0" encoding="UTF-8"?>
|
| 2 |
+
<module type="PYTHON_MODULE" version="4">
|
| 3 |
+
<component name="NewModuleRootManager">
|
| 4 |
+
<content url="file://$MODULE_DIR$">
|
| 5 |
+
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
| 6 |
+
</content>
|
| 7 |
+
<orderEntry type="jdk" jdkName="Python 3.10 (webscreper)" jdkType="Python SDK" />
|
| 8 |
+
<orderEntry type="sourceFolder" forTests="false" />
|
| 9 |
+
</component>
|
| 10 |
+
<component name="PyDocumentationSettings">
|
| 11 |
+
<option name="format" value="PLAIN" />
|
| 12 |
+
<option name="myDocStringFormat" value="Plain" />
|
| 13 |
+
</component>
|
| 14 |
+
</module>
|
DEPLOYMENT_GUIDE.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 🚀 Deployment Guide (Free & Lifetime)
|
| 2 |
+
|
| 3 |
+
This guide will show you how to deploy your Web Scraper API to **Render.com** for free. Render is an excellent cloud platform that supports Python and Playwright out of the box.
|
| 4 |
+
|
| 5 |
+
## ✅ Prerequisites
|
| 6 |
+
|
| 7 |
+
1. **GitHub Account**: You need a GitHub account to host your code.
|
| 8 |
+
2. **Git Installed**: You must have Git installed on your computer.
|
| 9 |
+
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
## Step 1: Push Code to GitHub
|
| 13 |
+
|
| 14 |
+
First, we need to get your code onto GitHub.
|
| 15 |
+
|
| 16 |
+
1. **Log in to GitHub** and create a **New Repository**.
|
| 17 |
+
* Name it something like `seo-scraper-api`.
|
| 18 |
+
* Make it **Public** or **Private** (Private is fine).
|
| 19 |
+
* **Do not** initialize with a README (we have code locally).
|
| 20 |
+
|
| 21 |
+
2. **Open your terminal** in the project folder (`d:\webscreper`) and run these commands one by one:
|
| 22 |
+
|
| 23 |
+
```bash
|
| 24 |
+
# Initialize git
|
| 25 |
+
git init
|
| 26 |
+
|
| 27 |
+
# Add all files
|
| 28 |
+
git add .
|
| 29 |
+
|
| 30 |
+
# Commit changes
|
| 31 |
+
git commit -m "Initial commit for deployment"
|
| 32 |
+
|
| 33 |
+
# Link to your new GitHub repo (Replace YOUR_USERNAME and REPO_NAME)
|
| 34 |
+
git remote add origin https://github.com/YOUR_USERNAME/seo-scraper-api.git
|
| 35 |
+
|
| 36 |
+
# Push code
|
| 37 |
+
git push -u origin master
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
---
|
| 41 |
+
tney aavdi gyu imean bolta
|
| 42 |
+
## Step 2: Create a Web Service on Render
|
| 43 |
+
|
| 44 |
+
1. Go to [dashboard.render.com](https://dashboard.render.com) and sign up/log in.
|
| 45 |
+
2. Click the **"New +"** button and select **"Web Service"**.
|
| 46 |
+
3. Connect your **GitHub** account.
|
| 47 |
+
4. Find your `seo-scraper-api` repository and click **"Connect"**.
|
| 48 |
+
|
| 49 |
+
---
|
| 50 |
+
|
| 51 |
+
## Step 3: Configure the Service
|
| 52 |
+
|
| 53 |
+
Fill in the details exactly as follows to ensure it works with Flask and Playwright:
|
| 54 |
+
|
| 55 |
+
* **Name**: `seo-scraper` (or whatever you like)
|
| 56 |
+
* **Region**: Closest to you (e.g., Frankfurt or Oregon)
|
| 57 |
+
* **Branch**: `master`
|
| 58 |
+
* **Root Directory**: (Leave blank)
|
| 59 |
+
* **Runtime**: **Python 3**
|
| 60 |
+
* **Build Command**:
|
| 61 |
+
```bash
|
| 62 |
+
./build.sh
|
| 63 |
+
```
|
| 64 |
+
* **Start Command**:
|
| 65 |
+
```bash
|
| 66 |
+
gunicorn api:app
|
| 67 |
+
```
|
| 68 |
+
* **Instance Type**: Select **Free**
|
| 69 |
+
|
| 70 |
+
### ⚠️ crucial: Environment Variables
|
| 71 |
+
|
| 72 |
+
Scroll down to "Advanced" or "Environment Variables" and make sure these settings are correct. We already set up the `build.sh` script to handle dependencies, so this part should be automatic.
|
| 73 |
+
|
| 74 |
+
---
|
| 75 |
+
|
| 76 |
+
## Step 4: Deploy
|
| 77 |
+
|
| 78 |
+
1. Click **"Create Web Service"**.
|
| 79 |
+
2. Render will start building your app. You can watch the logs.
|
| 80 |
+
* It will install Python dependencies.
|
| 81 |
+
* It will install Chromium (for Playwright).
|
| 82 |
+
3. Once finished, you will see a green checkmark **"Live"**.
|
| 83 |
+
4. Copy your URL (e.g., `https://seo-scraper.onrender.com`).
|
| 84 |
+
|
| 85 |
+
---
|
| 86 |
+
|
| 87 |
+
## Step 5: Test Your API
|
| 88 |
+
|
| 89 |
+
Now you can use this URL in Postman instead of `127.0.0.1:5050`.
|
| 90 |
+
|
| 91 |
+
**New Request:**
|
| 92 |
+
* **Method**: `GET`
|
| 93 |
+
* **URL**: `https://YOUR-APP-NAME.onrender.com/api/seo-report?domain=https://example.com`
|
| 94 |
+
|
| 95 |
+
---
|
| 96 |
+
|
| 97 |
+
## ℹ️ Important Notes about "Free" Tier
|
| 98 |
+
|
| 99 |
+
1. **Spin Down**: The free instance "sleeps" after 15 minutes of inactivity. The first request after sleeping might take 30-50 seconds to wake it up. This is normal.
|
| 100 |
+
2. **Keep Alive (Optional)**: If you want it to stay awake, you can use a free "Uptime Monitor" (like UptimeRobot) to ping your API every 10 minutes.
|
| 101 |
+
|
| 102 |
+
You now have a fully functional, free, lifetime deployment of your scraper! 🚀
|
Procfile
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
web: gunicorn api:app
|
api.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Flask, jsonify, request
|
| 2 |
+
from flask_cors import CORS
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import random
|
| 6 |
+
from src.crawler import Crawler
|
| 7 |
+
from src.analyzer import ImageAnalyzer
|
| 8 |
+
|
| 9 |
+
app = Flask(__name__)
|
| 10 |
+
CORS(app)
|
| 11 |
+
|
| 12 |
+
REPORT_FILE = 'seo_report.json'
|
| 13 |
+
|
| 14 |
+
@app.route('/api/seo-report', methods=['GET', 'POST'])
|
| 15 |
+
def get_seo_report():
|
| 16 |
+
|
| 17 |
+
def get_param(name, default):
|
| 18 |
+
val = request.args.get(name) or request.form.get(name)
|
| 19 |
+
if val is None and request.is_json:
|
| 20 |
+
val = request.json.get(name)
|
| 21 |
+
return val if val is not None else default
|
| 22 |
+
|
| 23 |
+
domain = get_param('domain', None)
|
| 24 |
+
limit = int(get_param('limit', 25))
|
| 25 |
+
|
| 26 |
+
if domain:
|
| 27 |
+
try:
|
| 28 |
+
print(f"Starting live scan for: {domain}")
|
| 29 |
+
|
| 30 |
+
# Initialize Crawler
|
| 31 |
+
crawler = Crawler()
|
| 32 |
+
# Crawl the domain with the requested limit
|
| 33 |
+
site_data, total_discovered, _ = crawler.crawl_domain(domain, max_pages=limit)
|
| 34 |
+
|
| 35 |
+
if not site_data:
|
| 36 |
+
return jsonify({
|
| 37 |
+
"summary": {
|
| 38 |
+
"total_pages_scanned": 0,
|
| 39 |
+
"total_images_found": 0,
|
| 40 |
+
"total_images_missing_alt": 0,
|
| 41 |
+
"total_pages_discovered": 0
|
| 42 |
+
},
|
| 43 |
+
"details": []
|
| 44 |
+
})
|
| 45 |
+
|
| 46 |
+
# Analyze Results
|
| 47 |
+
analyzer = ImageAnalyzer()
|
| 48 |
+
results = analyzer.analyze_site(site_data)
|
| 49 |
+
|
| 50 |
+
# Add discovery stats
|
| 51 |
+
results['summary']['total_pages_discovered'] = total_discovered
|
| 52 |
+
results['details_count'] = len(results['details'])
|
| 53 |
+
|
| 54 |
+
return jsonify(results)
|
| 55 |
+
|
| 56 |
+
except Exception as e:
|
| 57 |
+
return jsonify({"error": f"Scraping failed: {str(e)}"}), 500
|
| 58 |
+
|
| 59 |
+
if not os.path.exists(REPORT_FILE):
|
| 60 |
+
return jsonify({"error": "Report file not found. Please run result logic first."}), 404
|
| 61 |
+
|
| 62 |
+
try:
|
| 63 |
+
with open(REPORT_FILE, 'r', encoding='utf-8') as f:
|
| 64 |
+
data = json.load(f)
|
| 65 |
+
|
| 66 |
+
# Get parameters for filtering existing report
|
| 67 |
+
# limit is already extracted above
|
| 68 |
+
random_param = str(get_param('random', 'true')).lower()
|
| 69 |
+
is_random = random_param == 'true'
|
| 70 |
+
|
| 71 |
+
summary = data.get('summary', {})
|
| 72 |
+
details = data.get('details', [])
|
| 73 |
+
|
| 74 |
+
# Filter details if limit is provided
|
| 75 |
+
if limit is not None and limit > 0:
|
| 76 |
+
if is_random and limit < len(details):
|
| 77 |
+
details = random.sample(details, limit)
|
| 78 |
+
else:
|
| 79 |
+
details = details[:limit]
|
| 80 |
+
|
| 81 |
+
response = {
|
| 82 |
+
"summary": summary,
|
| 83 |
+
"details_count": len(details), # Useful logic for client
|
| 84 |
+
"details": details
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
return jsonify(response)
|
| 88 |
+
|
| 89 |
+
except Exception as e:
|
| 90 |
+
return jsonify({"error": f"Failed to read report: {str(e)}"}), 500
|
| 91 |
+
|
| 92 |
+
if __name__ == '__main__':
|
| 93 |
+
# Run on 0.0.0.0 to be accessible if needed, default port 5000
|
| 94 |
+
app.run(debug=True, host='0.0.0.0', port=5050)
|
build.sh
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Exit on error
|
| 3 |
+
set -o errexit
|
| 4 |
+
|
| 5 |
+
pip install -r requirements.txt
|
| 6 |
+
playwright install chromium
|
current_reqs.txt
ADDED
|
Binary file (1.2 kB). View file
|
|
|
debug_http.html
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html><html lang="en-US"><head><title>Just a moment...</title><meta http-equiv="Content-Type" content="text/html; charset=UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=Edge"><meta name="robots" content="noindex,nofollow"><meta name="viewport" content="width=device-width,initial-scale=1"><style>*{box-sizing:border-box;margin:0;padding:0}html{line-height:1.15;-webkit-text-size-adjust:100%;color:#313131;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"}body{display:flex;flex-direction:column;height:100vh;min-height:100vh}.main-content{margin:8rem auto;padding-left:1.5rem;max-width:60rem}@media (width <= 720px){.main-content{margin-top:4rem}}.h2{line-height:2.25rem;font-size:1.5rem;font-weight:500}@media (width <= 720px){.h2{line-height:1.5rem;font-size:1.25rem}}#challenge-error-text{background-image:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgZmlsbD0ibm9uZSI+PHBhdGggZmlsbD0iI0IyMEYwMyIgZD0iTTE2IDNhMTMgMTMgMCAxIDAgMTMgMTNBMTMuMDE1IDEzLjAxNSAwIDAgMCAxNiAzbTAgMjRhMTEgMTEgMCAxIDEgMTEtMTEgMTEuMDEgMTEuMDEgMCAwIDEtMTEgMTEiLz48cGF0aCBmaWxsPSIjQjIwRjAzIiBkPSJNMTcuMDM4IDE4LjYxNUgxNC44N0wxNC41NjMgOS41aDIuNzgzem0tMS4wODQgMS40MjdxLjY2IDAgMS4wNTcuMzg4LjQwNy4zODkuNDA3Ljk5NCAwIC41OTYtLjQwNy45ODQtLjM5Ny4zOS0xLjA1Ny4zODktLjY1IDAtMS4wNTYtLjM4OS0uMzk4LS4zODktLjM5OC0uOTg0IDAtLjU5Ny4zOTgtLjk4NS40MDYtLjM5NyAxLjA1Ni0uMzk3Ii8+PC9zdmc+");background-repeat:no-repeat;background-size:contain;padding-left:34px}@media (prefers-color-scheme: dark){body{background-color:#222;color:#d9d9d9}}</style><meta http-equiv="refresh" content="360"></head><body><div class="main-wrapper" role="main"><div class="main-content"><noscript><div class="h2"><span id="challenge-error-text">Enable JavaScript and cookies to continue</span></div></noscript></div></div><script>(function(){window._cf_chl_opt = {cvId: '3',cZone: 'www.fbi.gov',cType: 'managed',cRay: '9c05546b1e633f68',cH: 'J0yrkRNPaDCziury9CSlfYwesCNpXp4kfPy4irDm.Ds-1768815197-1.2.1.1-q9YiSrWgTxtwx.XCt_qPc8YJxzgkXCfALBg_O0uaN1ZCE5Ze.MxpjZFgaFLPhMLY',cUPMDTk:"\/how-we-can-help-you\/victim-services?__cf_chl_tk=n6KGOAmWagfs6QF1_abE2BWYdTj2eUqXw1UwGom7jEY-1768815197-1.0.1.1-idHEIIdjaYWVO1lt5QRtWNsei_9ZLgurGHbuL02THdM",cFPWv: 'b',cITimeS: '1768815197',cTplC:0,cTplV:5,cTplB: '0',fa:"\/how-we-can-help-you\/victim-services?__cf_chl_f_tk=n6KGOAmWagfs6QF1_abE2BWYdTj2eUqXw1UwGom7jEY-1768815197-1.0.1.1-idHEIIdjaYWVO1lt5QRtWNsei_9ZLgurGHbuL02THdM",md: 'wj0rckWOoYO2WQkHlTxw1ExmOTg9NBIt0fRbvMzMw8I-1768815197-1.2.1.1-FuUpPe.mvUciLGY4rakp03B5Vsxa3rAN3.rhwvvK7KZQmscXEgw1VD_Acm1vdRIxhNyr9l4o02E2Z1KDevFXb0qukXJ.DJlLyB5fKS6MYTEkaP9dpT7eJM220EnEuUzD8gBPSXk1UW6__Iw7xhho0sAZpq.eCK6So5PN1NRTqxbz6Be3GUR4OJIoMULQlPLhvSEI42tmB_g8cibCXsR1xZtBjDXsB10DR_zIAD.NOn02SLPrWsUOE0fCtTwcc8MdEDZw1wSdWi6fkG13goTFOutuK5aPD.eb_b4mdamOrAEm3qoG42OxkXwrQB1gqb63NEaxyZdejqtO2772x4V0dbvDJ5sLtaQvleK7vpgQENG1Q03f.sLfFiI0fnhpOeAvROCtc7ow_sAAYNawKiVESJln_96MfClrzD8PUyhdCA.8nWc1ZWP.8qoQoC.8qgljsjaClSSyKvFjz9j0Mo41HADu5em5PDpeo5Y.VeFaaX6_HijDL5MkdFW.TEdy0U6_..9ZTMMComrsRSO9nUTzUZBEcdg1J9Q5JvZ2dF_TmIGJ5dvUDiuS2_qKyncU9QMstD0oiHW39vDP4OlAsYmsMvqx7OMZUcu3NWc_8RkiTfuMCzovqp66gOn9Tmh6jqpf7bx6bbkKSw_vl3hi4bKRFHqN4jT2DtMKROy5gz9YsTmtQWoMZmgv9EAVUGudQhg4xbIXyHULwVNh9oRLcsuJXmIB8AvdSZ6kjd97C3HAOWOkaIZn1YIb7UFyhHsrnBqq7lFU06OkdUnvVc3bjqv9VX.AeIR3h5ke0xO.N1GoqI3WWjpFKYeHf6JZ_nQP7m0JOaLfJ_tB7Ni2qSWEaZ1FE2QQVZN4yh_BgngRZFSNyiHvc6ytL8T.GLiFhu.IaeMYpgG7uYmqp6wmmPj.duEpLT_ttzSKJlsfjCjXri55DLlzfIfHfTdSSigKQKQEsqoBaHu85KHR13a7ZkoJmEhSBAnIGnowYa8HoxSHB0L9ldHOVfBnuq3tfMeTCmHHJ5oX9ZP3lLHIhgFeMHfyMS.9uojxpEun7vNv1kh.rYFnu2G169Sgty8uf_Ndw56GTVVKsPKoUf2TAeQUnm8vhD.drf9EAejQKp.paC.KTal1T28',mdrd: 'rSzKj773kw_pKp6Uryow95IH30LYZtHZH5zqRhYuMEs-1768815197-1.2.1.1-.2nU5JUt1K_Oy.DWKwUKZO4Kl8rDVO942Ll916fyIFt1l_aQOnJ8UDgzLDbkLe1uds_8NtD6SVT3TbXaHLN1f2Si0u2YL40nA4BWnPaf.bww65u1n8zlqBPhFazJJviMYv1na7Vifcs9DAmRAGICv36dNXMnxa0BbZVCcmxeXnVBaDej1_9pV8VZMiWrkzLTGDJi3vWZOpXt2LV6lK3ys_FGG0d7Rq2cPo2T4kdQyZ05pHAciJlIt1KcTn47n3N1yCpQoDPysyiohzlYjfX2jC6Ui6ZU0illV.dhrFu1C4HGo7_EP6iuSPH6y4e1sCi349WWcs7xpkU1FbQnOPzETRUki1mntwp0FuZbBtc0AvX82K5V_fpmYp9xSb2_6GP5YA52gHwnsvnPq.D6tCIwIrzeqsDRsw6PU55QSC8eFWd1j0hug5hIGYwkKzQRX20N7pWmGtc59t_gWpS_db.wUiE2HXlLSWbZvW8fDi283LLobk_Cpa5OosYa1guj6yjCgau8tKdbFmperQOCmhJZ4K4rTsktw4qqHSJ4f7fHa0m38VaWtnp20okJlaZXH5APc9MJv2QlD.djqZORc9lHklmfqKPdXB5wMSjslzac8zoINu8dURuUkhY8o3JXUgo83AKCf5WUSkAUBEG8ba6fFA1W6GGvyQxN.oeDxuKoMc3V2dMMDsRgGzaEaij.NWo.wxLaFFEC0EEVtMM.bKne9q1fLZ7u9.M0ZyjdzfFmb9dCNfRXzjNh8FwKssjmImGlxY50CMPgWaDK3CMSrS_aZSjs8mha_kAjq1MpINh08feCwCeiH5sXHYKxYJvxDz6VkSP5VyG8PW6P00JWY6xAiqY8csXmp2Ah.jS9x3KhUMowqhfzwWHsYriKeSGRkw5XBcqShThaskeuIfPrqmDPIWrqJtl0I3ByVZmNVnXY7.bp4i3o6xD61zb8vcTAr4XOHY.lt8ncpeNj0AfV8FTwMphc9FjggWPKK0DWyYmQ9Ah8eGPUSqfcaiJ1JdCM_BWQwJHwr8ctWjJAI1HL3PCJPz3MR55qPOVRYNqeQKhc4jA32fN9_h6ZAw5JhyRwNbws83eGQ1R2mdocBgG29NW_lOvKR0rYH9nbR3UHrqO5xo5ytha28imyfELwEyFWRU4ojms8DrLKeGgA7W3QKnlM90Z1jXjTrX0t4ypqzbbUHIUOzDlI0ibALHs92dAIb7Vi1BQj3Of1JL18u8Vn7ZqTT4MHrq8M7NFAvb12vW7cd2tSnfUPp2MM4Y1jvw75jibtZv0l6dFdcXd9xHVcJ3FhIlzfwBqT.K1FHHqJvEm59xMvL4HbHu0I0pdrPuUXkqsUo2Q2q4xEaMcZscdoLp7JKWYILfdQCP9dQIaR0pXSBMpM3snUpNH.GIyq08qev699t26KiN2L9TXfmNnizIoHj9viS1CToZ2FvJHL4HNNfHDdVbNzrBUS6LgCxSvtBql8M4ATuOFV.4QgbKT2O3Xew7g3V9BtCwfmLx7A3F4X9rgBjtirRTeuL.nFqd3WOnSh_phGEhm.8n8Fku5WcKiL9vQoOFvPNgAoq4qBgQxB254puHzDMEVGs_eGljrO87KDBaYGhFjj7gEwUy62GzZdkuW_ryolkHcIyrXMlzSIgQ5KLSZ_XH7yYA..QXMiJP8p4ju0Iy_6ycAW8EHzbH2LbjD46i9NobQHImqDtFHFE2oEZWu0UeA5ILk4dn8.5Zm4G3TBDCx1ETi4nKRFqW0nk6gOmvQKSVTmQP4KldusuJfvHWoImT_zzBQ.uLkaKc.dnBp3VWt.GJF_qWtEbGG7L75dypkzkhrS.IkajYZFOQMeEkdKPwYgcEU8RnstkMDoAN9tXn.DMpDx22Yun50j3R1j9CRRq9UMLswUX2LZygoM5vIWVDw9S_.EL5tMCJp_NSYP8NY3k_JWaZtAnnJMqBRVu_Qr0nhwYlzGK_tgkNmsMHGtU499uE58ZsmgA3e.iQ3ADmYtX9v_5t7TTtMf6HO8gOANHxBmMpI4KkMMr6FvR6DswYSnAPuNSEFz7UKkxfnAxTJa9G.pNwP.Qsr3iWlz1z_5xrVej1Rwq2sRn2xhGRq6Ud8ctFa.2vb4AaA6o4gD_.3YWb41oalPDTkUZBEjXMm2gTnehKwv3Q3JMYUU1nIODJwu_IOryVgOaHKACjOiNaaJ3DgHJu9WA839m3_fZYShU6SxIX2TWTZBwDmhyYsuVGnqswNTmbIwf1P5g6LzUn.izvrUfLNtqeZ5qqj3k0UbdOqDnZ5h0c5dP5r0UdTMBMmid26Ji47SuK0eu4E1R2T4FqgPOHc.Phy7.SUJE0d5FSKpoBklI_d5AwZg1Mzuw.FhsfrigHo0KDlk6bbDpk348x3Yy5iMmEzqfpRw_0ufJgaRpT88yUM9j3lOAQeEhL4jTEsdO_GpI8rj7ZF5sYPnAeHHxLXQH6m9vs7x40RS8k5cYxliKwlhWdZARRr11Cjk0gsOnS.DSty8Y3gYnvv02yEWY2yxG.RfyVdewVRDHnf7XWQ7mnpGOg5VG6n5RaSvsjZLHQn66kQC0l0vC8FZX0zBRMmUIOPnk7uX.0zzjhxTweuxni8CoMYmVV6HRyKHiGFTscB5zWmJIE8NNuL8Yf64ikt_VRGiMrHqg8H3z.usm3or5fS9UBlvpqv36H0fzEMfJMqtjbo.i2IUK2kVsvnWPoo3nVQcPqnJqKp35q1MItpVhAzRE4I',};var a = document.createElement('script');a.src = '/cdn-cgi/challenge-platform/h/b/orchestrate/chl_page/v1?ray=9c05546b1e633f68';window._cf_chl_opt.cOgUHash = location.hash === '' && location.href.indexOf('#') !== -1 ? '#' : location.hash;window._cf_chl_opt.cOgUQuery = location.search === '' && location.href.slice(0, location.href.length - window._cf_chl_opt.cOgUHash.length).indexOf('?') !== -1 ? '?' : location.search;if (window.history && window.history.replaceState) {var ogU = location.pathname + window._cf_chl_opt.cOgUQuery + window._cf_chl_opt.cOgUHash;history.replaceState(null, null,"\/how-we-can-help-you\/victim-services?__cf_chl_rt_tk=n6KGOAmWagfs6QF1_abE2BWYdTj2eUqXw1UwGom7jEY-1768815197-1.0.1.1-idHEIIdjaYWVO1lt5QRtWNsei_9ZLgurGHbuL02THdM"+ window._cf_chl_opt.cOgUHash);a.onload = function() {history.replaceState(null, null, ogU);}}document.getElementsByTagName('head')[0].appendChild(a);}());</script></body></html>
|
main.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import sys
|
| 3 |
+
from src.crawler import Crawler
|
| 4 |
+
from src.analyzer import ImageAnalyzer
|
| 5 |
+
from src.reporter import generate_json_report, generate_html_report
|
| 6 |
+
|
| 7 |
+
def main():
|
| 8 |
+
parser = argparse.ArgumentParser(description="Web Image Alt Text Checker (Whole Domain)")
|
| 9 |
+
parser.add_argument("url", help="The Start URL of the domain to crawl (e.g. https://example.com)")
|
| 10 |
+
parser.add_argument("--output", choices=['json', 'html', 'both'], default='html', help="Report output format")
|
| 11 |
+
parser.add_argument("--limit", type=int, default=100, help="Max pages to crawl (default 100)")
|
| 12 |
+
args = parser.parse_args()
|
| 13 |
+
|
| 14 |
+
# Crawl
|
| 15 |
+
# Crawl
|
| 16 |
+
print(f"Starting domain crawl from: {args.url}")
|
| 17 |
+
crawler = Crawler()
|
| 18 |
+
site_data, total_discovered, blocked_reason = crawler.crawl_domain(args.url, max_pages=args.limit)
|
| 19 |
+
|
| 20 |
+
if not site_data and blocked_reason:
|
| 21 |
+
print(f"\n[!] CRAWL BLOCKED: {blocked_reason}")
|
| 22 |
+
# Proceed to generate report even if empty to show the block status
|
| 23 |
+
elif not site_data:
|
| 24 |
+
print("No data crawled. Please check the URL and try again.")
|
| 25 |
+
sys.exit(1)
|
| 26 |
+
|
| 27 |
+
print(f"Crawl complete. Scanned {len(site_data)} pages.")
|
| 28 |
+
|
| 29 |
+
# Analyze
|
| 30 |
+
print("Analyzing images for missing alt text...")
|
| 31 |
+
analyzer = ImageAnalyzer()
|
| 32 |
+
results = analyzer.analyze_site(site_data)
|
| 33 |
+
|
| 34 |
+
# Add discovery stats to summary
|
| 35 |
+
results['summary']['total_pages_discovered'] = total_discovered
|
| 36 |
+
results['summary']['blocked_reason'] = blocked_reason
|
| 37 |
+
results['summary']['crawl_blocked'] = bool(blocked_reason)
|
| 38 |
+
|
| 39 |
+
# Report
|
| 40 |
+
print("Generating reports...")
|
| 41 |
+
if args.output in ['json', 'both']:
|
| 42 |
+
generate_json_report(results, "seo_report.json")
|
| 43 |
+
if args.output in ['html', 'both']:
|
| 44 |
+
generate_html_report(results, "seo_report.html")
|
| 45 |
+
|
| 46 |
+
summary = results['summary']
|
| 47 |
+
print("\n--- Summary ---")
|
| 48 |
+
if summary.get('crawl_blocked'):
|
| 49 |
+
print(f"STATUS: BLOCKED ({summary['blocked_reason']})")
|
| 50 |
+
print("Results are likely incomplete.")
|
| 51 |
+
|
| 52 |
+
print(f"Total Pages Discovered: {summary['total_pages_discovered']}")
|
| 53 |
+
print(f"Pages Scanned: {summary['total_pages_scanned']}")
|
| 54 |
+
print(f"Total Images: {summary['total_images_found']}")
|
| 55 |
+
print(f"Images Missing Alt: {summary['total_images_missing_alt']}")
|
| 56 |
+
|
| 57 |
+
if summary['total_images_missing_alt'] > 0:
|
| 58 |
+
print("Please check the generated report for details.")
|
| 59 |
+
elif not summary.get('crawl_blocked'):
|
| 60 |
+
print("All images have alt text! Great job.")
|
| 61 |
+
|
| 62 |
+
if __name__ == "__main__":
|
| 63 |
+
main()
|
qodana.yaml
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#-------------------------------------------------------------------------------#
|
| 2 |
+
# Qodana analysis is configured by qodana.yaml file #
|
| 3 |
+
# https://www.jetbrains.com/help/qodana/qodana-yaml.html #
|
| 4 |
+
#-------------------------------------------------------------------------------#
|
| 5 |
+
version: "1.0"
|
| 6 |
+
#Specify inspection profile for code analysis
|
| 7 |
+
profile:
|
| 8 |
+
name: qodana.starter
|
| 9 |
+
#Enable inspections
|
| 10 |
+
#include:
|
| 11 |
+
# - name: <SomeEnabledInspectionId>
|
| 12 |
+
#Disable inspections
|
| 13 |
+
#exclude:
|
| 14 |
+
# - name: <SomeDisabledInspectionId>
|
| 15 |
+
# paths:
|
| 16 |
+
# - <path/where/not/run/inspection>
|
| 17 |
+
#Execute shell command before Qodana execution (Applied in CI/CD pipeline)
|
| 18 |
+
#bootstrap: sh ./prepare-qodana.sh
|
| 19 |
+
#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline)
|
| 20 |
+
#plugins:
|
| 21 |
+
# - id: <plugin.id> #(plugin id can be found at https://plugins.jetbrains.com)
|
| 22 |
+
#Specify Qodana linter for analysis (Applied in CI/CD pipeline)
|
| 23 |
+
linter: jetbrains/qodana-<linter>:2024.3
|
requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
requests
|
| 2 |
+
beautifulsoup4
|
| 3 |
+
Pillow
|
| 4 |
+
fake-useragent
|
| 5 |
+
cloudscraper
|
| 6 |
+
curl_cffi
|
| 7 |
+
flask
|
| 8 |
+
flask-cors
|
| 9 |
+
playwright
|
| 10 |
+
gunicorn
|
seo_report.json
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"summary": {
|
| 3 |
+
"total_pages_scanned": 25,
|
| 4 |
+
"total_images_found": 233,
|
| 5 |
+
"total_images_missing_alt": 68,
|
| 6 |
+
"total_pages_discovered": 301,
|
| 7 |
+
"blocked_reason": null,
|
| 8 |
+
"crawl_blocked": false
|
| 9 |
+
},
|
| 10 |
+
"details": [
|
| 11 |
+
{
|
| 12 |
+
"page_url": "https://www.fbi.gov/",
|
| 13 |
+
"missing_alt_count": 2,
|
| 14 |
+
"images_without_alt": [
|
| 15 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-dot-gov.svg",
|
| 16 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-https.svg"
|
| 17 |
+
]
|
| 18 |
+
},
|
| 19 |
+
{
|
| 20 |
+
"page_url": "https://www.fbi.gov/history",
|
| 21 |
+
"missing_alt_count": 3,
|
| 22 |
+
"images_without_alt": [
|
| 23 |
+
"https://www.fbi.gov/++theme++fbigov.theme/images/fbi_seal_new.png",
|
| 24 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-dot-gov.svg",
|
| 25 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-https.svg"
|
| 26 |
+
]
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
"page_url": "https://www.fbi.gov/how-we-can-help-you/active-shooter-safety-resources",
|
| 30 |
+
"missing_alt_count": 0,
|
| 31 |
+
"images_without_alt": []
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
"page_url": "https://www.fbi.gov/about/faqs",
|
| 35 |
+
"missing_alt_count": 3,
|
| 36 |
+
"images_without_alt": [
|
| 37 |
+
"https://www.fbi.gov/++theme++fbigov.theme/images/fbi_seal_new.png",
|
| 38 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-dot-gov.svg",
|
| 39 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-https.svg"
|
| 40 |
+
]
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"page_url": "https://www.fbi.gov/about/faqs/can-i-obtain-detailed-information-about-a-current-fbi-investigation-that-i-see-in-the-news",
|
| 44 |
+
"missing_alt_count": 3,
|
| 45 |
+
"images_without_alt": [
|
| 46 |
+
"https://www.fbi.gov/++theme++fbigov.theme/images/fbi_seal_new.png",
|
| 47 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-dot-gov.svg",
|
| 48 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-https.svg"
|
| 49 |
+
]
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
"page_url": "https://www.fbi.gov/investigate/violent-crime",
|
| 53 |
+
"missing_alt_count": 3,
|
| 54 |
+
"images_without_alt": [
|
| 55 |
+
"https://www.fbi.gov/++theme++fbigov.theme/images/fbi_seal_new.png",
|
| 56 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-dot-gov.svg",
|
| 57 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-https.svg"
|
| 58 |
+
]
|
| 59 |
+
},
|
| 60 |
+
{
|
| 61 |
+
"page_url": "https://www.fbi.gov/about/faqs/what-career-paths-does-the-fbi-offer-besides-the-special-agent-role",
|
| 62 |
+
"missing_alt_count": 3,
|
| 63 |
+
"images_without_alt": [
|
| 64 |
+
"https://www.fbi.gov/++theme++fbigov.theme/images/fbi_seal_new.png",
|
| 65 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-dot-gov.svg",
|
| 66 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-https.svg"
|
| 67 |
+
]
|
| 68 |
+
},
|
| 69 |
+
{
|
| 70 |
+
"page_url": "https://www.fbi.gov/how-we-can-help-you/victim-services",
|
| 71 |
+
"missing_alt_count": 0,
|
| 72 |
+
"images_without_alt": []
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
"page_url": "https://www.fbi.gov/about/faqs/what-is-the-fbis-foreign-counterintelligence-responsibility",
|
| 76 |
+
"missing_alt_count": 3,
|
| 77 |
+
"images_without_alt": [
|
| 78 |
+
"https://www.fbi.gov/++theme++fbigov.theme/images/fbi_seal_new.png",
|
| 79 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-dot-gov.svg",
|
| 80 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-https.svg"
|
| 81 |
+
]
|
| 82 |
+
},
|
| 83 |
+
{
|
| 84 |
+
"page_url": "https://www.fbi.gov/about/faqs/has-the-ending-of-the-cold-war-reduced-the-amount-of-espionage",
|
| 85 |
+
"missing_alt_count": 3,
|
| 86 |
+
"images_without_alt": [
|
| 87 |
+
"https://www.fbi.gov/++theme++fbigov.theme/images/fbi_seal_new.png",
|
| 88 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-dot-gov.svg",
|
| 89 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-https.svg"
|
| 90 |
+
]
|
| 91 |
+
},
|
| 92 |
+
{
|
| 93 |
+
"page_url": "https://www.fbi.gov/about/faqs/what-is-the-fbis-responsibility-in-bombing-cases",
|
| 94 |
+
"missing_alt_count": 3,
|
| 95 |
+
"images_without_alt": [
|
| 96 |
+
"https://www.fbi.gov/++theme++fbigov.theme/images/fbi_seal_new.png",
|
| 97 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-dot-gov.svg",
|
| 98 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-https.svg"
|
| 99 |
+
]
|
| 100 |
+
},
|
| 101 |
+
{
|
| 102 |
+
"page_url": "https://artcrimes.fbi.gov",
|
| 103 |
+
"missing_alt_count": 7,
|
| 104 |
+
"images_without_alt": [
|
| 105 |
+
"https://artcrimes.fbi.gov/image-repository/nsaf_slide2.jpg/@@images/image/large",
|
| 106 |
+
"https://artcrimes.fbi.gov/image-repository/nsaf_slide1.jpg/@@images/image/large",
|
| 107 |
+
"https://artcrimes.fbi.gov/++theme++fbinsaf.theme/img/assets/logo/banner_1295x200.png",
|
| 108 |
+
"https://artcrimes.fbi.gov/++theme++fbinsaf.theme/uswds-2.9.0/img/icon-dot-gov.svg",
|
| 109 |
+
"https://artcrimes.fbi.gov/++theme++fbinsaf.theme/img/assets/logo/fbi-seal.png",
|
| 110 |
+
"https://artcrimes.fbi.gov/++theme++fbinsaf.theme/uswds-2.9.0/img/icon-https.svg",
|
| 111 |
+
"https://artcrimes.fbi.gov/image-repository/nsaf_slide3.jpg/@@images/image/large"
|
| 112 |
+
]
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
"page_url": "https://www.fbi.gov/investigate/organized-crime",
|
| 116 |
+
"missing_alt_count": 3,
|
| 117 |
+
"images_without_alt": [
|
| 118 |
+
"https://www.fbi.gov/++theme++fbigov.theme/images/fbi_seal_new.png",
|
| 119 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-dot-gov.svg",
|
| 120 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-https.svg"
|
| 121 |
+
]
|
| 122 |
+
},
|
| 123 |
+
{
|
| 124 |
+
"page_url": "https://www.fbi.gov/about/faqs/do-fbi-agents-work-with-state-local-or-other-law-enforcement-officers-on-task-forces",
|
| 125 |
+
"missing_alt_count": 3,
|
| 126 |
+
"images_without_alt": [
|
| 127 |
+
"https://www.fbi.gov/++theme++fbigov.theme/images/fbi_seal_new.png",
|
| 128 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-dot-gov.svg",
|
| 129 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-https.svg"
|
| 130 |
+
]
|
| 131 |
+
},
|
| 132 |
+
{
|
| 133 |
+
"page_url": "https://www.fbi.gov/investigate",
|
| 134 |
+
"missing_alt_count": 3,
|
| 135 |
+
"images_without_alt": [
|
| 136 |
+
"https://www.fbi.gov/++theme++fbigov.theme/images/fbi_seal_new.png",
|
| 137 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-dot-gov.svg",
|
| 138 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-https.svg"
|
| 139 |
+
]
|
| 140 |
+
},
|
| 141 |
+
{
|
| 142 |
+
"page_url": "https://www.fbi.gov/about/faqs/what-is-the-critical-incident-response-group",
|
| 143 |
+
"missing_alt_count": 3,
|
| 144 |
+
"images_without_alt": [
|
| 145 |
+
"https://www.fbi.gov/++theme++fbigov.theme/images/fbi_seal_new.png",
|
| 146 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-dot-gov.svg",
|
| 147 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-https.svg"
|
| 148 |
+
]
|
| 149 |
+
},
|
| 150 |
+
{
|
| 151 |
+
"page_url": "https://www.fbi.gov/history/history-stories--multimedia",
|
| 152 |
+
"missing_alt_count": 3,
|
| 153 |
+
"images_without_alt": [
|
| 154 |
+
"https://www.fbi.gov/++theme++fbigov.theme/images/fbi_seal_new.png",
|
| 155 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-dot-gov.svg",
|
| 156 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-https.svg"
|
| 157 |
+
]
|
| 158 |
+
},
|
| 159 |
+
{
|
| 160 |
+
"page_url": "https://www.fbi.gov/history/wall-of-honor/jimmie-john-daniels",
|
| 161 |
+
"missing_alt_count": 3,
|
| 162 |
+
"images_without_alt": [
|
| 163 |
+
"https://www.fbi.gov/++theme++fbigov.theme/images/fbi_seal_new.png",
|
| 164 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-dot-gov.svg",
|
| 165 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-https.svg"
|
| 166 |
+
]
|
| 167 |
+
},
|
| 168 |
+
{
|
| 169 |
+
"page_url": "https://www.fbi.gov/investigate/violent-crime/gangs",
|
| 170 |
+
"missing_alt_count": 3,
|
| 171 |
+
"images_without_alt": [
|
| 172 |
+
"https://www.fbi.gov/++theme++fbigov.theme/images/fbi_seal_new.png",
|
| 173 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-dot-gov.svg",
|
| 174 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-https.svg"
|
| 175 |
+
]
|
| 176 |
+
},
|
| 177 |
+
{
|
| 178 |
+
"page_url": "https://www.fbi.gov/about/faqs/what-does-the-fbi-do-with-information-and-evidence-gathered-during-an-investigation",
|
| 179 |
+
"missing_alt_count": 3,
|
| 180 |
+
"images_without_alt": [
|
| 181 |
+
"https://www.fbi.gov/++theme++fbigov.theme/images/fbi_seal_new.png",
|
| 182 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-dot-gov.svg",
|
| 183 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-https.svg"
|
| 184 |
+
]
|
| 185 |
+
},
|
| 186 |
+
{
|
| 187 |
+
"page_url": "https://www.fbi.gov/contact-us/field-offices/baltimore/news/ms-13-clique-leader-indicted-in-connection-with-racketeering-conspiracy-involving-four-murders-and-two-attempted-murders-in-baltimore",
|
| 188 |
+
"missing_alt_count": 0,
|
| 189 |
+
"images_without_alt": []
|
| 190 |
+
},
|
| 191 |
+
{
|
| 192 |
+
"page_url": "https://www.fbi.gov/news/espanol",
|
| 193 |
+
"missing_alt_count": 2,
|
| 194 |
+
"images_without_alt": [
|
| 195 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-dot-gov.svg",
|
| 196 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-https.svg"
|
| 197 |
+
]
|
| 198 |
+
},
|
| 199 |
+
{
|
| 200 |
+
"page_url": "https://www.fbi.gov/about/faqs/when-was-the-fbi-founded",
|
| 201 |
+
"missing_alt_count": 3,
|
| 202 |
+
"images_without_alt": [
|
| 203 |
+
"https://www.fbi.gov/++theme++fbigov.theme/images/fbi_seal_new.png",
|
| 204 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-dot-gov.svg",
|
| 205 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-https.svg"
|
| 206 |
+
]
|
| 207 |
+
},
|
| 208 |
+
{
|
| 209 |
+
"page_url": "https://www.fbi.gov/image-repository/fbi-nasa-flag-certificate-070522.jpg/view",
|
| 210 |
+
"missing_alt_count": 3,
|
| 211 |
+
"images_without_alt": [
|
| 212 |
+
"https://www.fbi.gov/++theme++fbigov.theme/images/fbi_seal_new.png",
|
| 213 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-dot-gov.svg",
|
| 214 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-https.svg"
|
| 215 |
+
]
|
| 216 |
+
},
|
| 217 |
+
{
|
| 218 |
+
"page_url": "https://www.fbi.gov/news/stories/events-mark-the-50th-anniversary-of-female-special-agents-in-the-fbi",
|
| 219 |
+
"missing_alt_count": 3,
|
| 220 |
+
"images_without_alt": [
|
| 221 |
+
"https://www.fbi.gov/++theme++fbigov.theme/images/fbi_seal_new.png",
|
| 222 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-dot-gov.svg",
|
| 223 |
+
"https://www.fbi.gov/++theme++fbigov.theme/uswds-2.9.0/img/icon-https.svg"
|
| 224 |
+
]
|
| 225 |
+
}
|
| 226 |
+
]
|
| 227 |
+
}
|
src/__init__.py
ADDED
|
File without changes
|
src/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (147 Bytes). View file
|
|
|
src/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (128 Bytes). View file
|
|
|
src/__pycache__/analyzer.cpython-313.pyc
ADDED
|
Binary file (3.25 kB). View file
|
|
|
src/__pycache__/analyzer.cpython-314.pyc
ADDED
|
Binary file (1.62 kB). View file
|
|
|
src/__pycache__/crawler.cpython-313.pyc
ADDED
|
Binary file (2.5 kB). View file
|
|
|
src/__pycache__/crawler.cpython-314.pyc
ADDED
|
Binary file (13 kB). View file
|
|
|
src/__pycache__/reporter.cpython-313.pyc
ADDED
|
Binary file (3.25 kB). View file
|
|
|
src/__pycache__/reporter.cpython-314.pyc
ADDED
|
Binary file (5.8 kB). View file
|
|
|
src/analyzer.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
class ImageAnalyzer:
|
| 3 |
+
def analyze_site(self, site_data):
|
| 4 |
+
"""
|
| 5 |
+
Analyzes the crawled site data for missing ALT text.
|
| 6 |
+
:param site_data: Dict { page_url: [image_dicts] }
|
| 7 |
+
:return: Dict containing summary and detailed report.
|
| 8 |
+
"""
|
| 9 |
+
total_images = 0
|
| 10 |
+
total_missing_alt = 0
|
| 11 |
+
pages_report = []
|
| 12 |
+
|
| 13 |
+
for page_url, images in site_data.items():
|
| 14 |
+
missing_images = []
|
| 15 |
+
for img in images:
|
| 16 |
+
total_images += 1
|
| 17 |
+
alt_text = img.get('alt')
|
| 18 |
+
|
| 19 |
+
# Check for strictly missing alt text (None or empty string)
|
| 20 |
+
if not alt_text or alt_text.strip() == "":
|
| 21 |
+
# Double check if 'alt' attribute existed but was empty vs didn't exist?
|
| 22 |
+
# The crawler uses .get('alt', '') so it's '' if missing.
|
| 23 |
+
# User asked for "missing alt text".
|
| 24 |
+
# We will count empty string as missing alt text for SEO purposes.
|
| 25 |
+
missing_images.append(img['src'])
|
| 26 |
+
total_missing_alt += 1
|
| 27 |
+
|
| 28 |
+
pages_report.append({
|
| 29 |
+
"page_url": page_url,
|
| 30 |
+
"missing_alt_count": len(missing_images),
|
| 31 |
+
"images_without_alt": list(set(missing_images))
|
| 32 |
+
})
|
| 33 |
+
|
| 34 |
+
return {
|
| 35 |
+
"summary": {
|
| 36 |
+
"total_pages_scanned": len(site_data),
|
| 37 |
+
"total_images_found": total_images,
|
| 38 |
+
"total_images_missing_alt": total_missing_alt
|
| 39 |
+
},
|
| 40 |
+
"details": pages_report
|
| 41 |
+
}
|
src/crawler.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from curl_cffi import requests
|
| 2 |
+
import time
|
| 3 |
+
import random
|
| 4 |
+
from bs4 import BeautifulSoup
|
| 5 |
+
from urllib.parse import urljoin, urlparse
|
| 6 |
+
import sys
|
| 7 |
+
import logging
|
| 8 |
+
import os
|
| 9 |
+
|
| 10 |
+
# Configure logging
|
| 11 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
class Crawler:
|
| 15 |
+
def __init__(self, use_playwright=True):
|
| 16 |
+
# 1. Improved HTTP Client Setup
|
| 17 |
+
self.session = requests.Session(impersonate="chrome120")
|
| 18 |
+
self.session.headers.update({
|
| 19 |
+
"Referer": "https://www.google.com/",
|
| 20 |
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7",
|
| 21 |
+
"Accept-Language": "en-US,en;q=0.9",
|
| 22 |
+
"Upgrade-Insecure-Requests": "1",
|
| 23 |
+
"Sec-Fetch-Site": "none",
|
| 24 |
+
"Sec-Fetch-Mode": "navigate",
|
| 25 |
+
"Sec-Fetch-User": "?1",
|
| 26 |
+
"Sec-Fetch-Dest": "document",
|
| 27 |
+
})
|
| 28 |
+
self.visited_urls = set()
|
| 29 |
+
self.use_playwright_fallback = use_playwright
|
| 30 |
+
self.blocked_reason = None
|
| 31 |
+
|
| 32 |
+
def fetch(self, url):
|
| 33 |
+
"""
|
| 34 |
+
Orchestrates fetching: tries HTTP first, detects WAF/Challenge, falls back to Playwright if needed.
|
| 35 |
+
"""
|
| 36 |
+
content = self._fetch_http(url)
|
| 37 |
+
|
| 38 |
+
# WAF/Challenge Detection
|
| 39 |
+
# Key terms often found in block pages
|
| 40 |
+
block_keywords = [
|
| 41 |
+
"api-services-support@amazon.com",
|
| 42 |
+
"robot check",
|
| 43 |
+
"type the characters you see",
|
| 44 |
+
"challenge.js",
|
| 45 |
+
"verify you are human",
|
| 46 |
+
"captcha",
|
| 47 |
+
"incident id"
|
| 48 |
+
]
|
| 49 |
+
|
| 50 |
+
is_blocked = False
|
| 51 |
+
if content:
|
| 52 |
+
lower_content = content.lower()
|
| 53 |
+
if any(k in lower_content for k in block_keywords):
|
| 54 |
+
is_blocked = True
|
| 55 |
+
elif content is None:
|
| 56 |
+
# If HTTP failed completely (e.g. 403 or connection error), we treat it as potentially blocked if it's high-profile
|
| 57 |
+
is_blocked = True
|
| 58 |
+
|
| 59 |
+
if is_blocked:
|
| 60 |
+
logger.warning(f"WAF/CAPTCHA or block detected for {url} using HTTP client.")
|
| 61 |
+
self.blocked_reason = "WAF/JS challenge/consent"
|
| 62 |
+
|
| 63 |
+
if self.use_playwright_fallback:
|
| 64 |
+
logger.info("Attempting fallback to Playwright (Browser Automation)...")
|
| 65 |
+
return self._fetch_playwright(url)
|
| 66 |
+
else:
|
| 67 |
+
logger.error("Playwright fallback disabled. Skipping page.")
|
| 68 |
+
return None
|
| 69 |
+
|
| 70 |
+
return content
|
| 71 |
+
|
| 72 |
+
def _fetch_http(self, url):
|
| 73 |
+
"""
|
| 74 |
+
Standard robust HTTP fetch with retry and backoff.
|
| 75 |
+
"""
|
| 76 |
+
max_retries = 3
|
| 77 |
+
for attempt in range(max_retries):
|
| 78 |
+
try:
|
| 79 |
+
response = self.session.get(url, timeout=30, allow_redirects=True)
|
| 80 |
+
|
| 81 |
+
# Debug logging for response
|
| 82 |
+
logger.info(f"HTTP GET {url} - Status: {response.status_code} - URL: {response.url}")
|
| 83 |
+
|
| 84 |
+
# Check for 200 OK (or close to it)
|
| 85 |
+
if 200 <= response.status_code < 300:
|
| 86 |
+
return response.text
|
| 87 |
+
|
| 88 |
+
# If 403, it might be WAF. Return text so we can check for challenge keywords.
|
| 89 |
+
if response.status_code == 403:
|
| 90 |
+
# Save HTML for debugging
|
| 91 |
+
with open("debug_http.html", "w", encoding="utf-8") as f:
|
| 92 |
+
f.write(response.text)
|
| 93 |
+
logger.info("Saved 403 response body to debug_http.html")
|
| 94 |
+
return response.text
|
| 95 |
+
|
| 96 |
+
if response.status_code in [429, 500, 502, 503]:
|
| 97 |
+
logger.warning(f"Status {response.status_code} for {url}. Retrying ({attempt + 1}/{max_retries})...")
|
| 98 |
+
time.sleep(2 * (attempt + 1))
|
| 99 |
+
continue
|
| 100 |
+
|
| 101 |
+
logger.error(f"Error fetching {url}: Status {response.status_code}")
|
| 102 |
+
return None
|
| 103 |
+
|
| 104 |
+
except Exception as e:
|
| 105 |
+
logger.error(f"Exception fetching {url}: {e}. Retrying...")
|
| 106 |
+
time.sleep(2 * (attempt + 1))
|
| 107 |
+
|
| 108 |
+
return None
|
| 109 |
+
|
| 110 |
+
def _fetch_playwright(self, url):
|
| 111 |
+
"""
|
| 112 |
+
Fallback Strategy A: Use Playwright to render full page.
|
| 113 |
+
"""
|
| 114 |
+
try:
|
| 115 |
+
from playwright.sync_api import sync_playwright
|
| 116 |
+
except ImportError:
|
| 117 |
+
logger.error("Playwright not installed. Run `pip install playwright` and `playwright install`.")
|
| 118 |
+
return None
|
| 119 |
+
|
| 120 |
+
try:
|
| 121 |
+
with sync_playwright() as p:
|
| 122 |
+
# Launch options
|
| 123 |
+
# headless=False can be useful for visual debugging if needed, but we keep True for automation.
|
| 124 |
+
browser = p.chromium.launch(headless=True)
|
| 125 |
+
|
| 126 |
+
# Context with realistic viewport and locale
|
| 127 |
+
context = browser.new_context(
|
| 128 |
+
viewport={'width': 1920, 'height': 1080},
|
| 129 |
+
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",
|
| 130 |
+
locale="en-US",
|
| 131 |
+
timezone_id="America/New_York",
|
| 132 |
+
java_script_enabled=True
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
page = context.new_page()
|
| 136 |
+
try:
|
| 137 |
+
logger.info(f"Playwright navigating to {url}...")
|
| 138 |
+
page.goto(url, timeout=60000, wait_until="domcontentloaded")
|
| 139 |
+
|
| 140 |
+
# Wait for a bit of hydration
|
| 141 |
+
page.wait_for_timeout(3000)
|
| 142 |
+
|
| 143 |
+
# Log page title
|
| 144 |
+
title = page.title()
|
| 145 |
+
logger.info(f"Page Title: {title}")
|
| 146 |
+
|
| 147 |
+
# CAPTCHA Detection via Selectors
|
| 148 |
+
# Amazon often uses #captchacharacters
|
| 149 |
+
if page.locator("input#captchacharacters").count() > 0 or "Robot Check" in title:
|
| 150 |
+
logger.error("Playwright hit visible CAPTCHA. Manual intervention required.")
|
| 151 |
+
self.blocked_reason = "Hard Catcha/Robot Check"
|
| 152 |
+
|
| 153 |
+
# Save screenshot for proof
|
| 154 |
+
page.screenshot(path="amazon.png")
|
| 155 |
+
logger.info("Saved screenshot to amazon.png")
|
| 156 |
+
|
| 157 |
+
# Save HTML
|
| 158 |
+
with open("debug_playwright.html", "w", encoding="utf-8") as f:
|
| 159 |
+
f.write(page.content())
|
| 160 |
+
|
| 161 |
+
return None # Return None to signify blocked/useless content
|
| 162 |
+
|
| 163 |
+
content = page.content()
|
| 164 |
+
return content
|
| 165 |
+
|
| 166 |
+
except Exception as nav:
|
| 167 |
+
logger.error(f"Playwright navigation error: {nav}")
|
| 168 |
+
return None
|
| 169 |
+
finally:
|
| 170 |
+
browser.close()
|
| 171 |
+
|
| 172 |
+
except Exception as e:
|
| 173 |
+
logger.error(f"Playwright critical error: {e}")
|
| 174 |
+
return None
|
| 175 |
+
|
| 176 |
+
def crawl_domain(self, start_url, max_pages=100):
|
| 177 |
+
"""
|
| 178 |
+
Crawls domain with robust link discovery and normalization.
|
| 179 |
+
"""
|
| 180 |
+
# Reset blocked state
|
| 181 |
+
self.blocked_reason = None
|
| 182 |
+
|
| 183 |
+
parsed_start = urlparse(start_url)
|
| 184 |
+
start_domain = parsed_start.netloc
|
| 185 |
+
base_domain = start_domain.replace('www.', '') # simplistic base domain
|
| 186 |
+
|
| 187 |
+
queue = [start_url]
|
| 188 |
+
self.visited_urls.add(start_url)
|
| 189 |
+
|
| 190 |
+
site_data = {}
|
| 191 |
+
pages_crawled = 0
|
| 192 |
+
|
| 193 |
+
logger.info(f"Starting crawl for domain: {base_domain}")
|
| 194 |
+
|
| 195 |
+
while queue and pages_crawled < max_pages:
|
| 196 |
+
pop_index = random.randint(0, len(queue) - 1)
|
| 197 |
+
current_url = queue.pop(pop_index)
|
| 198 |
+
|
| 199 |
+
logger.info(f"Crawling [{pages_crawled + 1}]: {current_url}")
|
| 200 |
+
|
| 201 |
+
html_content = self.fetch(current_url)
|
| 202 |
+
|
| 203 |
+
# If fetch failed or blocked
|
| 204 |
+
if not html_content:
|
| 205 |
+
if self.blocked_reason and pages_crawled == 0:
|
| 206 |
+
logger.error("Crawl blocked on first page. Aborting.")
|
| 207 |
+
break
|
| 208 |
+
continue
|
| 209 |
+
|
| 210 |
+
# Extract images
|
| 211 |
+
images = self.extract_images(html_content, current_url)
|
| 212 |
+
site_data[current_url] = images
|
| 213 |
+
pages_crawled += 1
|
| 214 |
+
|
| 215 |
+
# --- Robust Link Discovery ---
|
| 216 |
+
soup = BeautifulSoup(html_content, 'html.parser')
|
| 217 |
+
links_found = soup.find_all('a', href=True)
|
| 218 |
+
|
| 219 |
+
links_stats = {"total": len(links_found), "kept": 0, "skipped": 0}
|
| 220 |
+
|
| 221 |
+
for link in links_found:
|
| 222 |
+
href = link['href']
|
| 223 |
+
|
| 224 |
+
# Resolve relative URL
|
| 225 |
+
full_url = urljoin(current_url, href)
|
| 226 |
+
parsed_url = urlparse(full_url)
|
| 227 |
+
|
| 228 |
+
# Normalize: remove fragment
|
| 229 |
+
full_url = parsed_url._replace(fragment="").geturl()
|
| 230 |
+
|
| 231 |
+
link_domain = parsed_url.netloc
|
| 232 |
+
|
| 233 |
+
# Domain Check (Allow www. and non-www.)
|
| 234 |
+
is_internal = link_domain == start_domain or link_domain.endswith('.' + base_domain) or link_domain == base_domain
|
| 235 |
+
|
| 236 |
+
if is_internal:
|
| 237 |
+
# Filter non-html resources
|
| 238 |
+
path = parsed_url.path.lower()
|
| 239 |
+
excluded_exts = ['.jpg', '.jpeg', '.png', '.gif', '.css', '.js', '.ico', '.svg', '.pdf', '.zip', '.xml']
|
| 240 |
+
|
| 241 |
+
if any(path.endswith(ext) for ext in excluded_exts):
|
| 242 |
+
links_stats["skipped"] += 1
|
| 243 |
+
continue
|
| 244 |
+
|
| 245 |
+
if full_url not in self.visited_urls:
|
| 246 |
+
self.visited_urls.add(full_url)
|
| 247 |
+
queue.append(full_url)
|
| 248 |
+
links_stats["kept"] += 1
|
| 249 |
+
else:
|
| 250 |
+
links_stats["skipped"] += 1 # Already visited
|
| 251 |
+
else:
|
| 252 |
+
links_stats["skipped"] += 1 # External
|
| 253 |
+
|
| 254 |
+
logger.info(f"Link Discovery: Found {links_stats['total']}, Added {links_stats['kept']} new unique internal links.")
|
| 255 |
+
|
| 256 |
+
# Final block check: if we scraped 0 pages or only 1 page with 0 images and blocked_reason is set
|
| 257 |
+
if pages_crawled == 0 and self.blocked_reason:
|
| 258 |
+
pass # blocked_reason is already set
|
| 259 |
+
elif pages_crawled > 0 and not self.blocked_reason:
|
| 260 |
+
# Check if we successfully scanned but found suspiciously nothing?
|
| 261 |
+
pass
|
| 262 |
+
|
| 263 |
+
return site_data, len(self.visited_urls), self.blocked_reason
|
| 264 |
+
|
| 265 |
+
def extract_images(self, html_content, base_url):
|
| 266 |
+
if not html_content:
|
| 267 |
+
return []
|
| 268 |
+
soup = BeautifulSoup(html_content, 'html.parser')
|
| 269 |
+
images = []
|
| 270 |
+
for img in soup.find_all('img'):
|
| 271 |
+
raw_src = img.get('src')
|
| 272 |
+
if not raw_src:
|
| 273 |
+
continue
|
| 274 |
+
full_url = urljoin(base_url, raw_src)
|
| 275 |
+
images.append({'src': full_url, 'alt': img.get('alt', '')})
|
| 276 |
+
return images
|
src/reporter.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
def generate_json_report(data, output_file="report.json"):
|
| 5 |
+
"""
|
| 6 |
+
Generates a JSON report of the site analysis.
|
| 7 |
+
"""
|
| 8 |
+
try:
|
| 9 |
+
with open(output_file, 'w') as f:
|
| 10 |
+
json.dump(data, f, indent=4)
|
| 11 |
+
print(f"JSON report generated at {os.path.abspath(output_file)}")
|
| 12 |
+
except Exception as e:
|
| 13 |
+
print(f"Error generating JSON report: {e}")
|
| 14 |
+
|
| 15 |
+
def generate_html_report(data, output_file="report.html"):
|
| 16 |
+
"""
|
| 17 |
+
Generates an HTML report for the domain scan.
|
| 18 |
+
"""
|
| 19 |
+
summary = data['summary']
|
| 20 |
+
details = data['details']
|
| 21 |
+
|
| 22 |
+
html = f"""
|
| 23 |
+
<!DOCTYPE html>
|
| 24 |
+
<html>
|
| 25 |
+
<head>
|
| 26 |
+
<title>SEO Image Alt Text Report</title>
|
| 27 |
+
<style>
|
| 28 |
+
body {{ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 0; padding: 20px; background-color: #f4f6f9; }}
|
| 29 |
+
h1, h2 {{ color: #333; }}
|
| 30 |
+
.container {{ max-width: 1200px; margin: 0 auto; background: white; padding: 30px; border-radius: 8px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); }}
|
| 31 |
+
.summary-box {{ display: flex; gap: 20px; margin-bottom: 30px; }}
|
| 32 |
+
.card {{ flex: 1; padding: 20px; border-radius: 8px; color: white; text-align: center; }}
|
| 33 |
+
.bg-blue {{ background-color: #007bff; }}
|
| 34 |
+
.bg-green {{ background-color: #28a745; }}
|
| 35 |
+
.bg-red {{ background-color: #dc3545; }}
|
| 36 |
+
.card h3 {{ margin: 0; font-size: 3em; }}
|
| 37 |
+
.card p {{ margin: 0; opacity: 0.9; }}
|
| 38 |
+
|
| 39 |
+
table {{ border-collapse: collapse; width: 100%; margin-top: 10px; }}
|
| 40 |
+
th, td {{ border: 1px solid #dee2e6; padding: 12px; text-align: left; }}
|
| 41 |
+
th {{ background-color: #f8f9fa; color: #495057; }}
|
| 42 |
+
tr:nth-child(even) {{ background-color: #f9f9f9; }}
|
| 43 |
+
.page-section {{ margin-bottom: 40px; border-top: 2px solid #eee; padding-top: 20px; }}
|
| 44 |
+
.page-url {{ font-size: 1.2em; font-weight: bold; color: #0056b3; margin-bottom: 10px; display: block; }}
|
| 45 |
+
.no-issues {{ color: #28a745; font-style: italic; }}
|
| 46 |
+
.img-link {{ word-break: break-all; color: #555; }}
|
| 47 |
+
</style>
|
| 48 |
+
</head>
|
| 49 |
+
<body>
|
| 50 |
+
<div class="container">
|
| 51 |
+
<h1>SEO Image Alt Text Report</h1>
|
| 52 |
+
|
| 53 |
+
<div class="summary-box">
|
| 54 |
+
<div class="card bg-blue">
|
| 55 |
+
<h3>{summary['total_pages_scanned']}</h3>
|
| 56 |
+
<p>Pages Scanned</p>
|
| 57 |
+
</div>
|
| 58 |
+
<div class="card bg-green">
|
| 59 |
+
<h3>{summary['total_images_found']}</h3>
|
| 60 |
+
<p>Total Images Found</p>
|
| 61 |
+
</div>
|
| 62 |
+
<div class="card bg-red">
|
| 63 |
+
<h3>{summary['total_images_missing_alt']}</h3>
|
| 64 |
+
<p>Images Missing Alt Text</p>
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
"""
|
| 68 |
+
|
| 69 |
+
if summary.get('crawl_blocked'):
|
| 70 |
+
html += f"""
|
| 71 |
+
<div style="background-color: #fff3cd; color: #856404; padding: 15px; border: 1px solid #ffeeba; border-radius: 5px; margin-bottom: 20px;">
|
| 72 |
+
<strong>⚠️ CRAWL BLOCKED:</strong> The crawler was blocked by the site ({summary.get('blocked_reason')}). Results are likely incomplete.
|
| 73 |
+
<p>Try using a residential proxy or a headless browser with stealth plugins to bypass this restriction.</p>
|
| 74 |
+
</div>
|
| 75 |
+
"""
|
| 76 |
+
|
| 77 |
+
html += """
|
| 78 |
+
<h2>Detailed Breakdown</h2>
|
| 79 |
+
"""
|
| 80 |
+
|
| 81 |
+
if not details:
|
| 82 |
+
html += "<p class='no-issues'>No pages found with missing alt text images! Great job.</p>"
|
| 83 |
+
|
| 84 |
+
for page in details:
|
| 85 |
+
html += f"""
|
| 86 |
+
<div class="page-section">
|
| 87 |
+
<a href="{page['page_url']}" class="page-url" target="_blank">Page: {page['page_url']}</a>
|
| 88 |
+
<p><strong>{page['missing_alt_count']}</strong> images missing alt text</p>
|
| 89 |
+
<table>
|
| 90 |
+
<tr><th>Image Source URL</th></tr>
|
| 91 |
+
"""
|
| 92 |
+
for img_src in page['images_without_alt']:
|
| 93 |
+
html += f"""
|
| 94 |
+
<tr>
|
| 95 |
+
<td><a href="{img_src}" target="_blank" class="img-link">{img_src}</a></td>
|
| 96 |
+
</tr>
|
| 97 |
+
"""
|
| 98 |
+
html += "</table></div>"
|
| 99 |
+
|
| 100 |
+
html += """
|
| 101 |
+
</div>
|
| 102 |
+
</body>
|
| 103 |
+
</html>
|
| 104 |
+
"""
|
| 105 |
+
|
| 106 |
+
try:
|
| 107 |
+
with open(output_file, 'w') as f:
|
| 108 |
+
f.write(html)
|
| 109 |
+
print(f"HTML report generated at {os.path.abspath(output_file)}")
|
| 110 |
+
except Exception as e:
|
| 111 |
+
print(f"Error generating HTML report: {e}")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
|
tests/__init__.py
ADDED
|
File without changes
|