Files
OSINT-Framework/tools/link_checker.py
T
s0lrayandPaperclip 8c2de4d242 Phase 1: CI/CD pipeline, dead link cleanup, and URL normalization
Rebased cleanly onto current master (resolving conflicts from
community PRs #240-#471 merged while this branch was in flight).

Changes:
- Add GitHub Actions CI workflow (JSON lint, link check, smoke tests)
- Remove 176 confirmed dead links from arf.json
- Normalize 393 URLs from http:// to https://
- Fix invalid JSON (missing commas/braces from old format)
- Remove Bower dependency; lock D3 to npm
- Add link audit tooling (tools/)
- Cherry-pick alive tools from community PR #391:
  FaceCheckID, Cisco Talos, VerifyEmail, YouTube Geofind, Whitepages,
  Hiya, Berla Vehicle Lookup, OpenAIP, Valkyrie File Analysis
- Fix OpenInfrastructureMap URL (missing https://)

Community PR #359 (Risk Toolbox / fraudfightersalliance.com):
DNS does not resolve — all 27 proposed additions are dead, not merged.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-21 23:00:15 -04:00

178 lines
5.0 KiB
Python

#!/usr/bin/env python3
"""
Dead link checker for arf.json.
Extracts all URLs, checks HTTP status, and reports dead/redirected/slow links.
"""
import json
import re
import sys
import time
import urllib.request
import urllib.error
import urllib.parse
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from typing import Optional
import ssl
TIMEOUT = 30
MAX_WORKERS = 20
SLOW_THRESHOLD = 10 # seconds
# Skip these domains known to block bots or require auth
SKIP_DOMAINS = set()
# User agent to avoid some bot blocks
HEADERS = {
"User-Agent": "Mozilla/5.0 (compatible; OSINT-Framework-LinkChecker/1.0; +https://osintframework.com)"
}
@dataclass
class LinkResult:
url: str
status: Optional[int] = None
final_url: Optional[str] = None
elapsed: float = 0.0
error: Optional[str] = None
@property
def is_dead(self):
if self.error and "404" not in str(self.error):
return True
return self.status is not None and self.status >= 400
@property
def is_redirect(self):
return self.final_url and self.final_url != self.url
@property
def is_slow(self):
return self.elapsed >= SLOW_THRESHOLD
def check_url(url: str) -> LinkResult:
result = LinkResult(url=url)
start = time.time()
try:
# Build request with headers
req = urllib.request.Request(url, headers=HEADERS)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with urllib.request.urlopen(req, timeout=TIMEOUT, context=ctx) as resp:
result.status = resp.status
result.final_url = resp.url
except urllib.error.HTTPError as e:
result.status = e.code
result.error = str(e)
except urllib.error.URLError as e:
result.error = str(e.reason)
except Exception as e:
result.error = str(e)
finally:
result.elapsed = time.time() - start
return result
def extract_urls(json_path: str) -> list[str]:
with open(json_path) as f:
raw = f.read()
urls = re.findall(r'https?://[^\s"\'<>]+', raw)
# Clean trailing punctuation
cleaned = []
for u in urls:
u = u.rstrip(".,;:)'\"")
cleaned.append(u)
return sorted(set(cleaned))
def main():
json_path = "public/arf.json"
urls = extract_urls(json_path)
total = len(urls)
print(f"Checking {total} unique URLs (timeout={TIMEOUT}s, workers={MAX_WORKERS})...\n")
results = []
completed = 0
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
future_to_url = {executor.submit(check_url, url): url for url in urls}
for future in as_completed(future_to_url):
result = future.result()
results.append(result)
completed += 1
if completed % 50 == 0:
print(f" Progress: {completed}/{total}", flush=True)
# Sort results by URL for consistent output
results.sort(key=lambda r: r.url)
dead = [r for r in results if r.is_dead]
redirected = [r for r in results if r.is_redirect and not r.is_dead]
slow = [r for r in results if r.is_slow and not r.is_dead]
ok = [r for r in results if not r.is_dead and not r.is_slow]
print("\n" + "=" * 70)
print(f"LINK AUDIT REPORT")
print("=" * 70)
print(f"Total URLs checked : {total}")
print(f"OK : {len(ok)}")
print(f"Dead (errors/404+) : {len(dead)}")
print(f"Redirected : {len(redirected)}")
print(f"Slow (>{SLOW_THRESHOLD}s) : {len(slow)}")
print()
if dead:
print("=" * 70)
print(f"DEAD LINKS ({len(dead)})")
print("=" * 70)
for r in dead:
status_str = f"HTTP {r.status}" if r.status else f"ERROR: {r.error}"
print(f" [{status_str}] {r.url}")
print()
if redirected:
print("=" * 70)
print(f"REDIRECTED LINKS ({len(redirected)})")
print("=" * 70)
for r in redirected:
print(f" [HTTP {r.status}] {r.url}")
print(f" -> {r.final_url}")
print()
if slow:
print("=" * 70)
print(f"SLOW LINKS (>{SLOW_THRESHOLD}s) ({len(slow)})")
print("=" * 70)
for r in slow:
print(f" [{r.elapsed:.1f}s] {r.url}")
print()
# Write JSON report
report = {
"summary": {
"total": total,
"ok": len(ok),
"dead": len(dead),
"redirected": len(redirected),
"slow": len(slow),
},
"dead": [{"url": r.url, "status": r.status, "error": r.error} for r in dead],
"redirected": [{"url": r.url, "status": r.status, "final_url": r.final_url} for r in redirected],
"slow": [{"url": r.url, "elapsed": round(r.elapsed, 2)} for r in slow],
}
report_path = "tools/link_audit_report.json"
with open(report_path, "w") as f:
json.dump(report, f, indent=2)
print(f"Full report written to: {report_path}")
return len(dead)
if __name__ == "__main__":
sys.exit(main())