Files
wehub-resource-sync 97e91a83f3
Ruff / Ruff (push) Waiting to run
Test / Core Tests (push) Waiting to run
Test / Offline Coverage Tests (Python 3.10) (push) Waiting to run
Test / Offline Coverage Tests (Python 3.11) (push) Waiting to run
Test / Offline Coverage Tests (Python 3.12) (push) Waiting to run
Test / Offline Coverage Tests (Python 3.13) (push) Waiting to run
Test / Offline Coverage Tests (Python 3.9) (push) Waiting to run
Test / Full Coverage (Python 3.11) (push) Waiting to run
Test / Core Provider Tests (OpenAI) (push) Blocked by required conditions
Test / Core Provider Tests (Anthropic) (push) Blocked by required conditions
Test / Core Provider Tests (Google) (push) Blocked by required conditions
Test / Core Provider Tests (Other) (push) Blocked by required conditions
Test / Anthropic Tests (push) Blocked by required conditions
Test / Gemini Tests (push) Blocked by required conditions
Test / Google GenAI Tests (push) Blocked by required conditions
Test / Vertex AI Tests (push) Blocked by required conditions
Test / OpenAI Tests (push) Blocked by required conditions
Test / Writer Tests (push) Blocked by required conditions
Test / Auto Client Tests (push) Blocked by required conditions
ty / type-check (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:36:38 +08:00

315 lines
10 KiB
Python
Executable File

import os
import asyncio
import yaml
from typing import Optional, Any
from collections.abc import Generator
from openai import AsyncOpenAI
import typer
from rich.console import Console
from rich.progress import Progress
import hashlib
from asyncio import as_completed
import tenacity
import re
console = Console()
def traverse_docs(
root_dir: str = "docs",
) -> Generator[tuple[str, str, str], None, None]:
"""
Recursively traverse the docs folder and yield the path, content, and content hash of each file.
Args:
root_dir (str): The root directory to start traversing from. Defaults to 'docs'.
Yields:
Tuple[str, str, str]: A tuple containing the relative path from 'docs', the file content, and the content hash.
"""
for root, _, files in os.walk(root_dir):
for file in files:
if file.endswith(".md"): # Assuming we're only interested in Markdown files
file_path = os.path.join(root, file)
relative_path = os.path.relpath(file_path, root_dir)
with open(file_path, encoding="utf-8") as f:
content = f.read()
content_hash = hashlib.md5(content.encode()).hexdigest()
yield relative_path, content, content_hash
def extract_markdown_links(content: str) -> list[str]:
"""
Extract all markdown links from the content.
Args:
content (str): The markdown content to analyze
Returns:
List[str]: List of extracted link paths
"""
# Match markdown links [text](path)
link_pattern = r"\[([^\]]+)\]\(([^)]+)\)"
matches = re.findall(link_pattern, content)
links = []
for _, link_path in matches:
# Filter out external links and anchors
if not link_path.startswith(("http://", "https://", "#", "mailto:")):
# Clean up relative paths
link_path = link_path.strip("/")
if link_path.endswith(".md"):
links.append(link_path)
elif "." not in link_path:
# Assume it's a directory reference, add index.md
links.append(f"{link_path}/index.md")
return links
def normalize_path(path: str, current_path: str) -> str:
"""
Normalize a relative path based on the current file's location.
Args:
path (str): The path to normalize
current_path (str): The current file's path
Returns:
str: The normalized path
"""
if path.startswith("/"):
# Absolute path from docs root
return path.strip("/")
# Relative path
current_dir = os.path.dirname(current_path)
if current_dir:
normalized = os.path.normpath(os.path.join(current_dir, path))
# Remove any leading '../' that go outside docs/
while normalized.startswith("../"):
normalized = normalized[3:]
return normalized
return path
@tenacity.retry(
stop=tenacity.stop_after_attempt(3),
wait=tenacity.wait_exponential(multiplier=1, min=4, max=10),
retry=tenacity.retry_if_exception_type(Exception),
before_sleep=lambda retry_state: console.print(
f"[yellow]Retrying analysis... (Attempt {retry_state.attempt_number})[/yellow]"
),
)
async def analyze_content(
client: AsyncOpenAI, path: str, content: str
) -> dict[str, Any]:
"""
Analyze the content of a file to extract summary, keywords, topics, and references.
Args:
client (AsyncOpenAI): The AsyncOpenAI client.
path (str): The path of the file.
content (str): The content of the file.
Returns:
Dict[str, Any]: Analysis results including summary, keywords, topics, and references.
Raises:
Exception: If all retry attempts fail.
"""
try:
response = await client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": """You are a documentation analyzer. Extract and return the following information in a structured format:
1. A concise summary (2-3 sentences) for SEO
2. A list of important keywords (5-10 words/phrases)
3. Main topics/concepts covered (3-5 topics)
4. Any references to other documentation pages mentioned in the text
Return the response in this exact format:
SUMMARY: [Your summary here]
KEYWORDS: [keyword1, keyword2, keyword3, ...]
TOPICS: [topic1, topic2, topic3, ...]
REFERENCES: [referenced_page1.md, referenced_page2.md, ...]
If no references are found, write: REFERENCES: none""",
},
{"role": "user", "content": content},
],
max_tokens=4000,
)
result_text = response.choices[0].message.content
# Parse the structured response
summary = ""
keywords = []
topics = []
references = []
if result_text:
for line in result_text.split("\n"):
line = line.strip()
if line.startswith("SUMMARY:"):
summary = line[8:].strip()
elif line.startswith("KEYWORDS:"):
keywords_text = line[9:].strip()
if keywords_text and keywords_text != "none":
keywords = [k.strip() for k in keywords_text.split(",")]
elif line.startswith("TOPICS:"):
topics_text = line[7:].strip()
if topics_text and topics_text != "none":
topics = [t.strip() for t in topics_text.split(",")]
elif line.startswith("REFERENCES:"):
refs_text = line[11:].strip()
if refs_text and refs_text != "none":
references = [r.strip() for r in refs_text.split(",")]
return {
"summary": summary,
"keywords": keywords,
"topics": topics,
"ai_references": references,
}
except Exception as e:
console.print(f"[bold red]Error analyzing {path}: {str(e)}[/bold red]")
raise
async def generate_sitemap(
root_dir: str,
output_file: str,
api_key: Optional[str] = None,
max_concurrency: int = 5,
) -> None:
"""
Generate a sitemap from the given root directory.
Args:
root_dir (str): The root directory to start traversing from.
output_file (str): The output file to save the sitemap.
api_key (Optional[str]): The OpenAI API key. If not provided, it will be read from the OPENAI_API_KEY environment variable.
max_concurrency (int): The maximum number of concurrent tasks. Defaults to 5.
"""
client = AsyncOpenAI(api_key=api_key)
# Load existing sitemap if it exists
existing_sitemap: dict[str, dict[str, Any]] = {}
if os.path.exists(output_file):
with open(output_file, encoding="utf-8") as sitemap_file:
existing_sitemap = yaml.safe_load(sitemap_file) or {}
sitemap_data: dict[str, dict[str, Any]] = {}
async def process_file(
path: str, content: str, content_hash: str
) -> tuple[str, dict[str, Any]]:
# Check if we can reuse existing data
if (
path in existing_sitemap
and existing_sitemap[path].get("hash") == content_hash
):
# Extract markdown links even for cached content
links = extract_markdown_links(content)
normalized_links = []
for link in links:
normalized = normalize_path(link, path)
if normalized:
normalized_links.append(normalized)
existing_data = existing_sitemap[path].copy()
existing_data["references"] = normalized_links
return path, existing_data
try:
# Extract markdown links
links = extract_markdown_links(content)
normalized_links = []
for link in links:
normalized = normalize_path(link, path)
if normalized:
normalized_links.append(normalized)
# Get AI analysis
analysis = await analyze_content(client, path, content)
return path, {
"summary": analysis["summary"],
"keywords": analysis["keywords"],
"topics": analysis["topics"],
"references": normalized_links,
"ai_references": analysis["ai_references"],
"hash": content_hash,
}
except Exception as e:
console.print(
f"[bold red]Failed to analyze {path} after multiple attempts: {str(e)}[/bold red]"
)
return path, {
"summary": "Failed to generate summary",
"keywords": [],
"topics": [],
"references": normalized_links,
"ai_references": [],
"hash": content_hash,
}
files_to_process: list[tuple[str, str, str]] = list(traverse_docs(root_dir))
total_files = len(files_to_process)
with Progress() as progress:
task = progress.add_task("[green]Processing files...", total=total_files)
semaphore = asyncio.Semaphore(max_concurrency)
async def bounded_process_file(*args):
async with semaphore:
return await process_file(*args)
tasks = [
bounded_process_file(path, content, content_hash)
for path, content, content_hash in files_to_process
]
for completed_task in as_completed(tasks):
path, result = await completed_task
sitemap_data[path] = result
progress.update(task, advance=1)
# Save final results
with open(output_file, "w", encoding="utf-8") as sitemap_file:
yaml.dump(sitemap_data, sitemap_file, default_flow_style=False, sort_keys=True)
console.print(
f"[bold green]Sitemap has been generated and saved to {output_file}[/bold green]"
)
console.print(f"[green]Processed {total_files} files[/green]")
app = typer.Typer()
@app.command()
def main(
root_dir: str = typer.Option("docs", help="Root directory to traverse"),
output_file: str = typer.Option("sitemap.yaml", help="Output file for the sitemap"),
api_key: Optional[str] = typer.Option(None, help="OpenAI API key"),
max_concurrency: int = typer.Option(5, help="Maximum number of concurrent tasks"),
):
"""
Generate a sitemap with keywords, topics, and reference analysis.
"""
asyncio.run(generate_sitemap(root_dir, output_file, api_key, max_concurrency))
if __name__ == "__main__":
app()