#!/usr/bin/env python3
"""Read-only sitemap HTML audit. Python 3 standard library; no API keys.
Does not impersonate or verify any search crawler, indexing or AI citation.
Usage: python3 audit-public-seo.py --base https://www.ethorx.com --out audit.json
"""
import argparse
import concurrent.futures
import hashlib
from html.parser import HTMLParser
import json
import time
from datetime import datetime, timezone
from urllib.parse import urljoin, urlparse
from urllib.request import Request, urlopen
from urllib.robotparser import RobotFileParser
import xml.etree.ElementTree as ET

UA = 'EthorXSEOAudit/1.0'
LIMIT = 8 * 1024 * 1024

class Page(HTMLParser):
    def __init__(self):
        super().__init__(convert_charrefs=True)
        self.title_parts = []
        self.in_title = False
        self.meta = {}
        self.canonical = []
        self.h1_count = 0
        self.image_count = 0
        self.images_without_alt = 0
    def handle_starttag(self, tag, attrs):
        attr = dict(attrs)
        if tag == 'title': self.in_title = True
        if tag == 'h1': self.h1_count += 1
        if tag == 'meta': self.meta[(attr.get('name') or '').lower()] = attr.get('content') or ''
        if tag == 'link' and 'canonical' in (attr.get('rel') or '').lower().split(): self.canonical.append(attr.get('href'))
        if tag == 'img':
            self.image_count += 1
            # Bare alt and alt="" are both present, empty alternatives in HTML.
            self.images_without_alt += int('alt' not in attr)
    def handle_endtag(self, tag):
        if tag == 'title': self.in_title = False
    def handle_data(self, data):
        if self.in_title: self.title_parts.append(data)

def get(url):
    with urlopen(Request(url, headers={'User-Agent': UA}), timeout=25) as response:
        raw = response.read(LIMIT + 1)
        if len(raw) > LIMIT: raise ValueError('response exceeds 8 MiB audit limit')
        return response.status, response.url, dict(response.headers.items()), raw

def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--base', required=True)
    parser.add_argument('--out', required=True)
    args = parser.parse_args()
    origin = args.base.rstrip('/')
    if urlparse(origin).scheme not in ('http', 'https') or urlparse(origin).path not in ('', '/'):
        parser.error('--base must be an HTTP(S) origin without a path')
    started = datetime.now(timezone.utc).isoformat()
    _, _, _, raw_robots = get(origin + '/robots.txt')
    robots_text = raw_robots.decode('utf-8')
    robots = RobotFileParser(); robots.parse(robots_text.splitlines())
    if not robots.can_fetch(UA, origin + '/sitemap.xml'): raise RuntimeError('robots disallows sitemap')
    _, _, _, raw_sitemap = get(origin + '/sitemap.xml')
    tree = ET.fromstring(raw_sitemap)
    if not tree.tag.endswith('urlset'): raise ValueError('This small audit supports a URL sitemap, not a sitemap index')
    urls = list(dict.fromkeys((el.text or '').strip() for el in tree.iter() if el.tag.endswith('}loc') or el.tag == 'loc'))
    if not urls or len(urls) > 300: raise ValueError('Expected 1–300 URLs; split larger audits explicitly')
    if any(urlparse(url).netloc != urlparse(origin).netloc for url in urls): raise ValueError('Sitemap contains a different host')
    def audit(url):
        row = {'url': url, 'robots_allowed_for_test_ua': robots.can_fetch(UA, url)}
        if not row['robots_allowed_for_test_ua']: return {**row, 'state': 'skipped_robots'}
        time.sleep(1)
        try:
            status, final, headers, raw = get(url)
            page = Page(); page.feed(raw.decode('utf-8'))
            return {**row, 'state': 'fetched', 'status': status, 'final_url': final,
                    'content_type': next((v for k,v in headers.items() if k.lower() == 'content-type'), ''),
                    'x_robots_tag': next((v for k,v in headers.items() if k.lower() == 'x-robots-tag'), None),
                    'title': ''.join(page.title_parts).strip(), 'description': page.meta.get('description', ''),
                    'robots_meta': page.meta.get('robots', ''), 'canonical': page.canonical,
                    'h1_count': page.h1_count, 'images_without_alt': page.images_without_alt,
                    'html_bytes': len(raw), 'html_sha256': hashlib.sha256(raw).hexdigest()}
        except Exception as error:
            return {**row, 'state': 'error', 'error': type(error).__name__ + ': ' + str(error)}
    with concurrent.futures.ThreadPoolExecutor(max_workers=5) as pool:
        pages = list(pool.map(audit, urls))
    fetched = [p for p in pages if p['state'] == 'fetched']
    summary = {'sitemap_urls': len(urls), 'fetched': len(fetched), 'http_200': sum(p['status'] == 200 for p in fetched),
               'with_title': sum(bool(p['title']) for p in fetched), 'with_description': sum(bool(p['description']) for p in fetched),
               'single_h1': sum(p['h1_count'] == 1 for p in fetched), 'self_canonical': sum(p['canonical'] == [p['url']] for p in fetched),
               'noindex': sum('noindex' in (p['robots_meta'] + ' ' + (p['x_robots_tag'] or '')).lower() for p in fetched),
               'images_without_alt': sum(p['images_without_alt'] for p in fetched)}
    report = {'schema_version': 1, 'started_at_utc': started, 'finished_at_utc': datetime.now(timezone.utc).isoformat(),
              'base': origin, 'user_agent': UA, 'method': 'GET, initial HTML, 5 workers, 1s delay per request, 25s timeout, UTF-8',
              'scope': 'URLs in this sitemap only; no JS rendering, WAF logs, media crawl, crawler identity verification, indexing or AI answers',
              'robots_txt': robots_text, 'summary': summary, 'pages': pages}
    with open(args.out, 'w', encoding='utf-8') as handle: json.dump(report, handle, ensure_ascii=False, indent=2); handle.write('\n')
    print(json.dumps(summary, ensure_ascii=False))
    if len(fetched) != len(urls): raise SystemExit(1)

if __name__ == '__main__': main()
