Untitled

Eonara avatar
Eonara
python
03/01/2026 6:10 AM
28.9 KB
15
Indexable
"""
URL Processing Tool with PHP API Integration
Processes URLs through various PHP payment gateway APIs with multi-threading support.
Features: progress resume, live stats, graceful shutdown, structured logging, auto-retry.
"""

import os
import sys
import subprocess
import logging
import json
import signal
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import List, Optional, Dict
from dataclasses import dataclass, field
from threading import Lock, Event
import time


# ─── ANSI Colors ─────────────────────────────────────────────────────────────
class C:
    """ANSI color codes for terminal output."""
    RESET   = "\033[0m"
    BOLD    = "\033[1m"
    DIM     = "\033[2m"
    RED     = "\033[91m"
    GREEN   = "\033[92m"
    YELLOW  = "\033[93m"
    BLUE    = "\033[94m"
    MAGENTA = "\033[95m"
    CYAN    = "\033[96m"
    WHITE   = "\033[97m"
    BG_RED  = "\033[41m"
    BG_GREEN = "\033[42m"


# ─── Configuration ───────────────────────────────────────────────────────────
@dataclass
class Config:
    """Configuration settings for the processor."""
    PHP_EXECUTABLE: str = r'C:\xampp\php\php.exe'
    INPUT_FILE: str = 'cc.txt'
    PROGRESS_FILE: str = 'progress.json'
    ERROR_FILE: str = 'errors.txt'
    LOG_FILE: str = 'processor.log.json'
    API_FOLDER: str = 'api'

    API_CONFIGS: Dict[str, str] = field(default_factory=lambda: {
        '1': 'vanco',
        '2': 'adyen',
    })

    MAX_RETRIES: int = 2
    RETRY_BACKOFF: float = 0.5        # seconds between retries (doubles each attempt)
    TIMEOUT_SECONDS: int = 45
    PROGRESS_SAVE_INTERVAL: int = 10   # flush progress to disk every N lines

    def __post_init__(self):
        """Validate configuration on initialization."""
        php_path = Path(self.PHP_EXECUTABLE)
        if not php_path.exists():
            print(f"{C.YELLOW}  Warning: PHP executable not found: {self.PHP_EXECUTABLE}{C.RESET}")
            print(f"{C.DIM}  Trying system PATH...{C.RESET}")
            # Try to find php on PATH
            import shutil
            found = shutil.which("php")
            if found:
                self.PHP_EXECUTABLE = found
                print(f"{C.GREEN}  Found PHP at: {found}{C.RESET}")
            else:
                raise FileNotFoundError(f"PHP executable not found: {self.PHP_EXECUTABLE}")

        api_folder = Path(self.API_FOLDER)
        if not api_folder.exists():
            raise FileNotFoundError(f"API folder not found: {self.API_FOLDER}")

        if not self.API_CONFIGS:
            raise ValueError("API_CONFIGS cannot be empty")

        # Validate all API PHP files exist at init
        for key, name in self.API_CONFIGS.items():
            php_file = api_folder / name
            if php_file.suffix != '.php':
                php_file = php_file.with_suffix('.php')
            if not php_file.exists():
                print(f"{C.YELLOW}  Warning: API '{name}' file not found: {php_file}{C.RESET}")


# ─── JSON Log Formatter ─────────────────────────────────────────────────────
class JsonFormatter(logging.Formatter):
    """Outputs each log record as a single JSON line."""
    def format(self, record):
        return json.dumps({
            'time': self.formatTime(record, '%Y-%m-%d %H:%M:%S'),
            'level': record.levelname,
            'message': record.getMessage(),
        })


