
Pagerangers Seo
- 35 installs
- 2 repo stars
- Updated August 3, 2026
- netresearch/pagerangers-skill
Helps with marketing & seo tasks.
About
pagerangers-seo is a Claude Code skill for marketing & seo. It helps solo builders move faster with AI-assisted coding.
- pagerangers-seo
- Marketing & SEO
- AI-coding skill
Pagerangers Seo by the numbers
- 35 all-time installs (skills.sh)
- Ranked #1,384 of 1,879 Marketing & SEO skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/netresearch/pagerangers-skill --skill pagerangers-seoAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 35 |
|---|---|
| repo stars | ★ 2 |
| Last updated | August 3, 2026 |
| Repository | netresearch/pagerangers-skill ↗ |
What it does
Helps with marketing & seo tasks.
Files
PageRangers SEO
Query the PageRangers Monitoring API for SEO insights directly from your AI assistant.
Commands
| Command | Description |
|---|---|
keyword <term> | Analyze a keyword (SERP, volume, competition) |
rankings | List current keyword rankings |
kpis | Get project KPIs (ranking index, top 10/100) |
prospects | Find high-opportunity keywords |
Quick Start
# 1. Create credentials file (see references/setup.md for details)
cat > ~/.env.pagerangers << 'EOF'
PAGERANGERS_API_TOKEN=your_api_key_here
PAGERANGERS_PROJECT_HASH=your_project_hash_here
EOF
# 2. Run commands (global flags like --json go BEFORE the subcommand)
python3 ${CLAUDE_SKILL_DIR}/scripts/pagerangers.py --json keyword "SEO Analyse" --top 5
python3 ${CLAUDE_SKILL_DIR}/scripts/pagerangers.py --json rankings --limit 10
python3 ${CLAUDE_SKILL_DIR}/scripts/pagerangers.py --json kpis
python3 ${CLAUDE_SKILL_DIR}/scripts/pagerangers.py --json prospects --limit 10Usage Examples
Keyword Analysis
python3 ${CLAUDE_SKILL_DIR}/scripts/pagerangers.py --json keyword "online marketing" --top 10Returns: keyword, search volume, competition (low/medium/high), top URLs, related keywords.
Project Rankings
python3 ${CLAUDE_SKILL_DIR}/scripts/pagerangers.py --json rankings --limit 20Returns: keyword, position, ranking URL.
Project KPIs
python3 ${CLAUDE_SKILL_DIR}/scripts/pagerangers.py --json kpisReturns: ranking index, top 10 count, top 100 count, average position.
Keyword Opportunities
python3 ${CLAUDE_SKILL_DIR}/scripts/pagerangers.py --json prospects --limit 10Returns: keywords with best ranking potential.
References
| Topic | Reference |
|---|---|
| API documentation | references/pagerangers-api.md |
| Endpoint configuration | references/pagerangers-api.json |
| Setup and credentials | references/setup.md |
| Error handling | references/error-handling.md |
| Module distinction (Monitoring vs Explorer) | references/module-distinction.md |
| API costs and credits | references/api-costs.md |
| CLI implementation | ${CLAUDE_SKILL_DIR}/scripts/pagerangers.py |
{
"name": "pagerangers-seo",
"version": "1.2.0",
"description": "PageRangers SEO API integration for AI assistants",
"repository": "https://github.com/netresearch/pagerangers-skill",
"license": "(MIT AND CC-BY-SA-4.0)",
"author": {
"name": "Netresearch DTT GmbH",
"url": "https://www.netresearch.de/"
},
"keywords": [
"pagerangers",
"seo",
"serp",
"keyword-research",
"rankings",
"monitoring"
],
"support": {
"issues": "https://github.com/netresearch/pagerangers-skill/issues",
"source": "https://github.com/netresearch/pagerangers-skill"
},
"skills": [
"."
],
"hooks": "./hooks/hooks.json"
}
# Install git hooks for version validation
git config core.hooksPath Build/hooks
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
github-actions:
patterns:
- "*"
name: Auto-merge dependency PRs
on:
pull_request_target:
permissions: {}
jobs:
auto-merge:
uses: netresearch/.github/.github/workflows/auto-merge-deps.yml@main
permissions:
contents: write
pull-requests: write
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions: {}
jobs:
ci:
uses: netresearch/skill-repo-skill/.github/workflows/ci-python.yml@main
permissions:
contents: read
with:
python-versions: '["3.12", "3.13"]'
test-command: |
uv python install "$PYTHON_VERSION"
uv sync --extra dev
uv run pytest --cov=scripts --cov-report=xml --cov-report=term-missing
upload-coverage: true
coverage-files: 'coverage.xml'
secrets:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
name: Harness Verification
on:
pull_request:
branches: [main]
permissions:
contents: read
jobs:
harness-verify:
uses: netresearch/skill-repo-skill/.github/workflows/harness-verify.yml@main
name: Lint
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
validate:
name: Skill Validation
uses: netresearch/skill-repo-skill/.github/workflows/validate.yml@main
name: Release
on:
push:
tags:
- 'v*'
jobs:
release:
uses: netresearch/skill-repo-skill/.github/workflows/release.yml@main
permissions:
contents: write # release upload
id-token: write # OIDC for sigstore (required by the attest job)
attestations: write # GitHub native attestation API (required by the attest job)
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
.venv/
venv/
ENV/
env/
# Testing
.pytest_cache/
.coverage
htmlcov/
.tox/
.nox/
# Ruff
.ruff_cache/
# IDE and Editor
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Logs
*.log
# Temporary files
*.tmp
*.temp
.cache/
# OS Files
Thumbs.db
# Environment/credentials
.env
.env.*
!.env.example
# uv
.uv/
# Serena MCP
.serena/
# Packaged skills
*.skill
// markdownlint configuration
// See: https://github.com/DavidAnson/markdownlint
{
"config": {
// Disable line length - tables and URLs regularly exceed 80 chars
"MD013": false,
// Disable table column style - compact style used consistently
"MD060": false
}
}
# Pre-commit hooks — embodies the CI/Hook Parity Principle for this repo.
#
# Every hook below ALSO runs in CI (via the reusable validate.yml workflow
# from netresearch/skill-repo-skill). CI is the authoritative backstop;
# local hooks are pinned by `rev:` and Renovate bumps them automatically.
#
# See netresearch/agent-harness-skill references/enforcement-mechanisms.md
# for the CI/Hook Parity Principle in full.
#
# Install once after clone: `pre-commit install --install-hooks`.
# Bypass (use sparingly): `git commit --no-verify`. If you need this often,
# the hook is wrong — fix it; don't tolerate the bypass.
default_install_hook_types: [pre-commit]
default_stages: [pre-commit]
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-merge-conflict
- id: check-added-large-files
- id: check-json
- id: check-yaml
args: [--allow-multiple-documents]
- repo: https://github.com/netresearch/skill-repo-skill
rev: v1.22.0
hooks:
- id: validate-skill
- id: check-version-parity
- repo: https://github.com/DavidAnson/markdownlint-cli2
rev: v0.22.1
hooks:
- id: markdownlint-cli2
files: '\.md$'
- repo: https://github.com/adrienverge/yamllint
rev: v1.38.0
hooks:
- id: yamllint
args: [-c, .yamllint.yml]
- repo: https://github.com/rhysd/actionlint
rev: v1.7.12
hooks:
- id: actionlint
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.14
hooks:
- id: ruff
- id: ruff-format
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.11.0.1
hooks:
- id: shellcheck
# Match the default config CI injects when no local file is present.
# See netresearch/skill-repo-skill .github/workflows/validate.yml.
extends: default
rules:
comments:
min-spaces-from-content: 1
document-start: disable
indentation: disable
line-length:
max: 200
truthy:
allowed-values: ['true', 'false', 'on']
PageRangers SEO Skill - Agent Instructions
This file provides guidance for AI agents working with the PageRangers SEO skill.
Overview
This skill provides access to the PageRangers Monitoring API for SEO data analysis. It enables AI assistants to retrieve keyword rankings, search volume data, competition metrics, and project KPIs.
Activation Triggers
AUTOMATICALLY ACTIVATE when user mentions:
- PageRangers, SEO keywords, search rankings
- SERP analysis, keyword research
- Ranking positions, search volume
- SEO KPIs, monitoring data
Available Commands
| Command | Purpose | Example |
|---|---|---|
kpis | Get project performance metrics | python3 scripts/pagerangers.py --json kpis |
rankings | List keyword positions | python3 scripts/pagerangers.py --json rankings --limit 20 |
keyword | Analyze specific keyword | python3 scripts/pagerangers.py --json keyword "SEO tools" |
prospects | Find opportunities | python3 scripts/pagerangers.py --json prospects --limit 10 |
Flag order: Global flags (--json,--debug) must come before the subcommand.
Workflow
1. Check credentials: Verify ~/.env.pagerangers exists with API token and project hash 2. Select command: Match user intent to appropriate command 3. Execute: Run with --json flag before subcommand for structured output 4. Interpret: Present data with actionable insights
Authentication Setup
If credentials are missing, guide user to create ~/.env.pagerangers:
cat > ~/.env.pagerangers << 'EOF'
PAGERANGERS_API_TOKEN=your_api_key_here
PAGERANGERS_PROJECT_HASH=your_project_hash_here
EOFCredentials are obtained from PageRangers → Profile → API Settings.
Error Handling
| Error | Meaning | Solution |
|---|---|---|
| 401 | Invalid token | Verify PAGERANGERS_API_TOKEN |
| 403 | Invalid project | Verify PAGERANGERS_PROJECT_HASH |
| 429 | Rate limited | Wait and retry |
| Empty keyword data | Keyword not in Explorer | Use rankings for Monitoring keywords; keyword requires Explorer data |
Module Distinction
PageRangers Monitoring ≠ Explorer:
- Monitoring (kpis, rankings, prospects): Your tracked keywords
- Explorer (keyword command): PageRangers' general SERP database
Keywords in Monitoring don't automatically have Explorer data. If keyword returns empty, use rankings instead.
Best Practices
1. Always use --json flag for structured data parsing 2. Limit results appropriately (--limit 10-20 for readability) 3. Combine multiple data sources for comprehensive analysis 4. Interpret metrics in context of user's business goals
Related Files
SKILL.md- Main skill definitionscripts/pagerangers.py- CLI implementationreferences/pagerangers-api.md- API documentationreferences/pagerangers-api.json- Endpoint configuration
#!/usr/bin/env bash
"$(dirname "$0")/../Scripts/check-plugin-version.sh"
#!/usr/bin/env bash
set -euo pipefail
TAGS=$(git tag --points-at HEAD | sed -nE 's/^v?([0-9]+\.[0-9]+\.[0-9]+)$/\1/p' || true)
[[ -z "${TAGS}" ]] && exit 0
PLUGIN_VERSION=$(python3 -c "import json; print(json.load(open('.claude-plugin/plugin.json'))['version'])")
if [[ -z "${PLUGIN_VERSION}" ]]; then
echo "ERROR: Could not extract version from .claude-plugin/plugin.json" >&2
exit 1
fi
if ! echo "${TAGS}" | grep -qFx "${PLUGIN_VERSION}"; then
echo "ERROR: .claude-plugin/plugin.json version (${PLUGIN_VERSION}) does not match any semver tag at HEAD." >&2
echo "Tags found at HEAD:" >&2
echo "${TAGS}" >&2
exit 1
fi
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
[1.2.0] - 2026-05-28
Changed
- Clarified documentation:
keywordcommand requires Explorer module data, not just Monitoring keywords - Updated error messages to explain Monitoring vs Explorer module distinction
- Fixed
apikeyparameter name in API reference (was incorrectlyapiKey)
[1.0.0] - 2026-01-26
Added
- Initial release of PageRangers SEO skill
kpiscommand for project KPIs (ranking index, top 10/100 counts, average position)rankingscommand for current keyword positionskeywordcommand for SERP analysis (search volume, competition, top URLs)prospectscommand for high-opportunity keyword identification- Comprehensive test suite with 34 tests
- Ruff linting with zero ignores
- GitHub Actions CI workflow with Python 3.12/3.13
- Renovate configuration for automated dependency updates
- UserPromptSubmit hook for credential detection and setup guidance
Technical
- Uses
CommandContextdataclass for clean parameter passing - Named constants for all magic values (HTTP codes, thresholds, defaults)
- Full type hints throughout codebase
- Inline script dependencies for
uv run --scriptcompatibility
[Unreleased]: https://github.com/netresearch/pagerangers-skill/compare/v1.0.0...HEAD [1.0.0]: https://github.com/netresearch/pagerangers-skill/releases/tag/v1.0.0
AGENTS.md
{
"name": "netresearch/pagerangers-skill",
"replace": {
"netresearch/agent-pagerangers-skill": "self.version"
},
"description": "PageRangers SEO API integration for AI assistants",
"type": "ai-agent-skill",
"license": "(MIT AND CC-BY-SA-4.0)",
"authors": [
{
"name": "Netresearch DTT GmbH",
"homepage": "https://www.netresearch.de/",
"role": "Manufacturer"
}
],
"require": {
"netresearch/composer-agent-skill-plugin": "*"
},
"extra": {
"ai-agent-skill": [
"SKILL.md"
]
}
}
Architecture
Purpose
This repository is an AI agent skill that provides a CLI tool and procedural knowledge for querying the PageRangers Monitoring API. Unlike content-only skills, this repo includes executable Python code and a test suite.
Component Overview
Skill Definition
- SKILL.md: Entry point loaded by AI agents. Contains command descriptions, usage examples, and reference links.
- AGENTS.md: Agent-facing instructions with activation triggers, workflow, error handling, and module distinction (Monitoring vs Explorer).
CLI Tool (scripts/)
- pagerangers.py: Main CLI implementation. Provides subcommands (
kpis,rankings,keyword,prospects) for querying the PageRangers API. Supports--jsonoutput for structured data parsing by agents. - detect_credentials.py: Helper for credential detection and validation.
References (references/)
API documentation and endpoint configuration:
- pagerangers-api.md: Human-readable API documentation
- pagerangers-api.json: Machine-readable endpoint configuration
Tests (tests/)
Pytest-based test suite with response mocking:
- conftest.py: Shared fixtures and mock configurations
- test_pagerangers.py: Tests for the CLI tool
Configuration
- pyproject.toml: Python project config (dependencies, ruff linting, pytest, coverage settings)
- uv.lock: Locked dependencies for reproducible installs
Data Flow
1. Agent loads SKILL.md when SEO/PageRangers intent is detected 2. Agent checks for credentials in ~/.env.pagerangers 3. Agent runs python3 scripts/pagerangers.py --json <subcommand> to query the API 4. Agent interprets JSON output and presents insights to the user
Key Design Decisions
- Standalone CLI: The script runs independently with no framework dependencies -- just Python 3.10+ and stdlib.
- Credential isolation: Credentials stored in
~/.env.pagerangers, not in project files. - JSON-first output:
--jsonflag enables structured output for reliable agent parsing. - Module distinction: Monitoring (tracked keywords) and Explorer (general SERP database) are separate API domains with different data availability.
{
"skill_name": "pagerangers-seo",
"evals": [
{
"id": 1,
"eval_name": "check-keyword-rankings",
"prompt": "Check keyword rankings for our domain. Show the top 20 keywords by position.",
"expected_output": "Uses pagerangers.py with --json rankings --limit 20 to retrieve current keyword positions.",
"files": [],
"assertions": [
"Uses python3 scripts/pagerangers.py command",
"Passes --json flag BEFORE the subcommand",
"Uses 'rankings' subcommand",
"Uses --limit flag to restrict results",
"Does not use 'keyword' subcommand (that's for Explorer, not Monitoring)"
]
},
{
"id": 2,
"eval_name": "find-keyword-opportunities",
"prompt": "Find keyword opportunities with the best ranking potential for our project.",
"expected_output": "Uses pagerangers.py with --json prospects to find high-opportunity keywords.",
"files": [],
"assertions": [
"Uses python3 scripts/pagerangers.py command",
"Passes --json flag BEFORE the subcommand",
"Uses 'prospects' subcommand (not 'keyword' or 'rankings')",
"Includes --limit flag for manageable output",
"Interprets results with actionable SEO insights"
]
}
]
}
{
"description": "PageRangers credential detection hook",
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/scripts/detect_credentials.py",
"timeout": 3
}
]
}
]
}
}
Creative Commons Attribution-ShareAlike 4.0 International
Copyright (c) 2025-2026 Netresearch DTT GmbH
This work is licensed under the Creative Commons Attribution-ShareAlike 4.0
International License. To view a copy of this license, visit
https://creativecommons.org/licenses/by-sa/4.0/ or send a letter to
Creative Commons, PO Box 1866, Mountain View, CA 94042, USA.
You are free to:
- Share: copy and redistribute the material in any medium or format
- Adapt: remix, transform, and build upon the material for any purpose,
even commercially
Under the following terms:
- Attribution: You must give appropriate credit, provide a link to the
license, and indicate if changes were made.
- ShareAlike: If you remix, transform, or build upon the material, you
must distribute your contributions under the same license as the original.
MIT License
Copyright (c) 2025-2026 Netresearch DTT GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
[project]
name = "pagerangers-seo"
version = "1.0.0"
description = "PageRangers SEO API integration for AI assistants"
readme = "README.md"
license = "MIT"
requires-python = ">=3.10"
authors = [
{ name = "Netresearch DTT GmbH" }
]
keywords = ["pagerangers", "seo", "api", "ai-assistant", "serp", "keyword-research"]
classifiers = [
"Development Status :: 4 - Beta",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Internet :: WWW/HTTP",
]
dependencies = []
[project.optional-dependencies]
dev = [
"pytest>=9.0.0",
"pytest-cov>=7.0.0",
"ruff>=0.14.0",
"responses>=0.25.0",
]
[project.urls]
Homepage = "https://github.com/netresearch/pagerangers-skill"
Repository = "https://github.com/netresearch/pagerangers-skill"
Issues = "https://github.com/netresearch/pagerangers-skill/issues"
Support = "https://github.com/netresearch/pagerangers-skill/issues"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["scripts"]
[tool.ruff]
target-version = "py310"
line-length = 120
src = ["scripts", "tests"]
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # Pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
"ARG", # flake8-unused-arguments
"SIM", # flake8-simplify
"PTH", # flake8-use-pathlib
"ERA", # eradicate
"PL", # Pylint
"RUF", # Ruff-specific rules
]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
addopts = [
"-v",
"--tb=short",
"-ra",
]
[tool.coverage.run]
source = ["scripts"]
branch = true
omit = ["tests/*"]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise NotImplementedError",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
]
PageRangers SEO Skill
 
