diff --git a/.github/workflows/auto-add-seo.yml b/.github/workflows/auto-add-seo.yml new file mode 100644 index 0000000000..ba10e6ac59 --- /dev/null +++ b/.github/workflows/auto-add-seo.yml @@ -0,0 +1,359 @@ +name: Auto Add SEO Descriptions + +on: + pull_request: + paths: + - 'docs/en/**/*.md' + branches: + - 'rel-*' + - 'dev' + types: [closed] + +jobs: + add-seo-descriptions: + if: | + github.event.pull_request.merged == true && + !startsWith(github.event.pull_request.head.ref, 'auto-seo/') + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + pip install openai + + - name: Create new branch for SEO updates + run: | + git config --local user.email "github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + + # Checkout base branch first + git checkout ${{ github.event.pull_request.base.ref }} + + # Create new branch from base + BRANCH_NAME="auto-seo/${{ github.event.pull_request.number }}" + git checkout -b $BRANCH_NAME + echo "BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV + + - name: Get changed markdown files from merged PR + id: changed-files + run: | + # Get files changed in the merged PR (only Added and Modified, exclude Deleted) + FILES=$(git diff --name-only --diff-filter=AM ${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.merge_commit_sha }} | grep 'docs/en/.*\.md$' || true) + + echo "Files changed in the merged PR (added/modified only):" + echo "$FILES" + echo "" + + echo "changed_files<> $GITHUB_OUTPUT + echo "$FILES" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + if [ -z "$FILES" ]; then + echo "has_files=false" >> $GITHUB_OUTPUT + echo "No markdown files changed in docs/en/" + else + echo "has_files=true" >> $GITHUB_OUTPUT + + # Checkout the changed files from merge commit to get the merged content + echo "" + echo "Checking out changed files from merge commit..." + while IFS= read -r file; do + if [ -n "$file" ]; then + echo " Checking out: $file" + # Create directory if it doesn't exist + mkdir -p "$(dirname "$file")" + # Checkout the file from merge commit + git show "${{ github.event.pull_request.merge_commit_sha }}:$file" > "$file" 2>/dev/null || echo " Warning: Could not checkout $file" + fi + done <<< "$FILES" + + echo "" + echo "Files now in working directory:" + git status --short + fi + + - name: Process changed files and add SEO descriptions + if: steps.changed-files.outputs.has_files == 'true' + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + run: | + python3 << 'PYTHON_SCRIPT' + import os + import sys + import re + from openai import OpenAI + + client = OpenAI(api_key=os.environ['OPENAI_API_KEY']) + + def has_seo_description(content): + """Check if content already has SEO description""" + return content.strip().startswith('```json') and '//[doc-seo]' in content + + def is_content_too_short(content): + """Check if content is less than 200 characters""" + # Remove SEO tags if present for accurate count + clean_content = content + if '//[doc-seo]' in clean_content: + parts = clean_content.split('```', 2) + if len(parts) > 2: + clean_content = parts[2].strip() + + return len(clean_content.strip()) < 200 + + def get_content_preview(content, max_length=1000): + """Get preview of content for OpenAI""" + # Remove existing SEO tags if present + clean_content = content + if '//[doc-seo]' in clean_content: + parts = clean_content.split('```', 2) + if len(parts) > 2: + clean_content = parts[2].strip() + + return clean_content[:max_length].strip() + + def generate_description(content, filename): + """Generate SEO description using OpenAI with system prompt from OpenAIService.cs""" + try: + preview = get_content_preview(content) + + response = client.chat.completions.create( + model="gpt-4o-mini", + messages=[ + {"role": "system", "content": """Create a short and engaging summary (1–2 sentences) for sharing this documentation link on Discord, LinkedIn, Reddit, Twitter and Facebook. Clearly describe what the page explains or teaches. + Highlight the value for developers using ABP Framework. + Be written in a friendly and professional tone. + Stay under 200 characters. + --> https://abp.io/docs/latest <--"""}, + {"role": "user", "content": f"""Generate a concise, informative meta description for this documentation page. + + File: {filename} + Content Preview: + {preview} + + Requirements: + - Maximum 200 characters + + Generate only the description text, nothing else:"""} + ], + max_tokens=150, + temperature=0.7 + ) + + description = response.choices[0].message.content.strip() + + # Ensure max length + if len(description) > 200: + description = description[:197] + "..." + + return description + except Exception as e: + print(f"āŒ Error generating description: {e}") + return f"Learn about {os.path.splitext(filename)[0]} in ABP Framework documentation." + + def add_seo_description(content, description): + """Add SEO description to content""" + # Escape special characters for JSON + escaped_desc = description.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n') + + seo_tag = f'''```json + //[doc-seo] + {{ + "Description": "{escaped_desc}" + }} + ``` + + ''' + return seo_tag + content + + # Ignored folders from appsettings.json + IGNORED_FOLDERS = ['Blog-Posts', 'Community-Articles', '_deleted', '_resources'] + + def is_file_ignored(filepath): + """Check if file is in an ignored folder""" + path_parts = filepath.split('/') + for ignored in IGNORED_FOLDERS: + if ignored in path_parts: + return True + return False + + # Process changed files + changed_files_str = """${{ steps.changed-files.outputs.changed_files }}""" + changed_files = [f.strip() for f in changed_files_str.strip().split('\n') if f.strip()] + processed_count = 0 + skipped_count = 0 + skipped_too_short = 0 + skipped_ignored = 0 + updated_files = [] # Track actually updated files + + print("šŸ¤– Processing changed markdown files...\n") + print(f"🚫 Ignored folders: {', '.join(IGNORED_FOLDERS)}\n") + + for filepath in changed_files: + if not filepath.endswith('.md'): + continue + + # Check if file is in ignored folder + if is_file_ignored(filepath): + print(f"šŸ“„ Processing: {filepath}") + print(f" 🚫 Skipped (ignored folder)\n") + skipped_ignored += 1 + skipped_count += 1 + continue + + print(f"šŸ“„ Processing: {filepath}") + + try: + # Read file + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + + # Check if content is too short (less than 200 characters) + if is_content_too_short(content): + print(f" ā­ļø Skipped (content less than 200 characters)\n") + skipped_too_short += 1 + skipped_count += 1 + continue + + # Check if already has SEO description + if has_seo_description(content): + print(f" ā­ļø Skipped (already has SEO description)\n") + skipped_count += 1 + continue + + # Generate description + filename = os.path.basename(filepath) + print(f" šŸ¤– Generating description...") + description = generate_description(content, filename) + print(f" šŸ’” Generated: {description}") + + # Add SEO tag + updated_content = add_seo_description(content, description) + + # Write back + with open(filepath, 'w', encoding='utf-8') as f: + f.write(updated_content) + + print(f" āœ… Updated successfully\n") + processed_count += 1 + updated_files.append(filepath) # Track this file as updated + + except Exception as e: + print(f" āŒ Error: {e}\n") + + print(f"\nšŸ“Š Summary:") + print(f" āœ… Updated: {processed_count}") + print(f" ā­ļø Skipped (total): {skipped_count}") + print(f" ā­ļø Skipped (too short): {skipped_too_short}") + print(f" 🚫 Skipped (ignored folder): {skipped_ignored}") + + # Save counts and updated files list for next step + with open('/tmp/seo_stats.txt', 'w') as f: + f.write(f"{processed_count}\n{skipped_count}\n{skipped_too_short}\n{skipped_ignored}") + + # Save updated files list + with open('/tmp/seo_updated_files.txt', 'w') as f: + f.write('\n'.join(updated_files)) + + PYTHON_SCRIPT + + + - name: Commit and push changes + if: steps.changed-files.outputs.has_files == 'true' + run: | + git add -A docs/en/ + + if git diff --staged --quiet; then + echo "No changes to commit" + echo "has_commits=false" >> $GITHUB_ENV + else + git commit -m "docs: Add SEO descriptions to modified documentation files" -m "Related to PR #${{ github.event.pull_request.number }}" + git push origin ${{ env.BRANCH_NAME }} + echo "has_commits=true" >> $GITHUB_ENV + fi + + - name: Create Pull Request + if: env.has_commits == 'true' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const stats = fs.readFileSync('/tmp/seo_stats.txt', 'utf8').split('\n'); + const processedCount = parseInt(stats[0]) || 0; + const skippedCount = parseInt(stats[1]) || 0; + const skippedTooShort = parseInt(stats[2]) || 0; + const skippedIgnored = parseInt(stats[3]) || 0; + const prNumber = ${{ github.event.pull_request.number }}; + const baseRef = '${{ github.event.pull_request.base.ref }}'; + const branchName = '${{ env.BRANCH_NAME }}'; + + if (processedCount > 0) { + // Read the actually updated files list (not all changed files) + const updatedFilesStr = fs.readFileSync('/tmp/seo_updated_files.txt', 'utf8'); + const updatedFiles = updatedFilesStr.trim().split('\n').filter(f => f.trim()); + + let prBody = 'šŸ¤– **Automated SEO Descriptions**\n\n'; + prBody += `This PR automatically adds SEO descriptions to documentation files that were modified in PR #${prNumber}.\n\n`; + prBody += '## šŸ“Š Summary\n'; + prBody += `- āœ… **Updated:** ${processedCount} file(s)\n`; + prBody += `- ā­ļø **Skipped (total):** ${skippedCount} file(s)\n`; + if (skippedTooShort > 0) { + prBody += ` - ā­ļø Content < 200 chars: ${skippedTooShort} file(s)\n`; + } + if (skippedIgnored > 0) { + prBody += ` - 🚫 Ignored folders: ${skippedIgnored} file(s)\n`; + } + prBody += '\n## šŸ“ Modified Files\n'; + prBody += updatedFiles.slice(0, 20).map(f => `- \`${f}\``).join('\n'); + if (updatedFiles.length > 20) { + prBody += `\n- ... and ${updatedFiles.length - 20} more`; + } + prBody += '\n\n## šŸ”§ Details\n'; + prBody += `- **Related PR:** #${prNumber}\n\n`; + prBody += 'These descriptions were automatically generated to improve SEO and search engine visibility. šŸš€'; + + const { data: pr } = await github.rest.pulls.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: `docs: Add SEO descriptions (from PR ${prNumber})`, + head: branchName, + base: baseRef, + body: prBody + }); + + console.log(`āœ… Created PR: ${pr.html_url}`); + + // Add reviewers to the PR + const reviewers = ['maliming']; //GitHub usernames + if (reviewers.length === 0) { + console.log('āš ļø No reviewers specified.'); + return; + } + try { + await github.rest.pulls.requestReviewers({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + reviewers: reviewers, + team_reviewers: [] + }); + console.log(`āœ… Added reviewers to PR ${pr.number}`); + } catch (error) { + console.log(`āš ļø Could not add reviewers: ${error.message}`); + } + } +