diff --git a/.github/workflows/02.1.intelligent_link_cleaner.yml b/.github/workflows/02.1.intelligent_link_cleaner.yml
index 49e6acbb..cf447bca 100644
--- a/.github/workflows/02.1.intelligent_link_cleaner.yml
+++ b/.github/workflows/02.1.intelligent_link_cleaner.yml
@@ -1,118 +1,120 @@
-name: 02.1. Intelligent Link Cleaner & Dedup
-
-on:
- schedule:
- - cron: '0 0 1 */3 *' # Quarterly: 1st of Jan, Apr, Jul, Oct
- workflow_dispatch:
- inputs:
- force_full_check:
- description: 'Force full re-validation (bypasses 21-day cache)'
- type: boolean
- default: false
-
-permissions:
- contents: write
- pull-requests: write
- issues: write
-
-concurrency:
- group: link-cleaner-${{ github.ref }}
- cancel-in-progress: true
-
-jobs:
- intelligent-clean-sequential:
- runs-on: ubuntu-latest
- env:
- FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
- steps:
- - name: Repository Synchronization
- uses: actions/checkout@v6
- with:
- ref: develop
-
- - name: Python 3.11 Environment Provisioning
- uses: actions/setup-python@v6
- with:
- python-version: '3.11'
- cache: 'pip'
-
- - name: Dependencies Installation
- run: |
- python -m pip install --upgrade pip
- pip install --no-cache-dir pydantic PyGithub aiohttp beautifulsoup4 httpx fake-useragent pytz python-dotenv playwright PyYAML tenacity
-
- - name: Cache Playwright Binaries
- uses: actions/cache@v5
- id: playwright-cache
- with:
- path: ~/.cache/ms-playwright
- key: ${{ runner.os }}-playwright-cleaner
-
- - name: Install Playwright Browsers
- if: steps.playwright-cache.outputs.cache-hit != 'true'
- run: playwright install chromium --with-deps
-
- - name: Restore Link Checker Cache
- uses: actions/cache/restore@v4
- id: link-checker-cache
- with:
- path: |
- shard_result.json
- triage_report.md
- key: link-cleaner-results-${{ github.run_id }}-${{ github.run_attempt }}
- restore-keys: |
- link-cleaner-results-${{ github.run_id }}-
- link-cleaner-results-
-
- - name: Global Intelligent Cleaning Execution (Sequential Fast-Track)
- env:
- GEMINI_API_KEY_1: ${{ secrets.GEMINI_API_KEY_1 }}
- GEMINI_API_KEY_2: ${{ secrets.GEMINI_API_KEY_2 }}
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- FORCE_FULL_CHECK: ${{ github.event.inputs.force_full_check || 'false' }}
- PYTHONPATH: ${{ github.workspace }}
- PYTHONUNBUFFERED: 1
- run: |
- if [ -f shard_result.json ]; then
- echo "shard_result.json found in cache/restored files. Skipping health checker execution."
- else
- python src/intelligent_health_checker.py
- fi
-
- - name: Run Inventory Reducer & Apply Changes
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- PYTHONPATH: ${{ github.workspace }}
- run: |
- # Since it's sequential, the script produces shard_result.json (default name for no shard)
- python src/inventory_reducer.py
-
- - name: Open Triage Issue for High-Value Links
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- if [ -f triage_report.md ]; then
- ISSUE_TITLE="🚨 Manual Triage Required: High-Value Links Failing ($(date +'%Y-%m-%d'))"
- EXISTING_ISSUE=$(gh issue list --search "$ISSUE_TITLE" --state open --json number --jq '.[0].number')
-
- if [ -z "$EXISTING_ISSUE" ]; then
- gh issue create --title "$ISSUE_TITLE" --body-file triage_report.md --label "triage, automated-report" || gh issue create --title "$ISSUE_TITLE" --body-file triage_report.md
- else
- echo "A triage issue already exists (#$EXISTING_ISSUE). Updating with new report..."
- gh issue comment $EXISTING_ISSUE --body-file triage_report.md
- fi
- fi
-
- - name: Ensure Cache Files Exist
- if: always()
- run: |
- touch shard_result.json triage_report.md
-
- - name: Save Link Checker Cache
- if: always()
- uses: actions/cache/save@v4
- with:
- path: |
- shard_result.json
- triage_report.md
- key: link-cleaner-results-${{ github.run_id }}-${{ github.run_attempt }}
+name: 02.1. Intelligent Link Cleaner & Dedup
+
+on:
+ schedule:
+ - cron: '0 0 1 */3 *' # Quarterly: 1st of Jan, Apr, Jul, Oct
+ workflow_dispatch:
+ inputs:
+ force_full_check:
+ description: 'Force full re-validation (bypasses 21-day cache)'
+ type: boolean
+ default: false
+
+permissions:
+ contents: write
+ pull-requests: write
+ issues: write
+
+concurrency:
+ group: link-cleaner-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ intelligent-clean-sequential:
+ runs-on: ubuntu-latest
+ env:
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
+ steps:
+ - name: Repository Synchronization
+ uses: actions/checkout@v6
+ with:
+ ref: develop
+
+ - name: Python 3.11 Environment Provisioning
+ uses: actions/setup-python@v6
+ with:
+ python-version: '3.11'
+ cache: 'pip'
+
+ - name: Dependencies Installation
+ run: |
+ python -m pip install --upgrade pip
+ pip install --no-cache-dir pydantic PyGithub aiohttp beautifulsoup4 httpx fake-useragent pytz python-dotenv playwright PyYAML tenacity
+
+ - name: Cache Playwright Binaries
+ uses: actions/cache@v5
+ id: playwright-cache
+ with:
+ path: ~/.cache/ms-playwright
+ key: ${{ runner.os }}-playwright-cleaner
+
+ - name: Install Playwright Browsers
+ if: steps.playwright-cache.outputs.cache-hit != 'true'
+ run: playwright install chromium --with-deps
+
+ - name: Restore Link Checker Cache
+ uses: actions/cache/restore@v4
+ id: link-checker-cache
+ with:
+ path: |
+ shard_result.json
+ triage_report.md
+ health_check_checkpoint.json
+ key: link-cleaner-results-${{ github.run_id }}-${{ github.run_attempt }}
+ restore-keys: |
+ link-cleaner-results-${{ github.run_id }}-
+ link-cleaner-results-
+
+ - name: Global Intelligent Cleaning Execution (Sequential Fast-Track)
+ env:
+ GEMINI_API_KEY_1: ${{ secrets.GEMINI_API_KEY_1 }}
+ GEMINI_API_KEY_2: ${{ secrets.GEMINI_API_KEY_2 }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ FORCE_FULL_CHECK: ${{ github.event.inputs.force_full_check || 'false' }}
+ PYTHONPATH: ${{ github.workspace }}
+ PYTHONUNBUFFERED: 1
+ run: |
+ if [ -s shard_result.json ]; then
+ echo "shard_result.json found in cache/restored files and has content. Skipping health checker execution."
+ else
+ python src/intelligent_health_checker.py
+ fi
+
+ - name: Run Inventory Reducer & Apply Changes
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PYTHONPATH: ${{ github.workspace }}
+ run: |
+ # Since it's sequential, the script produces shard_result.json (default name for no shard)
+ python src/inventory_reducer.py
+
+ - name: Open Triage Issue for High-Value Links
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ if [ -f triage_report.md ] && [ -s triage_report.md ]; then
+ ISSUE_TITLE="🚨 Manual Triage Required: High-Value Links Failing ($(date +'%Y-%m-%d'))"
+ EXISTING_ISSUE=$(gh issue list --search "$ISSUE_TITLE" --state open --json number --jq '.[0] | select(. != null) | .number')
+
+ if [ -z "$EXISTING_ISSUE" ] || [ "$EXISTING_ISSUE" = "null" ]; then
+ gh issue create --title "$ISSUE_TITLE" --body-file triage_report.md --label "triage, automated-report" || gh issue create --title "$ISSUE_TITLE" --body-file triage_report.md
+ else
+ echo "A triage issue already exists (#$EXISTING_ISSUE). Updating with new report..."
+ gh issue comment $EXISTING_ISSUE --body-file triage_report.md
+ fi
+ fi
+
+ - name: Ensure Cache Files Exist
+ if: always()
+ run: |
+ touch shard_result.json triage_report.md health_check_checkpoint.json
+
+ - name: Save Link Checker Cache
+ if: always()
+ uses: actions/cache/save@v4
+ with:
+ path: |
+ shard_result.json
+ triage_report.md
+ health_check_checkpoint.json
+ key: link-cleaner-results-${{ github.run_id }}-${{ github.run_attempt }}
diff --git a/src/intelligent_health_checker.py b/src/intelligent_health_checker.py
index ccc6db63..c73edb30 100644
--- a/src/intelligent_health_checker.py
+++ b/src/intelligent_health_checker.py
@@ -1,365 +1,424 @@
-import asyncio
-import json
-import os
-import re
-import httpx
-import random
-import yaml
-import hashlib
-from datetime import datetime
-import argparse
-from typing import Dict, List, Set, Tuple, Optional, Any
-from src.config import GH_TOKEN, TARGET_REPO, GEMINI_API_KEY, NUBENETES_CATEGORIES, MADRID_TZ
-from src.gitops_manager import RepositoryController
-from src.markdown_ast import MarkdownSanitizer
-from src.agentic_curator import AgenticCurator
-from src.logger import log_event
-from src.gemini_utils import call_gemini_with_retry, normalize_url
-from src.inventory_manager import load_inventory, get_shard_name, TOTAL_SHARDS
-
-# Configuración de Excepciones
-CORE_FILES = ["docs/index.md", "README.md", "docs/about.md"]
-MEMORY_FILE = "src/memory/health_learning.json"
-
-class IntelligentLinkCleaner:
- def __init__(self, shard_index: int = None, total_shards: int = TOTAL_SHARDS):
- self.shard_index = shard_index
- self.total_shards = total_shards
- self.git_controller = RepositoryController(GH_TOKEN, TARGET_REPO)
- self.sanitizer = MarkdownSanitizer()
- self.curator = AgenticCurator()
- self.ai_semaphore = asyncio.Semaphore(5)
- self.link_registry: Dict[str, List[Dict]] = {}
- self.dead_links: Dict[str, Tuple[str, str]] = {}
- self.learning_data = self._load_memory()
-
- # Determine the shard file if sharded mode
- self.shard_file = f"shard_{self.shard_index:02d}.yaml" if self.shard_index is not None else None
- self.inventory = load_inventory(self.shard_file)
-
- self.full_report_metrics = []
- self.detailed_stats = {"total_scanned": 0, "skipped_recent": 0, "by_file": {}, "operation_types": {"removals": 0, "consolidated": 0, "healed": 0, "enriched": 0}}
- self.stats = {"total_links": 0, "dead_links_removed": 0, "orphans_fixed": 0, "enriched_descriptions": 0}
-
- def _load_memory(self) -> Dict:
- if os.path.exists(MEMORY_FILE):
- try: return json.load(open(MEMORY_FILE, 'r'))
- except Exception as e:
- log_event(f"[WARN] load health learning memory: {str(e)[:100]}")
- return {"domains": {}, "known_soft_404_patterns": []}
-
- def _save_memory(self):
- os.makedirs(os.path.dirname(MEMORY_FILE), exist_ok=True)
- json.dump(self.learning_data, open(MEMORY_FILE, "w"), indent=2)
-
-
-
- async def execute_clean_cycle(self):
- log_event("STARTING INTELLIGENT CLEANING CYCLE", section_break=True)
- # 1. Map all links and Importance Markers
- for root, _, files in os.walk("docs"):
- for f in files:
- if f.endswith(".md"):
- path = os.path.join(root, f)
- content = open(path, "r").read()
-
- # Mandate 23: Detect Mosaic boundaries in index.md
- mosaic_lines = set()
- if path == "docs/index.md":
- # Find all center blocks and identify the one that looks like the YouTube mosaic
- mosaics = list(re.finditer(r'
\s*\n(.*?)\n\s*', content, re.DOTALL))
- target_mosaic = None
- max_yt_links = 0
-
- for m in mosaics:
- yt_count = m.group(1).count("youtube.com")
- if yt_count > max_yt_links:
- max_yt_links = yt_count
- target_mosaic = m
-
- if target_mosaic:
- mosaic_text = target_mosaic.group(1)
- # We find the start line of this specific block
- start_pos = target_mosaic.start(1)
- start_line = content[:start_pos].count('\n')
- line_count = mosaic_text.count('\n') + 1
- for i in range(start_line, start_line + line_count):
- mosaic_lines.add(i)
- log_event(f"[*] YouTube Mosaic identified in docs/index.md (Lines {start_line}-{start_line+line_count})")
-
- lines = content.splitlines()
- for idx, line in enumerate(lines):
- # Detect standard markdown links
- matches = list(re.finditer(r'(\*\*|==)?\s*\[(.*?)\]\((https?://.*?)\)\s*(\*\*|==)?\s*(.*)', line))
-
- # MANDATE 23: Also detect YouTube links in iframes for the videos section
- if path == "docs/index.md" and "iframe" in line and "youtube.com" in line:
- iframe_match = re.search(r'src="(https?://www\.youtube\.com/.*?)"', line)
- if iframe_match:
- url = iframe_match.group(1)
- nu = normalize_url(url)
- # We treat iframes as non-important by default but they must be checked
- self.link_registry.setdefault(nu, []).append({
- "file": path, "line_index": idx, "url": url,
- "is_important": False, "is_iframe": True
- })
-
- for m in matches:
- fmt_pre, title, url, fmt_post, desc = m.groups()
- nu = normalize_url(url)
-
- # MANDATE 23: Skip YouTube Mosaic links in docs/index.md
- if path == "docs/index.md" and idx in mosaic_lines and "youtube.com" in nu:
- continue
-
- is_important = False
- if fmt_pre or fmt_post: is_important = True
- if "🌟" in title or "🌟" in (desc or ""): is_important = True
- if desc and len(desc.strip()) > 100: is_important = True
- if path in CORE_FILES: is_important = True
-
- self.link_registry.setdefault(nu, []).append({
- "file": path, "line_index": idx, "url": url,
- "is_important": is_important
- })
-
- unique_urls = list(self.link_registry.keys())
- random.shuffle(unique_urls)
-
- to_check = []
- for u in unique_urls:
- nu = normalize_url(u)
-
- # Shard filtering
- if self.shard_index is not None:
- if get_shard_name(nu) != self.shard_file:
- continue
-
- entry = self.inventory.get(nu, {})
- is_suspicious = False
- if entry.get("status") == "online":
- path = nu.split("://")[-1].rstrip("/")
- if path.count("/") <= 1 or any(kw in path for kw in ["/about", "/products", "/index", "/whats-new"]):
- is_suspicious = True
-
- if is_suspicious or entry.get("needs_ai_refresh") or os.getenv("FORCE_FULL_CHECK") == "true":
- to_check.append(u)
- elif (datetime.now().timestamp() - (entry.get("last_checked") or 0)) > (86400 * 21):
- to_check.append(u)
-
- total_to_check = len(to_check)
- log_event(f"[*] Queue: {total_to_check} links prioritized for validation.")
-
- # 2. Parallel Network Checks (Optimized with Mandate 22 & 33)
- BATCH_SIZE = 15 # Slightly smaller batch to accommodate deep fetch
- check_results = {}
- for i in range(0, total_to_check, BATCH_SIZE):
- batch = to_check[i:i+BATCH_SIZE]
- tasks = [self._check_url_logic(url) for url in batch]
- results = await asyncio.gather(*tasks)
- for url, res in zip(batch, results): check_results[url] = res
- if i % 45 == 0: log_event(f" [>] Progress: [{i}/{total_to_check}] links validated...")
-
- # 2.5. UNIVERSAL AI RESCUE
- to_rescue = [u for u, res in check_results.items() if not res[0] or res[1] == "generic_redirect_loss"]
- if to_rescue:
- AI_BATCH_SIZE = 10
- for i in range(0, len(to_rescue), AI_BATCH_SIZE):
- batch = to_rescue[i:i+AI_BATCH_SIZE]
- batch_info = []
- for u in batch:
- entry = self.inventory.get(normalize_url(u), {})
- batch_info.append({"url": u, "title": entry.get("title", u), "context": entry.get("description", "")})
-
- prompt = (
- "You act as a Technical Librarian. Rescue missing resources based on title and description.\n"
- "Consider acquisitions (Ansible->RedHat, Nginx->F5) and cross-domain moves.\n"
- "Return ONLY JSON list: [{\"old_url\": \"...\", \"new_url\": \"...\"}, ...]\n\n"
- "RESOURCES:\n" + "\n".join([f"- {d['title']} | {d['url']}" for d in batch_info])
- )
-
- try:
- async with self.ai_semaphore:
- # Mandate 48: Use Flash/Lite for high-volume rescue to avoid Rate-Limits
- ai_results = await call_gemini_with_retry(prompt, prefer_flash=True, use_grounding=True, role="Link-Rescue")
- if isinstance(ai_results, list):
- res_map = {normalize_url(r.get("old_url", "")): r.get("new_url") for r in ai_results}
- for u in batch:
- new_loc = res_map.get(normalize_url(u))
- if new_loc and new_loc.startswith("http") and "NONE" not in new_loc.upper():
- try:
- async with httpx.AsyncClient(timeout=10, follow_redirects=True, verify=False) as client:
- resp_new = await client.get(new_loc)
- if resp_new.status_code < 400:
- final_new = str(resp_new.url)
- u_p = u.split("://")[-1].rstrip("/")
- f_p = final_new.split("://")[-1].rstrip("/")
- if u_p.count("/") >= 3 and (f_p.count("/") <= 2 or any(kw in f_p for kw in ["/about", "/products", "/home", "/learn"])):
- log_event(f" [⚠️] AI proposed a generic redirect for {u} -> {new_loc}. Rejecting.")
- else:
- log_event(f" [✨] RESCUED: {u} -> {new_loc}")
- check_results[u] = (True, "resurrected", new_loc)
- except Exception as e:
- log_event(f"[WARN] verify rescued URL {new_loc[:50]}: {str(e)[:100]}")
- except Exception as e:
- log_event(f"[WARN] AI rescue batch: {str(e)[:100]}")
-
- # 2.8. Finalize Status
- log_event("FINALIZING STATUS AND METRICS...", section_break=True)
- for url, (alive, reason, final) in check_results.items():
- nu = normalize_url(url); entry = self.inventory.get(nu, {})
- score = entry.get("health_score")
- if score is None:
- score = 100.0
- score = (score * 0.8) + (100 if alive else 0) * 0.2
- entry["health_score"] = round(score, 1); entry["last_checked"] = datetime.now().timestamp()
-
- # Mandate 22 & 33 were handled in _check_url_logic to avoid sequential bottlenecks
- is_important = any(occ.get("is_important") for occ in self.link_registry.get(nu, []))
- if (entry.get("stars") or 0) >= 3: is_important = True
-
- status, final_reason = ("INCLUDED", reason) if alive else ("FILTERED", reason)
-
- if not alive or reason == "generic_redirect_loss":
- if is_important:
- entry["status"] = "review_required"
- entry["review_metadata"] = {
- "original_url": url, "proposed_url": final if final else "NONE",
- "reason": f"High-Value Protection: {reason}", "timestamp": datetime.now().isoformat()
- }
- log_event(f" [⚠️] REVIEW STORED: {url}")
- status, final_reason = "INCLUDED", f"Preserved for Review ({reason})"
- elif score < 20:
- entry["status"] = "dead"; self.dead_links[url] = (None, reason)
- status, final_reason = "FILTERED", f"Dead: {reason}"
- elif final and alive:
- self.dead_links[url] = (f"CANONICAL:{final}", "Redirect/Resurrection")
- final_reason = "Updated (Canonical)"
-
- self.full_report_metrics.append({
- "url": url, "status": status, "reason": final_reason,
- "category": entry.get("category", "N/A"), "post_date": entry.get("pub_date"),
- "source": "Health Checker", "impact_score": (entry.get("stars") or 0) * 20,
- "language": entry.get("language", "EN"), "type": entry.get("resource_type", "Ref")
- })
- self.inventory[nu] = entry
-
- await self.apply_changes()
-
- async def apply_changes(self):
- # Export JSON artifact for the reducer instead of applying Git changes directly
- output_payload = {
- "shard_index": self.shard_index,
- "inventory_updates": self.inventory,
- "dead_links": self.dead_links,
- "full_report_metrics": self.full_report_metrics
- }
-
- out_name = f"shard_result_{self.shard_index:02d}.json" if self.shard_index is not None else "shard_result.json"
- with open(out_name, "w") as f:
- json.dump(output_payload, f, indent=2)
-
- log_event(f"EXPORTED SHARD RESULTS TO {out_name}", section_break=True)
-
- # --- AUTOMATED TRIAGE REPORT GENERATION ---
- triage_links = []
- for url, meta in self.inventory.items():
- if meta.get('status') == 'review_required':
- triage_links.append({"url": url, "stars": meta.get('stars') or 0, "desc": meta.get('description', 'N/A')})
-
- if triage_links:
- # Sort by stars (impact) DESC
- triage_links.sort(key=lambda x: x['stars'], reverse=True)
- with open("triage_report.md", "w") as f:
- f.write(f"### 🚨 Manual Triage Required ({len(triage_links)} High-Value Links)\n\n")
- f.write("The following resources were flagged for manual review because they failed health checks but are considered high-value assets.\n\n")
- f.write("| Impact | Resource | Description |\n| :---: | :--- | :--- |\n")
- for item in triage_links:
- f.write(f"| {'🌟'*item['stars']} | {item['url']} | {item['desc']} |\n")
-
- async def _check_url_logic(self, url: str) -> Tuple[bool, str, Optional[str]]:
- headers = {"User-Agent": "Mozilla/5.0", "Accept-Language": "en-US,en;q=0.5"}
- parked = ["buy this domain", "parked free", "domain is for sale"]
- nu = normalize_url(url); entry = self.inventory.get(nu, {})
-
- try:
- async with httpx.AsyncClient(headers=headers, follow_redirects=True, timeout=12) as client:
- resp = await client.get(url)
- if resp.status_code < 400:
- text = resp.text.lower()
- final_url = str(resp.url)
- from src.gemini_utils import sanitize_trailing_slashes
- final_url = sanitize_trailing_slashes(final_url)
-
- if any(kw in text for kw in parked): return False, "parked", None
-
- # Mandate 22: Content Drift detection (SHA256) - Integrated here for speed
- new_hash = hashlib.sha256(resp.text.encode()).hexdigest()
- old_hash = entry.get("content_hash", "N/A")
- if old_hash != "N/A" and new_hash != old_hash:
- log_event(f" [!] DRIFT DETECTED: {url}")
- entry["needs_ai_refresh"] = True
- entry["content_hash"] = new_hash
-
- # Mandate 33: License Guard for GitHub
- if "github.com" in url:
- try:
- from src.agentic_curator import _get_github_activity
- gh_meta = await _get_github_activity(url)
- if gh_meta.get("gh_license"):
- old_lic = entry.get("gh_license", "N/A")
- new_lic = gh_meta["gh_license"]
- if old_lic != "N/A" and old_lic != new_lic:
- if any(x in new_lic.upper() for x in ["BSL", "SSPL", "PROPRIETARY"]):
- log_event(f" [⚖️] LICENSE ALERT: {url} -> {new_lic}")
- entry["status"] = "review_required"
- entry["gh_license"] = new_lic
- except Exception as e:
- log_event(f"[WARN] license guard check for {url}: {str(e)[:100]}")
-
- if final_url != url:
- u_p = url.split("://")[-1].rstrip("/"); f_p = final_url.split("://")[-1].rstrip("/")
- if u_p == f_p: return True, "normalized_slashes", final_url
- if u_p.count("/") >= 3 and (f_p.count("/") <= 2 or any(kw in f_p for kw in ["/about", "/products", "/home", "/learn"])):
- return False, "generic_redirect_loss", None
-
- return True, "OK", final_url if final_url != url else None
-
- if resp.status_code in [404, 410]:
- if "github.com" in url:
- if "/master/" in url:
- h = url.replace("/master/", "/main/")
- try:
- if (await client.get(h)).status_code < 400: return True, "healed", h
- except Exception as e:
- log_event(f"[WARN] heal master->main for {url}: {str(e)[:100]}")
- m = re.search(r'(https?://github\.com/[^/]+/[^/]+)', url)
- if m and (await client.get(m.group(1))).status_code < 400: return True, "consolidated", m.group(1)
- return False, "404", None
- return True, f"Soft Block {resp.status_code}", None
- except Exception as e:
- log_event(f"[WARN] URL check logic for {url}: {str(e)[:100]}")
- return True, "Error", None
-
- async def prune_orphaned_metadata(self):
- valid_map = {}
- for root, _, files in os.walk("docs"):
- for f in files:
- if f.endswith(".md"):
- p = os.path.join(root, f); c = open(p, "r").read()
- for u in re.findall(r'\[.*?\]\((https?://.*?)\)', c): valid_map.setdefault(normalize_url(u), []).append(p)
- new_inv = {}
- for u, m in self.inventory.items():
- if u.startswith("INTRO:") or u in valid_map:
- if u in valid_map: m["v1_locations"] = sorted(list(set(valid_map[u])))
- new_inv[u] = m
- self.inventory = new_inv
-
-if __name__ == "__main__":
- parser = argparse.ArgumentParser()
- parser.add_argument("--shard-index", type=int, default=None, help="Index of the shard (0-63)")
- parser.add_argument("--total-shards", type=int, default=TOTAL_SHARDS, help="Total number of shards")
- args = parser.parse_args()
-
- cleaner = IntelligentLinkCleaner(shard_index=args.shard_index, total_shards=args.total_shards)
- asyncio.run(cleaner.execute_clean_cycle())
+import asyncio
+import json
+import os
+import re
+import httpx
+import random
+import yaml
+import hashlib
+from datetime import datetime
+import argparse
+from typing import Dict, List, Set, Tuple, Optional, Any
+from src.config import GH_TOKEN, TARGET_REPO, GEMINI_API_KEY, NUBENETES_CATEGORIES, MADRID_TZ
+from src.gitops_manager import RepositoryController
+from src.markdown_ast import MarkdownSanitizer
+from src.agentic_curator import AgenticCurator
+from src.logger import log_event
+from src.gemini_utils import call_gemini_with_retry, normalize_url
+from src.inventory_manager import load_inventory, get_shard_name, TOTAL_SHARDS
+
+# Configuración de Excepciones
+CORE_FILES = ["docs/index.md", "README.md", "docs/about.md"]
+MEMORY_FILE = "src/memory/health_learning.json"
+
+class IntelligentLinkCleaner:
+ def __init__(self, shard_index: int = None, total_shards: int = TOTAL_SHARDS):
+ self.shard_index = shard_index
+ self.total_shards = total_shards
+ self.git_controller = RepositoryController(GH_TOKEN, TARGET_REPO)
+ self.sanitizer = MarkdownSanitizer()
+ self.curator = AgenticCurator()
+ self.ai_semaphore = asyncio.Semaphore(5)
+ self.link_registry: Dict[str, List[Dict]] = {}
+ self.dead_links: Dict[str, Tuple[str, str]] = {}
+ self.learning_data = self._load_memory()
+
+ # Determine the shard file if sharded mode
+ self.shard_file = f"shard_{self.shard_index:02d}.yaml" if self.shard_index is not None else None
+ self.inventory = load_inventory(self.shard_file)
+
+ # Determine checkpoint file and load it
+ self.checkpoint_file = f"checkpoint_{self.shard_index:02d}.json" if self.shard_index is not None else "health_check_checkpoint.json"
+ self.checkpoint_data = self._load_checkpoint()
+
+ self.full_report_metrics = []
+ self.detailed_stats = {"total_scanned": 0, "skipped_recent": 0, "by_file": {}, "operation_types": {"removals": 0, "consolidated": 0, "healed": 0, "enriched": 0}}
+ self.stats = {"total_links": 0, "dead_links_removed": 0, "orphans_fixed": 0, "enriched_descriptions": 0}
+
+ def _load_memory(self) -> Dict:
+ if os.path.exists(MEMORY_FILE):
+ try: return json.load(open(MEMORY_FILE, 'r'))
+ except Exception as e:
+ log_event(f"[WARN] load health learning memory: {str(e)[:100]}")
+ return {"domains": {}, "known_soft_404_patterns": []}
+
+ def _save_memory(self):
+ os.makedirs(os.path.dirname(MEMORY_FILE), exist_ok=True)
+ json.dump(self.learning_data, open(MEMORY_FILE, "w"), indent=2)
+
+ def _load_checkpoint(self) -> Dict:
+ if os.path.exists(self.checkpoint_file) and os.path.getsize(self.checkpoint_file) > 0:
+ try:
+ with open(self.checkpoint_file, "r") as f:
+ data = json.load(f)
+ log_event(f"[*] Resuming from checkpoint file: {self.checkpoint_file}")
+ # Restore checkpoint data into self variables
+ self.inventory.update(data.get("inventory_updates", {}))
+ return data
+ except Exception as e:
+ log_event(f"[WARN] Failed to load checkpoint {self.checkpoint_file}: {str(e)[:100]}")
+ return {
+ "checked_urls": {}, # url -> [alive, reason, final_url]
+ "inventory_updates": {}, # url -> inventory_entry
+ "rescued_urls": [] # list of URLs that we attempted to rescue
+ }
+
+ def _save_checkpoint(self):
+ try:
+ with open(self.checkpoint_file, "w") as f:
+ json.dump(self.checkpoint_data, f, indent=2)
+ except Exception as e:
+ log_event(f"[WARN] Failed to save checkpoint {self.checkpoint_file}: {str(e)[:100]}")
+
+ def _delete_checkpoint(self):
+ if os.path.exists(self.checkpoint_file):
+ try:
+ os.remove(self.checkpoint_file)
+ log_event(f"[*] Cleaned up checkpoint file: {self.checkpoint_file}")
+ except Exception as e:
+ log_event(f"[WARN] Failed to delete checkpoint {self.checkpoint_file}: {str(e)[:100]}")
+
+
+
+ async def execute_clean_cycle(self):
+ log_event("STARTING INTELLIGENT CLEANING CYCLE", section_break=True)
+ # 1. Map all links and Importance Markers
+ for root, _, files in os.walk("docs"):
+ for f in files:
+ if f.endswith(".md"):
+ path = os.path.join(root, f)
+ content = open(path, "r").read()
+
+ # Mandate 23: Detect Mosaic boundaries in index.md
+ mosaic_lines = set()
+ if path == "docs/index.md":
+ # Find all center blocks and identify the one that looks like the YouTube mosaic
+ mosaics = list(re.finditer(r'\s*\n(.*?)\n\s*', content, re.DOTALL))
+ target_mosaic = None
+ max_yt_links = 0
+
+ for m in mosaics:
+ yt_count = m.group(1).count("youtube.com")
+ if yt_count > max_yt_links:
+ max_yt_links = yt_count
+ target_mosaic = m
+
+ if target_mosaic:
+ mosaic_text = target_mosaic.group(1)
+ # We find the start line of this specific block
+ start_pos = target_mosaic.start(1)
+ start_line = content[:start_pos].count('\n')
+ line_count = mosaic_text.count('\n') + 1
+ for i in range(start_line, start_line + line_count):
+ mosaic_lines.add(i)
+ log_event(f"[*] YouTube Mosaic identified in docs/index.md (Lines {start_line}-{start_line+line_count})")
+
+ lines = content.splitlines()
+ for idx, line in enumerate(lines):
+ # Detect standard markdown links
+ matches = list(re.finditer(r'(\*\*|==)?\s*\[(.*?)\]\((https?://.*?)\)\s*(\*\*|==)?\s*(.*)', line))
+
+ # MANDATE 23: Also detect YouTube links in iframes for the videos section
+ if path == "docs/index.md" and "iframe" in line and "youtube.com" in line:
+ iframe_match = re.search(r'src="(https?://www\.youtube\.com/.*?)"', line)
+ if iframe_match:
+ url = iframe_match.group(1)
+ nu = normalize_url(url)
+ # We treat iframes as non-important by default but they must be checked
+ self.link_registry.setdefault(nu, []).append({
+ "file": path, "line_index": idx, "url": url,
+ "is_important": False, "is_iframe": True
+ })
+
+ for m in matches:
+ fmt_pre, title, url, fmt_post, desc = m.groups()
+ nu = normalize_url(url)
+
+ # MANDATE 23: Skip YouTube Mosaic links in docs/index.md
+ if path == "docs/index.md" and idx in mosaic_lines and "youtube.com" in nu:
+ continue
+
+ is_important = False
+ if fmt_pre or fmt_post: is_important = True
+ if "🌟" in title or "🌟" in (desc or ""): is_important = True
+ if desc and len(desc.strip()) > 100: is_important = True
+ if path in CORE_FILES: is_important = True
+
+ self.link_registry.setdefault(nu, []).append({
+ "file": path, "line_index": idx, "url": url,
+ "is_important": is_important
+ })
+
+ unique_urls = list(self.link_registry.keys())
+ random.shuffle(unique_urls)
+
+ to_check = []
+ for u in unique_urls:
+ nu = normalize_url(u)
+
+ # Shard filtering
+ if self.shard_index is not None:
+ if get_shard_name(nu) != self.shard_file:
+ continue
+
+ # Checkpoint filtering: skip if already checked in checkpoint
+ if nu in self.checkpoint_data["checked_urls"]:
+ continue
+
+ entry = self.inventory.get(nu, {})
+ is_suspicious = False
+ if entry.get("status") == "online":
+ path = nu.split("://")[-1].rstrip("/")
+ if path.count("/") <= 1 or any(kw in path for kw in ["/about", "/products", "/index", "/whats-new"]):
+ is_suspicious = True
+
+ if is_suspicious or entry.get("needs_ai_refresh") or os.getenv("FORCE_FULL_CHECK") == "true":
+ to_check.append(u)
+ elif (datetime.now().timestamp() - (entry.get("last_checked") or 0)) > (86400 * 21):
+ to_check.append(u)
+
+ total_to_check = len(to_check)
+ log_event(f"[*] Queue: {total_to_check} links prioritized for validation (skipped {len(self.checkpoint_data['checked_urls'])} already checked).")
+
+ # 2. Parallel Network Checks (Optimized with Mandate 22 & 33)
+ BATCH_SIZE = 15 # Slightly smaller batch to accommodate deep fetch
+ check_results = {}
+ for nu, res in self.checkpoint_data["checked_urls"].items():
+ check_results[nu] = tuple(res)
+
+ for i in range(0, total_to_check, BATCH_SIZE):
+ batch = to_check[i:i+BATCH_SIZE]
+ tasks = [self._check_url_logic(url) for url in batch]
+ results = await asyncio.gather(*tasks)
+ for url, res in zip(batch, results):
+ check_results[url] = res
+ nu = normalize_url(url)
+ self.checkpoint_data["checked_urls"][nu] = list(res)
+ self.checkpoint_data["inventory_updates"][nu] = self.inventory.get(nu)
+
+ self._save_checkpoint()
+ if i % 45 == 0: log_event(f" [>] Progress: [{i}/{total_to_check}] links validated...")
+
+ # 2.5. UNIVERSAL AI RESCUE
+ to_rescue = [u for u, res in check_results.items() if not res[0] or res[1] == "generic_redirect_loss"]
+ to_rescue = [u for u in to_rescue if normalize_url(u) not in self.checkpoint_data["rescued_urls"]]
+
+ if to_rescue:
+ AI_BATCH_SIZE = 10
+ for i in range(0, len(to_rescue), AI_BATCH_SIZE):
+ batch = to_rescue[i:i+AI_BATCH_SIZE]
+ batch_info = []
+ for u in batch:
+ entry = self.inventory.get(normalize_url(u), {})
+ batch_info.append({"url": u, "title": entry.get("title", u), "context": entry.get("description", "")})
+
+ prompt = (
+ "You act as a Technical Librarian. Rescue missing resources based on title and description.\n"
+ "Consider acquisitions (Ansible->RedHat, Nginx->F5) and cross-domain moves.\n"
+ "Return ONLY JSON list: [{\"old_url\": \"...\", \"new_url\": \"...\"}, ...]\n\n"
+ "RESOURCES:\n" + "\n".join([f"- {d['title']} | {d['url']}" for d in batch_info])
+ )
+
+ try:
+ async with self.ai_semaphore:
+ # Mandate 48: Use Flash/Lite for high-volume rescue to avoid Rate-Limits
+ ai_results = await call_gemini_with_retry(prompt, prefer_flash=True, use_grounding=True, role="Link-Rescue")
+ if isinstance(ai_results, list):
+ res_map = {normalize_url(r.get("old_url", "")): r.get("new_url") for r in ai_results}
+ for u in batch:
+ nu = normalize_url(u)
+ if nu not in self.checkpoint_data["rescued_urls"]:
+ self.checkpoint_data["rescued_urls"].append(nu)
+
+ new_loc = res_map.get(nu)
+ if new_loc and new_loc.startswith("http") and "NONE" not in new_loc.upper():
+ try:
+ async with httpx.AsyncClient(timeout=10, follow_redirects=True, verify=False) as client:
+ resp_new = await client.get(new_loc)
+ if resp_new.status_code < 400:
+ final_new = str(resp_new.url)
+ u_p = u.split("://")[-1].rstrip("/")
+ f_p = final_new.split("://")[-1].rstrip("/")
+ if u_p.count("/") >= 3 and (f_p.count("/") <= 2 or any(kw in f_p for kw in ["/about", "/products", "/home", "/learn"])):
+ log_event(f" [⚠️] AI proposed a generic redirect for {u} -> {new_loc}. Rejecting.")
+ else:
+ log_event(f" [✨] RESCUED: {u} -> {new_loc}")
+ check_results[u] = (True, "resurrected", new_loc)
+ self.checkpoint_data["checked_urls"][nu] = [True, "resurrected", new_loc]
+ except Exception as e:
+ log_event(f"[WARN] verify rescued URL {new_loc[:50]}: {str(e)[:100]}")
+
+ self._save_checkpoint()
+ except Exception as e:
+ log_event(f"[WARN] AI rescue batch: {str(e)[:100]}")
+
+ # 2.8. Finalize Status
+ log_event("FINALIZING STATUS AND METRICS...", section_break=True)
+ for url, (alive, reason, final) in check_results.items():
+ nu = normalize_url(url); entry = self.inventory.get(nu, {})
+ score = entry.get("health_score")
+ if score is None:
+ score = 100.0
+ score = (score * 0.8) + (100 if alive else 0) * 0.2
+ entry["health_score"] = round(score, 1); entry["last_checked"] = datetime.now().timestamp()
+
+ # Mandate 22 & 33 were handled in _check_url_logic to avoid sequential bottlenecks
+ is_important = any(occ.get("is_important") for occ in self.link_registry.get(nu, []))
+ if (entry.get("stars") or 0) >= 3: is_important = True
+
+ status, final_reason = ("INCLUDED", reason) if alive else ("FILTERED", reason)
+
+ if not alive or reason == "generic_redirect_loss":
+ if is_important:
+ entry["status"] = "review_required"
+ entry["review_metadata"] = {
+ "original_url": url, "proposed_url": final if final else "NONE",
+ "reason": f"High-Value Protection: {reason}", "timestamp": datetime.now().isoformat()
+ }
+ log_event(f" [⚠️] REVIEW STORED: {url}")
+ status, final_reason = "INCLUDED", f"Preserved for Review ({reason})"
+ elif score < 20:
+ entry["status"] = "dead"; self.dead_links[url] = (None, reason)
+ status, final_reason = "FILTERED", f"Dead: {reason}"
+ elif final and alive:
+ self.dead_links[url] = (f"CANONICAL:{final}", "Redirect/Resurrection")
+ final_reason = "Updated (Canonical)"
+
+ self.full_report_metrics.append({
+ "url": url, "status": status, "reason": final_reason,
+ "category": entry.get("category", "N/A"), "post_date": entry.get("pub_date"),
+ "source": "Health Checker", "impact_score": (entry.get("stars") or 0) * 20,
+ "language": entry.get("language", "EN"), "type": entry.get("resource_type", "Ref")
+ })
+ self.inventory[nu] = entry
+
+ await self.apply_changes()
+ self._delete_checkpoint()
+
+ async def apply_changes(self):
+ # Export JSON artifact for the reducer instead of applying Git changes directly
+ output_payload = {
+ "shard_index": self.shard_index,
+ "inventory_updates": self.inventory,
+ "dead_links": self.dead_links,
+ "full_report_metrics": self.full_report_metrics
+ }
+
+ out_name = f"shard_result_{self.shard_index:02d}.json" if self.shard_index is not None else "shard_result.json"
+ with open(out_name, "w") as f:
+ json.dump(output_payload, f, indent=2)
+
+ log_event(f"EXPORTED SHARD RESULTS TO {out_name}", section_break=True)
+
+ # --- AUTOMATED TRIAGE REPORT GENERATION ---
+ triage_links = []
+ for url, meta in self.inventory.items():
+ if meta.get('status') == 'review_required':
+ triage_links.append({"url": url, "stars": meta.get('stars') or 0, "desc": meta.get('description', 'N/A')})
+
+ if triage_links:
+ # Sort by stars (impact) DESC
+ triage_links.sort(key=lambda x: x['stars'], reverse=True)
+ with open("triage_report.md", "w") as f:
+ f.write(f"### 🚨 Manual Triage Required ({len(triage_links)} High-Value Links)\n\n")
+ f.write("The following resources were flagged for manual review because they failed health checks but are considered high-value assets.\n\n")
+ f.write("| Impact | Resource | Description |\n| :---: | :--- | :--- |\n")
+ for item in triage_links:
+ f.write(f"| {'🌟'*item['stars']} | {item['url']} | {item['desc']} |\n")
+
+ async def _check_url_logic(self, url: str) -> Tuple[bool, str, Optional[str]]:
+ headers = {"User-Agent": "Mozilla/5.0", "Accept-Language": "en-US,en;q=0.5"}
+ parked = ["buy this domain", "parked free", "domain is for sale"]
+ nu = normalize_url(url); entry = self.inventory.get(nu, {})
+
+ try:
+ async with httpx.AsyncClient(headers=headers, follow_redirects=True, timeout=12) as client:
+ resp = await client.get(url)
+ if resp.status_code < 400:
+ text = resp.text.lower()
+ final_url = str(resp.url)
+ from src.gemini_utils import sanitize_trailing_slashes
+ final_url = sanitize_trailing_slashes(final_url)
+
+ if any(kw in text for kw in parked): return False, "parked", None
+
+ # Mandate 22: Content Drift detection (SHA256) - Integrated here for speed
+ new_hash = hashlib.sha256(resp.text.encode()).hexdigest()
+ old_hash = entry.get("content_hash", "N/A")
+ if old_hash != "N/A" and new_hash != old_hash:
+ log_event(f" [!] DRIFT DETECTED: {url}")
+ entry["needs_ai_refresh"] = True
+ entry["content_hash"] = new_hash
+
+ # Mandate 33: License Guard for GitHub
+ if "github.com" in url:
+ try:
+ from src.agentic_curator import _get_github_activity
+ gh_meta = await _get_github_activity(url)
+ if gh_meta.get("gh_license"):
+ old_lic = entry.get("gh_license", "N/A")
+ new_lic = gh_meta["gh_license"]
+ if old_lic != "N/A" and old_lic != new_lic:
+ if any(x in new_lic.upper() for x in ["BSL", "SSPL", "PROPRIETARY"]):
+ log_event(f" [⚖️] LICENSE ALERT: {url} -> {new_lic}")
+ entry["status"] = "review_required"
+ entry["gh_license"] = new_lic
+ except Exception as e:
+ log_event(f"[WARN] license guard check for {url}: {str(e)[:100]}")
+
+ if final_url != url:
+ u_p = url.split("://")[-1].rstrip("/"); f_p = final_url.split("://")[-1].rstrip("/")
+ if u_p == f_p: return True, "normalized_slashes", final_url
+ if u_p.count("/") >= 3 and (f_p.count("/") <= 2 or any(kw in f_p for kw in ["/about", "/products", "/home", "/learn"])):
+ return False, "generic_redirect_loss", None
+
+ return True, "OK", final_url if final_url != url else None
+
+ if resp.status_code in [404, 410]:
+ if "github.com" in url:
+ if "/master/" in url:
+ h = url.replace("/master/", "/main/")
+ try:
+ if (await client.get(h)).status_code < 400: return True, "healed", h
+ except Exception as e:
+ log_event(f"[WARN] heal master->main for {url}: {str(e)[:100]}")
+ m = re.search(r'(https?://github\.com/[^/]+/[^/]+)', url)
+ if m and (await client.get(m.group(1))).status_code < 400: return True, "consolidated", m.group(1)
+ return False, "404", None
+ return True, f"Soft Block {resp.status_code}", None
+ except Exception as e:
+ log_event(f"[WARN] URL check logic for {url}: {str(e)[:100]}")
+ return True, "Error", None
+
+ async def prune_orphaned_metadata(self):
+ valid_map = {}
+ for root, _, files in os.walk("docs"):
+ for f in files:
+ if f.endswith(".md"):
+ p = os.path.join(root, f); c = open(p, "r").read()
+ for u in re.findall(r'\[.*?\]\((https?://.*?)\)', c): valid_map.setdefault(normalize_url(u), []).append(p)
+ new_inv = {}
+ for u, m in self.inventory.items():
+ if u.startswith("INTRO:") or u in valid_map:
+ if u in valid_map: m["v1_locations"] = sorted(list(set(valid_map[u])))
+ new_inv[u] = m
+ self.inventory = new_inv
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--shard-index", type=int, default=None, help="Index of the shard (0-63)")
+ parser.add_argument("--total-shards", type=int, default=TOTAL_SHARDS, help="Total number of shards")
+ args = parser.parse_args()
+
+ cleaner = IntelligentLinkCleaner(shard_index=args.shard_index, total_shards=args.total_shards)
+ asyncio.run(cleaner.execute_clean_cycle())