AI agent skill for querying the PageRangers Monitoring API. Works with Claude Code, Codex CLI, and other AI assistants supporting the Agent Skills specification.
Features
- Keyword Analysis: SERP data, search volume, competition levels
- Rankings: Current keyword positions and ranking URLs
- KPIs: Ranking index, top 10/100 counts, average position
- Prospects: High-opportunity keyword identification
Quick Start
# 1. Create credentials file
cat > ~/.env.pagerangers << 'EOF'
PAGERANGERS_API_TOKEN=your_api_key_here
PAGERANGERS_PROJECT_HASH=your_project_hash_here
EOF
# 2. Run commands (--json flag must come before subcommand)
python3 scripts/pagerangers.py --json kpis
python3 scripts/pagerangers.py --json rankings --limit 10
python3 scripts/pagerangers.py --json keyword "SEO tools" --top 5
python3 scripts/pagerangers.py --json prospects --limit 10Installation
Marketplace (Recommended)
Add the Netresearch marketplace once, then browse and install skills:
# Claude Code
/plugin marketplace add netresearch/claude-code-marketplacenpx (skills.sh)
Install with any Agent Skills-compatible agent:
npx skills add https://github.com/netresearch/pagerangers-skill --skill pagerangers-seoDownload Release
Download the latest release and extract to your agent's skills directory.
Git Clone
git clone https://github.com/netresearch/pagerangers-skill.gitComposer (PHP Projects)
composer require netresearch/pagerangers-skillRequires netresearch/composer-agent-skill-plugin.
Development
Setup
# Install dev dependencies
uv pip install -e ".[dev]"
# Run tests
pytest
# Run tests with coverage
pytest --cov=scripts --cov-report=html
# Lint code
ruff check scripts tests
# Format code
ruff format scripts testsProject Structure
pagerangers-seo/
├── .claude-plugin/ # Claude Code plugin manifest
│ └── plugin.json
├── scripts/ # CLI scripts
│ └── pagerangers.py
├── references/ # API documentation
│ ├── pagerangers-api.json
│ └── pagerangers-api.md
├── tests/ # Pytest test suite
│ ├── conftest.py
│ └── test_pagerangers.py
├── SKILL.md # Skill definition
├── AGENTS.md # Agent documentation
├── pyproject.toml # Python project config
└── README.md # This fileConfiguration
Environment Variables
| Variable | Required | Description |
|---|---|---|
PAGERANGERS_API_TOKEN | Yes | API key from PageRangers profile |
PAGERANGERS_PROJECT_HASH | Yes | Project identifier |
PAGERANGERS_BASE_URL | No | Override API URL |
PAGERANGERS_TIMEOUT | No | Request timeout (default: 30s) |
Getting Credentials
1. Log in to PageRangers 2. Go to Profile → API Settings 3. Copy API Token and Project Hash 4. Store in ~/.env.pagerangers
API Reference
See references/pagerangers-api.md for complete API documentation.
Contributing
1. Fork the repository 2. Create a feature branch 3. Write tests for new functionality 4. Ensure all tests pass: pytest 5. Ensure code is formatted: ruff format 6. Submit a pull request
License
This project uses split licensing:
- Code (scripts, workflows, configs): MIT
- Content (skill definitions, documentation, references): CC-BY-SA-4.0
See the individual license files for full terms.
Author
API Costs
Each PageRangers API call costs credits:
| Endpoint | Credits |
|---|---|
| Most endpoints | 1 |
| Competitors | 2 |
| CompetitorRankings | 3 |
SEO Suite includes 100 credits/month.
Monitoring Usage
# Check remaining credits in your PageRangers account
# Log in to PageRangers → Profile → API Settings → Credit UsageError Handling
Common Errors
| Error | Cause | Solution |
|---|---|---|
| 401 | Invalid API token | Check PAGERANGERS_API_TOKEN in ~/.env.pagerangers |
| 403 | Invalid project | Check PAGERANGERS_PROJECT_HASH in ~/.env.pagerangers |
| 429 | Rate limit exceeded | Wait and retry; SEO Suite includes 100 credits/month |
| Timeout | Slow network | Increase PAGERANGERS_TIMEOUT (default: 30s) |
| Empty keyword data | Keyword not in Explorer database | See module-distinction.md; use rankings for Monitoring keywords |
Debugging
# Enable debug output to see raw API responses
python3 scripts/pagerangers.py --debug keyword "test"PageRangers Module Distinction
PageRangers has two separate data sources that are not interconnected:
| Module | Description | Skill Commands |
|---|---|---|
| Monitoring | Custom keywords you add to track your rankings | kpis, rankings, prospects |
| Explorer | PageRangers' general keyword database with SERP data | keyword |
Key Point
Keywords in your Monitoring list are NOT automatically in the Explorer database. The keyword command queries Explorer data (search volume, competition, top URLs). If a keyword exists only in your Monitoring list, the keyword command returns empty data.
What to Use When
- Use
rankingsto see positions for keywords you're tracking (Monitoring) - Use
keywordfor SERP analysis of keywords in PageRangers Explorer database - Not all keywords have Explorer data available
{
"base_url": "https://api.pagerangers.com",
"endpoints": {
"keyword": {
"method": "GET",
"path": "/Monitoring/KeywordSerp/",
"query": {
"keyword": "{keyword}",
"projectHash": "{project_hash}",
"apikey": "{api_token}",
"format": "json"
},
"headers": {
"Accept": "application/json"
},
"response": {
"main_keyword": "keyword",
"search_volume": "searchVolume",
"competition": "competition",
"top_urls": "serp",
"important_keywords": "relatedKeywords"
}
},
"rankings": {
"method": "GET",
"path": "/Monitoring/Rankings/",
"query": {
"projectHash": "{project_hash}",
"apikey": "{api_token}",
"format": "json",
"limit": "100"
},
"headers": {
"Accept": "application/json"
},
"response": {
"keywords": "rankings",
"total": "total"
}
},
"main_kpis": {
"method": "GET",
"path": "/Monitoring/MainKpis/",
"query": {
"projectHash": "{project_hash}",
"apikey": "{api_token}",
"format": "json"
},
"headers": {
"Accept": "application/json"
},
"response": {
"ranking_index": "rankingindex",
"top_10_count": "numberOfKeywordsInTop10",
"top_100_count": "numberOfKeywordsTop100",
"average_position": "averageTopPosition"
}
},
"prospects": {
"method": "GET",
"path": "/Monitoring/KeywordProspects/",
"query": {
"projectHash": "{project_hash}",
"apikey": "{api_token}",
"format": "json",
"limit": "50"
},
"headers": {
"Accept": "application/json"
},
"response": {
"prospects": "prospects"
}
}
}
}
PageRangers API Reference
Complete API documentation for the PageRangers Monitoring endpoints.
Base URL
https://api.pagerangers.comAuthentication
All endpoints require:
projectHash: Your project identifierapiKey: Your API key from Profile → API Settings
Available Endpoints
Keywords (/Monitoring/Keywords/)
Returns all keywords defined in the project.
Cost: 1 Credit
Parameters:
projectHash(required)apiKey(required)format(optional):jsonorxml
Rankings (/Monitoring/Rankings/)
Returns current ranking positions for all keywords.
Cost: 1 Credit
Parameters:
projectHash(required)apiKey(required)date(optional): Unix timestamplimit(optional): Max 1000offset(optional): Skip resultstagfilter(optional): Comma-separated tag listcompetitorDomain(optional): Compare with competitorformat(optional):jsonorxml
Ranking Changes (/Monitoring/RankingChanges/)
Shows how rankings changed between two dates.
Cost: 1 Credit
Parameters:
projectHash(required)apiKey(required)fromDate(optional): Unix timestamp (default: 7 days ago)toDate(optional): Unix timestamptypeFilter(optional):all,winner,looser,in,outlimit,offset,tagfilter,format
Keyword SERP (/Monitoring/KeywordSerp/)
Returns SERP results for a specific keyword.
Note: This endpoint requires the keyword to exist in PageRangers' Explorer database, not just your Monitoring keyword list. Keywords tracked only in Monitoring will return empty data. Use the Rankings endpoint for Monitoring keywords.
Cost: 1 Credit
Parameters:
projectHash(required)apiKey(required)keyword(required): The keyword to analyzedate(optional): Unix timestampformat(optional):jsonorxml
Keyword Prospects (/Monitoring/KeywordProspects/)
Identifies keywords with best ranking opportunities.
Cost: 1 Credit
Parameters:
projectHash(required)apiKey(required)limit,offset,tagfilter,format
Main KPIs (/Monitoring/MainKpis/)
Returns project performance indicators.
Cost: 1 Credit
Response includes:
- Ranking Index
- Keywords in Top 10
- Keywords in Top 100
- Average position of ranking keywords
Parameters:
projectHash(required)apiKey(required)competitorDomain(optional)tagfilter,format
URL Switches (/Monitoring/UrlSwitches/)
Identifies potential URL changes for top rankings.
Cost: 1 Credit
Multiple URL Rankings (/Monitoring/MultipleUrlRankings/)
Keywords ranking with multiple URLs in SERPs.
Cost: 1 Credit
Competitors (/Monitoring/Competitors/)
Competitor data with ranking indices.
Cost: 2 Credits
Competitor Rankings (/Monitoring/CompetitorRankings/)
Detailed competitor ranking positions.
Cost: 3 Credits
Credential Setup
Create ~/.env.pagerangers:
# PageRangers API Credentials
PAGERANGERS_API_TOKEN=your_api_key_here
PAGERANGERS_PROJECT_HASH=your_project_hash_here
# Optional overrides
# PAGERANGERS_BASE_URL=https://api.pagerangers.com
# PAGERANGERS_TIMEOUT=30Config File Structure
The pagerangers-api.json maps endpoints to response paths:
{
"base_url": "https://api.pagerangers.com",
"endpoints": {
"keyword": {
"method": "GET",
"path": "/Monitoring/KeywordSerp/",
"query": {
"keyword": "{keyword}",
"projectHash": "{project_hash}",
"apiKey": "{api_token}"
},
"response": {
"main_keyword": "keyword",
"search_volume": "searchVolume",
"top_urls": "serp"
}
}
}
}Placeholders
| Placeholder | Source |
|---|---|
{keyword} | Command argument |
{api_token} | PAGERANGERS_API_TOKEN env |
{project_hash} | PAGERANGERS_PROJECT_HASH env |
Response Path Syntax
Use dot notation with optional array indexes:
data.keyword → payload["data"]["keyword"]
serp[0].url → payload["serp"][0]["url"]
results.items[2].name → payload["results"]["items"][2]["name"]Error Codes
| Code | Meaning | Solution |
|---|---|---|
| 401 | Unauthorized | Check API token |
| 403 | Forbidden | Check project hash |
| 404 | Not found | Check endpoint path |
| 429 | Rate limited | Wait and retry |
| 500 | Server error | Contact PageRangers support |
API Credits
| Endpoint | Cost |
|---|---|
| Most endpoints | 1 credit |
| Competitors | 2 credits |
| CompetitorRankings | 3 credits |
SEO Suite plan includes 100 credits/month.
External Documentation
Setup and Authentication
Getting Credentials
1. Log in to PageRangers 2. Go to Profile → API Settings 3. Copy your API Token and Project Hash 4. Store in ~/.env.pagerangers
Credentials File
cat > ~/.env.pagerangers << 'EOF'
PAGERANGERS_API_TOKEN=your_api_key_here
PAGERANGERS_PROJECT_HASH=your_project_hash_here
EOFOptional Environment Variables
| Variable | Required | Description |
|---|---|---|
PAGERANGERS_API_TOKEN | Yes | API key from PageRangers profile |
PAGERANGERS_PROJECT_HASH | Yes | Project identifier |
PAGERANGERS_BASE_URL | No | Override API URL |
PAGERANGERS_TIMEOUT | No | Request timeout (default: 30s) |
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"github>netresearch/renovate-config",
"helpers:pinGitHubActionDigests"
],
"labels": [
"dependencies"
],
"packageRules": [
{
"description": "Do not pin org-internal reusable workflows (use @main)",
"matchManagers": [
"github-actions"
],
"matchPackageNames": [
"netresearch/**"
],
"pinDigests": false
},
{
"matchManagers": [
"github-actions"
],
"automerge": true,
"automergeType": "pr",
"matchUpdateTypes": [
"minor",
"patch"
]
},
{
"matchManagers": [
"pep621"
],
"automerge": true,
"automergeType": "pr",
"matchUpdateTypes": [
"minor",
"patch"
]
}
],
"lockFileMaintenance": {
"enabled": true,
"schedule": [
"before 6am on monday"
]
}
}
#!/usr/bin/env python3
"""
UserPromptSubmit hook to detect PageRangers-related queries and check credentials.
Provides setup instructions if credentials are missing.
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
# Keywords that indicate PageRangers-specific queries
PAGERANGERS_PATTERNS = [
r"\bpagerangers\b", # Direct mention
r"\branking\s+index\b", # PageRangers-specific metric
]
# SEO + skill command combinations
SEO_COMMAND_PATTERN = r"\bseo\b.*\b(rankings?|kpis?|prospects?)\b|\b(rankings?|kpis?|prospects?)\b.*\bseo\b"
CREDENTIALS_FILE = ".env.pagerangers"
REQUIRED_VARS = ["PAGERANGERS_API_TOKEN", "PAGERANGERS_PROJECT_HASH"]
def contains_pagerangers_keywords(text: str | None) -> bool:
"""Check if text contains PageRangers-specific keywords."""
if not text:
return False
text_lower = text.lower()
# Check direct PageRangers patterns
for pattern in PAGERANGERS_PATTERNS:
if re.search(pattern, text_lower):
return True
# Check SEO + command keyword combinations
return bool(re.search(SEO_COMMAND_PATTERN, text_lower))
def parse_prompt(input_data: str | None) -> str:
"""Parse prompt from stdin input (JSON or plain text)."""
if not input_data:
return ""
# Try JSON parsing
try:
data = json.loads(input_data)
return data.get("prompt", "") or data.get("message", "") or data.get("content", "") or ""
except (json.JSONDecodeError, TypeError):
return input_data
def check_credentials() -> dict[str, bool | str]:
"""Check if PageRangers credentials file exists and is valid."""
creds_path = Path.home() / CREDENTIALS_FILE
if not creds_path.exists():
return {
"valid": False,
"message": f"Credentials file not found: ~/{CREDENTIALS_FILE}",
}
content = creds_path.read_text()
missing_vars = []
for var in REQUIRED_VARS:
if var not in content or f"{var}=" not in content:
missing_vars.append(var)
if missing_vars:
missing_str = ", ".join(missing_vars)
return {
"valid": False,
"message": f"Missing required variables: {missing_str}",
}
# Check for empty values
for var in REQUIRED_VARS:
pattern = rf"{var}=\s*$"
if re.search(pattern, content, re.MULTILINE):
return {
"valid": False,
"message": f"Empty value for {var}",
}
return {"valid": True, "message": "Credentials valid"}
def output_setup_instructions(error_message: str) -> None:
"""Print setup instructions for missing credentials."""
print(f"""<user-prompt-submit-hook>
PageRangers credentials issue: {error_message}
Create ~/.env.pagerangers with:
PAGERANGERS_API_TOKEN=your_api_key
PAGERANGERS_PROJECT_HASH=your_project_hash
Get credentials from PageRangers → Profile → API Settings
</user-prompt-submit-hook>""")
def main() -> None:
"""Main hook entry point."""
try:
input_data = sys.stdin.read()
except Exception:
return
prompt = parse_prompt(input_data)
if not contains_pagerangers_keywords(prompt):
return
result = check_credentials()
if not result["valid"]:
output_setup_instructions(result["message"])
if __name__ == "__main__":
main()
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = []
# ///
"""PageRangers SEO API client for AI assistants.
Supports multiple commands:
keyword - Analyze a specific keyword (SERP data)
rankings - Get current keyword rankings
kpis - Get main KPIs (ranking index, top 10/100)
prospects - Find high-opportunity keywords
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
# HTTP Status Codes
HTTP_UNAUTHORIZED = 401
HTTP_FORBIDDEN = 403
HTTP_TOO_MANY_REQUESTS = 429
# Competition Thresholds
COMPETITION_LOW_THRESHOLD = 0.33
COMPETITION_MEDIUM_THRESHOLD = 0.66
# Default Values
DEFAULT_TIMEOUT = 30
DEFAULT_TOP_URLS = 5
DEFAULT_LIMIT = 20
MAX_RELATED_KEYWORDS = 10
@dataclass
class CommandContext:
"""Context for command execution."""
config: dict
variables: dict[str, str]
timeout: int
debug: bool
def load_env_file(path: Path) -> None:
"""Load environment variables from a file."""
if not path.is_file():
return
for raw_line in path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip()
value = value.strip().strip('"').strip("'")
os.environ.setdefault(key, value)
def load_config(path: Path) -> dict:
"""Load JSON configuration file."""
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def substitute(value: str | dict | list | None, variables: dict[str, str]) -> str | dict | list | None:
"""Replace {placeholder} strings with variable values."""
if isinstance(value, str):
result = value
for key, val in variables.items():
result = result.replace(f"{{{key}}}", val)
return result
if isinstance(value, dict):
return {key: substitute(item, variables) for key, item in value.items()}
if isinstance(value, list):
return [substitute(item, variables) for item in value]
return value
def get_by_path(data: dict | list | None, path: str) -> str | dict | list | None:
"""Extract value from nested dict using dot notation."""
if not path:
return data
current = data
for part in path.split("."):
if part == "":
continue
if "[" in part and part.endswith("]"):
name, idx_part = part[:-1].split("[", 1)
if name:
current = current.get(name) if isinstance(current, dict) else None
if current is None:
return None
idx = int(idx_part)
current = current[idx] if isinstance(current, list) and idx < len(current) else None
elif isinstance(current, dict):
current = current.get(part)
else:
return None
return current
def normalize_urls(value: list | None, limit: int | None = None) -> list[str]:
"""Extract URLs from SERP results."""
if not isinstance(value, list):
return []
urls = []
for item in value:
if isinstance(item, dict):
url = item.get("url") or item.get("link") or item.get("href") or item.get("domain")
if url:
urls.append(url)
elif isinstance(item, str):
urls.append(item)
if limit is not None:
return urls[:limit]
return urls
def normalize_competition(value: float | int | str | None) -> str:
"""Normalize competition score to low/medium/high."""
if value is None:
return "unknown"
if isinstance(value, (int, float)):
if value <= COMPETITION_LOW_THRESHOLD:
return "low"
if value <= COMPETITION_MEDIUM_THRESHOLD:
return "medium"
return "high"
return str(value)
def request_json(method: str, url: str, headers: dict, body: dict | None, timeout: int) -> dict:
"""Make HTTP request and return JSON response."""
data = None
req_headers = {"Accept": "application/json", "User-Agent": "PageRangers-Skill/1.0"}
req_headers.update(headers or {})
if body is not None:
data = json.dumps(body).encode("utf-8")
req_headers.setdefault("Content-Type", "application/json")
req = urllib.request.Request(url, data=data, method=method, headers=req_headers)
try:
with urllib.request.urlopen(req, timeout=timeout) as response:
payload = response.read()
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", errors="replace")
if exc.code == HTTP_UNAUTHORIZED:
raise RuntimeError("Authentication failed. Check PAGERANGERS_API_TOKEN.") from exc
if exc.code == HTTP_FORBIDDEN:
raise RuntimeError("Access denied. Check PAGERANGERS_PROJECT_HASH.") from exc
if exc.code == HTTP_TOO_MANY_REQUESTS:
raise RuntimeError("Rate limit exceeded. Try again later.") from exc
raise RuntimeError(f"HTTP {exc.code}: {detail}") from exc
except urllib.error.URLError as exc:
raise RuntimeError(f"Connection error: {exc.reason}") from exc
try:
return json.loads(payload)
except json.JSONDecodeError as exc:
raise RuntimeError("Invalid JSON response from API") from exc
def call_endpoint(ctx: CommandContext, endpoint_name: str) -> dict:
"""Call a configured API endpoint."""
base_url = os.environ.get("PAGERANGERS_BASE_URL", ctx.config.get("base_url", ""))
if not base_url:
raise RuntimeError("Missing base_url in config")
endpoint = ctx.config.get("endpoints", {}).get(endpoint_name)
if not endpoint:
raise RuntimeError(f"Unknown endpoint: {endpoint_name}")
endpoint = substitute(endpoint, ctx.variables)
method = endpoint.get("method", "GET").upper()
path = endpoint.get("path", "")
url = base_url.rstrip("/") + "/" + path.lstrip("/")
query = endpoint.get("query")
if query:
url += "?" + urllib.parse.urlencode(query)
if ctx.debug:
safe_url = url.replace(ctx.variables.get("api_token", ""), "***")
print(f"[DEBUG] {method} {safe_url}", file=sys.stderr)
result = request_json(method, url, endpoint.get("headers", {}), endpoint.get("body"), ctx.timeout)
if isinstance(result, dict) and "errormessage" in result:
error_msg = result["errormessage"]
if "api-key" in error_msg.lower():
raise RuntimeError(f"API Error: {error_msg}. Your API key may not have access to this endpoint.")
raise RuntimeError(f"API Error: {error_msg}")
return result
def cmd_keyword(args: argparse.Namespace, ctx: CommandContext) -> int:
"""Analyze a specific keyword."""
ctx.variables["keyword"] = args.keyword
try:
payload = call_endpoint(ctx, "keyword")
except RuntimeError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
response_map = ctx.config.get("endpoints", {}).get("keyword", {}).get("response", {})
result = {
"main_keyword": get_by_path(payload, response_map.get("main_keyword", "")) or args.keyword,
"search_volume": get_by_path(payload, response_map.get("search_volume", "")) or "unknown",
"competition": normalize_competition(get_by_path(payload, response_map.get("competition", ""))),
"top_urls": normalize_urls(get_by_path(payload, response_map.get("top_urls", "")), args.top),
"important_keywords": get_by_path(payload, response_map.get("important_keywords", "")) or [],
}
if args.json:
print(json.dumps(result, indent=2, ensure_ascii=False))
return 0
print(f"Keyword: {result['main_keyword']}")
print(f"Search Volume: {result['search_volume']}")
print(f"Competition: {result['competition']}")
if result["top_urls"]:
print(f"\nTop {len(result['top_urls'])} URLs:")
for i, url in enumerate(result["top_urls"], 1):
print(f" {i}. {url}")
if result["important_keywords"]:
print("\nRelated Keywords:")
for kw in result["important_keywords"][:MAX_RELATED_KEYWORDS]:
print(f" - {kw}")
return 0
def cmd_rankings(args: argparse.Namespace, ctx: CommandContext) -> int:
"""Get current keyword rankings."""
try:
payload = call_endpoint(ctx, "rankings")
except RuntimeError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
response_map = ctx.config.get("endpoints", {}).get("rankings", {}).get("response", {})
keywords = get_by_path(payload, response_map.get("keywords", "")) or []
if args.json:
print(json.dumps({"rankings": keywords[: args.limit]}, indent=2, ensure_ascii=False))
return 0
print(f"Top {min(len(keywords), args.limit)} Keyword Rankings:\n")
for i, kw in enumerate(keywords[: args.limit], 1):
name = kw.get("keyword", kw.get("name", "unknown"))
pos = kw.get("position", kw.get("rank", "?"))
url = kw.get("url", kw.get("rankingUrl", ""))
print(f" {i}. [{pos}] {name}")
if url:
print(f" {url}")
return 0
def cmd_kpis(args: argparse.Namespace, ctx: CommandContext) -> int:
"""Get main KPIs."""
try:
payload = call_endpoint(ctx, "main_kpis")
except RuntimeError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
response_map = ctx.config.get("endpoints", {}).get("main_kpis", {}).get("response", {})
result = {
"ranking_index": get_by_path(payload, response_map.get("ranking_index", "")),
"top_10_count": get_by_path(payload, response_map.get("top_10_count", "")),
"top_100_count": get_by_path(payload, response_map.get("top_100_count", "")),
"average_position": get_by_path(payload, response_map.get("average_position", "")),
}
if args.json:
print(json.dumps(result, indent=2, ensure_ascii=False))
return 0
print("Project KPIs:\n")
print(f" Ranking Index: {result['ranking_index'] or 'N/A'}")
print(f" Keywords in Top 10: {result['top_10_count'] or 'N/A'}")
print(f" Keywords in Top 100: {result['top_100_count'] or 'N/A'}")
print(f" Average Position: {result['average_position'] or 'N/A'}")
return 0
def cmd_prospects(args: argparse.Namespace, ctx: CommandContext) -> int:
"""Find high-opportunity keywords."""
try:
payload = call_endpoint(ctx, "prospects")
except RuntimeError as e:
print(f"Error: {e}", file=sys.stderr)
return 1
response_map = ctx.config.get("endpoints", {}).get("prospects", {}).get("response", {})
prospects = get_by_path(payload, response_map.get("prospects", "")) or []
if args.json:
print(json.dumps({"prospects": prospects[: args.limit]}, indent=2, ensure_ascii=False))
return 0
print(f"Top {min(len(prospects), args.limit)} Keyword Opportunities:\n")
for i, kw in enumerate(prospects[: args.limit], 1):
name = kw.get("keyword", kw.get("name", "unknown"))
pos = kw.get("position", kw.get("rank", "?"))
volume = kw.get("searchVolume", kw.get("volume", "?"))
print(f" {i}. {name}")
print(f" Position: {pos}, Search Volume: {volume}")
return 0
def create_parser() -> argparse.ArgumentParser:
"""Create and configure the argument parser."""
parser = argparse.ArgumentParser(
description="PageRangers SEO API client",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Commands:
keyword <kw> Analyze a specific keyword (SERP, volume, competition)
rankings Get current keyword rankings for the project
kpis Get main KPIs (ranking index, top 10/100 counts)
prospects Find high-opportunity keywords
Environment Variables:
PAGERANGERS_API_TOKEN Your PageRangers API key (required)
PAGERANGERS_PROJECT_HASH Your project identifier (required)
PAGERANGERS_BASE_URL Override API base URL (optional)
PAGERANGERS_TIMEOUT Request timeout in seconds (default: 30)
Configuration:
Store credentials in ~/.env.pagerangers:
PAGERANGERS_API_TOKEN=your_api_key
PAGERANGERS_PROJECT_HASH=your_project_hash
""",
)
parser.add_argument("--json", action="store_true", help="Output as JSON")
parser.add_argument("--debug", action="store_true", help="Show debug info")
parser.add_argument(
"--config",
default=str(Path(__file__).resolve().parent.parent / "references" / "pagerangers-api.json"),
help="Path to API config JSON",
)
subparsers = parser.add_subparsers(dest="command", required=True)
kw_parser = subparsers.add_parser("keyword", help="Analyze a keyword")
kw_parser.add_argument("keyword", help="Keyword to analyze")
kw_parser.add_argument("--top", type=int, default=DEFAULT_TOP_URLS, help=f"Top URLs (default: {DEFAULT_TOP_URLS})")
rank_parser = subparsers.add_parser("rankings", help="Get keyword rankings")
rank_parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT, help=f"Max results (default: {DEFAULT_LIMIT})")
subparsers.add_parser("kpis", help="Get main KPIs")
prosp_parser = subparsers.add_parser("prospects", help="Find keyword opportunities")
prosp_parser.add_argument(
"--limit", type=int, default=DEFAULT_LIMIT, help=f"Max results (default: {DEFAULT_LIMIT})"
)
return parser
def get_command_handlers() -> dict[str, Callable[[argparse.Namespace, CommandContext], int]]:
"""Return mapping of command names to handler functions."""
return {
"keyword": cmd_keyword,
"rankings": cmd_rankings,
"kpis": cmd_kpis,
"prospects": cmd_prospects,
}
def main() -> int:
"""Main entry point."""
parser = create_parser()
args = parser.parse_args()
load_env_file(Path.home() / ".env.pagerangers")
config_path = Path(args.config)
if not config_path.is_file():
print(f"Error: Config not found: {config_path}", file=sys.stderr)
print("Run from skill directory or specify --config path", file=sys.stderr)
return 1
config = load_config(config_path)
token = os.environ.get("PAGERANGERS_API_TOKEN")
project_hash = os.environ.get("PAGERANGERS_PROJECT_HASH")
if not token or not project_hash:
print("Error: Missing credentials.", file=sys.stderr)
print("\nSet environment variables or create ~/.env.pagerangers with:", file=sys.stderr)
print(" PAGERANGERS_API_TOKEN=your_api_key", file=sys.stderr)
print(" PAGERANGERS_PROJECT_HASH=your_project_hash", file=sys.stderr)
return 1
ctx = CommandContext(
config=config,
variables={"api_token": token, "project_hash": project_hash},
timeout=int(os.environ.get("PAGERANGERS_TIMEOUT", str(DEFAULT_TIMEOUT))),
debug=args.debug,
)
handlers = get_command_handlers()
handler = handlers.get(args.command)
if handler:
return handler(args, ctx)
parser.print_help()
return 1
if __name__ == "__main__":
raise SystemExit(main())
#!/usr/bin/env bash
# verify-harness.sh — Portable harness consistency checker
# Checks AGENTS.md and related files for agent harness maturity.
# Dependencies: coreutils + git (jq optional, graceful fallback)
set -euo pipefail
# ---------------------------------------------------------------------------
# Globals
# ---------------------------------------------------------------------------
ERRORS=0
WARNINGS=0
FORMAT=""
MAX_LEVEL=3
SINGLE_CHECK=""
STATUS_ONLY=false
# Collected output lines (for final rendering)
declare -a OUTPUT_LINES=()
declare -a GITHUB_LINES=()
# Per-level pass/total counters
declare -A LEVEL_PASS=( [1]=0 [2]=0 [3]=0 )
declare -A LEVEL_TOTAL=( [1]=0 [2]=0 [3]=0 )
# Track the first failing level-1 suggestion for --status
NEXT_STEP=""
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
usage() {
cat <<'USAGE'
Usage: verify-harness.sh [OPTIONS]
Verify agent harness consistency in the current repository.
Must be run from the repo root.
Options:
--format=text Plain text output (default for terminals)
--format=github GitHub Actions annotations (auto-detected in CI)
--level=N Only check up to level N (1, 2, or 3; default: all)
--check=NAME Run single check category: refs, commands, drift, structure
--status Show current maturity level summary only
--help Show this help message
Exit codes:
0 All checks pass
1 Errors found (Level 1/2 failures)
2 Only warnings (Level 3 suggestions)
USAGE
exit 0
}
# Detect output format: github if running in CI, otherwise text
detect_format() {
if [[ -n "$FORMAT" ]]; then
return
fi
if [[ "${GITHUB_ACTIONS:-}" == "true" ]]; then
FORMAT="github"
else
FORMAT="text"
fi
}
# Record a passing check
pass() {
local level="$1"
local msg="$2"
(( LEVEL_PASS[$level]++ )) || true
(( LEVEL_TOTAL[$level]++ )) || true
OUTPUT_LINES+=(" PASS|${level}|${msg}")
}
# Record a failing check (error)
fail() {
local level="$1"
local msg="$2"
local file="${3:-AGENTS.md}"
(( ERRORS++ )) || true
(( LEVEL_TOTAL[$level]++ )) || true
OUTPUT_LINES+=(" FAIL|${level}|${msg}")
GITHUB_LINES+=("::error file=${file}::${msg} -- required for Level ${level} harness maturity")
# Capture first actionable suggestion for --status
if [[ -z "$NEXT_STEP" ]]; then
NEXT_STEP="$msg"
fi
}
# Record a warning
warn() {
local level="$1"
local msg="$2"
local file="${3:-AGENTS.md}"
(( WARNINGS++ )) || true
(( LEVEL_TOTAL[$level]++ )) || true
OUTPUT_LINES+=(" WARN|${level}|${msg}")
GITHUB_LINES+=("::warning file=${file}::${msg}")
if [[ -z "$NEXT_STEP" ]]; then
NEXT_STEP="$msg"
fi
}
# ---------------------------------------------------------------------------
# Level 1 checks — Basic
# ---------------------------------------------------------------------------
check_agents_md_exists() {
if [[ -f "AGENTS.md" ]]; then
pass 1 "AGENTS.md exists"
else
fail 1 "AGENTS.md missing at repo root"
fi
}
check_agents_md_length() {
if [[ ! -f "AGENTS.md" ]]; then
fail 1 "AGENTS.md length check skipped (file missing)"
return
fi
local lines
lines=$(wc -l < "AGENTS.md")
if (( lines < 150 )); then
pass 1 "AGENTS.md is index-format (${lines} lines)"
else
fail 1 "AGENTS.md is ${lines} lines (should be under 150)"
fi
}
check_agents_md_commands() {
if [[ ! -f "AGENTS.md" ]]; then
fail 1 "Commands section check skipped (AGENTS.md missing)"
return
fi
if grep -qi '^## *\(available \)\?commands' "AGENTS.md"; then
pass 1 "Commands section found"
else
fail 1 "AGENTS.md missing ## Commands section"
fi
}
check_docs_exists() {
if [[ -d "docs" ]]; then
pass 1 "docs/ directory exists"
else
fail 1 "docs/ directory missing" ""
fi
}
run_level1() {
check_agents_md_exists
check_agents_md_length
check_agents_md_commands
check_docs_exists
}
# ---------------------------------------------------------------------------
# Level 2 checks — Verified
# ---------------------------------------------------------------------------
# Check that all local file references in AGENTS.md resolve
check_refs() {
if [[ ! -f "AGENTS.md" ]]; then
fail 2 "Reference check skipped (AGENTS.md missing)"
return
fi
local has_broken=false
# Extract markdown links: [text](path) — skip http(s):// and #anchors
while IFS= read -r ref; do
# Strip anchor (#...) and query string (?...)
local clean
clean="${ref%%#*}"
clean="${clean%%\?*}"
# Skip empty after stripping
[[ -z "$clean" ]] && continue
# Skip URLs
[[ "$clean" =~ ^https?:// ]] && continue
# Check if file/dir exists
if [[ ! -e "$clean" ]]; then
warn 2 "Broken reference in AGENTS.md: ${ref} -> ${clean} not found"
has_broken=true
fi
done < <(grep -oP '\]\(\K[^)]+' "AGENTS.md" 2>/dev/null || true)
if [[ "$has_broken" == false ]]; then
pass 2 "All references resolve"
fi
}
# Check that documented commands have matching targets/scripts
check_commands() {
if [[ ! -f "AGENTS.md" ]]; then
fail 2 "Command check skipped (AGENTS.md missing)"
return
fi
local found_any=false
# -- Makefile targets --
if [[ -f "Makefile" ]]; then
found_any=true
local has_make_issue=false
while IFS= read -r target; do
# Check if Makefile defines this target (pattern: "target:" at start of line)
if ! grep -qE "^${target}[[:space:]]*:" "Makefile"; then
warn 2 "make ${target}: no matching Makefile target (warning)"
has_make_issue=true
fi
done < <(grep -oP '`make\s+\K[a-zA-Z0-9_-]+' "AGENTS.md" 2>/dev/null || true)
if [[ "$has_make_issue" == false ]]; then
local make_count
make_count=$(grep -oP '`make\s+\K[a-zA-Z0-9_-]+' "AGENTS.md" 2>/dev/null | wc -l || true)
if [[ "$make_count" -gt 0 ]]; then
pass 2 "All make targets verified (${make_count} targets)"
fi
fi
fi
# -- composer.json scripts --
if [[ -f "composer.json" ]]; then
found_any=true
local has_composer_issue=false
# Built-in composer commands that are NOT user-defined scripts
local composer_builtins="install|update|require|remove|dump-autoload|dumpautoload|clear-cache|clearcache|config|create-project|exec|global|init|outdated|prohibits|why|why-not|search|self-update|selfupdate|show|status|validate|archive|browse|check-platform-reqs|diagnose|fund|licenses|run-script|suggests|upgrade"
while IFS= read -r script; do
# Skip built-in composer commands
if echo "$script" | grep -qE "^(${composer_builtins})$"; then
continue
fi
# Look for the script name in composer.json's scripts section
# Using grep since jq is optional
if ! grep -qE "\"${script}\"" "composer.json"; then
warn 2 "composer ${script}: no matching composer.json script (warning)"
has_composer_issue=true
fi
done < <(grep -oP '`composer\s+\K[a-zA-Z0-9:_-]+' "AGENTS.md" 2>/dev/null || true)
if [[ "$has_composer_issue" == false ]]; then
local composer_count
composer_count=$(grep -oP '`composer\s+\K[a-zA-Z0-9:_-]+' "AGENTS.md" 2>/dev/null | wc -l || true)
if [[ "$composer_count" -gt 0 ]]; then
pass 2 "All composer scripts verified (${composer_count} scripts)"
fi
fi
fi
# -- package.json scripts --
if [[ -f "package.json" ]]; then
found_any=true
local has_npm_issue=false
while IFS= read -r script; do
if ! grep -qE "\"${script}\"" "package.json"; then
warn 2 "npm run ${script}: no matching package.json script (warning)"
has_npm_issue=true
fi
done < <(grep -oP '`npm run\s+\K[a-zA-Z0-9:_-]+' "AGENTS.md" 2>/dev/null || true)
if [[ "$has_npm_issue" == false ]]; then
local npm_count
npm_count=$(grep -oP '`npm run\s+\K[a-zA-Z0-9:_-]+' "AGENTS.md" 2>/dev/null | wc -l || true)
if [[ "$npm_count" -gt 0 ]]; then
pass 2 "All npm scripts verified (${npm_count} scripts)"
fi
fi
fi
if [[ "$found_any" == false ]]; then
pass 2 "No build system files found to check commands against"
fi
}
check_architecture_doc() {
if [[ -f "docs/ARCHITECTURE.md" ]]; then
pass 2 "docs/ARCHITECTURE.md exists"
else
fail 2 "docs/ARCHITECTURE.md missing" ""
fi
}
check_ci_workflow() {
if [[ -f ".github/workflows/harness-verify.yml" ]]; then
pass 2 "CI harness workflow exists"
else
fail 2 "CI harness workflow missing -- create .github/workflows/harness-verify.yml" ""
fi
}
run_level2() {
check_refs
check_commands
check_architecture_doc
check_ci_workflow
}
# ---------------------------------------------------------------------------
# Level 3 checks — Enforced
# ---------------------------------------------------------------------------
check_hooks_autosetup() {
local found=false
local via=""
# Check .envrc for hooksPath
if [[ -f ".envrc" ]] && grep -q "hooksPath" ".envrc"; then
found=true
via=".envrc"
fi
# Check for Husky
if [[ -d ".husky" ]]; then
found=true
via=".husky"
fi
# Check composer.json for post-install-cmd with hooks
if [[ -f "composer.json" ]] && grep -q "post-install-cmd" "composer.json"; then
if grep -q "hook" "composer.json"; then
found=true
via="composer.json post-install-cmd"
fi
fi
if [[ "$found" == true ]]; then
pass 3 "Git hooks auto-setup via ${via}"
else
warn 3 "No git hooks auto-setup detected (.envrc hooksPath, .husky/, or composer.json post-install-cmd)"
fi
}
check_pr_template() {
# Check local repo first
if [[ -f ".github/pull_request_template.md" ]]; then
pass 3 "PR template exists (repo-level)"
return
fi
# Check for templates in subdirectory form
if [[ -d ".github/PULL_REQUEST_TEMPLATE" ]]; then
pass 3 "PR template exists (directory form)"
return
fi
# Try to detect org-level template via GitHub API (graceful fallback)
local org=""
org=$(git remote get-url origin 2>/dev/null | sed -n 's|.*github\.com[:/]\([^/]*\)/.*|\1|p')
if [[ -n "$org" ]]; then
# Try GitHub API — if accessible, check org .github repo for template
local api_result=""
api_result=$(gh api "repos/${org}/.github/contents/pull_request_template.md" --jq '.name' 2>/dev/null || true)
if [[ "$api_result" == "pull_request_template.md" ]]; then
pass 3 "PR template exists (org-level via ${org}/.github)"
return
fi
fi
warn 3 "PR template missing (.github/pull_request_template.md or org-level)"
}
check_drift() {
# Skip if git is not available
if ! command -v git &>/dev/null; then
pass 3 "Drift check skipped (git not available)"
return
fi
# Skip if not in a git repo
if ! git rev-parse --git-dir &>/dev/null 2>&1; then
pass 3 "Drift check skipped (not a git repository)"
return
fi
# Skip if no parent commit (initial commit)
if ! git rev-parse HEAD~1 &>/dev/null 2>&1; then
pass 3 "Drift check skipped (no parent commit)"
return
fi
# Check if build/CI files changed in last commit
local build_files_changed=false
local agents_changed=false
while IFS= read -r changed_file; do
case "$changed_file" in
Makefile|composer.json|package.json|.github/workflows/*)
build_files_changed=true
;;
AGENTS.md)
agents_changed=true
;;
esac
done < <(git diff --name-only HEAD~1 HEAD 2>/dev/null || true)
if [[ "$build_files_changed" == true && "$agents_changed" == false ]]; then
warn 3 "Potential drift: build/CI files changed in last commit but AGENTS.md was not updated"
else
pass 3 "No drift detected"
fi
}
run_level3() {
check_hooks_autosetup
check_pr_template
check_drift
}
# ---------------------------------------------------------------------------
# Output rendering
# ---------------------------------------------------------------------------
render_text() {
echo "Agent Harness Verification"
echo "=========================="
echo ""
local current_level=0
local level_names=( [1]="Basic" [2]="Verified" [3]="Enforced" )
for line in "${OUTPUT_LINES[@]}"; do
local kind level msg
kind="${line%%|*}"
local rest="${line#*|}"
level="${rest%%|*}"
msg="${rest#*|}"
kind="${kind#"${kind%%[![:space:]]*}"}" # trim leading whitespace
# Print level header when level changes
if (( level != current_level )); then
if (( current_level != 0 )); then
echo ""
fi
echo "Level ${level} -- ${level_names[$level]}"
current_level=$level
fi
case "$kind" in
PASS) echo " ✓ ${msg}" ;;
FAIL) echo " ✗ ${msg}" ;;
WARN) echo " ! ${msg}" ;;
esac
done
echo ""
# Summary line
local maturity_level=0
for lvl in 1 2 3; do
if (( ${LEVEL_TOTAL[$lvl]} > 0 && ${LEVEL_PASS[$lvl]} == ${LEVEL_TOTAL[$lvl]} )); then
maturity_level=$lvl
else
break
fi
done
local status="COMPLETE"
if (( maturity_level == 0 )); then
if (( LEVEL_TOTAL[1] > 0 && LEVEL_PASS[1] > 0 )); then
status="PARTIAL"
else
status="NONE"
fi
maturity_level=1
elif (( maturity_level < 3 )); then
# Check if next level is partially done
local next_lvl=$(( maturity_level + 1 ))
if (( ${LEVEL_TOTAL[$next_lvl]} > 0 && ${LEVEL_PASS[$next_lvl]} < ${LEVEL_TOTAL[$next_lvl]} )); then
status="PARTIAL"
fi
fi
echo "Summary: Level ${maturity_level} ${status} | ${ERRORS} error(s), ${WARNINGS} warning(s)"
}
render_github() {
for line in "${GITHUB_LINES[@]}"; do
echo "$line"
done
}
render_status() {
# Determine highest fully-passing level
local maturity_level=0
local level_names=( [1]="Basic" [2]="Verified" [3]="Enforced" )
for lvl in 1 2 3; do
if (( ${LEVEL_TOTAL[$lvl]} > 0 && ${LEVEL_PASS[$lvl]} == ${LEVEL_TOTAL[$lvl]} )); then
maturity_level=$lvl
else
break
fi
done
local status
if (( maturity_level == 0 )); then
if (( LEVEL_TOTAL[1] > 0 && LEVEL_PASS[1] > 0 )); then
status="PARTIAL"
else
status="NONE"
fi
# Display as Level 1 when no level is fully complete
local display_level=1
echo "Harness Maturity: Level ${display_level} (${level_names[$display_level]}) -- ${status}"
else
echo "Harness Maturity: Level ${maturity_level} (${level_names[$maturity_level]}) -- COMPLETE"
fi
for lvl in 1 2 3; do
if (( ${LEVEL_TOTAL[$lvl]} > 0 )); then
echo " Level ${lvl}: ${LEVEL_PASS[$lvl]}/${LEVEL_TOTAL[$lvl]} checks pass"
fi
done
if [[ -n "$NEXT_STEP" ]]; then
echo "Next step: ${NEXT_STEP}"
fi
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
main() {
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--format=*)
FORMAT="${1#--format=}"
;;
--level=*)
MAX_LEVEL="${1#--level=}"
if [[ ! "$MAX_LEVEL" =~ ^[123]$ ]]; then
echo "Error: --level must be 1, 2, or 3" >&2
exit 1
fi
;;
--check=*)
SINGLE_CHECK="${1#--check=}"
;;
--status)
STATUS_ONLY=true
;;
--help|-h)
usage
;;
*)
echo "Unknown option: $1" >&2
echo "Run with --help for usage info" >&2
exit 1
;;
esac
shift
done
detect_format
# Run single check category if requested
if [[ -n "$SINGLE_CHECK" ]]; then
case "$SINGLE_CHECK" in
refs) check_refs ;;
commands) check_commands ;;
drift) check_drift ;;
structure)
check_agents_md_exists
check_docs_exists
check_architecture_doc
check_ci_workflow
check_pr_template
;;
*)
echo "Unknown check: ${SINGLE_CHECK}" >&2
echo "Valid checks: refs, commands, drift, structure" >&2
exit 1
;;
esac
else
# Run all checks up to MAX_LEVEL
if (( MAX_LEVEL >= 1 )); then
run_level1
fi
if (( MAX_LEVEL >= 2 )); then
run_level2
fi
if (( MAX_LEVEL >= 3 )); then
run_level3
fi
fi
# Render output
if [[ "$STATUS_ONLY" == true ]]; then
render_status
elif [[ "$FORMAT" == "github" ]]; then
render_github
else
render_text
fi
# Exit code
if (( ERRORS > 0 )); then
exit 1
elif (( WARNINGS > 0 )); then
exit 2
else
exit 0
fi
}
main "$@"
"""Pytest fixtures for PageRangers API testing."""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
if TYPE_CHECKING:
from collections.abc import Generator
@pytest.fixture
def mock_env() -> Generator[dict[str, str], None, None]:
"""Provide mock environment variables for testing."""
env_vars = {
"PAGERANGERS_API_TOKEN": "test-api-token-12345",
"PAGERANGERS_PROJECT_HASH": "ABC1234",
"PAGERANGERS_BASE_URL": "https://api.pagerangers.com",
"PAGERANGERS_TIMEOUT": "30",
}
with patch.dict(os.environ, env_vars, clear=False):
yield env_vars
@pytest.fixture
def config_path() -> Path:
"""Return path to the API config file."""
return Path(__file__).parent.parent / "references" / "pagerangers-api.json"
@pytest.fixture
def api_config(config_path: Path) -> dict:
"""Load and return the API configuration."""
with config_path.open() as f:
return json.load(f)
@pytest.fixture
def mock_kpis_response() -> dict:
"""Sample KPIs API response."""
return {
"rankingindex": 7.4543557,
"numberOfKeywordsInTop10": 13,
"numberOfKeywordsTop100": 14,
"averageTopPosition": 10.857142,
}
@pytest.fixture
def mock_rankings_response() -> dict:
"""Sample rankings API response."""
return {
"rankings": [
{
"keyword": "typo3 agentur leipzig",
"position": 5,
"url": "https://example.com/typo3",
"device": "Desktop",
"searchengine": "Google - Germany",
},
{
"keyword": "magento extensions",
"position": 12,
"url": "https://example.com/magento",
"device": "Desktop",
"searchengine": "Google - Germany",
},
],
"total": 2,
}
@pytest.fixture
def mock_prospects_response() -> dict:
"""Sample prospects API response."""
return {
"prospects": [
{
"keyword": "typo3 update",
"position": 91,
"searchVolume": 1200,
},
{
"keyword": "magento hosting",
"position": 45,
"searchVolume": 800,
},
]
}
@pytest.fixture
def mock_keyword_response() -> dict:
"""Sample keyword SERP API response."""
return {
"keyword": "SEO tools",
"searchVolume": 12100,
"competition": 0.75,
"serp": [
{"url": "https://example.com/seo-tools", "position": 1},
{"url": "https://another.com/tools", "position": 2},
],
"relatedKeywords": ["seo software", "seo checker", "seo analyzer"],
}
@pytest.fixture
def mock_error_response() -> dict:
"""Sample API error response."""
return {"errormessage": "Invalid api-key"}
"""Tests for PageRangers credential detection hook."""
from __future__ import annotations
import json
import sys
from io import StringIO
from pathlib import Path
from unittest.mock import patch
import pytest
# Add scripts directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from detect_credentials import (
check_credentials,
contains_pagerangers_keywords,
main,
parse_prompt,
)
class TestKeywordDetection:
"""Tests for PageRangers keyword detection."""
def test_pagerangers_keyword_detected(self) -> None:
"""Direct 'pagerangers' mention triggers detection."""
assert contains_pagerangers_keywords("Check my PageRangers data")
assert contains_pagerangers_keywords("pagerangers rankings")
assert contains_pagerangers_keywords("PAGERANGERS api")
def test_skill_commands_with_seo_context(self) -> None:
"""Skill command names with SEO context trigger detection."""
assert contains_pagerangers_keywords("show me seo rankings")
assert contains_pagerangers_keywords("get SEO kpis for the site")
assert contains_pagerangers_keywords("find seo prospects")
def test_ranking_index_keyword(self) -> None:
"""PageRangers-specific 'ranking index' triggers detection."""
assert contains_pagerangers_keywords("what is my ranking index")
assert contains_pagerangers_keywords("check the ranking index")
def test_generic_seo_no_trigger(self) -> None:
"""Generic SEO mentions without specific keywords don't trigger."""
assert not contains_pagerangers_keywords("improve my SEO")
assert not contains_pagerangers_keywords("SEO best practices")
assert not contains_pagerangers_keywords("search engine optimization")
def test_unrelated_queries_no_trigger(self) -> None:
"""Unrelated queries don't trigger detection."""
assert not contains_pagerangers_keywords("write a Python function")
assert not contains_pagerangers_keywords("fix the bug in auth.py")
assert not contains_pagerangers_keywords("what is the weather")
def test_empty_and_none(self) -> None:
"""Empty or None input returns False."""
assert not contains_pagerangers_keywords("")
assert not contains_pagerangers_keywords(None)
class TestPromptParsing:
"""Tests for stdin prompt parsing."""
def test_json_with_prompt_field(self) -> None:
"""Parse JSON input with 'prompt' field."""
data = json.dumps({"prompt": "check pagerangers"})
assert parse_prompt(data) == "check pagerangers"
def test_json_with_message_field(self) -> None:
"""Parse JSON input with 'message' field."""
data = json.dumps({"message": "seo rankings please"})
assert parse_prompt(data) == "seo rankings please"
def test_json_with_content_field(self) -> None:
"""Parse JSON input with 'content' field."""
data = json.dumps({"content": "show kpis"})
assert parse_prompt(data) == "show kpis"
def test_plain_text_fallback(self) -> None:
"""Plain text input returned as-is."""
assert parse_prompt("plain text query") == "plain text query"
def test_empty_input(self) -> None:
"""Empty input returns empty string."""
assert parse_prompt("") == ""
assert parse_prompt(None) == ""
class TestCredentialCheck:
"""Tests for credential file checking."""
def test_credentials_file_missing(self, tmp_path: Path) -> None:
"""Missing credentials file returns error info."""
fake_home = tmp_path / "home"
fake_home.mkdir()
with patch("detect_credentials.Path.home", return_value=fake_home):
result = check_credentials()
assert result["valid"] is False
assert "not found" in result["message"].lower()
def test_credentials_file_empty(self, tmp_path: Path) -> None:
"""Empty credentials file returns error info."""
fake_home = tmp_path / "home"
fake_home.mkdir()
creds_file = fake_home / ".env.pagerangers"
creds_file.write_text("")
with patch("detect_credentials.Path.home", return_value=fake_home):
result = check_credentials()
assert result["valid"] is False
assert "missing" in result["message"].lower()
def test_credentials_missing_token(self, tmp_path: Path) -> None:
"""Credentials without API token returns error."""
fake_home = tmp_path / "home"
fake_home.mkdir()
creds_file = fake_home / ".env.pagerangers"
creds_file.write_text("PAGERANGERS_PROJECT_HASH=ABC123\n")
with patch("detect_credentials.Path.home", return_value=fake_home):
result = check_credentials()
assert result["valid"] is False
assert "token" in result["message"].lower()
def test_credentials_missing_hash(self, tmp_path: Path) -> None:
"""Credentials without project hash returns error."""
fake_home = tmp_path / "home"
fake_home.mkdir()
creds_file = fake_home / ".env.pagerangers"
creds_file.write_text("PAGERANGERS_API_TOKEN=secret123\n")
with patch("detect_credentials.Path.home", return_value=fake_home):
result = check_credentials()
assert result["valid"] is False
assert "hash" in result["message"].lower()
def test_credentials_valid(self, tmp_path: Path) -> None:
"""Valid credentials file returns success."""
fake_home = tmp_path / "home"
fake_home.mkdir()
creds_file = fake_home / ".env.pagerangers"
creds_file.write_text("PAGERANGERS_API_TOKEN=secret123\nPAGERANGERS_PROJECT_HASH=ABC123\n")
with patch("detect_credentials.Path.home", return_value=fake_home):
result = check_credentials()
assert result["valid"] is True
class TestMainFunction:
"""Integration tests for main hook function."""
def test_no_keywords_no_output(self, capsys: pytest.CaptureFixture) -> None:
"""No PageRangers keywords produces no output."""
stdin_data = json.dumps({"prompt": "write a function"})
with patch("sys.stdin", StringIO(stdin_data)):
main()
captured = capsys.readouterr()
assert captured.out == ""
def test_keywords_with_valid_creds_no_output(self, tmp_path: Path, capsys: pytest.CaptureFixture) -> None:
"""PageRangers keywords with valid credentials produces no output."""
fake_home = tmp_path / "home"
fake_home.mkdir()
creds_file = fake_home / ".env.pagerangers"
creds_file.write_text("PAGERANGERS_API_TOKEN=secret\nPAGERANGERS_PROJECT_HASH=ABC\n")
stdin_data = json.dumps({"prompt": "check pagerangers rankings"})
with patch("sys.stdin", StringIO(stdin_data)), patch("detect_credentials.Path.home", return_value=fake_home):
main()
captured = capsys.readouterr()
assert captured.out == ""
def test_keywords_with_missing_creds_outputs_help(self, tmp_path: Path, capsys: pytest.CaptureFixture) -> None:
"""PageRangers keywords with missing credentials outputs setup help."""
fake_home = tmp_path / "home"
fake_home.mkdir()
# No credentials file created
stdin_data = json.dumps({"prompt": "show my pagerangers kpis"})
with patch("sys.stdin", StringIO(stdin_data)), patch("detect_credentials.Path.home", return_value=fake_home):
main()
captured = capsys.readouterr()
assert "<user-prompt-submit-hook>" in captured.out
assert "PAGERANGERS_API_TOKEN" in captured.out
assert "PAGERANGERS_PROJECT_HASH" in captured.out
assert ".env.pagerangers" in captured.out
"""Tests for PageRangers SEO API client."""
from __future__ import annotations
import json
import sys
import urllib.error
from io import BytesIO, StringIO
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# Add scripts directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
from pagerangers import (
COMPETITION_LOW_THRESHOLD,
COMPETITION_MEDIUM_THRESHOLD,
DEFAULT_LIMIT,
DEFAULT_TIMEOUT,
DEFAULT_TOP_URLS,
HTTP_FORBIDDEN,
HTTP_TOO_MANY_REQUESTS,
HTTP_UNAUTHORIZED,
MAX_RELATED_KEYWORDS,
CommandContext,
call_endpoint,
cmd_keyword,
cmd_kpis,
cmd_rankings,
get_by_path,
load_config,
normalize_competition,
normalize_urls,
request_json,
substitute,
)
# Test constants
EXIT_SUCCESS = 0
EXPECTED_RANKINGS_COUNT = 2
EXPECTED_SEARCH_VOLUME = 12100
class TestConstants:
"""Tests for module constants."""
def test_http_status_codes_are_4xx(self) -> None:
"""Verify HTTP status codes are in 4xx client error range."""
for code in (HTTP_UNAUTHORIZED, HTTP_FORBIDDEN, HTTP_TOO_MANY_REQUESTS):
assert isinstance(code, int)
assert code >= HTTP_UNAUTHORIZED # All should be >= 401
def test_competition_thresholds_are_ordered(self) -> None:
"""Verify competition thresholds are properly ordered."""
assert isinstance(COMPETITION_LOW_THRESHOLD, float)
assert isinstance(COMPETITION_MEDIUM_THRESHOLD, float)
assert COMPETITION_LOW_THRESHOLD < COMPETITION_MEDIUM_THRESHOLD
def test_default_values_are_positive(self) -> None:
"""Verify default values are positive integers."""
for value in (DEFAULT_TIMEOUT, DEFAULT_TOP_URLS, DEFAULT_LIMIT, MAX_RELATED_KEYWORDS):
assert isinstance(value, int)
assert value > 0
class TestSubstitute:
"""Tests for placeholder substitution."""
def test_substitute_string(self) -> None:
"""Substitute placeholders in string."""
result = substitute("{api_token}", {"api_token": "secret123"})
assert result == "secret123"
def test_substitute_multiple_placeholders(self) -> None:
"""Substitute multiple placeholders."""
template = "token={api_token}&hash={project_hash}"
variables = {"api_token": "abc", "project_hash": "xyz"}
result = substitute(template, variables)
assert result == "token=abc&hash=xyz"
def test_substitute_dict(self) -> None:
"""Substitute placeholders in dictionary."""
template = {"key": "{value}", "other": "static"}
result = substitute(template, {"value": "dynamic"})
assert result == {"key": "dynamic", "other": "static"}
def test_substitute_list(self) -> None:
"""Substitute placeholders in list."""
template = ["{a}", "{b}", "c"]
result = substitute(template, {"a": "x", "b": "y"})
assert result == ["x", "y", "c"]
def test_substitute_non_string(self) -> None:
"""Non-string values returned unchanged."""
assert substitute(None, {"x": "y"}) is None
class TestGetByPath:
"""Tests for nested dictionary path extraction."""
def test_simple_path(self) -> None:
"""Extract value with simple path."""
data = {"keyword": "test"}
assert get_by_path(data, "keyword") == "test"
def test_nested_path(self) -> None:
"""Extract value with nested path."""
data = {"data": {"keyword": "nested"}}
assert get_by_path(data, "data.keyword") == "nested"
def test_array_index(self) -> None:
"""Extract value with array index."""
data = {"items": [{"name": "first"}, {"name": "second"}]}
assert get_by_path(data, "items[0].name") == "first"
assert get_by_path(data, "items[1].name") == "second"
def test_missing_key(self) -> None:
"""Return None for missing key."""
data = {"a": "b"}
assert get_by_path(data, "missing") is None
def test_empty_path(self) -> None:
"""Empty path returns original data."""
data = {"key": "value"}
assert get_by_path(data, "") == data
class TestNormalizeUrls:
"""Tests for URL extraction from SERP results."""
def test_extract_urls_from_dict_list(self) -> None:
"""Extract URLs from list of dicts."""
serp = [{"url": "https://a.com"}, {"url": "https://b.com"}]
result = normalize_urls(serp)
assert result == ["https://a.com", "https://b.com"]
def test_extract_urls_with_limit(self) -> None:
"""Limit number of URLs returned."""
url_limit = 3
serp = [{"url": f"https://{i}.com"} for i in range(10)]
result = normalize_urls(serp, limit=url_limit)
assert len(result) == url_limit
def test_extract_urls_different_keys(self) -> None:
"""Extract URLs from different key names."""
serp = [{"link": "https://a.com"}, {"href": "https://b.com"}]
result = normalize_urls(serp)
assert result == ["https://a.com", "https://b.com"]
def test_extract_urls_from_strings(self) -> None:
"""Handle list of plain strings."""
serp = ["https://a.com", "https://b.com"]
result = normalize_urls(serp)
assert result == ["https://a.com", "https://b.com"]
def test_non_list_returns_empty(self) -> None:
"""Non-list input returns empty list."""
assert normalize_urls(None) == []
class TestNormalizeCompetition:
"""Tests for competition score normalization."""
def test_low_competition(self) -> None:
"""Values <= threshold are low."""
assert normalize_competition(0.1) == "low"
assert normalize_competition(COMPETITION_LOW_THRESHOLD) == "low"
def test_medium_competition(self) -> None:
"""Values between thresholds are medium."""
assert normalize_competition(0.5) == "medium"
assert normalize_competition(COMPETITION_MEDIUM_THRESHOLD) == "medium"
def test_high_competition(self) -> None:
"""Values > medium threshold are high."""
assert normalize_competition(0.7) == "high"
assert normalize_competition(1.0) == "high"
def test_none_returns_unknown(self) -> None:
"""None value returns unknown."""
assert normalize_competition(None) == "unknown"
def test_string_passthrough(self) -> None:
"""String values passed through."""
assert normalize_competition("custom") == "custom"
class TestLoadConfig:
"""Tests for configuration loading."""
def test_load_config_success(self, config_path: Path) -> None:
"""Successfully load config file."""
config = load_config(config_path)
assert "base_url" in config
assert "endpoints" in config
assert "keyword" in config["endpoints"]
def test_config_has_required_endpoints(self, api_config: dict) -> None:
"""Config has all required endpoints."""
endpoints = api_config["endpoints"]
assert "keyword" in endpoints
assert "rankings" in endpoints
assert "main_kpis" in endpoints
assert "prospects" in endpoints
class TestCommandContext:
"""Tests for CommandContext dataclass."""
def test_create_context(self) -> None:
"""Create command context with all fields."""
ctx = CommandContext(
config={"endpoints": {}},
variables={"api_token": "test"},
timeout=DEFAULT_TIMEOUT,
debug=False,
)
assert ctx.config == {"endpoints": {}}
assert ctx.variables == {"api_token": "test"}
assert ctx.timeout == DEFAULT_TIMEOUT
assert ctx.debug is False
class TestCallEndpoint:
"""Tests for API endpoint calls."""
def test_call_endpoint_success(self, api_config: dict, mock_kpis_response: dict) -> None:
"""Successfully call an endpoint."""
ctx = CommandContext(
config=api_config,
variables={"api_token": "test", "project_hash": "ABC"},
timeout=DEFAULT_TIMEOUT,
debug=False,
)
with patch("pagerangers.request_json") as mock_request:
mock_request.return_value = mock_kpis_response
result = call_endpoint(ctx, "main_kpis")
assert result == mock_kpis_response
mock_request.assert_called_once()
def test_call_endpoint_unknown_endpoint(self, api_config: dict) -> None:
"""Raise error for unknown endpoint."""
ctx = CommandContext(
config=api_config,
variables={"api_token": "test", "project_hash": "ABC"},
timeout=DEFAULT_TIMEOUT,
debug=False,
)
with pytest.raises(RuntimeError, match="Unknown endpoint"):
call_endpoint(ctx, "nonexistent")
def test_call_endpoint_api_error(self, api_config: dict, mock_error_response: dict) -> None:
"""Handle API-level error in response."""
ctx = CommandContext(
config=api_config,
variables={"api_token": "test", "project_hash": "ABC"},
timeout=DEFAULT_TIMEOUT,
debug=False,
)
with patch("pagerangers.request_json") as mock_request:
mock_request.return_value = mock_error_response
with pytest.raises(RuntimeError, match="API Error"):
call_endpoint(ctx, "main_kpis")
class TestCLICommands:
"""Integration tests for CLI commands."""
def test_kpis_command_json_output(self, mock_kpis_response: dict) -> None:
"""Test kpis command with JSON output."""
ctx = CommandContext(
config={
"endpoints": {
"main_kpis": {
"response": {
"ranking_index": "rankingindex",
"top_10_count": "numberOfKeywordsInTop10",
"top_100_count": "numberOfKeywordsTop100",
"average_position": "averageTopPosition",
}
}
}
},
variables={"api_token": "test", "project_hash": "ABC"},
timeout=DEFAULT_TIMEOUT,
debug=False,
)
with patch("pagerangers.call_endpoint") as mock_call:
mock_call.return_value = mock_kpis_response
args = MagicMock()
args.json = True
captured = StringIO()
with patch("sys.stdout", captured):
result = cmd_kpis(args, ctx)
assert result == EXIT_SUCCESS
output = json.loads(captured.getvalue())
assert "ranking_index" in output
def test_rankings_command(self, mock_rankings_response: dict) -> None:
"""Test rankings command."""
ctx = CommandContext(
config={"endpoints": {"rankings": {"response": {"keywords": "rankings"}}}},
variables={"api_token": "test", "project_hash": "ABC"},
timeout=DEFAULT_TIMEOUT,
debug=False,
)
with patch("pagerangers.call_endpoint") as mock_call:
mock_call.return_value = mock_rankings_response
args = MagicMock()
args.json = True
args.limit = DEFAULT_LIMIT
captured = StringIO()
with patch("sys.stdout", captured):
result = cmd_rankings(args, ctx)
assert result == EXIT_SUCCESS
output = json.loads(captured.getvalue())
assert "rankings" in output
assert len(output["rankings"]) == EXPECTED_RANKINGS_COUNT
def test_keyword_command(self, mock_keyword_response: dict) -> None:
"""Test keyword command."""
ctx = CommandContext(
config={
"endpoints": {
"keyword": {
"response": {
"main_keyword": "keyword",
"search_volume": "searchVolume",
"competition": "competition",
"top_urls": "serp",
"important_keywords": "relatedKeywords",
}
}
}
},
variables={"api_token": "test", "project_hash": "ABC"},
timeout=DEFAULT_TIMEOUT,
debug=False,
)
with patch("pagerangers.call_endpoint") as mock_call:
mock_call.return_value = mock_keyword_response
args = MagicMock()
args.json = True
args.keyword = "SEO tools"
args.top = DEFAULT_TOP_URLS
captured = StringIO()
with patch("sys.stdout", captured):
result = cmd_keyword(args, ctx)
assert result == EXIT_SUCCESS
output = json.loads(captured.getvalue())
assert output["main_keyword"] == "SEO tools"
assert output["search_volume"] == EXPECTED_SEARCH_VOLUME
class TestErrorHandling:
"""Tests for error handling."""
def test_http_401_error(self) -> None:
"""Handle 401 authentication error."""
with patch("pagerangers.urllib.request.urlopen") as mock_urlopen:
mock_urlopen.side_effect = urllib.error.HTTPError(
url="http://test.com",
code=HTTP_UNAUTHORIZED,
msg="Unauthorized",
hdrs={},
fp=BytesIO(b"Unauthorized"),
)
with pytest.raises(RuntimeError, match="Authentication failed"):
request_json("GET", "http://test.com", {}, None, DEFAULT_TIMEOUT)
def test_http_429_rate_limit(self) -> None:
"""Handle 429 rate limit error."""
with patch("pagerangers.urllib.request.urlopen") as mock_urlopen:
mock_urlopen.side_effect = urllib.error.HTTPError(
url="http://test.com",
code=HTTP_TOO_MANY_REQUESTS,
msg="Too Many Requests",
hdrs={},
fp=BytesIO(b"Rate limited"),
)
with pytest.raises(RuntimeError, match="Rate limit exceeded"):
request_json("GET", "http://test.com", {}, None, DEFAULT_TIMEOUT)