# ─── Main Processor ─────────────────────────────────────────────────────────
class URLProcessor:
    """Processes URLs through PHP APIs with threading, progress tracking, and live stats."""

    def __init__(self, config: Config):
        self.config = config
        self._lock_progress = Lock()
        self._lock_error = Lock()
        self._lock_stats = Lock()
        self._lock_print = Lock()
        self._shutdown = Event()
        self.stats = {'processed': 0, 'successful': 0, 'failed': 0, 'errors': 0}
        self.total_urls = 0
        self.api_file_cache: Dict[str, Optional[Path]] = {}
        self.progress_counter = 0
        self._setup_logging()
        self._setup_signal_handlers()

    # ── Logging ──────────────────────────────────────────────────────────────

    def _setup_logging(self) -> None:
        """Configure JSON file logging + console logging (fresh each run)."""
        root = logging.getLogger()
        root.setLevel(logging.INFO)
        root.handlers.clear()

        # JSON file — overwrite each run
        fh = logging.FileHandler(self.config.LOG_FILE, mode='w', encoding='utf-8')
        fh.setFormatter(JsonFormatter())
        root.addHandler(fh)

        # Console — only warnings+ to avoid noise during threaded output
        ch = logging.StreamHandler(sys.stderr)
        ch.setLevel(logging.WARNING)
        ch.setFormatter(logging.Formatter('%(levelname)s: %(message)s'))
        root.addHandler(ch)

        self.logger = logging.getLogger(__name__)

    # ── Graceful Shutdown ────────────────────────────────────────────────────

    def _setup_signal_handlers(self) -> None:
        """Register Ctrl+C handler for graceful shutdown."""
        def handler(signum, frame):
            if not self._shutdown.is_set():
                self._safe_print(f"\n{C.YELLOW}{C.BOLD}  Shutting down gracefully... "
                                 f"(waiting for active tasks){C.RESET}")
                self._shutdown.set()
        signal.signal(signal.SIGINT, handler)

    # ── Thread-safe Printing ─────────────────────────────────────────────────

    def _safe_print(self, msg: str) -> None:
        """Print without garbled output in multi-threaded context."""
        with self._lock_print:
            print(msg, flush=True)

    # ── Screen ───────────────────────────────────────────────────────────────

    @staticmethod
    def clear_screen() -> None:
        os.system('cls' if os.name == 'nt' else 'clear')

    @staticmethod
    def banner() -> None:
        print(f"""
{C.CYAN}{C.BOLD}  ╔══════════════════════════════════════╗
  ║        EONARA - URL Processor        ║
  ╚══════════════════════════════════════╝{C.RESET}
""")

    # ── API File Resolution ──────────────────────────────────────────────────

    def _get_api_file_path(self, api_name: str) -> Optional[Path]:
        """Resolve and cache the full path to a PHP API script."""
        if api_name in self.api_file_cache:
            return self.api_file_cache[api_name]

        full_path = Path(self.config.API_FOLDER) / api_name
        if full_path.suffix != '.php':
            full_path = full_path.with_suffix('.php')

        if full_path.exists():
            self.api_file_cache[api_name] = full_path
            return full_path

        self.logger.error(f"API file not found: {full_path}")
        self.api_file_cache[api_name] = None
        return None

    # ── PHP Execution ────────────────────────────────────────────────────────

    def call_php_api(self, url: str, api_name: str) -> Optional[str]:
        """
        Execute a PHP script with the given URL piped to stdin.
        Retries with exponential backoff on timeout/failure.
        Returns stripped stdout or None.
        """
        file_path = self._get_api_file_path(api_name)
        if not file_path:
            return None

        backoff = self.config.RETRY_BACKOFF

        for attempt in range(1, self.config.MAX_RETRIES + 1):
            if self._shutdown.is_set():
                return None

            process = None
            try:
                process = subprocess.Popen(
                    [self.config.PHP_EXECUTABLE, str(file_path)],
                    stdin=subprocess.PIPE,
                    stdout=subprocess.PIPE,
                    stderr=subprocess.PIPE,
                    text=True,
                    encoding='utf-8',
                    errors='replace',
                )
                output, error = process.communicate(
                    input=url,
                    timeout=self.config.TIMEOUT_SECONDS,
                )

                if error and error.strip():
                    self.logger.debug(f"PHP stderr (attempt {attempt}): {error.strip()}")

                if output and output.strip():
                    return output.strip()

                # Empty output — treat as failure, retry
                self.logger.debug(f"Empty output on attempt {attempt} for {api_name}")

            except subprocess.TimeoutExpired:
                self.logger.info(f"Timeout attempt {attempt}/{self.config.MAX_RETRIES} [{api_name}]")
                if process:
                    try:
                        process.kill()
                        process.communicate(timeout=5)
                    except Exception:
                        pass
            except Exception as e:
                self.logger.error(f"Error attempt {attempt}/{self.config.MAX_RETRIES} [{api_name}]: {e}")

            # Backoff before retry
            if attempt < self.config.MAX_RETRIES:
                time.sleep(backoff)
                backoff *= 2

        return None

    # ── File I/O ─────────────────────────────────────────────────────────────

    def read_urls_from_file(self, file_path: str) -> List[str]:
        """Read non-empty lines from input file."""
        try:
            with open(file_path, 'r', encoding='utf-8', errors='replace') as f:
                urls = [line.strip() for line in f if line.strip()]
            self.logger.info(f"Loaded {len(urls)} lines from {file_path}")
            return urls
        except FileNotFoundError:
            self.logger.error(f"Input file not found: {file_path}")
            return []
        except Exception as e:
            self.logger.error(f"Error reading {file_path}: {e}")
            return []

    def save_progress(self, force: bool = False) -> None:
        """Increment and periodically flush progress to disk (thread-safe)."""
        with self._lock_progress:
            self.progress_counter += 1
            if force or self.progress_counter % self.config.PROGRESS_SAVE_INTERVAL == 0:
                try:
                    with open(self.config.PROGRESS_FILE, 'w') as f:
                        json.dump({'lines_processed': self.progress_counter}, f)
                except Exception as e:
                    self.logger.error(f"Error saving progress: {e}")

    def save_error_url(self, url: str) -> None:
        """Append a failed URL to the error file (thread-safe)."""
        with self._lock_error:
            try:
                with open(self.config.ERROR_FILE, 'a', encoding='utf-8') as f:
                    f.write(url + '\n')
            except Exception as e:
                self.logger.error(f"Error writing to error file: {e}")

    # ── Progress Resume ──────────────────────────────────────────────────────

    def get_start_line(self) -> int:
        """Check for saved progress and ask user whether to resume."""
        prog_path = Path(self.config.PROGRESS_FILE)
        if not prog_path.exists():
            return 1

        try:
            with open(prog_path, 'r') as f:
                data = json.load(f)
            lines_done = data.get('lines_processed', 0)

            if lines_done > 0:
                print(f"  {C.GREEN}Found saved progress: {lines_done} lines processed{C.RESET}")
                choice = input("  Continue from where you left off? (y/n): ").strip().lower()
                if choice == 'y':
                    self.progress_counter = lines_done
                    return lines_done + 1
                else:
                    self._reset_progress()
                    return 1
        except Exception as e:
            self.logger.warning(f"Could not read progress file: {e}")

        return 1

    def _reset_progress(self) -> None:
        """Reset progress file to zero."""
        try:
            with open(self.config.PROGRESS_FILE, 'w') as f:
                json.dump({'lines_processed': 0}, f)
        except Exception as e:
            self.logger.error(f"Error resetting progress: {e}")

    # ── Stats ────────────────────────────────────────────────────────────────

    def _update_stats(self, key: str) -> None:
        with self._lock_stats:
            self.stats[key] += 1

    def _get_stats_snapshot(self) -> dict:
        with self._lock_stats:
            return dict(self.stats)

    def _progress_line(self) -> str:
        """Build a compact one-line progress summary."""
        s = self._get_stats_snapshot()
        done = s['processed']
        pct = (done / self.total_urls * 100) if self.total_urls else 0
        return (f"  {C.DIM}[{done}/{self.total_urls}  {pct:5.1f}%]  "
                f"{C.GREEN}OK:{s['successful']}  {C.RED}FAIL:{s['failed']}  "
                f"{C.YELLOW}ERR:{s['errors']}{C.RESET}")

    def display_stats(self, elapsed: float) -> None:
        """Display final processing statistics."""
        s = self.stats
        speed = s['processed'] / max(elapsed, 0.01)
        print(f"""
{C.BOLD}  {'─' * 46}
  RESULTS
  {'─' * 46}{C.RESET}
  {C.GREEN}Successful : {s['successful']}{C.RESET}
  {C.RED}Failed     : {s['failed']}{C.RESET}
  {C.YELLOW}Errors     : {s['errors']}{C.RESET}
  {C.CYAN}Total      : {s['processed']}{C.RESET}
  {C.DIM}Time       : {elapsed:.1f}s  ({speed:.1f} URLs/sec){C.RESET}
  {'─' * 46}
""")

    # ── Single URL Processing ────────────────────────────────────────────────

    def process_single_url(self, url: str, line_number: int, api_name: str) -> None:
        """Process one URL and update stats/progress."""
        if self._shutdown.is_set():
            return

        if not url:
            self._update_stats('errors')
            return

        output = self.call_php_api(url, api_name)

        if self._shutdown.is_set():
            return

        if output:
            self._safe_print(f"  {C.BLUE}[{line_number}]{C.RESET} {output}")
            self.save_progress()
            self._update_stats('successful')
        else:
            self.save_error_url(url)
            self._safe_print(f"  {C.BLUE}[{line_number}]{C.RESET} {C.RED}TIMEOUT / NO RESPONSE{C.RESET}")
            self._update_stats('failed')

        self._update_stats('processed')

        # Print live progress every 25 items
        with self._lock_stats:
            if self.stats['processed'] % 25 == 0:
                pass  # printed below outside lock
        if self.stats['processed'] % 25 == 0:
            self._safe_print(self._progress_line())

    # ── User Input Helpers ───────────────────────────────────────────────────

    @staticmethod
    def get_int_input(prompt: str, lo: int, hi: int) -> int:
        """Prompt until a valid integer in [lo, hi] is entered."""
        while True:
            try:
                val = int(input(prompt).strip())
                if lo <= val <= hi:
                    return val
                print(f"  Enter a number between {lo} and {hi}.")
            except ValueError:
                print("  Invalid number.")

    @staticmethod
    def get_choice(prompt: str, options: List[str]) -> Optional[str]:
        """Prompt for a choice from a list."""
        try:
            choice = input(prompt).strip()
            return choice if choice in options else None
        except (EOFError, KeyboardInterrupt):
            return None

    # ── Main Loop ────────────────────────────────────────────────────────────

    def run(self) -> None:
        """Main execution entrypoint."""
        self.clear_screen()
        self.banner()

        # ── Workers
        num_workers = self.get_int_input(
            f"  {C.BOLD}Workers{C.RESET} (1-50): ", 1, 50
        )

        # ── Mode
        print(f"\n  {C.BOLD}Mode:{C.RESET}")
        print(f"  1. Debug  {C.DIM}(sequential, verbose){C.RESET}")
        print(f"  2. Normal {C.DIM}(multi-threaded){C.RESET}")
        mode = self.get_choice("  Choice (1/2): ", ['1', '2'])
        if not mode:
            print("  Invalid. Exiting.")
            return
        debug_mode = mode == '1'

        # ── API selection
        print(f"\n  {C.BOLD}APIs:{C.RESET}")
        for k, v in self.config.API_CONFIGS.items():
            print(f"  {k}. {v}")

        api_choice = self.get_choice(
            f"\n  {C.BOLD}Pick API:{C.RESET} ",
            list(self.config.API_CONFIGS.keys()),
        )
        if not api_choice:
            print("  Invalid API. Exiting.")
            return

        api_name = self.config.API_CONFIGS[api_choice]
        print(f"  {C.GREEN}API: {api_name}{C.RESET}")

        # ── Validate API file exists
        if not self._get_api_file_path(api_name):
            print(f"\n  {C.RED}PHP script not found for '{api_name}'. Exiting.{C.RESET}")
            return

        # ── Load URLs
        start_line = self.get_start_line()
        all_urls = self.read_urls_from_file(self.config.INPUT_FILE)
        if not all_urls:
            print(f"  {C.RED}No URLs to process. Add lines to {self.config.INPUT_FILE}{C.RESET}")
            return

        urls = all_urls[start_line - 1:]
        self.total_urls = len(urls)

        print(f"\n  {C.CYAN}Starting from line {start_line}, "
              f"processing {self.total_urls} URLs with {num_workers} workers...{C.RESET}\n")

        # ── Reset stats
        self.stats = {'processed': 0, 'successful': 0, 'failed': 0, 'errors': 0}
        start_time = time.time()

        # ── Process
        try:
            if debug_mode:
                for idx, url in enumerate(urls, start=start_line):
                    if self._shutdown.is_set():
                        break
                    self.process_single_url(url, idx, api_name)
            else:
                with ThreadPoolExecutor(max_workers=num_workers) as executor:
                    futures = {
                        executor.submit(self.process_single_url, url, idx, api_name): idx
                        for idx, url in enumerate(urls, start=start_line)
                    }
                    for future in as_completed(futures):
                        if self._shutdown.is_set():
                            # Cancel pending futures
                            for f in futures:
                                f.cancel()
                            break
                        try:
                            future.result()
                        except Exception as e:
                            self.logger.error(f"Future error: {e}")
        except Exception as e:
            self.logger.error(f"Processing error: {e}")

        # ── Finalize
        elapsed = time.time() - start_time
        self.save_progress(force=True)
        self.display_stats(elapsed)

        if self._shutdown.is_set():
            print(f"  {C.YELLOW}Interrupted — progress saved. Resume next run.{C.RESET}\n")


# ─── Entry Point ─────────────────────────────────────────────────────────────

def main():
    try:
        config = Config()
        processor = URLProcessor(config)
        processor.run()
        print(f"  {C.GREEN}{C.BOLD}All Done!{C.RESET}\n")
    except FileNotFoundError as e:
        print(f"\n  {C.RED}Config Error: {e}{C.RESET}")
        sys.exit(1)
    except KeyboardInterrupt:
        print(f"\n  {C.YELLOW}Interrupted.{C.RESET}")
    except Exception as e:
        logging.error(f"Unexpected error: {e}", exc_info=True)
        print(f"\n  {C.RED}Error: {e}{C.RESET}")
        sys.exit(1)


if __name__ == "__main__":
    main()
Editor is loading...
Leave a Comment