assets/templates/cwv-audit-report.md
# Core Web Vitals Audit
**URL/Origin:** {target}
**Strategy:** {strategy}
## CrUX Field Data (28-day rolling average)
Real Chrome user experience data from the Chrome UX Report.
| Metric | p75 Value | Rating | Good Threshold | Distribution |
|--------|-----------|--------|----------------|-------------|
| LCP | {lcp_value} | {lcp_rating} | ≤ 2,500ms | Good: {lcp_good}% / NI: {lcp_ni}% / Poor: {lcp_poor}% |
| INP | {inp_value} | {inp_rating} | ≤ 200ms | Good: {inp_good}% / NI: {inp_ni}% / Poor: {inp_poor}% |
| CLS | {cls_value} | {cls_rating} | ≤ 0.1 | Good: {cls_good}% / NI: {cls_ni}% / Poor: {cls_poor}% |
| FCP | {fcp_value} | {fcp_rating} | ≤ 1,800ms | Good: {fcp_good}% / NI: {fcp_ni}% / Poor: {fcp_poor}% |
| TTFB | {ttfb_value} | {ttfb_rating} | ≤ 800ms | Good: {ttfb_good}% / NI: {ttfb_ni}% / Poor: {ttfb_poor}% |
**Collection Period:** {collection_start} to {collection_end}
## Lighthouse Lab Scores
| Category | Score |
|----------|-------|
| Performance | {perf_score}/100 |
| Accessibility | {a11y_score}/100 |
| Best Practices | {bp_score}/100 |
| SEO | {seo_score}/100 |
## CrUX History Trends (25-week)
| Metric | Direction | Change | Earliest → Latest |
|--------|-----------|--------|-------------------|
{trends_table}
## Top Opportunities
| Opportunity | Estimated Savings |
|-------------|-------------------|
{opportunities_table}
## Recommendations
{recommendations}
---
*CrUX data updated daily ~04:00 UTC. 28-day rolling average.*
*INP replaced FID as the responsiveness Core Web Vital on March 12, 2024.*
*Generated {timestamp}.*
assets/templates/gsc-performance-report.md
# Google Search Console Performance Report
**Property:** {property}
**Date Range:** {start_date} - {end_date}
**Search Type:** {search_type}
## Summary
| Metric | Value |
|--------|-------|
| Total Clicks | {total_clicks} |
| Total Impressions | {total_impressions} |
| Average CTR | {avg_ctr}% |
| Average Position | {avg_position} |
## Top Queries
| # | Query | Clicks | Impressions | CTR | Position |
|---|-------|--------|-------------|-----|----------|
{queries_table}
## Top Pages
| # | Page | Clicks | Impressions | CTR | Position |
|---|------|--------|-------------|-----|----------|
{pages_table}
## Quick Wins (Position 4-10, High Impressions)
These queries rank on page 1 but below position 3. A small ranking improvement could yield significant traffic gains.
| Query | Position | Impressions | Clicks | CTR | Opportunity |
|-------|----------|-------------|--------|-----|-------------|
{quick_wins_table}
## Device Breakdown
| Device | Clicks | Impressions | CTR | Position |
|--------|--------|-------------|-----|----------|
{device_table}
---
*Data freshness: Search Analytics has a 2-3 day lag. Data available for ~16 months.*
*Generated {timestamp} via Google Search Console API.*
assets/templates/indexation-status-report.md
# URL Indexation Status Report
**Property:** {property}
**URLs Inspected:** {total_urls}
## Summary
| Status | Count | Percentage |
|--------|-------|-----------|
| Indexed (PASS) | {pass_count} | {pass_pct}% |
| Not Indexed (FAIL) | {fail_count} | {fail_pct}% |
| Neutral | {neutral_count} | {neutral_pct}% |
| Errors | {error_count} | {error_pct}% |
## Detailed Results
| URL | Verdict | Coverage State | Fetch State | Google Canonical | Last Crawl |
|-----|---------|---------------|-------------|-----------------|------------|
{results_table}
## Canonical Mismatches
URLs where Google selected a different canonical than declared:
| URL | User Canonical | Google Canonical |
|-----|---------------|-----------------|
{canonical_mismatches_table}
## Common Issues
| Issue | Count | Priority | Action |
|-------|-------|----------|--------|
{issues_table}
## Rich Results Detected
| URL | Rich Result Type | Status |
|-----|-----------------|--------|
{rich_results_table}
---
*URL Inspection API: 2,000 inspections/day per site, 600/min.*
*Generated {timestamp} via Google Search Console URL Inspection API.*
references/api-reference.md
# Blog Google - API Reference
Consolidated reference for all Google APIs used by the blog-google skill.
---
## PageSpeed Insights v5
**Endpoint:** `GET https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed` (canonical)
Legacy hostname also works: `https://www.googleapis.com/pagespeedonline/v5/runPagespeed`
| Param | Type | Description |
|-------|------|-------------|
| `url` | string | Required. URL to analyze |
| `category` | string | `ACCESSIBILITY`, `BEST_PRACTICES`, `PERFORMANCE`, `SEO` (can specify multiple) |
| `strategy` | string | `DESKTOP` or `MOBILE` (default) |
| `key` | string | API key (optional but recommended) |
Response contains `loadingExperience` (URL-level CrUX), `originLoadingExperience` (origin CrUX), and `lighthouseResult` with category scores and audit details.
**Note:** Google has signaled intent to remove CrUX from PSI, but as of April 2026 CrUX field
data (`loadingExperience`, `originLoadingExperience`) is still returned in PSI responses. The
standalone CrUX API is the recommended long-term solution for field data; use PSI primarily
for Lighthouse lab scores.
---
## CrUX API (Daily)
**Endpoint:** `POST https://chromeuxreport.googleapis.com/v1/records:queryRecord?key={API_KEY}`
```json
{
"origin": "https://example.com",
"formFactor": "PHONE",
"metrics": ["largest_contentful_paint", "interaction_to_next_paint", "cumulative_layout_shift"]
}
```
- `origin` and `url` are mutually exclusive. Use `origin` for site-wide, `url` for a specific page.
- `formFactor`: `DESKTOP`, `PHONE`, `TABLET` (omit for all).
- Each metric returns `histogram` (density buckets), `percentiles.p75`, and `category`.
- **CLS p75 is a string** (e.g., `"0.05"` not `0.05`). Always parse as float.
- 404 = insufficient Chrome traffic (not an auth error).
- Updated daily on a best-effort basis with ~2-day lag. No guaranteed update time; timezone is PST.
---
## CrUX History API (Weekly)
**Endpoint:** `POST https://chromeuxreport.googleapis.com/v1/records:queryHistoryRecord?key={API_KEY}`
Same request format as CrUX API. Returns up to **40 weekly collection periods** (~10 months)
as timeseries arrays (`p75s[]`, `densities[]`). Default is 25; configurable via
`collectionPeriodCount` parameter (range: 1-40). Use `crux_history.py --periods N`.
- Updated **Mondays** ~04:00 UTC.
- Each period = 28-day rolling average ending on a Sunday.
- Watch for `"NaN"` strings in densities and `null` in percentiles for ineligible periods.
---
## Core Web Vitals Thresholds
Current as of March 2026. INP replaced FID on March 12, 2024.
**Core Web Vitals (the 3 official CWV):**
| Metric | Good | Needs Improvement | Poor |
|--------|------|-------------------|------|
| **LCP** | ≤ 2,500ms | 2,500-4,000ms | > 4,000ms |
| **INP** | ≤ 200ms | 200-500ms | > 500ms |
| **CLS** | ≤ 0.1 | 0.1-0.25 | > 0.25 |
**Diagnostic metrics (not CWV - informational only):**
| Metric | Good | Needs Improvement | Poor |
|--------|------|-------------------|------|
| **FCP** | ≤ 1,800ms | 1,800-3,000ms | > 3,000ms |
| **TTFB** | ≤ 800ms | 800-1,800ms | > 1,800ms |
---
## GSC Search Analytics
**Endpoint:** `POST https://www.googleapis.com/webmasters/v3/sites/{siteUrl}/searchAnalytics/query`
### Request Body
| Field | Type | Description |
|-------|------|-------------|
| `startDate` | string | Required. YYYY-MM-DD |
| `endDate` | string | Required. YYYY-MM-DD |
| `dimensions` | string[] | `query`, `page`, `country`, `device`, `date`, `searchAppearance` |
| `type` | string | `web`, `image`, `video`, `news`, `discover`, `googleNews` |
| `dimensionFilterGroups` | object[] | Filter groups with `dimension`, `operator`, `expression` |
| `rowLimit` | int | 1-25000 (default: 1000) |
| `startRow` | int | Pagination offset (default: 0) |
| `dataState` | string | `final` (default), `all`, `hourly_all` (April 2025, requires `hour` dimension) |
### Filter Operators
`contains`, `equals`, `notContains`, `notEquals`, `includingRegex`, `excludingRegex`
### Response Fields
Each row: `keys[]`, `clicks`, `impressions`, `ctr`, `position`.
- Data lag by `dataState`: `final` = ~2-3 days; `all` = shorter lag; `hourly_all` = few hours (April 2025). Retention: ~16 months. Use `gsc_query.py --data-state hourly_all --dimensions date,hour,...`.
- Country codes are **ISO 3166-1 alpha-3** (e.g., `USA`, `GBR`).
- The dedicated generative-AI Search and Discover reports are a gradual
Search Console UI rollout. They are not an extra `type` or
`searchAppearance` value documented for this endpoint. Do not synthesize
clicks or queries for those reports or claim this API isolates AI Overviews
and AI Mode.
- Google's July 29 Search Central announcement says Instagram, TikTok, X, and
YouTube platform properties are globally available, while the current Help
Center still says gradual rollout. Verify availability in the account. Do not
promise that this endpoint supports their dedicated reports until Google
publishes API documentation.
---
## GSC URL Inspection
**Endpoint:** `POST https://searchconsole.googleapis.com/v1/urlInspection/index:inspect`
```json
{
"inspectionUrl": "https://example.com/blog/post",
"siteUrl": "sc-domain:example.com",
"languageCode": "en"
}
```
### Key Response Fields (`indexStatusResult`)
| Field | Values |
|-------|--------|
| `verdict` | `PASS`, `FAIL`, `NEUTRAL`, `PARTIAL` |
| `coverageState` | Human-readable coverage description |
| `robotsTxtState` | `ALLOWED`, `DISALLOWED` |
| `indexingState` | `INDEXING_ALLOWED`, `BLOCKED_BY_META_TAG`, `BLOCKED_BY_HTTP_HEADER` |
| `pageFetchState` | `SUCCESSFUL`, `SOFT_404`, `BLOCKED_ROBOTS_TXT`, `NOT_FOUND`, `SERVER_ERROR` |
| `lastCrawlTime` | ISO 8601 timestamp |
| `googleCanonical` | URL Google selected as canonical |
| `crawledAs` | `DESKTOP`, `MOBILE` |
---
## GA4 Data API v1beta
**Base URL:** `https://analyticsdata.googleapis.com/v1beta`
### runReport Overview
Key fields: `property`, `dimensions[]`, `metrics[]`, `dateRanges[]`, `dimensionFilter`, `orderBys[]`, `limit`.
### Blog-Relevant Dimensions
`date`, `landingPage`, `pagePath`, `pageTitle`, `sessionDefaultChannelGroup`, `sessionSource`, `sessionMedium`, `country`, `deviceCategory`
### Blog-Relevant Metrics
`sessions`, `totalUsers`, `screenPageViews`, `bounceRate`, `averageSessionDuration`, `engagementRate`, `keyEvents`
### Organic Traffic Filter
```json
{
"filter": {
"fieldName": "sessionDefaultChannelGroup",
"stringFilter": { "matchType": "EXACT", "value": "Organic Search" }
}
}
```
Uses token-based quotas (200,000 Core Tokens/day per standard property; 2M for 360).
Set `returnPropertyQuota: true` to monitor consumption.
---
## Cloud Natural Language API
**Endpoint:** `POST https://language.googleapis.com/v2/documents:annotateText?key={API_KEY}`
| Feature | What It Does | Blog Use |
|---------|-------------|----------|
| `extractEntities` | People, orgs, places with salience scores | Topic coverage depth, entity optimization |
| `extractDocumentSentiment` | Document + sentence-level sentiment | Content tone assessment |
| `classifyText` | Map content to 700+ Google categories | Topic relevance verification |
| `moderateText` | Detect harmful/sensitive content categories | Content safety screening |
Each entity includes `name`, `type`, `salience` (0-1), `sentiment`, and `metadata` (Wikipedia URL, Knowledge Graph MID).
**Pricing:** 5,000 free units/month for entities and sentiment. Requires billing enabled.
---
## YouTube Data API v3
YouTube results can support relevant media research and cross-platform
distribution. Third-party visibility correlations are observational and do not
establish a Google ranking or citation requirement.
| Method | Quota Cost | Description |
|--------|-----------|-------------|
| `search.list` | 100 units | Search videos matching a query |
| `videos.list` | 1 unit | Video details, statistics, tags |
| `channels.list` | 1 unit | Channel info, subscriber count |
Default quota: **10,000 units/day** (free). API key only, no OAuth needed.
---
## Keyword Planner (Google Ads API)
Gold-standard source for keyword search volume. Methods: **GenerateKeywordIdeas** (suggestions
from seeds), **GenerateKeywordHistoricalMetrics** (volume for specific keywords), and
**GenerateKeywordForecastMetrics** (future projections). Returns volume, competition, CPC bids.
**Current API version guidance:** Google Ads API release notes list v25.1 dated
2026-08-19. Google's support table lists Python client 31.2.0 as the minimum for
API v25. Check both official pages before changing a client or versioned path:
https://developers.google.com/google-ads/api/docs/release-notes
https://developers.google.com/google-ads/api/docs/sunset-dates
- Without active ad spend, volumes are **bucketed ranges** ("1K-10K") not exact numbers
- `competition` measures **advertiser competition**, not organic difficulty
- Requires: Google Ads Manager Account + Developer Token + OAuth credentials
references/auth-setup.md
# Blog Google - Authentication Setup
## Overview
Four credential tiers serve different API combinations:
| Tier | Credentials | APIs Unlocked |
|------|------------|---------------|
| **0** | API Key only | PageSpeed Insights, CrUX, CrUX History, YouTube Data, Cloud Natural Language |
| **1** | + Service Account | + Search Console, Indexing API |
| **2** | + GA4 property ID | + GA4 Data API |
| **3** | + Google Ads tokens | + Keyword Planner |
## Step 1: Create a Google Cloud Project
1. Go to [console.cloud.google.com](https://console.cloud.google.com)
2. Click **Select a project** > **New Project**
3. Name it (e.g., "Claude Blog") and note the project ID
4. Select the project after creation
## Step 2: Enable APIs
Navigate to **APIs & Services > Library** and enable:
| API | Required For |
|-----|-------------|
| PageSpeed Insights API | Lighthouse lab data, CWV scores |
| Chrome UX Report API | CrUX field data + History |
| Google Search Console API | Search Analytics, URL Inspection, Sitemaps |
| Web Search Indexing API | Indexing API v3 (new post notifications) |
| Google Analytics Data API | GA4 organic traffic analysis |
| YouTube Data API v3 | Video research for AI-search SEO visibility |
| Cloud Natural Language API | Entity salience, sentiment, classification |
## Step 3: Create an API Key (Tier 0)
1. **APIs & Services > Credentials > Create Credentials > API key**
2. Click **Restrict key** > under **API restrictions**, select the APIs above
3. Copy the key (starts with `AIzaSy...`)
## Step 4: Create a Service Account (Tier 1)
1. **IAM & Admin > Service Accounts > Create Service Account**
2. Name: `claude-blog` (or similar)
3. Skip optional permissions steps
4. Click on the created service account > **Keys > Add Key > Create new key > JSON**
5. Download and store securely (e.g., `~/.config/claude-seo/service_account.json`)
### Grant Search Console Access
1. Go to [Google Search Console](https://search.google.com/search-console)
2. Select your property > **Settings > Users and permissions > Add user**
3. Paste the service account `client_email` from the JSON file
4. Set permission: **Full** (read-only) or **Owner** (if using Indexing API)
## Step 5: Set Up OAuth for Interactive Flows
1. **APIs & Services > Credentials > Create Credentials > OAuth client ID**
2. Application type: **Desktop app**
3. Download the `client_secret_*.json` file
4. Store at `~/.config/claude-seo/oauth_client.json`
OAuth is needed for Keyword Planner and any user-consent flows.
## Step 6: GA4 Property ID (Tier 2)
1. Go to [Google Analytics](https://analytics.google.com)
2. **Admin > Property Access Management > Add users** (+ icon)
3. Paste the service account `client_email`, set role: **Viewer**
4. Note the numeric property ID from **Admin > Property Details** (e.g., `123456789`)
## Step 7: Google Ads Credentials (Tier 3)
1. Create a Google Ads Manager Account at ads.google.com
2. Apply for a Developer Token at Google Ads API Center
3. Note: Without active ad spend, Keyword Planner returns bucketed ranges ("1K-10K")
## Config File
Config is shared with claude-seo at `~/.config/claude-seo/google-api.json`:
```json
{
"service_account_path": "~/.config/claude-seo/service_account.json",
"api_key": "YOUR_GOOGLE_API_KEY",
"oauth_client_path": "~/.config/claude-seo/oauth_client.json",
"default_property": "sc-domain:example.com",
"ga4_property_id": "properties/123456789",
"ads_developer_token": "YOUR_DEV_TOKEN",
"ads_customer_id": "123-456-7890",
"ads_login_customer_id": "123-456-7890"
}
```
Protect the config and downloaded credential files:
```bash
chmod 700 ~/.config/claude-seo
chmod 600 ~/.config/claude-seo/google-api.json
chmod 600 ~/.config/claude-seo/service_account.json ~/.config/claude-seo/oauth_client.json
```
### GSC Property URL Formats
| Format | Example | When to Use |
|--------|---------|-------------|
| Domain property | `sc-domain:example.com` | All URLs on the domain (recommended) |
| URL-prefix property | `https://example.com/` | Only that specific prefix |
## Environment Variable Fallbacks
| Variable | Purpose |
|----------|---------|
| `GOOGLE_API_KEY` | API key for PSI/CrUX/YouTube/NLP |
| `GOOGLE_APPLICATION_CREDENTIALS` | Path to service account JSON |
| `GOOGLE_OAUTH_CLIENT_PATH` | Path to OAuth client JSON |
| `GA4_PROPERTY_ID` | GA4 property (e.g., `properties/123456789`) |
| `GSC_PROPERTY` | Default GSC property (e.g., `sc-domain:example.com`) |
| `GOOGLE_ADS_DEVELOPER_TOKEN` | Google Ads developer token |
| `GOOGLE_ADS_CUSTOMER_ID` | Google Ads customer ID |
| `GOOGLE_ADS_LOGIN_CUSTOMER_ID` | Optional manager account login customer ID |
## Verify Setup
```bash
python3 scripts/google_auth.py --check
```
## Quick Troubleshooting
| Error | Fix |
|-------|-----|
| `403 Forbidden` on GSC | Service account email not added to property, or wrong permission level |
| `403 Forbidden` on GA4 | Service account not added as Viewer in GA4 property |
| `404 Not Found` on GSC | Wrong property URL format: use `sc-domain:` or include trailing slash |
| `404 Not Found` on CrUX | Site has insufficient Chrome traffic (not a credentials issue) |
| `429 Rate Limit` | Wait and retry with backoff. See rate-limits-quotas.md |
| `API not enabled` | Enable the specific API in GCP Console > APIs & Services > Library |
| `Billing required` | NLP API requires billing enabled (free tier still applies) |
references/rate-limits-quotas.md
# Blog Google - Rate Limits & Quotas
## Implemented API Quota Table
| API | Per-Minute | Per-Day | Cost | Auth Type | Scope |
|-----|-----------|---------|------|-----------|-------|
| GSC Search Analytics | 1,200 QPM/user, 1,200 QPM/site | 30M QPD/project | Free | Service Account | Per user + per site |
| GSC URL Inspection | 600 QPM/site | 2,000 QPD/site | Free | Service Account | Per site |
| GSC Sitemaps | Standard | Standard | Free | Service Account | Per site |
| PageSpeed Insights v5 | 240 QPM | 25,000 QPD | Free | API Key | Per project |
| CrUX API | 150 QPM (shared) | Unlimited | Free | API Key | Per project |
| CrUX History API | 150 QPM (shared with CrUX) | Unlimited | Free | API Key | Per project |
| Indexing API | 380 RPM total, 180 read/min | 200 publish/day | Free | Service Account | Per project |
| GA4 Data API | 10 concurrent (50 for 360) | 200,000 Core Tokens/day (std); 2M (360) | Free | Service Account | Per property/project |
| YouTube Data API v3 |: | 10,000 units/day | Free | API Key | Per project |
| Cloud Natural Language API |: | 5,000 units/month free tier | Paid after free tier | API Key | Per project |
| Google Ads API | Account dependent | Account dependent | Free API access | OAuth + developer token | Per developer token and account |
**Key distinction:** "Per site" quotas are scoped to a specific GSC property. "Per project" quotas are shared across all properties in a GCP project. "Per user" quotas are per authenticated user (service account).
## Exponential Backoff Strategy
When receiving 429 or 5xx errors:
```
Attempt 1: wait 1 second
Attempt 2: wait 2 seconds
Attempt 3: wait 4 seconds
Attempt 4: wait 8 seconds
Attempt 5: wait 16 seconds
Max: give up after 5 retries
```
Add random jitter (0-500ms) to each wait to avoid thundering herd.
## Common Error Codes
| Code | Meaning | Applies To | Action |
|------|---------|------------|--------|
| 400 | Bad request | All | Check URL format, request body |
| 401 | Unauthorized | Service Account APIs | Refresh credentials |
| 403 | Forbidden | GSC, GA4, Indexing | Check permissions (service account access) |
| 404 | Not found | CrUX, GSC | Insufficient traffic (CrUX) or invalid property (GSC) |
| 429 | Rate limited | All | Backoff and retry. Check Retry-After header. |
| 500 | Server error | All | Retry with backoff |
| 503 | Service unavailable | All | Retry with backoff |
## Retry-After Header
Some Google APIs return a `Retry-After` header with 429 responses. When present, use this value (in seconds) instead of exponential backoff.
## GA4 Token Budgeting
GA4 uses a token system rather than simple request counts:
- Simple 1-dimension, 1-metric report: ~1-5 tokens
- Complex multi-dimension, multi-metric: ~10-100 tokens
- Set `returnPropertyQuota: true` to monitor consumption
- Daily limit: **200,000 Core Tokens/day** per standard property (2,000,000 for 360)
- Hourly limit: **40,000 tokens/hour** per property; **14,000/hour** per project per property
- Concurrent: max 10 simultaneous requests (50 for 360)
- Source: developers.google.com/analytics/devguides/reporting/data/v1/quotas (updated 2026-03-26)
## CrUX Shared Quota
The CrUX API and CrUX History API share the same 150 QPM quota per project. Plan accordingly if querying both APIs in the same workflow.
## Cost Summary
**Most APIs used by blog-google are free** at normal usage levels. No billing is required for:
- PSI, CrUX, CrUX History (API key, unlimited free)
- GSC (service account, 30M QPD)
- Indexing API (service account, 200 publish/day)
- GA4 Data API (service account, 200K tokens/day standard)
- YouTube Data API (API key, 10,000 units/day)
Cloud Natural Language requires billing enabled, with a free monthly tier. Google Ads API access is free, but Keyword Planner data quality depends on the Google Ads account.
## Planned or Out-of-Scope APIs
Knowledge Graph, Custom Search, and Web Risk are not implemented by scoped blog-google scripts. Do not count their quotas as available capabilities until a script and command surface exist.
references/search-currentness.md
# Google Search Currentness Playbook
Verified against Google-owned sources on 2026-08-25. Treat product
announcements as product context, not evidence of a ranking factor. Use
`data/google-updates.json` for the machine-readable source ledger.
Resolve the ledger in this order:
1. In a repository checkout, use the repository-root
`data/google-updates.json`.
2. In a standalone install, use `data/google-updates.json` beside the main
blog orchestrator skill, normally
`~/.claude/skills/blog/data/google-updates.json`.
Do not substitute a same-named file from the current working directory. If
neither trusted location exists, report the ledger as unavailable and continue
with the cited primary sources below.
The ledger separates four evidence states:
- `confirmed`: the cited Google source directly supports the recorded fact.
- `confirmed-event-pending-impact`: the event exists, but its site impact and
targets are not established.
- `confirmed-data-anomaly`: Google identifies a reporting defect, not a ranking
change.
- `confirmed-with-source-conflict`: current Google-owned pages disagree. Keep
both citations and verify the capability in the affected account.
Run `python3 scripts/check_google_currentness.py --root . --json` for the
read-only official-feed and review-age check. A `refresh_required` result means
the ledger needs human review. The checker never writes summaries or promotes
events automatically.
## Core and Spam Update Analysis
The latest confirmed ranking event is the August 2026 spam update. Google says
it ran from August 18 through August 21, applied globally, and covered all
languages. Google did not publish a target profile. The earliest complete
one-week post-update comparison is August 28. Until then, record impact as
`PENDING_OBSERVATION`.
1. Confirm the named update's start and end on the Search Status Dashboard.
2. Wait at least one full week after completion.
3. Compare a post-update week with a week before rollout.
4. Analyze Web Search, Images, Video mode, and News tab separately.
5. Distinguish small movement from a large, sustained drop.
6. Avoid quick fixes and mass deletion. Improve reader value and navigation in
durable ways; deletion is a last resort.
7. Do not infer what an update rewarded from dates alone. Smaller core changes
may be unannounced.
## Search Console Data Anomalies
Check Google's data-anomalies page before attributing a reporting change to a
ranking update. Google records these August 2026 logging defects:
- August 13: lower reported Discover clicks and impressions, including lower
generative-AI Discover impressions for properties with that report.
- August 13 through August 17: lower reported impressions in the generative-AI
Search report.
These defects affect logging only. They begin before the August 18 spam update,
so their dates cannot be used as evidence that the spam rollout caused an
August 13 through August 17 decline.
## Canonical Reevaluation
After a material canonicalization fix, Google may keep the URL in the duplicate
cluster for up to two weeks. Report the state as `PENDING_REEVALUATION` during
that window when the implementation is now correct. Search Console's Request
Indexing feature is quota-limited; reserve it for important URLs.
Request Indexing in URL Inspection is separate from the Indexing API. The
Indexing API remains restricted to eligible JobPosting and
BroadcastEvent/VideoObject pages.
## Generative AI Performance Reports
The dedicated Search Console generative-AI views are a gradual, subset rollout:
- Separate Search and Discover reports.
- Search includes AI Overviews and AI Mode.
- Documented dimensions are impressions, pages, countries, devices for Search,
and dates.
- Do not promise clicks or queries in these dedicated reports.
- No supported Search Console API endpoint is documented for this dedicated
view. The blog-google command must report `SKIPPED` or unavailable and direct
the user to the Search Console UI rather than synthesize data.
Standard Search Analytics totals continue to include AI-feature activity under
Google's documented aggregation rules; do not claim those totals isolate AI
Overviews or AI Mode.
## Platform Properties
Google's July 29 Search Central announcement says platform properties are
globally available to everyone for Instagram, TikTok, X, and YouTube. The
current Search Console Help page still says the feature is rolling out
gradually. Treat availability as `SOURCE_CONFLICT`: check the actual account,
cite both Google-owned pages, and do not promise that the current Search Console
API or `/blog google gsc` supports these reports.
## Discover
Run this checklist only when Discover is a declared target or the property has
Discover data:
- Useful, original, in-depth, and timely material.
- Country and topic relevance where applicable.
- Non-sensational titles and non-clickbait presentation.
- Topic-level expertise; older useful content remains eligible.
- No special structured data requirement.
Preferred images are at least 1200px wide, contain more than 300,000 total
pixels, use a useful 16:9 crop where possible, and are enabled by
`max-image-preview:large` or AMP. Use a relevant, representative image through
schema.org markup or `og:image`.
## Preferred Sources
Preferred Sources is optional audience development, not a ranking signal. It
works at domain or subdomain level, not subdirectory level. For a user who
selects the publication, its content is more likely to appear in Top Stories
and can receive a preferred badge in AI Mode or AI Overviews. Offer Google's
publisher assets only when this fits the site's audience strategy.
Google's August 20 documentation supports standard and custom interactive
buttons plus a non-JavaScript deeplink. The choice must remain reader-triggered.
Do not describe implementation as a general ranking improvement.
## Review Snippet Integrity
Google's July 24 review guidance prohibits fake reviews and incentivized reviews
without clear and prominent disclosure. Before recommending Review or
AggregateRating markup, verify all of the following:
- The review is based on a genuine experience and was not fabricated.
- Any benefit, payment, discount, voucher, or free product is disclosed clearly
and prominently.
- Review text and ratings in structured data are visible on the page.
- Aggregate ratings are not copied from other websites.
Parsing success cannot establish authenticity. If the evidence is unavailable,
report the review as unverified and do not generate a review or rating.
## Google Ads API Currentness
Google Ads API v25.1 was released on 2026-08-19. Google's support table lists
Python client 31.2.0 as the minimum for API v25. Dependency updates require an
offline compatibility test for the Keyword Plan services and requests before a
live, credentialed call. Never use a developer token, enable billing, or run a
live Ads request merely to prove package compatibility.
## Crawl and Interaction Checks
- Googlebot processes the first 2MB of a supported file and the first 64MB of a
PDF, measured uncompressed. Place critical title, metadata, canonical,
essential schema, and primary content before the HTML cutoff.
- Warn on inline base64, CSS, JavaScript, or navigation bloat that can push
critical content beyond the first 2MB. This is not a ranking factor.
- Back-button hijacking requires observed deceptive behavior. Do not flag
normal History API use by syntax alone.
- A section intended for a "Read more" deep link should be immediately visible
and retain its hash on load. Do not force a scroll reset. This is not a ban
on every accordion elsewhere.
## AMP
AMP is supported, not required, and has no special ranking benefit. Since
2026-07-01 Google Search sends users directly to publisher-hosted AMP pages.
Keep AMP only when it provides operational value; remove it with correct
canonicals and redirects.
## Generative AI Product Context
Google I/O 2026 announced Gemini 3.5 Flash as AI Mode's global default,
follow-ups from AI Overviews into AI Mode, multimodal inputs, and information
agents. The May Explore-the-web update highlighted original analyses, public
discussions, inline links, and link previews.
These announcements do not create new content-scoring factors. Do not recommend
agent-specific schema, fixed-size content chunks, or fan-out page factories.
Use foundational SEO, accurate non-commodity material, authentic discussion,
clear page identity, and useful media.
## Primary Sources
- https://developers.google.com/search/updates
- https://status.search.google.com/incidents/LEubPCm2octf2uMqCFKE
- https://status.search.google.com/products/rGHU1u87FJnkP6W2GwMi/history
- https://support.google.com/webmasters/answer/6211453?hl=en
- https://developers.google.com/search/docs/appearance/core-updates
- https://developers.google.com/search/docs/crawling-indexing/canonicalization-troubleshooting
- https://developers.google.com/search/docs/fundamentals/ai-optimization-guide
- https://developers.google.com/search/blog/2026/06/gen-ai-performance-reports
- https://developers.google.com/search/blog/2026/07/platform-properties-social-video-guide
- https://support.google.com/webmasters/answer/17148418?hl=en-GB
- https://developers.google.com/search/docs/appearance/google-discover
- https://developers.google.com/search/docs/appearance/preferred-sources
- https://developers.google.com/search/docs/appearance/structured-data/review-snippet
- https://developers.google.com/google-ads/api/docs/release-notes
- https://developers.google.com/google-ads/api/docs/sunset-dates
- https://developers.google.com/search/docs/crawling-indexing/googlebot
- https://developers.google.com/search/blog/2026/04/back-button-hijacking
- https://developers.google.com/search/docs/appearance/snippet
- https://blog.google/products-and-platforms/products/search/search-io-2026/
- https://blog.google/products-and-platforms/products/search/explore-web-generative-ai-search/
scripts/__init__.py
scripts/crux_history.py
#!/usr/bin/env python3
"""
CrUX History API for Core Web Vitals trends over time.
Fetches up to 25 weekly data points from the Chrome UX Report History API
and identifies improving, stable, or degrading trends per metric.
Usage:
python crux_history.py https://example.com
python crux_history.py https://example.com --form-factor PHONE --json
python crux_history.py https://example.com --origin
"""
import argparse
import json
import sys
from typing import Optional
from urllib.parse import urlparse
try:
import requests
except ImportError:
print("Error: requests library required. Install with: pip install requests")
sys.exit(1)
try:
from google_auth import get_api_key, request_with_retries
except ImportError:
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from google_auth import get_api_key, request_with_retries
CRUX_HISTORY_ENDPOINT = "https://chromeuxreport.googleapis.com/v1/records:queryHistoryRecord"
CWV_THRESHOLDS = {
"largest_contentful_paint": {"good": 2500, "poor": 4000, "label": "LCP", "unit": "ms"},
"interaction_to_next_paint": {"good": 200, "poor": 500, "label": "INP", "unit": "ms"},
"cumulative_layout_shift": {"good": 0.1, "poor": 0.25, "label": "CLS", "unit": ""},
"first_contentful_paint": {"good": 1800, "poor": 3000, "label": "FCP", "unit": "ms"},
"experimental_time_to_first_byte": {"good": 800, "poor": 1800, "label": "TTFB", "unit": "ms"},
}
def query_history(
url_or_origin: str,
api_key: str,
form_factor: Optional[str] = None,
periods: int = 25,
) -> dict:
"""
Query CrUX History API for weekly CWV trends.
Args:
url_or_origin: Full URL or origin.
api_key: Google API key.
form_factor: DESKTOP, PHONE, or TABLET. None for all.
Returns:
Dictionary with metrics timeseries, collection periods, and trend analysis.
"""
result = {
"target": url_or_origin,
"form_factor": form_factor or "ALL",
"periods_requested": periods,
"metrics": {},
"collection_periods": [],
"trends": {},
"error": None,
}
parsed = urlparse(url_or_origin)
is_origin = parsed.path in ("", "/") and not parsed.query
body = {}
if is_origin:
body["origin"] = f"{parsed.scheme}://{parsed.netloc}"
else:
body["url"] = url_or_origin
if form_factor:
body["formFactor"] = form_factor.upper()
body["collectionPeriodCount"] = periods
try:
resp = request_with_retries(
"POST",
f"{CRUX_HISTORY_ENDPOINT}?key={api_key}",
json=body,
timeout=30,
)
if resp.status_code == 404:
target_type = "origin" if is_origin else "URL"
result["error"] = (
f"No CrUX history data for this {target_type}. "
"Insufficient Chrome traffic volume for eligibility."
)
return result
if resp.status_code == 429:
result["error"] = "CrUX API rate limit exceeded (150 QPM shared). Wait and retry."
return result
resp.raise_for_status()
data = resp.json()
except requests.exceptions.RequestException as e:
result["error"] = f"CrUX History API request failed: {e}"
return result
record = data.get("record", {})
# Collection periods
periods = record.get("collectionPeriods", [])
for period in periods:
first = period.get("firstDate", {})
last = period.get("lastDate", {})
result["collection_periods"].append({
"first": f"{first.get('year')}-{first.get('month', 0):02d}-{first.get('day', 0):02d}",
"last": f"{last.get('year')}-{last.get('month', 0):02d}-{last.get('day', 0):02d}",
})
# Metrics timeseries
for metric_name, metric_data in record.get("metrics", {}).items():
if metric_name not in CWV_THRESHOLDS:
continue
thresholds = CWV_THRESHOLDS[metric_name]
p75s_data = metric_data.get("percentilesTimeseries", {})
p75s_raw = p75s_data.get("p75s", [])
# Parse p75 values (CLS is string-encoded)
p75s = []
for val in p75s_raw:
if val is None:
p75s.append(None)
elif metric_name == "cumulative_layout_shift":
try:
p75s.append(float(str(val)))
except (ValueError, TypeError):
p75s.append(None)
else:
try:
p75s.append(int(val))
except (ValueError, TypeError):
try:
p75s.append(float(val))
except (ValueError, TypeError):
p75s.append(None)
# Distributions timeseries
histogram_ts = metric_data.get("histogramTimeseries", [])
good_pcts = []
if len(histogram_ts) >= 3:
good_densities = histogram_ts[0].get("densities", [])
for d in good_densities:
if d is None or str(d) == "NaN":
good_pcts.append(None)
else:
try:
good_pcts.append(round(float(d) * 100, 1))
except (ValueError, TypeError):
good_pcts.append(None)
# Extract needs_improvement (bin 1) and poor (bin 2) percentages
ni_pcts = []
poor_pcts = []
if len(histogram_ts) >= 3:
for bin_idx, target_list in [(1, ni_pcts), (2, poor_pcts)]:
bin_densities = histogram_ts[bin_idx].get("densities", [])
for d in bin_densities:
if d is None or str(d) == "NaN":
target_list.append(None)
else:
try:
target_list.append(round(float(d) * 100, 1))
except (ValueError, TypeError):
target_list.append(None)
result["metrics"][metric_name] = {
"label": thresholds["label"],
"unit": thresholds["unit"],
"p75_values": p75s,
"good_percentages": good_pcts,
"needs_improvement_percentages": ni_pcts,
"poor_percentages": poor_pcts,
"latest_p75": p75s[-1] if p75s and p75s[-1] is not None else None,
"good_threshold": thresholds["good"],
"poor_threshold": thresholds["poor"],
}
# Trend analysis
result["trends"] = detect_trends(result["metrics"])
return result
def detect_trends(metrics: dict) -> dict:
"""
Analyze p75 timeseries to detect trends.
Compares the average of the last 4 weeks to the average of the first 4 weeks.
Returns:
Dictionary mapping metric names to trend info:
direction (improving/stable/degrading), change_pct, latest, earliest.
"""
trends = {}
for metric_name, data in metrics.items():
p75s = data.get("p75_values", [])
valid = [v for v in p75s if v is not None]
if len(valid) < 8:
trends[metric_name] = {
"direction": "insufficient_data",
"label": data.get("label", metric_name),
}
continue
# First 4 valid vs last 4 valid
first_4 = valid[:4]
last_4 = valid[-4:]
avg_first = sum(first_4) / len(first_4)
avg_last = sum(last_4) / len(last_4)
if avg_first == 0:
change_pct = 0
else:
change_pct = ((avg_last - avg_first) / avg_first) * 100
# For CWV, lower is better (except CLS where lower is also better)
# So a negative change_pct means improvement
if abs(change_pct) < 5:
direction = "stable"
elif change_pct < 0:
direction = "improving"
else:
direction = "degrading"
trends[metric_name] = {
"direction": direction,
"change_pct": round(change_pct, 1),
"earliest_avg": round(avg_first, 3) if data.get("unit") == "" else round(avg_first),
"latest_avg": round(avg_last, 3) if data.get("unit") == "" else round(avg_last),
"label": data.get("label", metric_name),
"data_points": len(valid),
}
return trends
def main():
parser = argparse.ArgumentParser(
description="CrUX History API - Core Web Vitals trends over time"
)
parser.add_argument("url", help="URL or origin to analyze")
parser.add_argument(
"--form-factor",
choices=["PHONE", "DESKTOP", "TABLET"],
help="Filter by form factor",
)
parser.add_argument(
"--api-key",
help="Google API key (overrides config/env)",
)
parser.add_argument(
"--origin",
action="store_true",
help="Force origin-level query (strip path/query)",
)
parser.add_argument(
"--periods",
type=int,
default=25,
help="Weekly collection periods to request, 1-40 (default: 25)",
)
parser.add_argument(
"--json", "-j",
action="store_true",
help="Output as JSON",
)
args = parser.parse_args()
if not 1 <= args.periods <= 40:
print("Error: --periods must be between 1 and 40.", file=sys.stderr)
sys.exit(1)
api_key = args.api_key or get_api_key()
if not api_key:
print("Error: API key required. Use --api-key or configure GOOGLE_API_KEY.", file=sys.stderr)
sys.exit(1)
target = args.url
if args.origin:
parsed = urlparse(target)
target = f"{parsed.scheme}://{parsed.netloc}"
result = query_history(target, api_key, form_factor=args.form_factor, periods=args.periods)
if args.json:
print(json.dumps(result, indent=2))
else:
if result.get("error"):
print(f"Error: {result['error']}", file=sys.stderr)
sys.exit(1)
print(f"=== CrUX History ({result.get('form_factor', 'ALL')}) ===")
print(f"Target: {result.get('target')}")
periods = result.get("collection_periods", [])
if periods:
print(f"Range: {periods[0]['first']} to {periods[-1]['last']} ({len(periods)} weeks)")
print("\nTrend Analysis:")
for name, trend in result.get("trends", {}).items():
label = trend.get("label", name)
direction = trend.get("direction", "?")
if direction == "insufficient_data":
print(f" {label}: Insufficient data")
continue
arrow = {"improving": "IMPROVING", "stable": "STABLE", "degrading": "DEGRADING"}.get(direction, "?")
change = trend.get("change_pct", 0)
earliest = trend.get("earliest_avg")
latest = trend.get("latest_avg")
print(f" {label}: {arrow} ({change:+.1f}%) | {earliest} -> {latest}")
if __name__ == "__main__":
main()
scripts/ga4_report.py
#!/usr/bin/env python3
"""
GA4 Data API v1beta - organic traffic reporting.
Queries the Google Analytics Data API for organic search traffic,
top landing pages, and session metrics with channel filtering.
Usage:
python ga4_report.py --property 123456789
python ga4_report.py --property 123456789 --days 90 --report top-pages
python ga4_report.py --property 123456789 --report organic --json
"""
import argparse
import json
import sys
from datetime import datetime, timedelta
from typing import Optional
try:
from google.analytics.data_v1beta import BetaAnalyticsDataClient
from google.analytics.data_v1beta.types import (
DateRange,
Dimension,
Filter,
FilterExpression,
Metric,
OrderBy,
RunReportRequest,
)
except ImportError:
print(
"Error: google-analytics-data required. "
"Install with: pip install google-analytics-data",
file=sys.stderr,
)
sys.exit(1)
try:
from google_auth import get_oauth_credentials, load_config
except ImportError:
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from google_auth import get_oauth_credentials, load_config
GA4_SCOPES = ["https://www.googleapis.com/auth/analytics.readonly"]
def _build_ga4_client():
"""Build the GA4 BetaAnalyticsDataClient."""
credentials = get_oauth_credentials(GA4_SCOPES)
if not credentials:
return None
try:
return BetaAnalyticsDataClient(credentials=credentials)
except Exception as e:
print(f"Error building GA4 client: {e}", file=sys.stderr)
return None
def _resolve_property(property_id: str) -> str:
"""Ensure property ID is in the correct format."""
if not property_id:
return ""
if property_id.startswith("properties/"):
return property_id
return f"properties/{property_id}"
def organic_traffic_report(
property_id: str,
days: int = 28,
limit: int = 100,
) -> dict:
"""
Generate organic traffic report from GA4.
Filters by sessionDefaultChannelGroup == "Organic Search" and returns
daily sessions, top landing pages, and key metrics.
Args:
property_id: GA4 property ID (numeric or 'properties/123456789').
days: Number of days to query (default: 28).
limit: Max rows (default: 100).
Returns:
Dictionary with daily_data, top_pages, totals, and quota usage.
"""
result = {
"property": property_id,
"report": "organic_traffic",
"date_range": None,
"totals": {},
"daily_data": [],
"top_pages": [],
"quota_tokens_used": None,
"error": None,
}
client = _build_ga4_client()
if not client:
result["error"] = (
"Could not build GA4 client. Ensure the service account has "
"Viewer access in GA4 Admin > Property Access Management."
)
return result
prop = _resolve_property(property_id)
start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
end_date = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
result["date_range"] = {"start": start_date, "end": end_date}
# Daily organic sessions
try:
daily_request = RunReportRequest(
property=prop,
dimensions=[Dimension(name="date")],
metrics=[
Metric(name="sessions"),
Metric(name="totalUsers"),
Metric(name="screenPageViews"),
Metric(name="bounceRate"),
Metric(name="averageSessionDuration"),
Metric(name="engagementRate"),
],
date_ranges=[DateRange(start_date=start_date, end_date=end_date)],
dimension_filter=FilterExpression(
filter=Filter(
field_name="sessionDefaultChannelGroup",
string_filter=Filter.StringFilter(
match_type=Filter.StringFilter.MatchType.EXACT,
value="Organic Search",
),
)
),
order_bys=[OrderBy(dimension=OrderBy.DimensionOrderBy(dimension_name="date"))],
limit=days + 5,
return_property_quota=True,
)
daily_response = client.run_report(daily_request)
for row in daily_response.rows:
result["daily_data"].append({
"date": row.dimension_values[0].value,
"sessions": int(row.metric_values[0].value),
"users": int(row.metric_values[1].value),
"pageviews": int(row.metric_values[2].value),
"bounce_rate": round(float(row.metric_values[3].value) * 100, 1),
"avg_session_duration": round(float(row.metric_values[4].value), 1),
"engagement_rate": round(float(row.metric_values[5].value) * 100, 1),
})
# Quota info
if daily_response.property_quota:
pq = daily_response.property_quota
result["quota_tokens_used"] = {
"daily_consumed": pq.tokens_per_day.consumed if pq.tokens_per_day else None,
"daily_remaining": pq.tokens_per_day.remaining if pq.tokens_per_day else None,
"hourly_consumed": pq.tokens_per_hour.consumed if pq.tokens_per_hour else None,
"hourly_remaining": pq.tokens_per_hour.remaining if pq.tokens_per_hour else None,
}
except Exception as e:
error_str = str(e)
if "403" in error_str or "PERMISSION_DENIED" in error_str:
result["error"] = (
f"Permission denied for property '{property_id}'. "
"Add the service account email as Viewer in "
"GA4 Admin > Property Access Management."
)
elif "404" in error_str or "NOT_FOUND" in error_str:
result["error"] = (
f"Property '{property_id}' not found. "
"Verify the numeric property ID in GA4 Admin > Property Details."
)
else:
result["error"] = f"GA4 API error: {e}"
return result
# Top landing pages by organic sessions
try:
pages_request = RunReportRequest(
property=prop,
dimensions=[Dimension(name="landingPage")],
metrics=[
Metric(name="sessions"),
Metric(name="totalUsers"),
Metric(name="screenPageViews"),
Metric(name="bounceRate"),
Metric(name="engagementRate"),
],
date_ranges=[DateRange(start_date=start_date, end_date=end_date)],
dimension_filter=FilterExpression(
filter=Filter(
field_name="sessionDefaultChannelGroup",
string_filter=Filter.StringFilter(
match_type=Filter.StringFilter.MatchType.EXACT,
value="Organic Search",
),
)
),
order_bys=[
OrderBy(
metric=OrderBy.MetricOrderBy(metric_name="sessions"),
desc=True,
)
],
limit=limit,
)
pages_response = client.run_report(pages_request)
for row in pages_response.rows:
result["top_pages"].append({
"landing_page": row.dimension_values[0].value,
"sessions": int(row.metric_values[0].value),
"users": int(row.metric_values[1].value),
"pageviews": int(row.metric_values[2].value),
"bounce_rate": round(float(row.metric_values[3].value) * 100, 1),
"engagement_rate": round(float(row.metric_values[4].value) * 100, 1),
})
except Exception as e:
# Non-fatal: daily data succeeded, pages failed
result["pages_error"] = f"Error fetching top pages: {e}"
# Calculate totals
if result["daily_data"]:
total_sessions = sum(d["sessions"] for d in result["daily_data"])
total_users = sum(d["users"] for d in result["daily_data"])
total_pageviews = sum(d["pageviews"] for d in result["daily_data"])
result["totals"] = {
"sessions": total_sessions,
"users": total_users,
"pageviews": total_pageviews,
"avg_daily_sessions": round(total_sessions / len(result["daily_data"]), 1),
}
return result
def top_pages_report(
property_id: str,
days: int = 28,
limit: int = 50,
) -> dict:
"""
Get top organic landing pages from GA4.
Args:
property_id: GA4 property ID.
days: Number of days.
limit: Max pages to return.
Returns:
Dictionary with top pages ranked by organic sessions.
"""
report = organic_traffic_report(property_id, days, limit)
# Slim it down to just pages
return {
"property": property_id,
"report": "top_organic_pages",
"date_range": report.get("date_range"),
"pages": report.get("top_pages", []),
"total_organic_sessions": report.get("totals", {}).get("sessions", 0),
"quota_tokens_used": report.get("quota_tokens_used"),
"error": report.get("error"),
}
def device_breakdown(
property_id: str,
days: int = 28,
) -> dict:
"""
Organic sessions broken down by device category.
Args:
property_id: GA4 property ID.
days: Number of days.
Returns:
Dictionary with device breakdown data.
"""
result = {"property": property_id, "report": "device_breakdown", "devices": [], "error": None}
client = _build_ga4_client()
if not client:
result["error"] = "Could not build GA4 client."
return result
prop = _resolve_property(property_id)
start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
end_date = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
result["date_range"] = {"start": start_date, "end": end_date}
try:
request = RunReportRequest(
property=prop,
dimensions=[Dimension(name="deviceCategory")],
metrics=[
Metric(name="sessions"),
Metric(name="totalUsers"),
Metric(name="bounceRate"),
Metric(name="engagementRate"),
],
date_ranges=[DateRange(start_date=start_date, end_date=end_date)],
dimension_filter=FilterExpression(
filter=Filter(
field_name="sessionDefaultChannelGroup",
string_filter=Filter.StringFilter(
match_type=Filter.StringFilter.MatchType.EXACT,
value="Organic Search",
),
)
),
order_bys=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="sessions"), desc=True)],
)
response = client.run_report(request)
for row in response.rows:
result["devices"].append({
"category": row.dimension_values[0].value,
"sessions": int(row.metric_values[0].value),
"users": int(row.metric_values[1].value),
"bounce_rate": round(float(row.metric_values[2].value) * 100, 1),
"engagement_rate": round(float(row.metric_values[3].value) * 100, 1),
})
except Exception as e:
result["error"] = f"GA4 device breakdown error: {e}"
return result
def country_breakdown(
property_id: str,
days: int = 28,
limit: int = 20,
) -> dict:
"""
Organic sessions broken down by country.
Args:
property_id: GA4 property ID.
days: Number of days.
limit: Max countries to return.
Returns:
Dictionary with country breakdown data.
"""
result = {"property": property_id, "report": "country_breakdown", "countries": [], "error": None}
client = _build_ga4_client()
if not client:
result["error"] = "Could not build GA4 client."
return result
prop = _resolve_property(property_id)
start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
end_date = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
result["date_range"] = {"start": start_date, "end": end_date}
try:
request = RunReportRequest(
property=prop,
dimensions=[Dimension(name="country")],
metrics=[
Metric(name="sessions"),
Metric(name="totalUsers"),
],
date_ranges=[DateRange(start_date=start_date, end_date=end_date)],
dimension_filter=FilterExpression(
filter=Filter(
field_name="sessionDefaultChannelGroup",
string_filter=Filter.StringFilter(
match_type=Filter.StringFilter.MatchType.EXACT,
value="Organic Search",
),
)
),
order_bys=[OrderBy(metric=OrderBy.MetricOrderBy(metric_name="sessions"), desc=True)],
limit=limit,
)
response = client.run_report(request)
for row in response.rows:
result["countries"].append({
"country": row.dimension_values[0].value,
"sessions": int(row.metric_values[0].value),
"users": int(row.metric_values[1].value),
})
except Exception as e:
result["error"] = f"GA4 country breakdown error: {e}"
return result
def main():
parser = argparse.ArgumentParser(
description="GA4 Data API - organic traffic reporting"
)
parser.add_argument(
"--property", "-p",
help="GA4 property ID (numeric, e.g., 123456789). Uses config default if not specified.",
)
parser.add_argument("--days", "-d", type=int, default=28, help="Number of days (default: 28)")
parser.add_argument(
"--report", "-r",
choices=["organic", "top-pages", "device", "country"],
default="organic",
help="Report type (default: organic)",
)
parser.add_argument("--limit", type=int, default=50, help="Max rows (default: 50)")
parser.add_argument("--json", "-j", action="store_true", help="Output as JSON")
args = parser.parse_args()
# Resolve property
prop = args.property
if not prop:
config = load_config()
prop = config.get("ga4_property_id") or ""
# Strip 'properties/' prefix if present for consistency
if prop and prop.startswith("properties/"):
prop = prop[len("properties/"):]
if not prop:
print(
"Error: No GA4 property specified. Use --property or set ga4_property_id in config.",
file=sys.stderr,
)
sys.exit(1)
if args.report == "top-pages":
result = top_pages_report(prop, args.days, args.limit)
elif args.report == "device":
result = device_breakdown(prop, args.days)
elif args.report == "country":
result = country_breakdown(prop, args.days, args.limit)
else:
result = organic_traffic_report(prop, args.days, args.limit)
if result.get("error"):
print(f"Error: {result['error']}", file=sys.stderr)
if not args.json:
sys.exit(1)
if args.json:
print(json.dumps(result, indent=2, default=str))
else:
if args.report == "top-pages":
print(f"=== Top Organic Landing Pages ===")
print(f"Property: {prop} | Period: {result.get('date_range', {}).get('start')} to {result.get('date_range', {}).get('end')}")
print(f"Total organic sessions: {result.get('total_organic_sessions', 0):,}")
print()
for i, page in enumerate(result.get("pages", [])[:20], 1):
print(f" {i:2d}. {page['landing_page']}")
print(f" Sessions: {page['sessions']:,} | Users: {page['users']:,} | Bounce: {page['bounce_rate']}%")
else:
totals = result.get("totals", {})
print(f"=== GA4 Organic Traffic Report ===")
print(f"Property: {prop}")
dr = result.get("date_range", {})
print(f"Period: {dr.get('start')} to {dr.get('end')}")
print(f"\nSessions: {totals.get('sessions', 0):,} | Users: {totals.get('users', 0):,} | Pageviews: {totals.get('pageviews', 0):,}")
print(f"Avg Daily Sessions: {totals.get('avg_daily_sessions', 0):,.0f}")
quota = result.get("quota_tokens_used")
if quota and quota.get("daily_remaining") is not None:
print(f"\nQuota: {quota['daily_consumed']} tokens used / {quota['daily_remaining']} remaining (daily)")
pages = result.get("top_pages", [])
if pages:
print(f"\nTop {min(10, len(pages))} Organic Landing Pages:")
for i, page in enumerate(pages[:10], 1):
print(f" {i:2d}. {page['landing_page']} ({page['sessions']:,} sessions)")
if __name__ == "__main__":
main()
scripts/google_auth.py
#!/usr/bin/env python3
"""
Google API credential management for Claude SEO.
Loads and validates credentials for Google Search Console, PageSpeed Insights,
CrUX, Indexing API, and GA4. Supports service accounts, OAuth web credentials
with token refresh, API keys, and environment variable fallbacks.
Usage:
python google_auth.py --check # Check all credentials
python google_auth.py --check gsc # Check specific service
python google_auth.py --check --json # JSON output
python google_auth.py --setup # Show setup instructions
python google_auth.py --tier # Show detected credential tier
python google_auth.py --auth --creds /path/to/client_secret.json # OAuth browser flow
"""
import argparse
import json
import os
import random
import secrets
import sys
import tempfile
import time
from typing import Optional
CONFIG_PATH = os.path.expanduser("~/.config/claude-seo/google-api.json")
TOKEN_PATH = os.path.expanduser("~/.config/claude-seo/oauth-token.json")
# Service-to-scope mapping
SCOPES = {
"gsc_readonly": "https://www.googleapis.com/auth/webmasters.readonly",
"gsc_write": "https://www.googleapis.com/auth/webmasters",
"indexing": "https://www.googleapis.com/auth/indexing",
"ga4": "https://www.googleapis.com/auth/analytics.readonly",
}
# Which services need which auth type
SERVICE_AUTH = {
"psi": "api_key",
"crux": "api_key",
"crux_history": "api_key",
"youtube": "api_key",
"nlp": "api_key",
"gsc": "oauth_or_sa",
"indexing": "oauth_or_sa",
"ga4": "oauth_or_sa",
"keywords": "ads",
}
OAUTH_REDIRECT_URI = "http://127.0.0.1:8085"
def _write_secret_atomic(path: str, content: str) -> None:
"""Atomically write `content` to `path` with mode 0o600.
Uses tempfile in same dir + os.replace for atomicity (no partial writes
on crash). Sets restrictive file mode before writing payload.
"""
# Bare-filename safety: os.path.dirname returns "" if path has no dir
# component. Pass that to mkstemp(dir="") and it errors with FileNotFoundError.
parent = os.path.dirname(path) or "."
os.makedirs(parent, mode=0o700, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=parent, prefix=".tmp-")
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, "w") as f:
f.write(content)
os.replace(tmp, path)
os.chmod(path, 0o600) # belt-and-braces if file pre-existed
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
def _scopes_for(services: list = None) -> str:
"""Build OAuth scope string for the requested services.
Defaults to a read-only set so OAuth-without-flag grants minimal scopes.
"""
if services is None:
# Safer default: read-only scopes only.
services = ["gsc_readonly", "ga4"]
scope_urls = []
for s in services:
if s in SCOPES:
scope_urls.append(SCOPES[s])
else:
raise ValueError(f"Unknown scope service: {s}")
return " ".join(scope_urls)
# Human-readable service names
SERVICE_NAMES = {
"psi": "PageSpeed Insights v5",
"crux": "Chrome UX Report (CrUX) API",
"crux_history": "CrUX History API",
"youtube": "YouTube Data API v3",
"nlp": "Cloud Natural Language API",
"gsc": "Google Search Console API",
"indexing": "Google Indexing API v3",
"ga4": "GA4 Data API v1beta",
"keywords": "Google Ads Keyword Planner",
}
def _retry_delay(headers: dict, attempt: int) -> float:
retry_after = None
if headers:
retry_after = headers.get("Retry-After") or headers.get("retry-after")
if retry_after:
try:
return min(float(retry_after), 60.0)
except ValueError:
pass
return min(2 ** attempt, 16) + random.uniform(0, 0.5)
def request_with_retries(method: str, url: str, max_attempts: int = 5, **kwargs):
"""Run a requests call with Google-friendly Retry-After backoff."""
try:
import requests
except ImportError:
raise RuntimeError("requests library required")
retry_statuses = {429, 500, 502, 503, 504}
last_response = None
for attempt in range(max_attempts):
response = requests.request(method, url, **kwargs)
last_response = response
if response.status_code not in retry_statuses:
return response
if attempt == max_attempts - 1:
return response
time.sleep(_retry_delay(response.headers, attempt))
return last_response
def execute_with_retries(request, max_attempts: int = 5):
"""Execute a googleapiclient request with Retry-After backoff."""
retry_statuses = {429, 500, 502, 503, 504}
for attempt in range(max_attempts):
try:
return request.execute()
except Exception as exc:
status = getattr(getattr(exc, "resp", None), "status", None)
headers = getattr(getattr(exc, "resp", None), "headers", {}) or {}
if status not in retry_statuses or attempt == max_attempts - 1:
raise
time.sleep(_retry_delay(headers, attempt))
def load_config() -> dict:
"""
Load configuration from config file with environment variable fallbacks.
Reads ~/.config/claude-seo/google-api.json first. Any missing fields
are filled from environment variables.
Returns:
Dictionary with keys: service_account_path, api_key,
default_property, ga4_property_id. Missing values are None.
"""
config = {
"service_account_path": None,
"api_key": None,
"oauth_client_path": None,
"default_property": None,
"ga4_property_id": None,
"ads_developer_token": None,
"ads_customer_id": None,
"ads_login_customer_id": None,
}
# Load from config file
if os.path.exists(CONFIG_PATH):
try:
with open(CONFIG_PATH, "r") as f:
file_config = json.load(f)
config.update({k: v for k, v in file_config.items() if v})
except (json.JSONDecodeError, IOError) as e:
print(f"Warning: Could not read config file: {e}", file=sys.stderr)
# Environment variable fallbacks
if not config["service_account_path"]:
config["service_account_path"] = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS")
if not config["api_key"]:
config["api_key"] = os.environ.get("GOOGLE_API_KEY")
if not config["ga4_property_id"]:
config["ga4_property_id"] = os.environ.get("GA4_PROPERTY_ID")
if not config["default_property"]:
config["default_property"] = os.environ.get("GSC_PROPERTY")
if not config["oauth_client_path"]:
config["oauth_client_path"] = os.environ.get("GOOGLE_OAUTH_CLIENT_PATH")
if not config["ads_developer_token"]:
config["ads_developer_token"] = os.environ.get("GOOGLE_ADS_DEVELOPER_TOKEN")
if not config["ads_customer_id"]:
config["ads_customer_id"] = os.environ.get("GOOGLE_ADS_CUSTOMER_ID")
if not config["ads_login_customer_id"]:
config["ads_login_customer_id"] = os.environ.get("GOOGLE_ADS_LOGIN_CUSTOMER_ID")
return config
def _save_config_value(key: str, value: str) -> None:
"""Persist one config value without weakening file permissions."""
config = {}
if os.path.exists(CONFIG_PATH):
try:
with open(CONFIG_PATH, "r") as f:
config = json.load(f)
except (json.JSONDecodeError, IOError):
config = {}
config[key] = value
_write_secret_atomic(CONFIG_PATH, json.dumps(config, indent=2))
def get_service_account_credentials(scopes: list):
"""
Load Google service account credentials.
Args:
scopes: List of OAuth scope URLs.
Returns:
google.oauth2.service_account.Credentials object, or None on failure.
"""
try:
from google.oauth2 import service_account
except ImportError:
print(
"Error: google-auth library required. "
"Install with: pip install google-auth",
file=sys.stderr,
)
return None
config = load_config()
sa_path = config.get("service_account_path")
if not sa_path:
return None
sa_path = os.path.expanduser(sa_path)
if not os.path.exists(sa_path):
print(
f"Error: Service account file not found: {sa_path}",
file=sys.stderr,
)
return None
try:
credentials = service_account.Credentials.from_service_account_file(
sa_path, scopes=scopes
)
return credentials
except Exception as e:
print(f"Error loading service account: {e}", file=sys.stderr)
return None
def _load_oauth_client(creds_path: str) -> Optional[dict]:
"""Load OAuth client credentials from a client_secret JSON file."""
try:
with open(creds_path, "r") as f:
data = json.load(f)
return data.get("web", data.get("installed", {}))
except (json.JSONDecodeError, IOError) as e:
print(f"Error reading OAuth client file: {e}", file=sys.stderr)
return None
def _load_oauth_token() -> Optional[dict]:
"""Load saved OAuth token from TOKEN_PATH."""
if not os.path.exists(TOKEN_PATH):
return None
try:
with open(TOKEN_PATH, "r") as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return None
def _save_oauth_token(token_data: dict):
"""Save OAuth token to TOKEN_PATH with mode 0o600 and atomic write."""
_write_secret_atomic(TOKEN_PATH, json.dumps(token_data, indent=2))
def _refresh_oauth_token(client: dict, token_data: dict) -> Optional[dict]:
"""Refresh an expired OAuth token using the refresh_token."""
import urllib.parse
import urllib.request
if not token_data.get("refresh_token"):
return None
params = urllib.parse.urlencode({
"client_id": client["client_id"],
"client_secret": client["client_secret"],
"refresh_token": token_data["refresh_token"],
"grant_type": "refresh_token",
}).encode()
try:
req = urllib.request.Request(client.get("token_uri", "https://oauth2.googleapis.com/token"), data=params)
with urllib.request.urlopen(req, timeout=30) as resp:
new_data = json.loads(resp.read())
token_data["access_token"] = new_data["access_token"]
token_data["expires_at"] = time.time() + new_data.get("expires_in", 3600)
if "refresh_token" in new_data: # Google now sometimes rotates these
token_data["refresh_token"] = new_data["refresh_token"]
# AUTH-001 (v1.9.1): strip client_secret on every save so older
# token files migrate forward the first time they're refreshed.
token_data.pop("client_secret", None)
_save_oauth_token(token_data)
return token_data
except Exception as e:
print(f"Error refreshing OAuth token: {e}", file=sys.stderr)
return None
def get_oauth_credentials(scopes: list):
"""
Get OAuth credentials from saved token, refreshing if needed.
Falls back to service account if no OAuth token is available.
Args:
scopes: List of OAuth scope URLs (used for service account fallback).
Returns:
google.oauth2.credentials.Credentials or service_account.Credentials, or None.
"""
config = load_config()
# Try OAuth token first
token_data = _load_oauth_token()
if token_data and token_data.get("access_token"):
# Check if token needs refresh
if time.time() > token_data.get("expires_at", 0) - 60:
oauth_creds_path = config.get("oauth_client_path")
if oauth_creds_path:
client = _load_oauth_client(os.path.expanduser(oauth_creds_path))
if client:
token_data = _refresh_oauth_token(client, token_data)
if not token_data:
print("OAuth token refresh failed. Re-run --auth.", file=sys.stderr)
return get_service_account_credentials(scopes)
if token_data and token_data.get("access_token"):
try:
from google.oauth2.credentials import Credentials
# AUTH-001 (v1.9.1): client_secret is no longer stored in
# the token file. Re-read from config["oauth_client_path"]
# so the long-lived app credential stays in its own
# 0o600 file (and is referenced, not duplicated).
# Backwards-compat: legacy token files still containing
# client_secret are honored (token_data.get fallback)
# until v1.10.0 makes oauth_client_path mandatory.
client_secret = None
oauth_creds_path = config.get("oauth_client_path")
if oauth_creds_path:
client = _load_oauth_client(os.path.expanduser(oauth_creds_path))
if client:
client_secret = client.get("client_secret")
if client_secret is None:
client_secret = token_data.get("client_secret") # legacy compat
return Credentials(
token=token_data["access_token"],
refresh_token=token_data.get("refresh_token"),
token_uri="https://oauth2.googleapis.com/token",
client_id=token_data.get("client_id"),
client_secret=client_secret,
)
except ImportError:
print("Error: google-auth required. Install with: pip install google-auth", file=sys.stderr)
# Fall back to service account
return get_service_account_credentials(scopes)
def run_oauth_flow(creds_path: str, services: list = None):
"""
Run OAuth browser-based authentication flow.
Opens a browser for consent, captures the auth code via local HTTP server,
exchanges for tokens, and saves them.
Args:
creds_path: Path to the OAuth client_secret JSON file.
services: Optional list of scope service keys (see SCOPES). Defaults to
the read-only set built by `_scopes_for(None)`.
"""
import http.server
import urllib.parse
import urllib.request
import webbrowser
client = _load_oauth_client(creds_path)
if not client:
print("Error: Could not load OAuth client credentials.", file=sys.stderr)
sys.exit(1)
state_token = secrets.token_urlsafe(32)
scopes_str = _scopes_for(services)
auth_params = {
"client_id": client["client_id"],
"redirect_uri": OAUTH_REDIRECT_URI,
"response_type": "code",
"scope": scopes_str,
"access_type": "offline",
"prompt": "consent",
"state": state_token,
"include_granted_scopes": "true",
}
auth_url = (
f"{client.get('auth_uri', 'https://accounts.google.com/o/oauth2/auth')}"
f"?{urllib.parse.urlencode(auth_params)}"
)
auth_code = [None]
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
received_state = params.get("state", [""])[0]
if received_state != state_token:
self.send_response(403)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"State mismatch - possible CSRF. Aborted.")
return
if "code" in params:
auth_code[0] = params["code"][0]
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(b"<html><body><h1>Authorization complete.</h1>You can close this tab.</body></html>")
else:
self.send_response(400)
self.end_headers()
def log_message(self, *a):
pass
server = http.server.HTTPServer(("127.0.0.1", 8085), Handler)
server.timeout = 300
print(f"\nOpen this URL in your browser:\n\n{auth_url}\n")
print("Waiting up to 5 minutes for authentication...")
try:
webbrowser.open(auth_url)
except Exception:
pass
server.handle_request()
server.server_close()
if not auth_code[0]:
print("\nAuthentication failed or timed out.", file=sys.stderr)
print("If the browser showed '127.0.0.1 refused to connect', copy the full URL")
print("from the browser address bar and run:")
print(f" python3 scripts/google_auth.py --exchange --creds {creds_path} --code 'THE_CODE'")
sys.exit(1)
# Exchange code for tokens
_exchange_code(client, auth_code[0], creds_path=creds_path)
def _exchange_code(client: dict, code: str, creds_path: str | None = None):
"""Exchange an authorization code for tokens."""
import urllib.parse
import urllib.request
params = urllib.parse.urlencode({
"code": code,
"client_id": client["client_id"],
"client_secret": client["client_secret"],
"redirect_uri": OAUTH_REDIRECT_URI,
"grant_type": "authorization_code",
}).encode()
try:
req = urllib.request.Request(
client.get("token_uri", "https://oauth2.googleapis.com/token"), data=params
)
with urllib.request.urlopen(req, timeout=30) as resp:
token_data = json.loads(resp.read())
token_data["expires_at"] = time.time() + token_data.get("expires_in", 3600)
token_data["client_id"] = client["client_id"]
# AUTH-001 (v1.9.1): client_secret is NO LONGER stored in the token
# file. Co-locating the long-lived app credential with the
# short-lived access token expands blast radius if the token file
# leaks. Refresh paths re-read client_secret from
# config["oauth_client_path"] instead. See get_oauth_credentials.
token_data.pop("client_secret", None)
_save_oauth_token(token_data)
print("OAuth token saved successfully!")
if creds_path:
_save_config_value("oauth_client_path", os.path.abspath(os.path.expanduser(creds_path)))
print(f"\nToken saved to: {TOKEN_PATH}")
except Exception as e:
print(f"Error exchanging authorization code: {e}", file=sys.stderr)
sys.exit(1)
def get_api_key() -> Optional[str]:
"""
Get the Google API key from config or environment.
Returns:
API key string, or None if not configured.
"""
config = load_config()
return config.get("api_key")
def build_service(api_name: str, version: str, scopes: list):
"""
Build a Google API discovery service client.
Args:
api_name: API name (e.g., 'searchconsole', 'indexing', 'pagespeedonline').
version: API version (e.g., 'v1', 'v3', 'v5').
scopes: OAuth scopes needed.
Returns:
googleapiclient.discovery.Resource object, or None on failure.
"""
try:
from googleapiclient.discovery import build
except ImportError:
print(
"Error: google-api-python-client required. "
"Install with: pip install google-api-python-client",
file=sys.stderr,
)
return None
credentials = get_oauth_credentials(scopes)
if not credentials:
return None
try:
service = build(api_name, version, credentials=credentials)
return service
except Exception as e:
print(f"Error building {api_name} service: {e}", file=sys.stderr)
return None
def check_credentials(service: str) -> dict:
"""
Validate credentials for a specific Google API service.
Args:
service: One of 'psi', 'crux', 'crux_history', 'gsc', 'indexing', 'ga4'.
Returns:
Dictionary with:
- available: bool
- method: 'api_key' or 'service_account'
- service: service name
- error: error message or None
"""
result = {
"available": False,
"method": SERVICE_AUTH.get(service, "unknown"),
"service": SERVICE_NAMES.get(service, service),
"error": None,
}
config = load_config()
if SERVICE_AUTH.get(service) == "api_key":
api_key = config.get("api_key")
if api_key:
result["available"] = True
else:
result["error"] = (
"No API key found. Set GOOGLE_API_KEY environment variable "
f"or add 'api_key' to {CONFIG_PATH}"
)
elif SERVICE_AUTH.get(service) == "oauth_or_sa":
# Check OAuth token first
token_data = _load_oauth_token()
if token_data and token_data.get("access_token"):
result["available"] = True
result["method"] = "oauth_token"
expired = time.time() > token_data.get("expires_at", 0) - 60
if expired and token_data.get("refresh_token"):
result["note"] = "Token expired but refresh_token available (will auto-refresh)"
elif expired:
result["available"] = False
result["error"] = "OAuth token expired and no refresh_token. Re-run --auth."
else:
# Fall back to service account
sa_path = config.get("service_account_path")
if not sa_path:
result["error"] = (
"No OAuth token or service account found. Either:\n"
" 1. Run: python3 scripts/google_auth.py --auth --creds /path/to/client_secret.json\n"
f" 2. Or add 'service_account_path' to {CONFIG_PATH}"
)
else:
sa_path = os.path.expanduser(sa_path)
if not os.path.exists(sa_path):
result["error"] = f"Service account file not found: {sa_path}"
else:
try:
with open(sa_path, "r") as f:
sa_data = json.load(f)
if "client_email" not in sa_data or "private_key" not in sa_data:
result["error"] = "Service account JSON missing required fields (client_email, private_key)"
else:
result["available"] = True
result["method"] = "service_account"
result["client_email"] = sa_data.get("client_email")
except (json.JSONDecodeError, IOError) as e:
result["error"] = f"Invalid service account file: {e}"
# GA4 also needs property ID
if service == "ga4" and result["available"]:
ga4_id = config.get("ga4_property_id")
if not ga4_id:
result["available"] = False
result["error"] = (
"Credentials found but no GA4 property ID configured. "
f"Set GA4_PROPERTY_ID or add 'ga4_property_id' to {CONFIG_PATH}"
)
elif SERVICE_AUTH.get(service) == "ads":
missing = []
if not config.get("ads_developer_token"):
missing.append("ads_developer_token")
if not config.get("ads_customer_id"):
missing.append("ads_customer_id")
token_data = _load_oauth_token()
if not token_data or not token_data.get("refresh_token"):
missing.append("OAuth refresh token from --auth")
if not config.get("oauth_client_path"):
missing.append("oauth_client_path")
if missing:
result["error"] = f"Missing Google Ads config: {', '.join(missing)}"
else:
result["available"] = True
result["method"] = "google_ads_oauth"
else:
result["error"] = f"Unknown service: {service}"
return result
def detect_tier() -> dict:
"""
Detect the credential tier available.
Returns:
Dictionary with:
- tier: 0, 1, or 2
- description: human-readable tier description
- capabilities: list of available API groups
- missing: what's needed for the next tier
"""
config = load_config()
has_api_key = bool(config.get("api_key"))
has_authenticated = False
has_ga4 = False
has_ads = False
auth_method = None
# Check OAuth token
token_data = _load_oauth_token()
if token_data and token_data.get("access_token"):
has_authenticated = True
auth_method = "oauth_token"
# Check service account
if not has_authenticated:
sa_path = config.get("service_account_path")
if sa_path:
sa_path = os.path.expanduser(sa_path)
if os.path.exists(sa_path):
try:
with open(sa_path, "r") as f:
sa_data = json.load(f)
if "client_email" in sa_data and "private_key" in sa_data:
has_authenticated = True
auth_method = "service_account"
except (json.JSONDecodeError, IOError):
pass
if has_authenticated and config.get("ga4_property_id"):
has_ga4 = True
token_data = _load_oauth_token()
has_ads = all([
config.get("ads_developer_token"),
config.get("ads_customer_id"),
config.get("oauth_client_path"),
token_data and token_data.get("refresh_token"),
])
tier0_caps = [
"PageSpeed Insights", "CrUX", "CrUX History",
"YouTube Data", "Cloud Natural Language",
]
auth_caps = [
"Search Console", "URL Inspection", "Sitemaps",
"Indexing API",
]
if has_ads:
capabilities = []
if has_api_key:
capabilities.extend(tier0_caps)
if has_authenticated:
capabilities.extend(auth_caps)
if has_ga4:
capabilities.append("GA4 Organic Traffic")
capabilities.append("Google Ads Keyword Planner")
missing = None
if not has_ga4:
missing = "Add 'ga4_property_id' if GA4 reports are also needed"
return {
"tier": 3,
"description": "Ads (Google Ads OAuth + developer token)",
"capabilities": capabilities,
"missing": missing,
}
elif has_ga4:
return {
"tier": 2,
"description": "Full (API key + Service Account + GA4)",
"capabilities": tier0_caps + auth_caps + ["GA4 Organic Traffic"],
"missing": None,
}
elif has_authenticated:
return {
"tier": 1,
"description": "Authenticated (API key + OAuth/Service Account)",
"capabilities": (tier0_caps if has_api_key else []) + auth_caps,
"missing": "Add 'ga4_property_id' to unlock GA4 organic traffic reports",
}
elif has_api_key:
return {
"tier": 0,
"description": "API Key Only",
"capabilities": tier0_caps,
"missing": "Add a service account to unlock Search Console, URL Inspection, and Indexing API",
}
else:
return {
"tier": -1,
"description": "No credentials configured",
"capabilities": [],
"missing": (
f"Create config at {CONFIG_PATH} with at minimum an 'api_key' field. "
"Run with --setup for full instructions."
),
}
def print_setup_instructions():
"""Print step-by-step setup instructions."""
print("""
Google SEO API Setup Instructions
=================================
1. CREATE A GOOGLE CLOUD PROJECT
- Go to https://console.cloud.google.com
- Create a new project (or select existing)
- Note the project ID
2. ENABLE APIs
In API Library (APIs & Services > Library), enable:
- Google Search Console API
- PageSpeed Insights API
- Chrome UX Report API
- Web Search Indexing API (for Indexing API)
- Google Analytics Data API (for GA4)
- YouTube Data API v3
- Cloud Natural Language API
3. CREATE AN API KEY (for PSI, CrUX, YouTube, NLP)
- APIs & Services > Credentials > Create Credentials > API key
- Restrict to: PageSpeed Insights API, Chrome UX Report API,
YouTube Data API v3, Cloud Natural Language API
4. CREATE A SERVICE ACCOUNT (for GSC, Indexing API, GA4)
- IAM & Admin > Service Accounts > Create Service Account
- Download JSON key file, store securely
5. GRANT ACCESS
- Search Console: Settings > Users and permissions > Add user
Paste the service account client_email, set as Owner (for Indexing API) or Full (read-only)
- GA4: Admin > Property Access Management > Add
Paste email, set Viewer role
6. CREATE CONFIG FILE
mkdir -p ~/.config/claude-seo
Save to ~/.config/claude-seo/google-api.json:
{
"service_account_path": "/path/to/service_account.json",
"api_key": "YOUR_GOOGLE_API_KEY",
"oauth_client_path": "/path/to/oauth_client.json",
"default_property": "sc-domain:example.com",
"ga4_property_id": "properties/123456789",
"ads_developer_token": "YOUR_DEV_TOKEN",
"ads_customer_id": "123-456-7890",
"ads_login_customer_id": "123-456-7890"
}
chmod 700 ~/.config/claude-seo
chmod 600 ~/.config/claude-seo/google-api.json
7. VERIFY
python3 scripts/google_auth.py --check
ENVIRONMENT VARIABLE ALTERNATIVES:
GOOGLE_API_KEY - API key
GOOGLE_APPLICATION_CREDENTIALS - Path to service account JSON
GOOGLE_OAUTH_CLIENT_PATH - Path to OAuth client JSON
GA4_PROPERTY_ID - GA4 property ID (e.g., properties/123456789)
GSC_PROPERTY - Default Search Console property
GOOGLE_ADS_DEVELOPER_TOKEN - Google Ads developer token
GOOGLE_ADS_CUSTOMER_ID - Google Ads customer ID
GOOGLE_ADS_LOGIN_CUSTOMER_ID - Optional manager account login customer ID
""")
def main():
parser = argparse.ArgumentParser(
description="Google API credential management for Claude SEO"
)
parser.add_argument(
"--check",
nargs="?",
const="all",
metavar="SERVICE",
help="Check credentials. Optionally specify service: psi, crux, crux_history, youtube, nlp, gsc, indexing, ga4, keywords",
)
parser.add_argument(
"--setup",
action="store_true",
help="Show setup instructions",
)
parser.add_argument(
"--tier",
action="store_true",
help="Show detected credential tier",
)
parser.add_argument(
"--json",
action="store_true",
help="Output as JSON",
)
parser.add_argument(
"--auth",
action="store_true",
help="Run OAuth browser-based authentication flow",
)
parser.add_argument(
"--exchange",
action="store_true",
help="Manually exchange an auth code for tokens",
)
parser.add_argument(
"--creds",
help="Path to OAuth client_secret JSON file (for --auth and --exchange)",
)
parser.add_argument(
"--code",
help="Authorization code to exchange (for --exchange)",
)
parser.add_argument(
"--scopes",
help=(
"Comma-separated scope service keys for --auth (e.g. "
"'gsc_readonly,ga4' or 'indexing,gsc_write'). Defaults to a "
"read-only set: gsc_readonly,ga4."
),
)
args = parser.parse_args()
if args.auth:
if not args.creds:
print("Error: --creds is required with --auth", file=sys.stderr)
sys.exit(1)
services = None
if args.scopes:
services = [s.strip() for s in args.scopes.split(",") if s.strip()]
try:
run_oauth_flow(args.creds, services=services)
except ValueError as e:
# _scopes_for raises ValueError for unknown service keys.
# Surface a clean error instead of a stack trace.
print(f"Error: {e}", file=sys.stderr)
print(
f"Valid scope keys: {', '.join(sorted(SCOPES.keys()))}",
file=sys.stderr,
)
sys.exit(2)
return
if args.exchange:
if not args.creds or not args.code:
print("Error: --creds and --code are required with --exchange", file=sys.stderr)
sys.exit(1)
client = _load_oauth_client(args.creds)
if client:
_exchange_code(client, args.code, creds_path=args.creds)
return
if args.setup:
print_setup_instructions()
return
if args.tier:
tier_info = detect_tier()
if args.json:
print(json.dumps(tier_info, indent=2))
else:
print(f"Credential Tier: {tier_info['tier']} - {tier_info['description']}")
if tier_info["capabilities"]:
print(f"Available APIs: {', '.join(tier_info['capabilities'])}")
if tier_info["missing"]:
print(f"Next tier: {tier_info['missing']}")
return
if args.check:
services = (
list(SERVICE_AUTH.keys())
if args.check == "all"
else [args.check]
)
results = {}
for svc in services:
if svc not in SERVICE_AUTH:
results[svc] = {"available": False, "error": f"Unknown service: {svc}"}
continue
results[svc] = check_credentials(svc)
if args.json:
tier_info = detect_tier()
output = {"tier": tier_info, "services": results}
print(json.dumps(output, indent=2))
else:
tier_info = detect_tier()
print(f"Credential Tier: {tier_info['tier']} - {tier_info['description']}")
print()
for svc, result in results.items():
status = "OK" if result["available"] else "MISSING"
print(f" [{status}] {result.get('service', svc)}")
if result.get("error"):
print(f" {result['error']}")
if result.get("client_email"):
print(f" Service account: {result['client_email']}")
print()
if tier_info["missing"]:
print(f"Tip: {tier_info['missing']}")
return
# Default: show tier
tier_info = detect_tier()
if args.json:
print(json.dumps(tier_info, indent=2))
else:
print(f"Credential Tier: {tier_info['tier']} - {tier_info['description']}")
if tier_info["missing"]:
print(f"Run --setup for configuration instructions.")
if __name__ == "__main__":
main()
scripts/google_report.py
#!/usr/bin/env python3
"""
Google SEO Report Generator - PDF/HTML reports from API data.
Consumes JSON output from blog-google scripts and generates formatted reports
with charts, analytics tables, and priority findings.
Usage:
python google_report.py --type cwv-audit --data cwv-data.json --domain example.com
python google_report.py --type gsc-performance --data gsc-data.json --domain example.com
python google_report.py --type indexation --data inspect-data.json --domain example.com
python google_report.py --type full --data full-data.json --domain example.com
cat data.json | python google_report.py --type cwv-audit --domain example.com
"""
import argparse
import html
import json
import os
import sys
from datetime import datetime
from pathlib import Path
from typing import Optional
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
except ImportError:
print("Error: matplotlib required. Install with: pip install matplotlib", file=sys.stderr)
sys.exit(1)
try:
from weasyprint import HTML
except ImportError:
HTML = None
# ─── Brand Colors ────────────────────────────────────────────────────────────
BRAND = {
"primary": "#1a56db",
"secondary": "#6366f1",
"accent": "#06b6d4",
"success": "#10b981",
"warning": "#f59e0b",
"danger": "#ef4444",
"dark": "#1e293b",
"light_bg": "#f8fafc",
"grid": "#e2e8f0",
"muted": "#94a3b8",
}
def _score_color(score):
if score >= 90:
return BRAND["success"]
elif score >= 50:
return BRAND["warning"]
return BRAND["danger"]
def _rating_color(rating):
r = str(rating).lower().replace("-", "_").replace(" ", "_")
if r in ("good", "pass", "fast"):
return BRAND["success"]
elif r in ("needs_improvement", "needs-improvement", "average", "warn"):
return BRAND["warning"]
return BRAND["danger"]
def _h(value) -> str:
return html.escape(str(value), quote=True)
def _css_string(value) -> str:
return str(value).replace("\\", "\\\\").replace('"', '\\"').replace("\n", " ")
# ─── Chart Setup ─────────────────────────────────────────────────────────────
def _setup_matplotlib():
plt.rcParams.update({
"font.family": "sans-serif",
"font.sans-serif": ["DejaVu Sans", "Arial", "Helvetica"],
"font.size": 11,
"axes.titlesize": 14,
"axes.titleweight": "bold",
"axes.labelsize": 11,
"axes.facecolor": "white",
"figure.facecolor": "white",
"axes.grid": False,
"axes.spines.top": False,
"axes.spines.right": False,
})
_setup_matplotlib()
# ─── Chart Functions ─────────────────────────────────────────────────────────
def chart_lighthouse_gauges(data: dict, output_dir: Path) -> str:
"""Generate 2x2 Lighthouse score gauges."""
scores = data.get("lighthouse_scores", {})
if not scores:
return ""
fig, axes = plt.subplots(2, 2, figsize=(8, 6), subplot_kw={"projection": "polar"})
categories = [
("performance", "Performance"),
("accessibility", "Accessibility"),
("best-practices", "Best Practices"),
("seo", "SEO"),
]
for ax, (key, label) in zip(axes.flat, categories):
score = scores.get(key, 0)
theta_bg = np.linspace(np.pi, 0, 100)
theta_fill = np.linspace(np.pi, np.pi - (score / 100) * np.pi, 100)
ax.plot(theta_bg, [1] * 100, linewidth=16, color="#e2e8f0", solid_capstyle="round")
ax.plot(theta_fill, [1] * 100, linewidth=16, color=_score_color(score), solid_capstyle="round")
ax.text(np.pi / 2, 0.35, f"{score}", ha="center", va="center",
fontsize=28, fontweight="bold", color=BRAND["dark"])
ax.text(np.pi / 2, -0.05, label, ha="center", va="center",
fontsize=10, color=BRAND["muted"])
ax.set_ylim(0, 1.3)
ax.set_rticks([])
ax.set_thetagrids([])
ax.spines["polar"].set_visible(False)
plt.tight_layout(pad=2)
path = output_dir / "lighthouse_gauges.png"
plt.savefig(path, dpi=200, bbox_inches="tight", facecolor="white")
plt.close()
return str(path)
def chart_cwv_distributions(data: dict, output_dir: Path) -> str:
"""Generate stacked horizontal bars for CWV metric distributions."""
crux = data.get("crux", {})
metrics = crux.get("metrics", {})
if not metrics:
return ""
cwv_order = [
"largest_contentful_paint", "interaction_to_next_paint",
"cumulative_layout_shift", "first_contentful_paint",
"experimental_time_to_first_byte",
]
labels, goods, nis, poors = [], [], [], []
for name in cwv_order:
m = metrics.get(name)
if not m or "distribution" not in m:
continue
d = m["distribution"]
labels.append(m.get("label", name))
goods.append(d.get("good", 0))
nis.append(d.get("needs_improvement", 0))
poors.append(d.get("poor", 0))
if not labels:
return ""
fig, ax = plt.subplots(figsize=(8, max(2.5, len(labels) * 0.7)))
y = range(len(labels))
ax.barh(y, goods, color=BRAND["success"], label="Good", height=0.5)
ax.barh(y, nis, left=goods, color=BRAND["warning"], label="Needs Improvement", height=0.5)
left2 = [g + n for g, n in zip(goods, nis)]
ax.barh(y, poors, left=left2, color=BRAND["danger"], label="Poor", height=0.5)
ax.set_yticks(y)
ax.set_yticklabels(labels)
ax.set_xlim(0, 100)
ax.set_xlabel("% of page loads")
ax.legend(loc="lower right", fontsize=9)
ax.invert_yaxis()
for i, (g, n, p) in enumerate(zip(goods, nis, poors)):
if g > 10:
ax.text(g / 2, i, f"{g:.0f}%", ha="center", va="center", fontsize=8, color="white", fontweight="bold")
if n > 10:
ax.text(g + n / 2, i, f"{n:.0f}%", ha="center", va="center", fontsize=8, color="white", fontweight="bold")
if p > 10:
ax.text(g + n + p / 2, i, f"{p:.0f}%", ha="center", va="center", fontsize=8, color="white", fontweight="bold")
plt.tight_layout()
path = output_dir / "cwv_distributions.png"
plt.savefig(path, dpi=200, bbox_inches="tight", facecolor="white")
plt.close()
return str(path)
def chart_cwv_timeline(data: dict, output_dir: Path) -> str:
"""Generate CWV timeline chart from CrUX History data."""
metrics = data.get("metrics", {})
periods = data.get("collection_periods", [])
if not metrics or not periods:
return ""
cwv_metrics = ["largest_contentful_paint", "interaction_to_next_paint", "cumulative_layout_shift"]
available = [m for m in cwv_metrics if m in metrics]
if not available:
return ""
fig, axes = plt.subplots(len(available), 1, figsize=(10, 3 * len(available)), sharex=True)
if len(available) == 1:
axes = [axes]
x_labels = [p.get("last", "")[-5:] for p in periods] # MM-DD format
x = range(len(x_labels))
for ax, metric_name in zip(axes, available):
m = metrics[metric_name]
p75s = m.get("p75_values", [])
label = m.get("label", metric_name)
good_t = m.get("good_threshold", 0)
poor_t = m.get("poor_threshold", 0)
valid_x = [i for i, v in enumerate(p75s) if v is not None]
valid_y = [v for v in p75s if v is not None]
if not valid_y:
continue
# Threshold bands
if good_t and poor_t:
ax.axhspan(0, good_t, alpha=0.1, color=BRAND["success"])
ax.axhspan(good_t, poor_t, alpha=0.1, color=BRAND["warning"])
ax.axhline(y=good_t, color=BRAND["success"], linestyle="--", alpha=0.5, linewidth=1)
ax.axhline(y=poor_t, color=BRAND["danger"], linestyle="--", alpha=0.5, linewidth=1)
ax.plot(valid_x, valid_y, color=BRAND["primary"], linewidth=2, marker="o", markersize=3)
ax.fill_between(valid_x, valid_y, alpha=0.1, color=BRAND["primary"])
unit = m.get("unit", "")
ax.set_ylabel(f"{label} (p75{unit})")
ax.set_title(label, fontsize=12, fontweight="bold")
if x_labels:
step = max(1, len(x_labels) // 8)
axes[-1].set_xticks(range(0, len(x_labels), step))
axes[-1].set_xticklabels([x_labels[i] for i in range(0, len(x_labels), step)], rotation=45, fontsize=8)
plt.tight_layout()
path = output_dir / "cwv_timeline.png"
plt.savefig(path, dpi=200, bbox_inches="tight", facecolor="white")
plt.close()
return str(path)
def chart_top_queries(data: dict, output_dir: Path) -> str:
"""Generate horizontal bar chart of top queries by clicks."""
rows = data.get("rows", [])
if not rows:
return ""
top = sorted(rows, key=lambda r: r.get("clicks", 0), reverse=True)[:15]
if not top:
return ""
labels = [r.get("query", r.get("keys", ["?"])[0])[:40] for r in top]
clicks = [r.get("clicks", 0) for r in top]
fig, ax = plt.subplots(figsize=(8, max(3, len(labels) * 0.4)))
y = range(len(labels))
bars = ax.barh(y, clicks, color=BRAND["primary"], height=0.6)
ax.set_yticks(y)
ax.set_yticklabels(labels, fontsize=9)
ax.set_xlabel("Clicks")
ax.invert_yaxis()
for bar, val in zip(bars, clicks):
if val > 0:
ax.text(bar.get_width() + max(clicks) * 0.02, bar.get_y() + bar.get_height() / 2,
str(val), va="center", fontsize=8, color=BRAND["dark"])
plt.tight_layout()
path = output_dir / "top_queries.png"
plt.savefig(path, dpi=200, bbox_inches="tight", facecolor="white")
plt.close()
return str(path)
def chart_index_status(data: dict, output_dir: Path) -> str:
"""Generate donut chart for URL inspection results."""
summary = data.get("summary", {})
if not summary:
return ""
labels, sizes, colors = [], [], []
for key, label, color in [
("pass", "Indexed", BRAND["success"]),
("fail", "Not Indexed", BRAND["danger"]),
("neutral", "Neutral", BRAND["grid"]),
("error", "Error", BRAND["muted"]),
]:
val = summary.get(key, 0)
if val > 0:
labels.append(f"{label} ({val})")
sizes.append(val)
colors.append(color)
if not sizes:
return ""
fig, ax = plt.subplots(figsize=(5, 4))
wedges, texts, autotexts = ax.pie(
sizes, labels=labels, colors=colors, autopct="%1.0f%%",
startangle=90, pctdistance=0.75, textprops={"fontsize": 9},
)
centre = plt.Circle((0, 0), 0.50, fc="white")
ax.add_artist(centre)
total = sum(sizes)
ax.text(0, 0, f"{total}\nURLs", ha="center", va="center",
fontsize=16, fontweight="bold", color=BRAND["dark"])
plt.tight_layout()
path = output_dir / "index_status.png"
plt.savefig(path, dpi=200, bbox_inches="tight", facecolor="white")
plt.close()
return str(path)
# ─── CSS Template ────────────────────────────────────────────────────────────
def _base_css(domain: str) -> str:
"""Battle-tested A4 report CSS extracted from generate_pdf.py."""
css_domain = _css_string(domain)
return f"""
@page {{ size: A4; margin: 22mm 18mm 25mm 18mm;
@bottom-center {{ content: counter(page); font-size: 9pt; color: #94a3b8; font-family: 'DejaVu Sans', Arial, sans-serif; }}
@bottom-right {{ content: "{css_domain} Google SEO Report"; font-size: 8pt; color: #cbd5e1; font-family: 'DejaVu Sans', Arial, sans-serif; }}
}}
@page :first {{ margin: 0; @bottom-center {{ content: none; }} @bottom-right {{ content: none; }} }}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: 'DejaVu Sans', Arial, Helvetica, sans-serif; font-size: 10pt; line-height: 1.55; color: #1e293b; background: white; }}
.title-page {{ page: first; width: 210mm; height: 297mm; background: linear-gradient(135deg, #0f172a 0%, #1e3a5f 50%, #1a56db 100%); display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; color: white; position: relative; padding: 40mm 25mm; }}
.title-page .badge {{ background: rgba(255,255,255,0.12); border: 1px solid rgba(255,255,255,0.2); border-radius: 20px; padding: 6px 18px; font-size: 10pt; letter-spacing: 2px; text-transform: uppercase; margin-bottom: 20mm; color: #93c5fd; }}
.title-page h1 {{ font-size: 30pt; font-weight: bold; margin-bottom: 6mm; letter-spacing: -0.5px; line-height: 1.2; }}
.title-page .subtitle {{ font-size: 16pt; color: #93c5fd; margin-bottom: 12mm; font-weight: 300; }}
.title-page .url {{ font-size: 14pt; color: #60a5fa; margin-bottom: 20mm; padding: 5mm 10mm; border: 1px solid rgba(96, 165, 250, 0.3); border-radius: 8px; background: rgba(96, 165, 250, 0.08); }}
.title-page .score-box {{ background: rgba(255,255,255,0.1); border: 2px solid rgba(255,255,255,0.2); border-radius: 16px; padding: 8mm 15mm; margin-bottom: 15mm; }}
.title-page .score-number {{ font-size: 48pt; font-weight: bold; color: #fbbf24; line-height: 1; }}
.title-page .score-label {{ font-size: 11pt; color: #93c5fd; margin-top: 2mm; }}
.title-page .meta {{ font-size: 10pt; color: #94a3b8; margin-top: 10mm; }}
div.section {{ page-break-before: always; }}
.section-header {{ background: #f8fafc; border-left: 4px solid #1a56db; padding: 5mm 6mm; margin-bottom: 6mm; page-break-after: avoid; }}
.section-header h2 {{ font-size: 16pt; color: #0f172a; margin-bottom: 1mm; }}
.section-header .section-score {{ font-size: 12pt; font-weight: bold; float: right; margin-top: -6mm; }}
h3 {{ font-size: 12pt; color: #1a56db; margin-top: 6mm; margin-bottom: 3mm; padding-bottom: 1.5mm; border-bottom: 1px solid #e2e8f0; page-break-after: avoid; }}
h4 {{ font-size: 10.5pt; color: #334155; margin-top: 4mm; margin-bottom: 2mm; page-break-after: avoid; }}
p {{ margin-bottom: 3mm; color: #334155; }}
.highlight {{ background: #fef3c7; border-left: 3px solid #f59e0b; padding: 3mm 4mm; margin: 4mm 0; font-size: 9.5pt; page-break-inside: avoid; }}
.critical-box {{ background: #fef2f2; border-left: 3px solid #ef4444; padding: 3mm 4mm; margin: 4mm 0; font-size: 9.5pt; page-break-inside: avoid; }}
.success-box {{ background: #f0fdf4; border-left: 3px solid #10b981; padding: 3mm 4mm; margin: 4mm 0; font-size: 9.5pt; page-break-inside: avoid; }}
table {{ width: 100%; border-collapse: collapse; margin: 4mm 0 6mm 0; font-size: 9pt; page-break-inside: avoid; }}
thead th {{ background: #f1f5f9; color: #0f172a; font-weight: bold; padding: 2.5mm 3mm; text-align: left; border-bottom: 2px solid #cbd5e1; font-size: 9pt; }}
tbody td {{ padding: 2.5mm 3mm; border-bottom: 1px solid #f1f5f9; vertical-align: top; }}
tbody tr:nth-child(even) {{ background: #fafbfc; }}
.status-pass {{ color: #10b981; font-weight: bold; }}
.status-fail {{ color: #ef4444; font-weight: bold; }}
.status-warn {{ color: #f59e0b; font-weight: bold; }}
.chart-container {{ text-align: center; margin: 5mm 0; page-break-inside: avoid; }}
.chart-container img {{ max-width: 100%; height: auto; }}
.chart-caption {{ font-size: 8.5pt; color: #94a3b8; font-style: italic; margin-top: 2mm; text-align: center; }}
.chart-half {{ display: inline-block; width: 48%; vertical-align: top; text-align: center; margin: 2mm 0; }}
.chart-half img {{ max-width: 100%; height: auto; }}
.two-col {{ display: table; width: 100%; table-layout: fixed; margin: 3mm 0; }}
.two-col .col {{ display: table-cell; vertical-align: top; padding: 0 2mm; }}
.metric-card {{ background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px; padding: 3mm 4mm; text-align: center; margin: 2mm 0; }}
.metric-card .value {{ font-size: 18pt; font-weight: bold; line-height: 1.2; }}
.metric-card .label {{ font-size: 8pt; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; }}
.action-item {{ background: #f8fafc; border-radius: 4px; padding: 3mm 4mm; margin: 3mm 0; border-left: 3px solid #cbd5e1; page-break-inside: avoid; }}
.action-item.critical {{ border-left-color: #ef4444; background: #fef2f2; }}
.action-item.high {{ border-left-color: #f59e0b; background: #fffbeb; }}
.action-item.medium {{ border-left-color: #1a56db; background: #eff6ff; }}
.priority-tag {{ display: inline-block; padding: 0.5mm 3mm; border-radius: 3px; font-size: 8pt; font-weight: bold; color: white; margin-right: 2mm; }}
.priority-critical {{ background: #ef4444; }}
.priority-high {{ background: #f59e0b; }}
.priority-medium {{ background: #1a56db; }}
.data-freshness {{ font-size: 8pt; color: #94a3b8; font-style: italic; margin-top: 4mm; padding-top: 2mm; border-top: 1px solid #e2e8f0; }}
"""
# ─── Section Builders ────────────────────────────────────────────────────────
def _img(path):
"""Convert file path to file:// URI for WeasyPrint."""
if not path:
return ""
uri = Path(path).resolve().as_uri()
return f'<div class="chart-container"><img src="{_h(uri)}"></div>'
def _metric_card(value, label, color=None):
style = f' style="color: {_h(color)};"' if color else ""
return f'<div class="metric-card"><div class="value"{style}>{_h(value)}</div><div class="label">{_h(label)}</div></div>'
def _rating_class(rating):
r = str(rating).lower()
if "good" in r or "pass" in r:
return "status-pass"
elif "poor" in r or "fail" in r:
return "status-fail"
return "status-warn"
def section_title_page(domain, report_title, subtitle, score=None, meta_items=None):
score_html = ""
if score is not None:
score_html = f'''
<div class="score-box">
<div class="score-number">{_h(score)}</div>
<div class="score-label">Lighthouse Performance Score</div>
</div>'''
meta_html = ""
if meta_items:
spans = " • ".join(f"<span>{_h(item)}</span>" for item in meta_items)
meta_html = f'<div class="meta">{spans}</div>'
return f'''
<div class="title-page">
<div class="badge">{_h(report_title)}</div>
<h1>Google SEO Report</h1>
<div class="subtitle">{_h(subtitle)}</div>
<div class="url">{_h(domain)}</div>
{score_html}
{meta_html}
</div>'''
def section_cwv_audit(psi_data, crux_data, charts, history_data=None):
"""Build the Core Web Vitals audit section."""
html = '<div class="section"><div class="section-header"><h2>Core Web Vitals Audit</h2></div>'
# Lighthouse scores
psi = psi_data if isinstance(psi_data, dict) else {}
mobile = psi.get("psi", {}).get("mobile", psi)
scores = mobile.get("lighthouse_scores", {})
if scores:
html += '<h3>Lighthouse Scores</h3>'
html += charts.get("gauges", "")
# Lab metrics
lab = mobile.get("lab_metrics", {})
if lab:
html += '<h3>Lab Metrics</h3><table><thead><tr><th>Metric</th><th>Value</th><th>Score</th></tr></thead><tbody>'
for k, v in lab.items():
score_val = v.get("score")
score_pct = f"{score_val:.0%}" if score_val is not None else "N/A"
cls = "status-pass" if score_val and score_val >= 0.9 else ("status-warn" if score_val and score_val >= 0.5 else "status-fail")
html += f'<tr><td>{_h(k)}</td><td>{_h(v.get("display", ""))}</td><td class="{_h(cls)}">{_h(score_pct)}</td></tr>'
html += '</tbody></table>'
# CrUX field data
crux = crux_data if isinstance(crux_data, dict) else {}
crux_metrics = crux.get("metrics", {})
if crux_metrics:
html += '<h3>CrUX Field Data (28-day Rolling Average)</h3>'
html += charts.get("distributions", "")
html += '<table><thead><tr><th>Metric</th><th>p75</th><th>Rating</th><th>Good %</th><th>NI %</th><th>Poor %</th></tr></thead><tbody>'
for name, m in crux_metrics.items():
rating = m.get("rating", "?")
dist = m.get("distribution", {})
unit = m.get("unit", "")
p75 = m.get("p75", "?")
display_val = f"{p75:.3f}" if name == "cumulative_layout_shift" else f"{p75}{unit}"
html += f'<tr><td>{_h(m.get("label", name))}</td><td>{_h(display_val)}</td>'
html += f'<td class="{_h(_rating_class(rating))}">{_h(str(rating).upper())}</td>'
html += f'<td>{_h(dist.get("good", "N/A"))}%</td><td>{_h(dist.get("needs_improvement", "N/A"))}%</td><td>{_h(dist.get("poor", "N/A"))}%</td></tr>'
html += '</tbody></table>'
cp = crux.get("collection_period", {})
if cp:
html += f'<p class="data-freshness">Collection period: {_h(cp.get("first", "?"))} to {_h(cp.get("last", "?"))}. CrUX data is a 28-day rolling average updated daily ~04:00 UTC.</p>'
elif crux.get("error"):
html += f'<div class="highlight"><strong>CrUX Field Data:</strong> {_h(crux["error"])}</div>'
# CrUX History timeline
if history_data and not history_data.get("error"):
html += '<h3>Core Web Vitals Trends (25-week)</h3>'
html += charts.get("timeline", "")
trends = history_data.get("trends", {})
if trends:
html += '<table><thead><tr><th>Metric</th><th>Direction</th><th>Change</th><th>Earliest Avg</th><th>Latest Avg</th></tr></thead><tbody>'
for name, t in trends.items():
direction = t.get("direction", "?")
cls = "status-pass" if direction == "improving" else ("status-fail" if direction == "degrading" else "")
change_pct = f"{t.get('change_pct', 0):+.1f}"
html += f'<tr><td>{_h(t.get("label", name))}</td><td class="{_h(cls)}">{_h(str(direction).upper())}</td>'
html += f'<td>{_h(change_pct)}%</td><td>{_h(t.get("earliest_avg", "?"))}</td><td>{_h(t.get("latest_avg", "?"))}</td></tr>'
html += '</tbody></table>'
# Failed audits
failed = mobile.get("failed_audits", [])
if failed:
html += f'<h3>Failed / Warning Audits ({len(failed)})</h3>'
html += '<table><thead><tr><th>Audit</th><th>Score</th><th>Details</th></tr></thead><tbody>'
for a in failed[:20]:
score_pct = f"{a['score']:.0%}" if a.get("score") is not None else "?"
html += f'<tr><td>{_h(a.get("title", ""))}</td><td class="status-fail">{_h(score_pct)}</td><td>{_h(a.get("display", ""))}</td></tr>'
html += '</tbody></table>'
# SEO audits
seo_audits = mobile.get("seo_audits", [])
if seo_audits:
seo_failed = [a for a in seo_audits if not a.get("pass")]
if seo_failed:
html += f'<h3>SEO Audit Issues ({len(seo_failed)})</h3>'
for a in seo_failed:
html += f'<div class="action-item critical"><h4>{_h(a.get("title", ""))}</h4></div>'
else:
html += f'<div class="success-box"><strong>SEO:</strong> All {len(seo_audits)} Lighthouse SEO checks passed.</div>'
# Accessibility issues
a11y = mobile.get("accessibility_audits", [])
if a11y:
html += f'<h3>Accessibility Issues ({len(a11y)})</h3>'
html += '<table><thead><tr><th>Issue</th><th>Score</th></tr></thead><tbody>'
for a in a11y:
score_text = f"{a.get('score', 0):.0%}"
html += f'<tr><td>{_h(a.get("title", ""))}</td><td class="status-fail">{_h(score_text)}</td></tr>'
html += '</tbody></table>'
# Opportunities
opps = mobile.get("opportunities", [])
if opps:
html += f'<h3>Optimization Opportunities ({len(opps)})</h3>'
html += '<table><thead><tr><th>Opportunity</th><th>Estimated Savings</th></tr></thead><tbody>'
for o in opps:
html += f'<tr><td>{_h(o.get("title", ""))}</td><td>{_h(o.get("savings_ms", 0))}ms</td></tr>'
html += '</tbody></table>'
html += '</div>'
return html
def section_gsc_performance(gsc_data, charts):
"""Build the GSC performance section."""
html = '<div class="section"><div class="section-header"><h2>Search Console Performance</h2></div>'
totals = gsc_data.get("totals", {})
dr = gsc_data.get("date_range", {})
if totals:
html += f'<p>Period: {_h(dr.get("start", "?"))} to {_h(dr.get("end", "?"))} | Property: {_h(gsc_data.get("property", "?"))}</p>'
clicks_val = f'{totals.get("clicks", 0):,}'
impr_val = f'{totals.get("impressions", 0):,}'
ctr_val = f'{totals.get("ctr", 0)}%'
rows_val = str(gsc_data.get("row_count", 0))
html += '<div class="two-col">'
html += f'<div class="col">{_metric_card(clicks_val, "Total Clicks", BRAND["primary"])}</div>'
html += f'<div class="col">{_metric_card(impr_val, "Total Impressions", BRAND["secondary"])}</div>'
html += '</div><div class="two-col">'
html += f'<div class="col">{_metric_card(ctr_val, "Average CTR", BRAND["accent"])}</div>'
html += f'<div class="col">{_metric_card(rows_val, "Queries Found")}</div>'
html += '</div>'
# Top queries chart
html += charts.get("top_queries", "")
# Top queries table
rows = gsc_data.get("rows", [])
if rows:
html += '<h3>Top Queries</h3>'
html += '<table><thead><tr><th>#</th><th>Query</th><th>Clicks</th><th>Impressions</th><th>CTR</th><th>Position</th></tr></thead><tbody>'
sorted_rows = sorted(rows, key=lambda r: r.get("clicks", 0), reverse=True)
for i, r in enumerate(sorted_rows[:25], 1):
query = r.get("query", r.get("keys", ["?"])[0])
impressions = f'{r.get("impressions", 0):,}'
html += f'<tr><td>{_h(i)}</td><td>{_h(query)}</td><td>{_h(r.get("clicks", 0))}</td><td>{_h(impressions)}</td>'
html += f'<td>{_h(r.get("ctr", 0))}%</td><td>{_h(r.get("position", 0))}</td></tr>'
html += '</tbody></table>'
# Quick wins
qw = gsc_data.get("quick_wins", [])
if qw:
html += f'<h3>Quick Wins ({len(qw)} opportunities)</h3>'
html += '<div class="highlight">These queries rank at position 4-10 with high impressions. A small ranking improvement could yield significant traffic gains.</div>'
html += '<table><thead><tr><th>Query</th><th>Position</th><th>Impressions</th><th>Clicks</th></tr></thead><tbody>'
for w in qw:
query = w.get("keys", ["?"])[0] if w.get("keys") else "?"
impressions = f'{w.get("impressions", 0):,}'
html += f'<tr><td>{_h(query)}</td><td>{_h(w.get("position", 0))}</td><td>{_h(impressions)}</td><td>{_h(w.get("clicks", 0))}</td></tr>'
html += '</tbody></table>'
html += f'<p class="data-freshness">Search Analytics data has a 2-3 day lag. Data available for ~16 months.</p>'
html += '</div>'
return html
def section_indexation(inspect_data, charts):
"""Build the indexation status section."""
html = '<div class="section"><div class="section-header"><h2>Indexation Status</h2></div>'
summary = inspect_data.get("summary", {})
total = inspect_data.get("total", 0)
if summary:
html += charts.get("index_status", "")
html += f'<p>Total URLs inspected: {_h(total)}</p>'
html += '<div class="two-col">'
html += f'<div class="col">{_metric_card(summary.get("pass", 0), "Indexed", BRAND["success"])}</div>'
html += f'<div class="col">{_metric_card(summary.get("fail", 0), "Not Indexed", BRAND["danger"])}</div>'
html += '</div>'
results = inspect_data.get("results", [])
if results:
html += '<h3>Per-URL Results</h3>'
html += '<table><thead><tr><th>URL</th><th>Verdict</th><th>Coverage</th><th>Last Crawl</th></tr></thead><tbody>'
for r in results:
verdict = r.get("verdict", "?")
cls = "status-pass" if verdict == "PASS" else ("status-fail" if verdict == "FAIL" else "")
idx = r.get("index_status", {})
cov = idx.get("coverage_state", r.get("error", "N/A"))
crawl = idx.get("last_crawl_time", "N/A")
if crawl and crawl != "N/A":
crawl = crawl[:10]
html += f'<tr><td style="word-break:break-all;font-size:8pt;">{_h(r.get("url", "?"))}</td>'
html += f'<td class="{_h(cls)}">{_h(verdict)}</td><td>{_h(cov)}</td><td>{_h(crawl)}</td></tr>'
html += '</tbody></table>'
html += f'<p class="data-freshness">URL Inspection API: 2,000 inspections/day per site.</p>'
html += '</div>'
return html
# ─── Report Assemblers ───────────────────────────────────────────────────────
def generate_report(report_type, data, domain, output_dir, output_format="pdf"):
"""
Generate a complete report.
Args:
report_type: 'cwv-audit', 'gsc-performance', 'indexation', or 'full'.
data: Dictionary with all input data.
domain: Domain name for the report header.
output_dir: Directory for output files.
output_format: 'pdf', 'html', or 'both'.
Returns:
Dictionary with output paths.
"""
output_dir = Path(output_dir)
charts_dir = output_dir / "charts"
charts_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
result = {"report_type": report_type, "domain": domain, "files": [], "error": None}
# Generate charts based on report type
chart_paths = {}
if report_type in ("cwv-audit", "full"):
psi = data.get("psi", data)
mobile = psi.get("psi", {}).get("mobile", psi) if isinstance(psi, dict) else {}
chart_paths["gauges"] = _img(chart_lighthouse_gauges(mobile, charts_dir))
crux = data.get("crux", {})
chart_paths["distributions"] = _img(chart_cwv_distributions({"crux": crux} if crux else data, charts_dir))
history = data.get("crux_history", {})
if history and not history.get("error"):
chart_paths["timeline"] = _img(chart_cwv_timeline(history, charts_dir))
if report_type in ("gsc-performance", "full"):
gsc = data.get("gsc", data)
chart_paths["top_queries"] = _img(chart_top_queries(gsc, charts_dir))
if report_type in ("indexation", "full"):
inspect = data.get("inspection", data)
chart_paths["index_status"] = _img(chart_index_status(inspect, charts_dir))
# Build HTML sections
sections = []
# Title page
if report_type == "cwv-audit":
mobile = data.get("psi", data).get("psi", {}).get("mobile", data) if isinstance(data, dict) else {}
perf_score = mobile.get("lighthouse_scores", {}).get("performance")
sections.append(section_title_page(domain, "Core Web Vitals Audit", "Performance & User Experience Analysis",
score=perf_score, meta_items=[timestamp, "PSI + CrUX"]))
sections.append(section_cwv_audit(data, data.get("crux", {}), chart_paths, data.get("crux_history")))
elif report_type == "gsc-performance":
gsc = data.get("gsc", data)
clicks = gsc.get("totals", {}).get("clicks", 0)
sections.append(section_title_page(domain, "Search Console Performance", "Google Search Analytics Report",
score=clicks, meta_items=[timestamp, "Google Search Console API"]))
sections.append(section_gsc_performance(gsc, chart_paths))
elif report_type == "indexation":
inspect = data.get("inspection", data)
total = inspect.get("total", 0)
sections.append(section_title_page(domain, "Indexation Status Report", "URL Index Coverage Analysis",
score=total, meta_items=[timestamp, "URL Inspection API"]))
sections.append(section_indexation(inspect, chart_paths))
elif report_type == "full":
sections.append(section_title_page(domain, "Google SEO Intelligence Report", "Comprehensive Analysis",
meta_items=[timestamp, "All Google APIs"]))
if data.get("psi") or data.get("crux"):
sections.append(section_cwv_audit(data.get("psi", {}), data.get("crux", {}), chart_paths, data.get("crux_history")))
if data.get("gsc"):
sections.append(section_gsc_performance(data["gsc"], chart_paths))
if data.get("inspection"):
sections.append(section_indexation(data["inspection"], chart_paths))
# Assemble HTML
css = _base_css(domain)
body = "\n".join(sections)
html_content = f"""<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><style>{css}</style></head><body>{body}</body></html>"""
# Output (closes audit VULN-014 path traversal on --domain)
# Whitelist: alphanumerics + dot + underscore + hyphen. Anything else
# (incl. "..", "/", "\\", NUL, Windows drive letters) becomes "_".
import re
safe_domain = re.sub(r"[^A-Za-z0-9._-]", "_", domain)[:128] or "report"
base_name = f"Google-SEO-Report-{safe_domain}-{report_type}"
# Re-resolve and assert containment inside output_dir (defense in depth).
out_root = Path(output_dir).resolve()
def _safe_path(name: str) -> Path:
candidate = (out_root / name).resolve()
if out_root != candidate.parent and out_root not in candidate.parents:
raise ValueError(
f"Refusing to write outside output_dir: {candidate}"
)
return candidate
html_path = _safe_path(f"{base_name}.html")
def _write_html_once() -> str:
if str(html_path) not in result["files"]:
with open(html_path, "w", encoding="utf-8") as f:
f.write(html_content)
result["files"].append(str(html_path))
return str(html_path)
if output_format in ("html", "both"):
_write_html_once()
if output_format in ("pdf", "both"):
pdf_path = _safe_path(f"{base_name}.pdf")
if HTML is None:
_write_html_once()
result["error"] = "PDF generation skipped: weasyprint is not installed. HTML report generated."
else:
try:
HTML(string=html_content).write_pdf(str(pdf_path))
result["files"].append(str(pdf_path))
except Exception as e:
_write_html_once()
result["error"] = f"PDF generation failed: {e}. HTML report generated."
return result
# ─── CLI ─────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="Google SEO Report Generator - PDF/HTML reports"
)
parser.add_argument(
"--type", "-t",
choices=["cwv-audit", "gsc-performance", "indexation", "full"],
required=True,
help="Report type",
)
parser.add_argument("--data", "-d", help="Path to JSON data file (or pipe via stdin)")
parser.add_argument("--domain", required=True, help="Domain name for the report header")
parser.add_argument("--output-dir", "-o", default=".", help="Output directory (default: current)")
parser.add_argument(
"--format", "-f",
choices=["pdf", "html", "both"],
default="pdf",
help="Output format (default: pdf)",
)
parser.add_argument("--json", "-j", action="store_true", help="Output metadata as JSON")
args = parser.parse_args()
# Load data
if args.data:
try:
with open(args.data, "r") as f:
data = json.load(f)
except (json.JSONDecodeError, IOError) as e:
print(f"Error reading data file: {e}", file=sys.stderr)
sys.exit(1)
elif not sys.stdin.isatty():
try:
data = json.load(sys.stdin)
except json.JSONDecodeError as e:
print(f"Error parsing stdin JSON: {e}", file=sys.stderr)
sys.exit(1)
else:
print("Error: Provide --data file or pipe JSON via stdin.", file=sys.stderr)
sys.exit(1)
result = generate_report(
report_type=args.type,
data=data,
domain=args.domain,
output_dir=args.output_dir,
output_format=args.format,
)
if result.get("error"):
print(f"Error: {result['error']}", file=sys.stderr)
if args.json:
print(json.dumps(result, indent=2))
else:
for f in result.get("files", []):
print(f"Generated: {f}")
if __name__ == "__main__":
main()
scripts/gsc_inspect.py
#!/usr/bin/env python3
"""
Google Search Console URL Inspection API helper.
Inspects URLs for indexing status, canonical selection, crawl info,
mobile usability, and rich results. Supports single URL and batch mode.
Usage:
python gsc_inspect.py https://example.com/page --site-url sc-domain:example.com
python gsc_inspect.py --batch urls.txt --site-url sc-domain:example.com
python gsc_inspect.py https://example.com/page --json
"""
import argparse
import datetime as dt
import json
import sys
import time
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
try:
from googleapiclient.discovery import build
except ImportError:
print(
"Error: google-api-python-client required. "
"Install with: pip install google-api-python-client",
file=sys.stderr,
)
sys.exit(1)
try:
from google_auth import get_oauth_credentials, load_config, execute_with_retries
except ImportError:
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from google_auth import get_oauth_credentials, load_config, execute_with_retries
GSC_SCOPES = ["https://www.googleapis.com/auth/webmasters.readonly"]
# Daily limit per site
DAILY_LIMIT = 2000
QPM_LIMIT = 600
CANONICAL_REEVALUATION_DAYS = 14
def _path_contains_symlink(path: Path, root: Path) -> bool:
"""Return True if path or any existing parent below root is a symlink."""
current = path if path.exists() or path.is_symlink() else path.parent
current = current.absolute()
root = root.absolute()
while current != current.parent:
if current.is_symlink():
return True
if current == root:
return False
current = current.parent
return False
def _resolve_batch_file(path_value: str) -> Path:
"""Resolve a batch file under the current working directory."""
root = Path.cwd().resolve()
candidate = Path(path_value).expanduser()
if not candidate.is_absolute():
candidate = root / candidate
if _path_contains_symlink(candidate, root):
raise ValueError("Batch file must not use symlinks")
try:
resolved = candidate.resolve(strict=True)
except FileNotFoundError as exc:
raise ValueError(f"Batch file not found: {path_value}") from exc
try:
resolved.relative_to(root)
except ValueError as exc:
raise ValueError(f"Refusing to read batch file outside working directory: {path_value}") from exc
if not resolved.is_file():
raise ValueError(f"Batch path is not a regular file: {path_value}")
return resolved
def _validate_url(url: str) -> str:
"""Validate an http or https URL before calling Google APIs."""
clean = str(url).strip()
parsed = urlparse(clean)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError(f"Invalid URL: {url}")
return clean
def _load_batch_urls(path_value: str) -> list[str]:
"""Load and validate URLs from a cwd-local batch file."""
path = _resolve_batch_file(path_value)
urls = []
with open(path, "r", encoding="utf-8") as f:
for line_number, line in enumerate(f, 1):
clean = line.strip()
if not clean:
continue
try:
urls.append(_validate_url(clean))
except ValueError as exc:
raise ValueError(f"Invalid URL on line {line_number}: {clean}") from exc
return urls
def _exit_error(message: str, as_json: bool) -> None:
"""Emit a CLI error in the requested format."""
if as_json:
print(json.dumps({"error": message}, indent=2))
else:
print(f"Error: {message}", file=sys.stderr)
sys.exit(1)
def _build_inspection_service():
"""Build the Search Console v1 service for URL Inspection."""
credentials = get_oauth_credentials(GSC_SCOPES)
if not credentials:
return None
try:
return build("searchconsole", "v1", credentials=credentials)
except Exception as e:
print(f"Error building service: {e}", file=sys.stderr)
return None
def _parse_fix_date(value: str | dt.date | None) -> dt.date | None:
"""Parse an optional declared canonical-fix date."""
if value is None or value == "":
return None
if isinstance(value, dt.datetime):
return value.date()
if isinstance(value, dt.date):
return value
try:
return dt.date.fromisoformat(str(value))
except ValueError as exc:
raise ValueError("canonical fix date must use YYYY-MM-DD") from exc
def classify_canonical_reevaluation(
*,
inspection_url: str,
google_canonical: str | None,
user_canonical: str | None,
fix_date: str | dt.date,
expected_canonical: str | None = None,
today: dt.date | None = None,
) -> dict:
"""Classify a declared canonical fix without replacing Google's verdict.
Pending status requires all three facts: the inspected markup now declares
the expected canonical, Google still selects a different canonical, and the
declared fix is no more than 14 days old.
"""
parsed_fix_date = _parse_fix_date(fix_date)
if parsed_fix_date is None:
raise ValueError("canonical fix date is required")
expected = _validate_url(expected_canonical or inspection_url)
current_date = today or dt.datetime.now(dt.timezone.utc).date()
age_days = (current_date - parsed_fix_date).days
implementation_correct = user_canonical == expected
selection_pending = bool(google_canonical) and google_canonical != expected
pending = (
implementation_correct
and selection_pending
and 0 <= age_days <= CANONICAL_REEVALUATION_DAYS
)
if pending:
classification = "PENDING_REEVALUATION"
elif google_canonical == expected and user_canonical == expected:
classification = "MATCH"
elif age_days < 0:
classification = "INVALID_FUTURE_FIX_DATE"
else:
classification = "MISMATCH"
return {
"classification": classification,
"pending_reevaluation": pending,
"fix_date": parsed_fix_date.isoformat(),
"age_days": age_days,
"expected_canonical": expected,
"implementation_correct": implementation_correct,
}
def inspect_url(
inspection_url: str,
site_url: str,
language_code: str = "en",
service=None,
canonical_fix_date: str | dt.date | None = None,
expected_canonical: str | None = None,
today: dt.date | None = None,
) -> dict:
"""
Inspect a single URL via the GSC URL Inspection API.
Args:
inspection_url: The URL to inspect.
site_url: The GSC property (e.g., 'sc-domain:example.com').
language_code: Language for localized messages (default: 'en').
Returns:
Dictionary with inspection results including index status,
crawl info, canonical, mobile usability, and rich results.
"""
result = {
"url": inspection_url,
"property": site_url,
"index_status": None,
"crawl_info": None,
"canonical": None,
"mobile_usability": None,
"rich_results": None,
"verdict": None,
"error": None,
}
try:
inspection_url = _validate_url(inspection_url)
result["url"] = inspection_url
except ValueError as e:
result["error"] = str(e)
return result
try:
parsed_fix_date = _parse_fix_date(canonical_fix_date)
if expected_canonical is not None:
expected_canonical = _validate_url(expected_canonical)
except ValueError as e:
result["error"] = str(e)
return result
service = service or _build_inspection_service()
if not service:
result["error"] = "Could not build GSC service. Check service account credentials."
return result
body = {
"inspectionUrl": inspection_url,
"siteUrl": site_url,
"languageCode": language_code,
}
try:
response = execute_with_retries(service.urlInspection().index().inspect(body=body))
except Exception as e:
error_str = str(e)
if "403" in error_str:
result["error"] = (
f"Permission denied. Add the service account as an Owner "
f"in GSC property '{site_url}'."
)
elif "429" in error_str:
result["error"] = (
f"Rate limit exceeded. URL Inspection: {QPM_LIMIT} QPM / {DAILY_LIMIT} QPD per site."
)
elif "400" in error_str:
result["error"] = (
f"Invalid request. Ensure the URL '{inspection_url}' belongs to "
f"property '{site_url}'."
)
else:
result["error"] = f"URL Inspection API error: {e}"
return result
ir = response.get("inspectionResult", {})
# Index status
idx = ir.get("indexStatusResult", {})
result["verdict"] = idx.get("verdict", "VERDICT_UNSPECIFIED")
result["index_status"] = {
"verdict": idx.get("verdict"),
"coverage_state": idx.get("coverageState"),
"robots_txt_state": idx.get("robotsTxtState"),
"indexing_state": idx.get("indexingState"),
"page_fetch_state": idx.get("pageFetchState"),
"last_crawl_time": idx.get("lastCrawlTime"),
"crawled_as": idx.get("crawledAs"),
"referring_urls": idx.get("referringUrls", []),
}
# Canonical
result["canonical"] = {
"google_canonical": idx.get("googleCanonical"),
"user_canonical": idx.get("userCanonical"),
"match": idx.get("googleCanonical") == idx.get("userCanonical")
if idx.get("googleCanonical") and idx.get("userCanonical") else None,
}
if parsed_fix_date is not None:
result["canonical"].update(
classify_canonical_reevaluation(
inspection_url=inspection_url,
google_canonical=idx.get("googleCanonical"),
user_canonical=idx.get("userCanonical"),
fix_date=parsed_fix_date,
expected_canonical=expected_canonical,
today=today,
)
)
# Mobile usability (deprecated April 2023 but may still return data)
mu = ir.get("mobileUsabilityResult", {})
if mu:
result["mobile_usability"] = {
"verdict": mu.get("verdict"),
"issues": [
{"type": issue.get("issueType"), "message": issue.get("message")}
for issue in mu.get("issues", [])
],
}
# Rich results
rr = ir.get("richResultsResult", {})
if rr:
result["rich_results"] = {
"verdict": rr.get("verdict"),
"detected_items": [
{
"type": item.get("richResultType"),
"items": [
{"name": i.get("name"), "issues": i.get("issues", [])}
for i in item.get("items", [])
],
}
for item in rr.get("detectedItems", [])
],
}
return result
def batch_inspect(
urls: list,
site_url: str,
delay: float = 1.0,
language_code: str = "en",
canonical_fix_date: str | dt.date | None = None,
) -> dict:
"""
Batch inspect multiple URLs with rate limiting.
Args:
urls: List of URLs to inspect.
site_url: GSC property.
delay: Seconds between requests (default: 1.0 for safety).
language_code: Language code.
Returns:
Dictionary with results list and summary.
"""
result = {
"property": site_url,
"total": len(urls),
"results": [],
"summary": {
"pass": 0,
"fail": 0,
"neutral": 0,
"error": 0,
},
"error": None,
}
try:
urls = [_validate_url(url) for url in urls if str(url).strip()]
except ValueError as e:
result["error"] = str(e)
result["total"] = 0
return result
result["total"] = len(urls)
if len(urls) > DAILY_LIMIT:
result["error"] = (
f"Batch size ({len(urls)}) exceeds daily limit ({DAILY_LIMIT}). "
f"Only the first {DAILY_LIMIT} URLs will be processed."
)
urls = urls[:DAILY_LIMIT]
service = _build_inspection_service()
if not service:
result["error"] = "Could not build GSC service. Check service account credentials."
return result
for i, url in enumerate(urls):
url = url.strip()
if not url:
continue
print(f"Inspecting [{i + 1}/{len(urls)}]: {url}", file=sys.stderr)
inspection = inspect_url(
url,
site_url,
language_code,
service=service,
canonical_fix_date=canonical_fix_date,
)
result["results"].append(inspection)
verdict = inspection.get("verdict", "")
if inspection.get("error"):
result["summary"]["error"] += 1
elif verdict == "PASS":
result["summary"]["pass"] += 1
elif verdict == "FAIL":
result["summary"]["fail"] += 1
else:
result["summary"]["neutral"] += 1
# Rate limiting
if i < len(urls) - 1:
time.sleep(delay)
return result
def main():
parser = argparse.ArgumentParser(
description="Google Search Console URL Inspection API helper"
)
parser.add_argument("url", nargs="?", help="URL to inspect")
parser.add_argument(
"--site-url", "-s",
help="GSC property (e.g., sc-domain:example.com). Uses default from config if not specified.",
)
parser.add_argument(
"--batch", "-b",
help="File with URLs to inspect (one per line)",
)
parser.add_argument(
"--delay",
type=float,
default=1.0,
help="Delay between batch requests in seconds (default: 1.0)",
)
parser.add_argument("--json", "-j", action="store_true", help="Output as JSON")
parser.add_argument(
"--canonical-fix-date",
help=(
"Declared date a canonical implementation was fixed (YYYY-MM-DD). "
"Adds a pending-reevaluation classification without replacing the "
"Google URL Inspection verdict."
),
)
parser.add_argument(
"--expected-canonical",
help=(
"Expected canonical URL for single-URL mode. Defaults to the "
"inspected URL."
),
)
args = parser.parse_args()
if args.batch and args.expected_canonical:
_exit_error("--expected-canonical is only valid for single-URL mode", args.json)
# Resolve site URL
site_url = args.site_url
if not site_url:
config = load_config()
site_url = config.get("default_property")
if not site_url:
print("Error: No site URL specified. Use --site-url or set default_property in config.", file=sys.stderr)
sys.exit(1)
if args.batch:
# Batch mode
try:
urls = _load_batch_urls(args.batch)
except (OSError, ValueError) as e:
_exit_error(f"Error reading batch file: {e}", args.json)
result = batch_inspect(
urls,
site_url,
delay=args.delay,
canonical_fix_date=args.canonical_fix_date,
)
elif args.url:
result = inspect_url(
args.url,
site_url,
canonical_fix_date=args.canonical_fix_date,
expected_canonical=args.expected_canonical,
)
else:
parser.print_help()
sys.exit(1)
if args.json:
print(json.dumps(result, indent=2))
else:
if args.batch:
summary = result.get("summary", {})
print(f"=== URL Inspection Batch Results ===")
print(f"Property: {site_url}")
print(f"Total: {result.get('total', 0)} | Pass: {summary.get('pass', 0)} | Fail: {summary.get('fail', 0)} | Errors: {summary.get('error', 0)}")
print()
for r in result.get("results", []):
verdict = r.get("verdict", "?")
status = {"PASS": "OK", "FAIL": "FAIL", "NEUTRAL": "--"}.get(verdict, "ERR")
print(f" [{status}] {r.get('url')}")
if r.get("error"):
print(f" Error: {r['error']}")
elif verdict == "FAIL":
idx = r.get("index_status", {})
print(f" Coverage: {idx.get('coverage_state')} | Fetch: {idx.get('page_fetch_state')}")
else:
if result.get("error"):
print(f"Error: {result['error']}", file=sys.stderr)
sys.exit(1)
verdict = result.get("verdict", "?")
print(f"=== URL Inspection: {result.get('url')} ===")
print(f"Verdict: {verdict}")
idx = result.get("index_status", {})
if idx:
print(f"\nIndex Status:")
print(f" Coverage: {idx.get('coverage_state')}")
print(f" Robots.txt: {idx.get('robots_txt_state')}")
print(f" Indexing: {idx.get('indexing_state')}")
print(f" Page Fetch: {idx.get('page_fetch_state')}")
print(f" Last Crawl: {idx.get('last_crawl_time', 'N/A')}")
print(f" Crawled As: {idx.get('crawled_as')}")
canon = result.get("canonical", {})
if canon:
print(f"\nCanonical:")
print(f" Google: {canon.get('google_canonical', 'N/A')}")
print(f" User: {canon.get('user_canonical', 'N/A')}")
match = canon.get("match")
if match is not None:
print(f" Match: {'Yes' if match else 'MISMATCH'}")
if canon.get("classification"):
print(f" Reevaluation: {canon['classification']}")
rr = result.get("rich_results")
if rr and rr.get("detected_items"):
print(f"\nRich Results: {rr.get('verdict')}")
for item in rr.get("detected_items", []):
print(f" Type: {item.get('type')}")
if __name__ == "__main__":
main()
scripts/gsc_query.py
#!/usr/bin/env python3
"""
Google Search Console Search Analytics query helper.
Queries the GSC Search Analytics API for clicks, impressions, CTR, and position
data. Supports filtering by dimensions, auto-pagination, and quick-win detection.
Usage:
python gsc_query.py --property sc-domain:example.com
python gsc_query.py --property sc-domain:example.com --days 90 --dimensions query
python gsc_query.py sitemaps --property sc-domain:example.com
python gsc_query.py sites
"""
import argparse
import json
import sys
from datetime import datetime, timedelta
from typing import Optional
try:
from googleapiclient.discovery import build
except ImportError:
print(
"Error: google-api-python-client required. "
"Install with: pip install google-api-python-client",
file=sys.stderr,
)
sys.exit(1)
try:
from google_auth import get_oauth_credentials, load_config, execute_with_retries
except ImportError:
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from google_auth import get_oauth_credentials, load_config, execute_with_retries
GSC_SCOPES = ["https://www.googleapis.com/auth/webmasters.readonly"]
def _build_gsc_service():
"""Build the Search Console API service."""
credentials = get_oauth_credentials(GSC_SCOPES)
if not credentials:
return None
try:
return build("searchconsole", "v1", credentials=credentials)
except Exception as e:
print(f"Error building GSC service: {e}", file=sys.stderr)
return None
def query_search_analytics(
site_url: str,
start_date: Optional[str] = None,
end_date: Optional[str] = None,
dimensions: Optional[list] = None,
search_type: str = "web",
row_limit: int = 1000,
filters: Optional[list] = None,
data_state: str = "final",
) -> dict:
"""
Query GSC Search Analytics API.
Args:
site_url: GSC property (e.g., 'sc-domain:example.com' or 'https://example.com/').
start_date: Start date (YYYY-MM-DD). Default: 28 days ago.
end_date: End date (YYYY-MM-DD). Default: 3 days ago (data lag).
dimensions: List of dimensions: query, page, country, device, date, searchAppearance.
search_type: web, image, video, news, discover, googleNews.
row_limit: Max rows per request (1-25000). Auto-paginates if more.
filters: List of filter dicts with dimension, operator, expression.
data_state: 'final', 'all', or 'hourly_all'. hourly_all requires hour dimension.
Returns:
Dictionary with rows, totals, and quick_wins.
"""
result = {
"property": site_url,
"rows": [],
"totals": {"clicks": 0, "impressions": 0, "ctr": 0, "position": 0},
"quick_wins": [],
"row_count": 0,
"error": None,
}
service = _build_gsc_service()
if not service:
result["error"] = "Could not build GSC service. Check service account credentials."
return result
if not start_date:
start_date = (datetime.now() - timedelta(days=28)).strftime("%Y-%m-%d")
if not end_date:
end_date = (datetime.now() - timedelta(days=3)).strftime("%Y-%m-%d")
if dimensions is None:
dimensions = ["query", "page"]
dims_lower = {d.lower() for d in dimensions}
if data_state == "hourly_all" and "hour" not in dims_lower:
result["error"] = "data_state=hourly_all requires the hour dimension."
return result
result["date_range"] = {"start": start_date, "end": end_date}
body = {
"startDate": start_date,
"endDate": end_date,
"dimensions": dimensions,
"type": search_type,
"rowLimit": min(row_limit, 25000),
"dataState": data_state,
}
if filters:
body["dimensionFilterGroups"] = [{"filters": filters}]
# Auto-paginate
all_rows = []
start_row = 0
page_size = min(row_limit, 25000)
try:
while True:
body["startRow"] = start_row
body["rowLimit"] = page_size
response = execute_with_retries(service.searchanalytics().query(
siteUrl=site_url, body=body
))
rows = response.get("rows", [])
all_rows.extend(rows)
if len(rows) < page_size:
break
start_row += page_size
# Safety: cap at 100,000 rows
if start_row >= 100000:
break
except Exception as e:
error_str = str(e)
if "403" in error_str:
result["error"] = (
f"Permission denied for property '{site_url}'. "
"Ensure the service account email is added as a user in "
"Google Search Console > Settings > Users and permissions."
)
elif "404" in error_str:
result["error"] = (
f"Property '{site_url}' not found. "
"Use 'sc-domain:example.com' for domain properties or "
"'https://example.com/' for URL-prefix properties."
)
else:
result["error"] = f"GSC API error: {e}"
return result
# Process rows
total_clicks = 0
total_impressions = 0
for row in all_rows:
keys = row.get("keys", [])
clicks = row.get("clicks", 0)
impressions = row.get("impressions", 0)
ctr = row.get("ctr", 0)
position = row.get("position", 0)
processed = {
"keys": keys,
"clicks": clicks,
"impressions": impressions,
"ctr": round(ctr * 100, 2),
"position": round(position, 1),
}
# Label keys by dimension name
for i, dim in enumerate(dimensions):
if i < len(keys):
processed[dim] = keys[i]
result["rows"].append(processed)
total_clicks += clicks
total_impressions += impressions
result["row_count"] = len(all_rows)
result["totals"]["clicks"] = total_clicks
result["totals"]["impressions"] = total_impressions
if total_impressions > 0:
result["totals"]["ctr"] = round((total_clicks / total_impressions) * 100, 2)
# Quick wins: position 4-10 with high impressions
if "query" in dimensions:
sorted_by_impressions = sorted(all_rows, key=lambda r: r.get("impressions", 0), reverse=True)
for row in sorted_by_impressions[:200]:
pos = row.get("position", 0)
if 4 <= pos <= 10 and row.get("impressions", 0) > 50:
result["quick_wins"].append({
"keys": row.get("keys", []),
"position": round(pos, 1),
"impressions": row.get("impressions", 0),
"clicks": row.get("clicks", 0),
"ctr": round(row.get("ctr", 0) * 100, 2),
"opportunity": "Position 4-10 with high impressions - small ranking improvement yields significant traffic gain",
})
result["quick_wins"] = result["quick_wins"][:20]
return result
def list_sitemaps(site_url: str) -> dict:
"""
List sitemaps for a GSC property.
Args:
site_url: GSC property URL.
Returns:
Dictionary with sitemaps list.
"""
result = {"property": site_url, "sitemaps": [], "error": None}
service = _build_gsc_service()
if not service:
result["error"] = "Could not build GSC service."
return result
try:
response = execute_with_retries(service.sitemaps().list(siteUrl=site_url))
for sm in response.get("sitemap", []):
result["sitemaps"].append({
"path": sm.get("path"),
"last_submitted": sm.get("lastSubmitted"),
"is_pending": sm.get("isPending"),
"is_index": sm.get("isSitemapsIndex"),
"type": sm.get("type"),
"warnings": sm.get("warnings", 0),
"errors": sm.get("errors", 0),
"contents": sm.get("contents", []),
})
except Exception as e:
result["error"] = f"Error listing sitemaps: {e}"
return result
def list_sites() -> dict:
"""
List all verified GSC properties.
Returns:
Dictionary with sites list.
"""
result = {"sites": [], "error": None}
service = _build_gsc_service()
if not service:
result["error"] = "Could not build GSC service."
return result
try:
response = execute_with_retries(service.sites().list())
for site in response.get("siteEntry", []):
result["sites"].append({
"url": site.get("siteUrl"),
"permission": site.get("permissionLevel"),
})
except Exception as e:
result["error"] = f"Error listing sites: {e}"
return result
def main():
parser = argparse.ArgumentParser(
description="Google Search Console Search Analytics query helper"
)
parser.add_argument(
"command",
nargs="?",
default="query",
choices=["query", "sitemaps", "sites"],
help="Command: query (default), sitemaps, sites",
)
parser.add_argument(
"--property", "-p",
help="GSC property (e.g., sc-domain:example.com). Uses default from config if not specified.",
)
parser.add_argument("--days", "-d", type=int, default=28, help="Number of days (default: 28)")
parser.add_argument("--start-date", help="Start date (YYYY-MM-DD)")
parser.add_argument("--end-date", help="End date (YYYY-MM-DD)")
parser.add_argument(
"--dimensions",
default="query,page",
help="Comma-separated dimensions (default: query,page)",
)
parser.add_argument("--type", default="web", help="Search type (default: web)")
parser.add_argument("--limit", type=int, default=1000, help="Row limit (default: 1000)")
parser.add_argument(
"--data-state",
choices=["final", "all", "hourly_all"],
default="final",
help="Search Analytics data state (default: final)",
)
parser.add_argument(
"--device",
choices=["desktop", "mobile", "tablet"],
help="Filter by device type",
)
parser.add_argument("--country", help="Filter by country (ISO 3166-1 alpha-3, e.g., USA)")
parser.add_argument("--json", "-j", action="store_true", help="Output as JSON")
args = parser.parse_args()
# Resolve property
prop = args.property
if not prop:
config = load_config()
prop = config.get("default_property")
if not prop and args.command != "sites":
print("Error: No property specified. Use --property or set default_property in config.", file=sys.stderr)
sys.exit(1)
if args.command == "sites":
result = list_sites()
elif args.command == "sitemaps":
result = list_sitemaps(prop)
else:
start = args.start_date or (datetime.now() - timedelta(days=args.days)).strftime("%Y-%m-%d")
end = args.end_date or (datetime.now() - timedelta(days=3)).strftime("%Y-%m-%d")
dims = [d.strip() for d in args.dimensions.split(",")]
filters = []
if args.device:
filters.append({
"dimension": "device",
"operator": "equals",
"expression": args.device.upper(),
})
if args.country:
filters.append({
"dimension": "country",
"operator": "equals",
"expression": args.country.upper(),
})
result = query_search_analytics(
prop, start_date=start, end_date=end,
dimensions=dims, search_type=args.type, row_limit=args.limit,
filters=filters if filters else None,
data_state=args.data_state,
)
if result.get("error"):
print(f"Error: {result['error']}", file=sys.stderr)
if not args.json:
sys.exit(1)
if args.json:
print(json.dumps(result, indent=2))
else:
if args.command == "sites":
print("=== Verified GSC Properties ===")
for site in result.get("sites", []):
print(f" {site['url']} ({site['permission']})")
elif args.command == "sitemaps":
print(f"=== Sitemaps for {prop} ===")
for sm in result.get("sitemaps", []):
status = "pending" if sm.get("is_pending") else "processed"
print(f" {sm['path']} [{status}] errors={sm.get('errors', 0)} warnings={sm.get('warnings', 0)}")
else:
totals = result.get("totals", {})
print(f"=== Search Analytics: {prop} ===")
print(f"Period: {result.get('date_range', {}).get('start')} to {result.get('date_range', {}).get('end')}")
print(f"Clicks: {totals.get('clicks', 0):,} | Impressions: {totals.get('impressions', 0):,} | CTR: {totals.get('ctr', 0)}% | Rows: {result.get('row_count', 0)}")
qw = result.get("quick_wins", [])
if qw:
print(f"\nQuick Wins ({len(qw)} found):")
for w in qw[:10]:
keys = " | ".join(w.get("keys", []))
print(f" Pos {w['position']} | {w['impressions']:,} imp | {w['clicks']} clicks | {keys}")
if __name__ == "__main__":
main()
scripts/indexing_notify.py
#!/usr/bin/env python3
"""
Google Indexing API v3 - notify Google of URL updates and removals.
Publishes URL_UPDATED or URL_DELETED notifications. Supports single URL
and batch mode (up to 200 URLs/day). Includes quota tracking.
IMPORTANT: The Indexing API is officially restricted to pages with
JobPosting or BroadcastEvent/VideoObject structured data. Google may
process other page types but provides no guarantees.
Usage:
python indexing_notify.py https://example.com/jobs/123
python indexing_notify.py https://example.com/jobs/123 --action URL_DELETED
python indexing_notify.py --batch urls.txt
python indexing_notify.py --status https://example.com/jobs/123
"""
import argparse
import json
import sys
import time
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
try:
from googleapiclient.discovery import build
from googleapiclient.http import BatchHttpRequest
except ImportError:
print(
"Error: google-api-python-client required. "
"Install with: pip install google-api-python-client",
file=sys.stderr,
)
sys.exit(1)
try:
from google_auth import get_oauth_credentials
except ImportError:
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from google_auth import get_oauth_credentials
INDEXING_SCOPES = ["https://www.googleapis.com/auth/indexing"]
DAILY_QUOTA = 200
SCOPE_WARNING = (
"NOTE: The Indexing API is officially for JobPosting and "
"BroadcastEvent/VideoObject pages only. Google may process other "
"page types but provides no guarantees."
)
def _path_contains_symlink(path: Path, root: Path) -> bool:
"""Return True if path or any existing parent below root is a symlink."""
current = path if path.exists() or path.is_symlink() else path.parent
current = current.absolute()
root = root.absolute()
while current != current.parent:
if current.is_symlink():
return True
if current == root:
return False
current = current.parent
return False
def _resolve_batch_file(path_value: str) -> Path:
"""Resolve a batch file under the current working directory."""
root = Path.cwd().resolve()
candidate = Path(path_value).expanduser()
if not candidate.is_absolute():
candidate = root / candidate
if _path_contains_symlink(candidate, root):
raise ValueError("Batch file must not use symlinks")
try:
resolved = candidate.resolve(strict=True)
except FileNotFoundError as exc:
raise ValueError(f"Batch file not found: {path_value}") from exc
try:
resolved.relative_to(root)
except ValueError as exc:
raise ValueError(f"Refusing to read batch file outside working directory: {path_value}") from exc
if not resolved.is_file():
raise ValueError(f"Batch path is not a regular file: {path_value}")
return resolved
def _validate_url(url: str) -> str:
"""Validate an http or https URL before calling Google APIs."""
clean = str(url).strip()
parsed = urlparse(clean)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ValueError(f"Invalid URL: {url}")
return clean
def _load_batch_urls(path_value: str) -> list[str]:
"""Load and validate URLs from a cwd-local batch file."""
path = _resolve_batch_file(path_value)
urls = []
with open(path, "r", encoding="utf-8") as f:
for line_number, line in enumerate(f, 1):
clean = line.strip()
if not clean:
continue
try:
urls.append(_validate_url(clean))
except ValueError as exc:
raise ValueError(f"Invalid URL on line {line_number}: {clean}") from exc
return urls
def _exit_error(message: str, as_json: bool) -> None:
"""Emit a CLI error in the requested format."""
if as_json:
print(json.dumps({"error": message}, indent=2))
else:
print(f"Error: {message}", file=sys.stderr)
sys.exit(1)
def _build_indexing_service():
"""Build the Indexing API v3 service."""
credentials = get_oauth_credentials(INDEXING_SCOPES)
if not credentials:
return None
try:
return build("indexing", "v3", credentials=credentials)
except Exception as e:
print(f"Error building Indexing service: {e}", file=sys.stderr)
return None
def notify_url(
url: str,
action: str = "URL_UPDATED",
) -> dict:
"""
Publish a single URL notification to the Indexing API.
Args:
url: The URL to notify about.
action: 'URL_UPDATED' or 'URL_DELETED'.
Returns:
Dictionary with notification result.
"""
result = {
"url": url,
"action": action,
"notify_time": None,
"error": None,
}
try:
url = _validate_url(url)
result["url"] = url
except ValueError as e:
result["error"] = str(e)
return result
service = _build_indexing_service()
if not service:
result["error"] = (
"Could not build Indexing service. Ensure the service account has "
"'https://www.googleapis.com/auth/indexing' scope and is added as "
"Owner in Google Search Console for the target domain."
)
return result
body = {
"url": url,
"type": action,
}
try:
response = service.urlNotifications().publish(body=body).execute()
metadata = response.get("urlNotificationMetadata", {})
latest = metadata.get("latestUpdate", {}) or metadata.get("latestRemove", {})
result["notify_time"] = latest.get("notifyTime")
except Exception as e:
error_str = str(e)
if "403" in error_str:
result["error"] = (
"Permission denied. The service account must be added as an "
"Owner in Google Search Console for this domain. "
"Also ensure the Indexing API is enabled in your GCP project."
)
elif "429" in error_str:
result["error"] = (
f"Quota exceeded. Daily limit: {DAILY_QUOTA} publish requests. "
"Apply for a quota increase at https://developers.google.com/search/apis/indexing-api/v3/quota-increase"
)
elif "400" in error_str:
result["error"] = f"Invalid URL or request: {e}"
else:
result["error"] = f"Indexing API error: {e}"
return result
def get_notification_metadata(url: str) -> dict:
"""
Get the latest notification metadata for a URL.
Args:
url: The URL to check.
Returns:
Dictionary with latest update and remove timestamps.
"""
result = {
"url": url,
"latest_update": None,
"latest_remove": None,
"error": None,
}
try:
url = _validate_url(url)
result["url"] = url
except ValueError as e:
result["error"] = str(e)
return result
service = _build_indexing_service()
if not service:
result["error"] = "Could not build Indexing service."
return result
try:
response = service.urlNotifications().getMetadata(url=url).execute()
update = response.get("latestUpdate", {})
remove = response.get("latestRemove", {})
if update:
result["latest_update"] = {
"url": update.get("url"),
"type": update.get("type"),
"notify_time": update.get("notifyTime"),
}
if remove:
result["latest_remove"] = {
"url": remove.get("url"),
"type": remove.get("type"),
"notify_time": remove.get("notifyTime"),
}
except Exception as e:
if "404" in str(e):
result["error"] = "No notification metadata found for this URL."
else:
result["error"] = f"Error fetching metadata: {e}"
return result
def batch_notify(
urls: list,
action: str = "URL_UPDATED",
delay: float = 0.5,
) -> dict:
"""
Batch notify multiple URLs with quota awareness.
Args:
urls: List of URLs.
action: 'URL_UPDATED' or 'URL_DELETED'.
delay: Seconds between requests.
Returns:
Dictionary with results and quota usage.
"""
result = {
"action": action,
"total": len(urls),
"results": [],
"summary": {"success": 0, "error": 0},
"quota_warning": None,
"error": None,
}
try:
urls = [_validate_url(url) for url in urls if str(url).strip()]
except ValueError as e:
result["error"] = str(e)
result["total"] = 0
return result
result["total"] = len(urls)
if len(urls) > DAILY_QUOTA:
result["quota_warning"] = (
f"Batch size ({len(urls)}) exceeds daily quota ({DAILY_QUOTA}). "
f"Only the first {DAILY_QUOTA} URLs will be submitted."
)
urls = urls[:DAILY_QUOTA]
if len(urls) > 50:
result["quota_warning"] = (
f"Submitting {len(urls)} URLs will use {len(urls)}/{DAILY_QUOTA} "
f"of your daily quota."
)
for i, url in enumerate(urls):
url = url.strip()
if not url:
continue
print(f"Notifying [{i + 1}/{len(urls)}]: {url}", file=sys.stderr)
notification = notify_url(url, action)
result["results"].append(notification)
if notification.get("error"):
result["summary"]["error"] += 1
# Stop on quota errors
if "429" in str(notification.get("error", "")):
result["error"] = "Stopped: daily quota exceeded."
break
else:
result["summary"]["success"] += 1
if i < len(urls) - 1:
time.sleep(delay)
remaining = DAILY_QUOTA - result["summary"]["success"]
result["estimated_remaining_batch_quota"] = max(0, remaining)
result["quota_note"] = "Batch-local estimate only. Prior same-day usage is not persisted."
return result
def main():
parser = argparse.ArgumentParser(
description="Google Indexing API v3 - URL notification helper"
)
parser.add_argument("url", nargs="?", help="URL to notify")
parser.add_argument(
"--action", "-a",
choices=["URL_UPDATED", "URL_DELETED"],
default="URL_UPDATED",
help="Notification type (default: URL_UPDATED)",
)
parser.add_argument("--batch", "-b", help="File with URLs (one per line)")
parser.add_argument("--status", help="Check notification status for a URL")
parser.add_argument(
"--delay",
type=float,
default=0.5,
help="Delay between batch requests in seconds (default: 0.5)",
)
parser.add_argument("--json", "-j", action="store_true", help="Output as JSON")
args = parser.parse_args()
if args.status:
result = get_notification_metadata(args.status)
elif args.batch:
print(SCOPE_WARNING, file=sys.stderr)
try:
urls = _load_batch_urls(args.batch)
except (OSError, ValueError) as e:
_exit_error(f"Error reading batch file: {e}", args.json)
result = batch_notify(urls, args.action, delay=args.delay)
elif args.url:
print(SCOPE_WARNING, file=sys.stderr)
result = notify_url(args.url, args.action)
else:
parser.print_help()
sys.exit(1)
if args.json:
print(json.dumps(result, indent=2))
else:
if result.get("error"):
print(f"Error: {result['error']}", file=sys.stderr)
if args.status:
print(f"=== Notification Status: {args.status} ===")
update = result.get("latest_update")
remove = result.get("latest_remove")
if update:
print(f" Latest Update: {update.get('notify_time')} ({update.get('type')})")
if remove:
print(f" Latest Remove: {remove.get('notify_time')} ({remove.get('type')})")
if not update and not remove and not result.get("error"):
print(" No notifications found.")
elif args.batch:
summary = result.get("summary", {})
print(f"=== Batch Indexing Notification ===")
print(f"Action: {args.action}")
print(f"Total: {result.get('total', 0)} | Success: {summary.get('success', 0)} | Errors: {summary.get('error', 0)}")
print(f"Estimated remaining daily quota: {result.get('estimated_remaining_batch_quota', '?')}")
if result.get("quota_warning"):
print(f"Warning: {result['quota_warning']}")
else:
if result.get("notify_time"):
print(f"Notified: {result['url']} ({result['action']}) at {result['notify_time']}")
elif not result.get("error"):
print(f"Notification sent for: {result['url']} ({result['action']})")
if __name__ == "__main__":
main()
scripts/keyword_planner.py
#!/usr/bin/env python3
"""
Google Ads API - Keyword Planner for SEO keyword research.
Gold-standard source for keyword search volume, CPC, and competition data.
Requires a Google Ads Manager account with a developer token.
Usage:
python keyword_planner.py ideas "seo tools" --json
python keyword_planner.py volume "seo tools,seo audit,seo checker" --json
python keyword_planner.py forecast "seo tools" --json
Prerequisites:
- Google Ads Manager account (can be free)
- Developer Token (apply at Google Ads API Center)
- OAuth credentials or service account
- google-ads Python library: pip install google-ads
- Config: ~/.config/claude-seo/google-api.json with:
{
"ads_developer_token": "YOUR_DEV_TOKEN",
"ads_customer_id": "123-456-7890",
"ads_login_customer_id": "123-456-7890"
}
Note: Accounts without active ad spend receive bucketed volume ranges
(e.g., "1K-10K") instead of exact numbers.
"""
import argparse
import json
import os
import sys
from typing import Optional
try:
from google.ads.googleads.client import GoogleAdsClient
from google.ads.googleads.errors import GoogleAdsException
HAS_GOOGLE_ADS = True
except ImportError:
HAS_GOOGLE_ADS = False
try:
from google_auth import load_config
except ImportError:
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from google_auth import load_config
def _build_ads_client() -> Optional[object]:
"""Build Google Ads client from config."""
if not HAS_GOOGLE_ADS:
print(
"Error: google-ads library required. Install with: "
"pip install 'claude-blog[ads]' (or pip install google-ads).",
file=sys.stderr,
)
return None
config = load_config()
dev_token = config.get("ads_developer_token")
customer_id = config.get("ads_customer_id", "").replace("-", "")
login_customer_id = config.get("ads_login_customer_id", "").replace("-", "")
oauth_client_path = config.get("oauth_client_path")
if not dev_token:
print(
"Error: No Google Ads developer token configured. "
"Add 'ads_developer_token' to ~/.config/claude-seo/google-api.json. "
"Get a token at: https://ads.google.com/aw/apicenter",
file=sys.stderr,
)
return None
if not customer_id:
print(
"Error: No Google Ads customer ID configured. "
"Add 'ads_customer_id' (format: 123-456-7890) to config.",
file=sys.stderr,
)
return None
try:
# Build from dict configuration
ads_config = {
"developer_token": dev_token,
"use_proto_plus": True,
}
if login_customer_id:
ads_config["login_customer_id"] = login_customer_id
# Try to use OAuth token if available
token_path = os.path.expanduser("~/.config/claude-seo/oauth-token.json")
if os.path.exists(token_path):
with open(token_path) as f:
token_data = json.load(f)
if oauth_client_path:
with open(os.path.expanduser(oauth_client_path)) as f:
client_data = json.load(f)
client_info = client_data.get("web", client_data.get("installed", {}))
ads_config["client_id"] = client_info.get("client_id")
ads_config["client_secret"] = client_info.get("client_secret")
ads_config["refresh_token"] = token_data.get("refresh_token")
client = GoogleAdsClient.load_from_dict(ads_config)
return client, customer_id
except Exception as e:
print(f"Error building Google Ads client: {e}", file=sys.stderr)
return None
def generate_keyword_ideas(
seed_keywords: list,
language_id: str = "1000",
location_id: str = "2840",
limit: int = 50,
) -> dict:
"""
Generate keyword ideas from seed keywords.
Args:
seed_keywords: List of seed keyword strings.
language_id: Language ID (1000 = English).
location_id: Location ID (2840 = United States).
limit: Max results.
Returns:
Dictionary with keyword ideas and metrics.
"""
result = {
"seed_keywords": seed_keywords,
"ideas": [],
"error": None,
}
client_data = _build_ads_client()
if not client_data:
result["error"] = "Could not build Google Ads client. Check config."
return result
client, customer_id = client_data
try:
kp_service = client.get_service("KeywordPlanIdeaService")
request = client.get_type("GenerateKeywordIdeasRequest")
request.customer_id = customer_id
request.language = f"languageConstants/{language_id}"
request.geo_target_constants.append(f"geoTargetConstants/{location_id}")
request.keyword_plan_network = client.enums.KeywordPlanNetworkEnum.GOOGLE_SEARCH
request.keyword_seed.keywords.extend(seed_keywords)
response = kp_service.generate_keyword_ideas(request=request)
for idea in response.results:
metrics = idea.keyword_idea_metrics
monthly_volumes = []
for mv in metrics.monthly_search_volumes:
monthly_volumes.append({
"year": mv.year,
"month": mv.month,
"volume": mv.monthly_searches,
})
result["ideas"].append({
"keyword": idea.text,
"avg_monthly_searches": metrics.avg_monthly_searches,
"competition": metrics.competition.name if metrics.competition else "UNSPECIFIED",
"competition_index": metrics.competition_index,
"low_top_of_page_bid": metrics.low_top_of_page_bid_micros / 1_000_000 if metrics.low_top_of_page_bid_micros else None,
"high_top_of_page_bid": metrics.high_top_of_page_bid_micros / 1_000_000 if metrics.high_top_of_page_bid_micros else None,
"monthly_volumes": monthly_volumes[-12:] if monthly_volumes else [],
})
if len(result["ideas"]) >= limit:
break
# Sort by volume descending
result["ideas"].sort(key=lambda k: k.get("avg_monthly_searches", 0) or 0, reverse=True)
except GoogleAdsException as e:
errors = [err.message for err in e.failure.errors]
result["error"] = f"Google Ads API error: {'; '.join(errors)}"
except Exception as e:
result["error"] = f"Keyword Planner error: {e}"
return result
def get_keyword_volumes(
keywords: list,
language_id: str = "1000",
location_id: str = "2840",
) -> dict:
"""
Get search volume for specific keywords.
Args:
keywords: List of keywords to check.
language_id: Language ID.
location_id: Location ID.
Returns:
Dictionary with keyword metrics.
"""
result = {
"keywords": [],
"error": None,
}
client_data = _build_ads_client()
if not client_data:
result["error"] = "Could not build Google Ads client."
return result
client, customer_id = client_data
try:
kp_service = client.get_service("KeywordPlanIdeaService")
request = client.get_type("GenerateKeywordHistoricalMetricsRequest")
request.customer_id = customer_id
request.keywords.extend(keywords)
request.language = f"languageConstants/{language_id}"
request.geo_target_constants.append(f"geoTargetConstants/{location_id}")
request.keyword_plan_network = client.enums.KeywordPlanNetworkEnum.GOOGLE_SEARCH
response = kp_service.generate_keyword_historical_metrics(request=request)
for kw_result in response.results:
metrics = kw_result.keyword_metrics
result["keywords"].append({
"keyword": kw_result.text,
"avg_monthly_searches": metrics.avg_monthly_searches,
"competition": metrics.competition.name if metrics.competition else "UNSPECIFIED",
"competition_index": metrics.competition_index,
"low_top_of_page_bid": metrics.low_top_of_page_bid_micros / 1_000_000 if metrics.low_top_of_page_bid_micros else None,
"high_top_of_page_bid": metrics.high_top_of_page_bid_micros / 1_000_000 if metrics.high_top_of_page_bid_micros else None,
})
except GoogleAdsException as e:
errors = [err.message for err in e.failure.errors]
result["error"] = f"Google Ads API error: {'; '.join(errors)}"
except Exception as e:
result["error"] = f"Keyword volume error: {e}"
return result
def main():
parser = argparse.ArgumentParser(
description="Google Ads Keyword Planner - SEO keyword research"
)
parser.add_argument(
"command",
choices=["ideas", "volume"],
help="Command: ideas (keyword suggestions), volume (search volume lookup)",
)
parser.add_argument("keywords", help="Seed keyword(s), comma-separated for volume")
parser.add_argument("--limit", type=int, default=50, help="Max results for ideas (default: 50)")
parser.add_argument("--language", default="1000", help="Language ID (default: 1000 = English)")
parser.add_argument("--location", default="2840", help="Location ID (default: 2840 = US)")
parser.add_argument("--json", "-j", action="store_true", help="Output as JSON")
args = parser.parse_args()
if args.command == "ideas":
seeds = [k.strip() for k in args.keywords.split(",")]
result = generate_keyword_ideas(seeds, language_id=args.language, location_id=args.location, limit=args.limit)
elif args.command == "volume":
kws = [k.strip() for k in args.keywords.split(",")]
result = get_keyword_volumes(kws, language_id=args.language, location_id=args.location)
if result.get("error"):
print(f"Error: {result['error']}", file=sys.stderr)
if not args.json:
sys.exit(1)
if args.json:
print(json.dumps(result, indent=2, default=str))
else:
if args.command == "ideas":
print(f"=== Keyword Ideas ===")
for i, idea in enumerate(result.get("ideas", [])[:20], 1):
vol = idea.get("avg_monthly_searches", "?")
comp = idea.get("competition", "?")
bid_low = idea.get("low_top_of_page_bid")
bid_high = idea.get("high_top_of_page_bid")
bid_str = f"${bid_low:.2f}-${bid_high:.2f}" if bid_low and bid_high else "N/A"
print(f" {i:2d}. {idea['keyword']:40s} | Vol: {vol:>8} | Comp: {comp:8s} | CPC: {bid_str}")
elif args.command == "volume":
print(f"=== Keyword Volumes ===")
for kw in result.get("keywords", []):
vol = kw.get("avg_monthly_searches", "?")
comp = kw.get("competition", "?")
print(f" {kw['keyword']:40s} | Vol: {vol:>8} | Comp: {comp}")
if __name__ == "__main__":
main()
scripts/nlp_analyze.py
#!/usr/bin/env python3
"""
Google Cloud Natural Language API - Entity, sentiment, and content analysis.
Enhances E-E-A-T scoring with NLP entity coverage, sentiment analysis,
and Google's own content classification taxonomy.
Usage:
python nlp_analyze.py --text "Your content here" --json
python nlp_analyze.py --url https://example.com --json
python nlp_analyze.py --text "Your content" --features entities,sentiment,classify
"""
import argparse
import ipaddress
import json
import socket
import sys
from typing import Optional
from urllib.parse import urljoin, urlparse
try:
import requests
except ImportError:
print("Error: requests library required. Install with: pip install requests", file=sys.stderr)
sys.exit(1)
try:
from google_auth import get_api_key, request_with_retries
except ImportError:
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from google_auth import get_api_key, request_with_retries
NLP_ENDPOINT = "https://language.googleapis.com/v2/documents:annotateText"
MAX_TEXT_CHARS = 100000
MAX_FETCH_BYTES = 1_000_000
# Free tier: 5,000 units/month per feature
# Paid: $0.001 per 1,000-character unit for entity/sentiment
FEATURES = {
"entities": "extractEntities",
"sentiment": "extractDocumentSentiment",
"classify": "classifyText",
"categories": "classifyText",
"moderate": "moderateText",
}
def analyze_text(
text: str,
features: Optional[list] = None,
api_key: Optional[str] = None,
language: str = "en",
) -> dict:
"""
Analyze text using Google Cloud Natural Language API.
Args:
text: Text content to analyze (max 100,000 characters).
features: List of features: entities, sentiment, classify, moderate.
api_key: Google API key.
language: Language code (default: en).
Returns:
Dictionary with entities, sentiment, categories, and moderation results.
"""
result = {
"text_length": len(text),
"language": language,
"entities": [],
"sentiment": None,
"categories": [],
"moderation": [],
"error": None,
}
key = api_key or get_api_key()
if not key:
result["error"] = "No API key. Set GOOGLE_API_KEY or add 'api_key' to config."
return result
if features is None:
features = ["entities", "sentiment", "classify"]
# Build request
feature_map = {}
for f in features:
api_feature = FEATURES.get(f)
if api_feature:
feature_map[api_feature] = True
body = {
"document": {
"type": "PLAIN_TEXT",
"content": text[:MAX_TEXT_CHARS],
"languageCode": language,
},
"features": feature_map,
"encodingType": "UTF8",
}
try:
resp = request_with_retries(
"POST",
f"{NLP_ENDPOINT}?key={key}",
json=body,
timeout=30,
)
if resp.status_code == 403:
result["error"] = (
"Cloud Natural Language API access denied. Enable it in "
"GCP Console: APIs & Services > Library > Cloud Natural Language API. "
"Billing must be enabled on the project."
)
return result
if resp.status_code == 429:
result["error"] = "NLP API quota exceeded. Free tier: 5,000 units/month."
return result
resp.raise_for_status()
data = resp.json()
except requests.exceptions.RequestException as e:
result["error"] = f"NLP API request failed: {e}"
return result
# Entities
for entity in data.get("entities", []):
mentions = entity.get("mentions", [])
result["entities"].append({
"name": entity.get("name", ""),
"type": entity.get("type", "UNKNOWN"),
"salience": round(entity.get("salience", 0), 4),
"sentiment_score": entity.get("sentiment", {}).get("score"),
"sentiment_magnitude": entity.get("sentiment", {}).get("magnitude"),
"mention_count": len(mentions),
"metadata": entity.get("metadata", {}),
})
# Sort by salience (most important first)
result["entities"].sort(key=lambda e: e["salience"], reverse=True)
# Document sentiment
doc_sentiment = data.get("documentSentiment", {})
if doc_sentiment:
score = doc_sentiment.get("score", 0)
magnitude = doc_sentiment.get("magnitude", 0)
if score > 0.25:
tone = "positive"
elif score < -0.25:
tone = "negative"
else:
tone = "neutral"
result["sentiment"] = {
"score": round(score, 3),
"magnitude": round(magnitude, 3),
"tone": tone,
"interpretation": (
f"{'Positive' if score > 0 else 'Negative' if score < 0 else 'Neutral'} "
f"(score: {score:.2f}) with "
f"{'high' if magnitude > 2 else 'moderate' if magnitude > 0.5 else 'low'} "
f"emotional content (magnitude: {magnitude:.2f})"
),
}
# Sentence-level sentiment
sentences = data.get("sentences", [])
if sentences:
result["sentiment"]["sentence_count"] = len(sentences)
sent_scores = [s.get("sentiment", {}).get("score", 0) for s in sentences]
result["sentiment"]["most_positive"] = max(sent_scores) if sent_scores else 0
result["sentiment"]["most_negative"] = min(sent_scores) if sent_scores else 0
# Categories (content classification)
for cat in data.get("categories", []):
result["categories"].append({
"name": cat.get("name", ""),
"confidence": round(cat.get("confidence", 0), 4),
})
# Moderation categories
for mod in data.get("moderationCategories", []):
if mod.get("confidence", 0) > 0.5:
result["moderation"].append({
"name": mod.get("name", ""),
"confidence": round(mod.get("confidence", 0), 4),
})
return result
def _validate_fetch_url(url: str) -> str:
parsed = urlparse(url)
if parsed.scheme not in {"http", "https"}:
raise ValueError("URL must use http or https")
if parsed.username or parsed.password:
raise ValueError("URL must not contain credentials")
if not parsed.hostname:
raise ValueError("URL must include a host")
try:
infos = socket.getaddrinfo(parsed.hostname, parsed.port or (443 if parsed.scheme == "https" else 80), type=socket.SOCK_STREAM)
except socket.gaierror as exc:
raise ValueError(f"Could not resolve host: {exc}") from exc
for info in infos:
ip = ipaddress.ip_address(info[4][0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast or ip.is_unspecified:
raise ValueError("URL resolves to a blocked network address")
return url
def _fetch_url_text(url: str, max_redirects: int = 3) -> str:
current = _validate_fetch_url(url)
headers = {"User-Agent": "Mozilla/5.0 (compatible; ClaudeSEO/1.10 NLP Analyzer)"}
for _ in range(max_redirects + 1):
resp = requests.get(
current,
timeout=(5, 15),
headers=headers,
allow_redirects=False,
stream=True,
)
if 300 <= resp.status_code < 400:
location = resp.headers.get("Location")
if not location:
raise ValueError("Redirect response missing Location header")
current = _validate_fetch_url(urljoin(current, location))
continue
resp.raise_for_status()
chunks = []
total = 0
for chunk in resp.iter_content(chunk_size=65536):
if not chunk:
continue
total += len(chunk)
if total > MAX_FETCH_BYTES:
raise ValueError("Fetched response exceeded 1 MB")
chunks.append(chunk)
encoding = resp.encoding or "utf-8"
return b"".join(chunks).decode(encoding, errors="replace")
raise ValueError("Too many redirects")
def analyze_url(
url: str,
features: Optional[list] = None,
api_key: Optional[str] = None,
) -> dict:
"""
Fetch a URL's text content and analyze it.
Args:
url: URL to fetch and analyze.
features: NLP features to extract.
api_key: API key override.
Returns:
Dictionary with NLP analysis results.
"""
try:
html = _fetch_url_text(url)
except (requests.exceptions.RequestException, ValueError) as e:
return {"error": f"Could not fetch URL: {e}"}
# Extract text from HTML (simple approach)
try:
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
# Remove script and style
for tag in soup(["script", "style", "nav", "footer", "header"]):
tag.decompose()
text = soup.get_text(separator=" ", strip=True)
except ImportError:
# Fallback: regex-based text extraction
import re
text = re.sub(r"<script[^>]*>.*?</script>", "", html, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL | re.IGNORECASE)
text = re.sub(r"<[^>]+>", " ", text)
text = re.sub(r"\s+", " ", text).strip()
if not text or len(text) < 50:
return {"error": "Extracted text too short for meaningful NLP analysis."}
result = analyze_text(text, features=features, api_key=api_key)
result["source_url"] = url
result["extracted_text_length"] = len(text)
return result
def main():
parser = argparse.ArgumentParser(
description="Google Cloud Natural Language API - Entity/sentiment/classification for SEO"
)
parser.add_argument("--text", "-t", help="Text to analyze")
parser.add_argument("--url", "-u", help="URL to fetch and analyze")
parser.add_argument(
"--features", "-f",
default="entities,sentiment,classify",
help="Comma-separated features: entities, sentiment, classify, moderate (default: entities,sentiment,classify)",
)
parser.add_argument("--api-key", help="API key override")
parser.add_argument("--json", "-j", action="store_true", help="Output as JSON")
args = parser.parse_args()
if not args.text and not args.url:
print("Error: Provide --text or --url to analyze.", file=sys.stderr)
sys.exit(1)
features = [f.strip() for f in args.features.split(",")]
if args.url:
result = analyze_url(args.url, features=features, api_key=args.api_key)
else:
result = analyze_text(args.text, features=features, api_key=args.api_key)
if result.get("error"):
print(f"Error: {result['error']}", file=sys.stderr)
if not args.json:
sys.exit(1)
if args.json:
print(json.dumps(result, indent=2))
else:
if result.get("source_url"):
print(f"=== NLP Analysis: {result['source_url']} ===")
print(f"Text extracted: {result.get('extracted_text_length', 0):,} chars")
else:
print(f"=== NLP Analysis ({result.get('text_length', 0):,} chars) ===")
sent = result.get("sentiment")
if sent:
print(f"\nSentiment: {sent['tone'].upper()} (score: {sent['score']}, magnitude: {sent['magnitude']})")
print(f" {sent['interpretation']}")
entities = result.get("entities", [])
if entities:
print(f"\nTop Entities ({len(entities)} total):")
for e in entities[:15]:
print(f" [{e['type']:12s}] {e['name']} (salience: {e['salience']:.3f})")
categories = result.get("categories", [])
if categories:
print(f"\nContent Categories:")
for c in categories:
print(f" {c['name']} ({c['confidence']:.1%})")
moderation = result.get("moderation", [])
if moderation:
print(f"\nModeration Flags:")
for m in moderation:
print(f" {m['name']} ({m['confidence']:.1%})")
if __name__ == "__main__":
main()
scripts/pagespeed_check.py
#!/usr/bin/env python3
"""
PageSpeed Insights v5 + CrUX API combined checker.
Runs Lighthouse lab analysis via PSI and fetches real Chrome UX field data
via the CrUX API. Merges both perspectives into a single report.
Usage:
python pagespeed_check.py https://example.com
python pagespeed_check.py https://example.com --strategy mobile
python pagespeed_check.py https://example.com --crux-only
python pagespeed_check.py https://example.com --psi-only --json
"""
import argparse
import json
import sys
from typing import Optional
from urllib.parse import urlparse
try:
import requests
except ImportError:
print("Error: requests library required. Install with: pip install requests")
sys.exit(1)
# Import credential helper (same directory)
try:
from google_auth import get_api_key, load_config, request_with_retries
except ImportError:
# Fallback: try relative import from scripts/
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from google_auth import get_api_key, load_config, request_with_retries
PSI_ENDPOINT = "https://www.googleapis.com/pagespeedonline/v5/runPagespeed"
CRUX_ENDPOINT = "https://chromeuxreport.googleapis.com/v1/records:queryRecord"
# Core Web Vitals thresholds (March 2026)
CWV_THRESHOLDS = {
"largest_contentful_paint": {"good": 2500, "poor": 4000, "unit": "ms", "label": "LCP"},
"interaction_to_next_paint": {"good": 200, "poor": 500, "unit": "ms", "label": "INP"},
"cumulative_layout_shift": {"good": 0.1, "poor": 0.25, "unit": "", "label": "CLS"},
"first_contentful_paint": {"good": 1800, "poor": 3000, "unit": "ms", "label": "FCP"},
"experimental_time_to_first_byte": {"good": 800, "poor": 1800, "unit": "ms", "label": "TTFB"},
}
PSI_METRIC_MAP = {
"LARGEST_CONTENTFUL_PAINT_MS": "largest_contentful_paint",
"INTERACTION_TO_NEXT_PAINT": "interaction_to_next_paint",
"CUMULATIVE_LAYOUT_SHIFT_SCORE": "cumulative_layout_shift",
"FIRST_CONTENTFUL_PAINT_MS": "first_contentful_paint",
"EXPERIMENTAL_TIME_TO_FIRST_BYTE": "experimental_time_to_first_byte",
}
def rate_metric(metric_name: str, value: float) -> str:
"""Rate a CWV metric as good/needs-improvement/poor."""
thresholds = CWV_THRESHOLDS.get(metric_name)
if not thresholds:
return "unknown"
if value <= thresholds["good"]:
return "good"
elif value <= thresholds["poor"]:
return "needs-improvement"
else:
return "poor"
def run_pagespeed(
url: str,
strategy: str = "mobile",
api_key: Optional[str] = None,
categories: Optional[list] = None,
) -> dict:
"""
Run PageSpeed Insights v5 analysis.
Args:
url: URL to analyze.
strategy: 'mobile' or 'desktop'.
api_key: Google API key (optional but recommended for quota).
categories: List of categories: PERFORMANCE, ACCESSIBILITY, BEST_PRACTICES, SEO.
Returns:
Dictionary with lighthouse scores, lab metrics, field data (if available),
and opportunities. Error in 'error' field on failure.
"""
result = {
"url": url,
"strategy": strategy,
"lighthouse_scores": {},
"lab_metrics": {},
"field_metrics": {},
"audit_details": {},
"opportunities": [],
"diagnostics": [],
"failed_audits": [],
"passed_audits_count": 0,
"seo_audits": [],
"accessibility_audits": [],
"analysis_timestamp": None,
"error": None,
}
if categories is None:
categories = ["PERFORMANCE", "ACCESSIBILITY", "BEST_PRACTICES", "SEO"]
params = {
"url": url,
"strategy": strategy.upper(),
}
for cat in categories:
params.setdefault("category", [])
if isinstance(params["category"], list):
params["category"].append(cat)
if api_key:
params["key"] = api_key
try:
resp = request_with_retries("GET", PSI_ENDPOINT, params=params, timeout=120)
resp.raise_for_status()
data = resp.json()
except requests.exceptions.Timeout:
result["error"] = "PageSpeed Insights request timed out (120s). The target page may be very slow."
return result
except requests.exceptions.HTTPError as e:
if resp.status_code == 429:
result["error"] = "PSI rate limit exceeded (240 QPM / 25,000 QPD). Wait and retry."
elif resp.status_code == 400:
result["error"] = f"Invalid URL or parameters: {resp.text}"
else:
result["error"] = f"PSI API error {resp.status_code}: {e}"
return result
except requests.exceptions.RequestException as e:
result["error"] = f"Request failed: {e}"
return result
result["analysis_timestamp"] = data.get("analysisUTCTimestamp")
# Lighthouse scores
lr = data.get("lighthouseResult", {})
for cat_key, cat_data in lr.get("categories", {}).items():
result["lighthouse_scores"][cat_key] = round(cat_data.get("score", 0) * 100)
# Lab metrics from Lighthouse audits
audits = lr.get("audits", {})
lab_audit_ids = [
"first-contentful-paint", "largest-contentful-paint",
"total-blocking-time", "cumulative-layout-shift",
"speed-index", "interactive",
]
for audit_id in lab_audit_ids:
audit = audits.get(audit_id, {})
if audit.get("numericValue") is not None:
result["lab_metrics"][audit_id] = {
"value": audit["numericValue"],
"display": audit.get("displayValue", ""),
"score": audit.get("score"),
}
# Field data from PSI (loading experience)
for exp_key in ["loadingExperience", "originLoadingExperience"]:
exp = data.get(exp_key, {})
metrics = exp.get("metrics", {})
if metrics:
field_source = "url" if exp_key == "loadingExperience" else "origin"
for psi_name, crux_name in PSI_METRIC_MAP.items():
metric_data = metrics.get(psi_name, {})
if metric_data:
p75 = metric_data.get("percentile")
category = metric_data.get("category", "NONE")
if p75 is not None:
# CLS from PSI is already numeric
if crux_name == "cumulative_layout_shift":
p75_val = p75 / 100 if p75 > 1 else p75
else:
p75_val = p75
result["field_metrics"][f"{field_source}_{crux_name}"] = {
"p75": p75_val,
"rating": category.lower().replace("_", "-"),
"source": f"PSI {field_source}-level",
}
# Opportunities
for audit_id, audit in audits.items():
if audit.get("details", {}).get("type") == "opportunity":
savings = audit.get("details", {}).get("overallSavingsMs")
if savings and savings > 0:
result["opportunities"].append({
"id": audit_id,
"title": audit.get("title", audit_id),
"savings_ms": savings,
"description": audit.get("description", ""),
})
result["opportunities"].sort(key=lambda x: x["savings_ms"], reverse=True)
# Diagnostics (performance bottlenecks)
diagnostic_ids = [
"dom-size", "render-blocking-resources", "uses-long-cache-ttl",
"total-byte-weight", "mainthread-work-breakdown", "bootup-time",
"font-display", "third-party-summary", "largest-contentful-paint-element",
"layout-shifts", "long-tasks", "duplicated-javascript",
"legacy-javascript", "unused-javascript", "unused-css-rules",
]
for diag_id in diagnostic_ids:
audit = audits.get(diag_id, {})
if audit:
score = audit.get("score")
result["diagnostics"].append({
"id": diag_id,
"title": audit.get("title", diag_id),
"display": audit.get("displayValue", ""),
"score": score,
"description": audit.get("description", ""),
})
# Failed and warning audits (score < 0.9, excluding opportunities already captured)
opportunity_ids = {o["id"] for o in result["opportunities"]}
passed_count = 0
for audit_id, audit in audits.items():
score = audit.get("score")
if score is None:
continue
if score >= 0.9:
passed_count += 1
continue
if audit_id in opportunity_ids:
continue
result["failed_audits"].append({
"id": audit_id,
"title": audit.get("title", audit_id),
"score": score,
"display": audit.get("displayValue", ""),
"description": audit.get("description", ""),
})
result["passed_audits_count"] = passed_count
result["failed_audits"].sort(key=lambda x: x.get("score", 1))
# SEO audits from the SEO category
seo_cat = lr.get("categories", {}).get("seo", {})
for ref in seo_cat.get("auditRefs", []):
audit = audits.get(ref.get("id"), {})
if audit and audit.get("score") is not None:
result["seo_audits"].append({
"id": ref["id"],
"title": audit.get("title", ref["id"]),
"score": audit["score"],
"pass": audit["score"] >= 0.9,
})
# Accessibility audits from the accessibility category
a11y_cat = lr.get("categories", {}).get("accessibility", {})
for ref in a11y_cat.get("auditRefs", []):
audit = audits.get(ref.get("id"), {})
if audit and audit.get("score") is not None and audit["score"] < 0.9:
result["accessibility_audits"].append({
"id": ref["id"],
"title": audit.get("title", ref["id"]),
"score": audit["score"],
"display": audit.get("displayValue", ""),
})
# Audit details: extract top items from audits with details.items[]
# This captures WHICH specific resources are problems (e.g., "hero.jpg is 2MB")
for audit_id, audit in audits.items():
details = audit.get("details", {})
items = details.get("items", [])
headings = details.get("headings", [])
if items and headings:
heading_keys = [h.get("key", "") for h in headings if h.get("key")]
extracted_items = []
for item in items[:5]:
row = {}
for key in heading_keys:
val = item.get(key)
if isinstance(val, dict):
row[key] = val.get("url") or val.get("text") or str(val)[:200]
elif val is not None:
row[key] = val
if row:
extracted_items.append(row)
if extracted_items:
result["audit_details"][audit_id] = {
"title": audit.get("title", audit_id),
"headings": heading_keys,
"items": extracted_items,
"total_items": len(items),
}
return result
def query_crux(
url_or_origin: str,
api_key: str,
form_factor: Optional[str] = None,
) -> dict:
"""
Query the CrUX API for field data (28-day rolling average).
Args:
url_or_origin: Full URL or origin (e.g., https://example.com).
api_key: Google API key.
form_factor: DESKTOP, PHONE, or TABLET. None for all form factors.
Returns:
Dictionary with p75 metrics, distributions, collection period, and rating.
Error in 'error' field on failure.
"""
result = {
"target": url_or_origin,
"metrics": {},
"collection_period": None,
"form_factor": form_factor or "ALL",
"error": None,
}
parsed = urlparse(url_or_origin)
# Determine if this is a URL or an origin
is_origin = parsed.path in ("", "/") and not parsed.query
body = {}
if is_origin:
body["origin"] = f"{parsed.scheme}://{parsed.netloc}"
else:
body["url"] = url_or_origin
if form_factor:
body["formFactor"] = form_factor.upper()
try:
resp = request_with_retries(
"POST",
f"{CRUX_ENDPOINT}?key={api_key}",
json=body,
timeout=30,
)
if resp.status_code == 404:
target_type = "origin" if is_origin else "URL"
result["error"] = (
f"No CrUX data for this {target_type}. "
"The site likely has insufficient Chrome traffic volume for eligibility."
)
return result
if resp.status_code == 429:
result["error"] = "CrUX API rate limit exceeded (150 QPM shared with History API). Wait and retry."
return result
resp.raise_for_status()
data = resp.json()
except requests.exceptions.RequestException as e:
result["error"] = f"CrUX API request failed: {e}"
return result
record = data.get("record", {})
# Collection period
cp = record.get("collectionPeriod", {})
if cp:
first = cp.get("firstDate", {})
last = cp.get("lastDate", {})
result["collection_period"] = {
"first": f"{first.get('year')}-{first.get('month', 0):02d}-{first.get('day', 0):02d}",
"last": f"{last.get('year')}-{last.get('month', 0):02d}-{last.get('day', 0):02d}",
}
# Metrics
for metric_name, metric_data in record.get("metrics", {}).items():
p75s = metric_data.get("percentiles", {})
p75 = p75s.get("p75")
if p75 is None:
continue
# CLS is string-encoded in CrUX - parse carefully
if metric_name == "cumulative_layout_shift":
try:
p75_val = float(str(p75))
except (ValueError, TypeError):
p75_val = 0.0
else:
try:
p75_val = int(p75)
except (ValueError, TypeError):
try:
p75_val = float(p75)
except (ValueError, TypeError):
continue
rating = rate_metric(metric_name, p75_val)
thresholds = CWV_THRESHOLDS.get(metric_name, {})
result["metrics"][metric_name] = {
"p75": p75_val,
"rating": rating,
"label": thresholds.get("label", metric_name),
"unit": thresholds.get("unit", ""),
"good_threshold": thresholds.get("good"),
"poor_threshold": thresholds.get("poor"),
}
# Distributions
histogram = metric_data.get("histogram", [])
if histogram:
densities = [bin_data.get("density", 0) for bin_data in histogram]
if len(densities) >= 3:
result["metrics"][metric_name]["distribution"] = {
"good": round(densities[0] * 100, 1),
"needs_improvement": round(densities[1] * 100, 1),
"poor": round(densities[2] * 100, 1),
}
return result
def combined_check(
url: str,
api_key: Optional[str] = None,
strategy: str = "both",
form_factor: Optional[str] = None,
) -> dict:
"""
Run combined PSI + CrUX check.
Args:
url: URL to analyze.
api_key: Google API key.
strategy: 'mobile', 'desktop', or 'both'.
Returns:
Dictionary with PSI results (per strategy) and CrUX field data.
"""
result = {
"url": url,
"psi": {},
"crux": None,
"error": None,
}
strategies = ["mobile", "desktop"] if strategy == "both" else [strategy]
for strat in strategies:
psi_result = run_pagespeed(url, strategy=strat, api_key=api_key)
result["psi"][strat] = psi_result
if psi_result.get("error"):
result["error"] = psi_result["error"]
# CrUX (separate call for accurate field data)
if api_key:
crux_result = query_crux(url, api_key, form_factor=form_factor)
result["crux"] = crux_result
# Also try origin-level if URL-level has no data
if crux_result.get("error") and "insufficient" in crux_result.get("error", ""):
parsed = urlparse(url)
origin = f"{parsed.scheme}://{parsed.netloc}"
origin_result = query_crux(origin, api_key, form_factor=form_factor)
if not origin_result.get("error"):
result["crux"] = origin_result
result["crux"]["note"] = "URL-level data unavailable; showing origin-level data"
return result
def main():
parser = argparse.ArgumentParser(
description="PageSpeed Insights v5 + CrUX API combined checker"
)
parser.add_argument("url", help="URL to analyze")
parser.add_argument(
"--strategy", "-s",
choices=["mobile", "desktop", "both"],
default="both",
help="Analysis strategy (default: both)",
)
parser.add_argument(
"--api-key",
help="Google API key (overrides config/env)",
)
parser.add_argument(
"--crux-only",
action="store_true",
help="Only fetch CrUX field data (skip PSI Lighthouse)",
)
parser.add_argument(
"--psi-only",
action="store_true",
help="Only run PSI Lighthouse (skip CrUX API)",
)
parser.add_argument(
"--form-factor",
choices=["PHONE", "DESKTOP", "TABLET"],
help="CrUX form factor filter",
)
parser.add_argument(
"--json", "-j",
action="store_true",
help="Output as JSON",
)
args = parser.parse_args()
api_key = args.api_key or get_api_key()
if args.crux_only:
if not api_key:
print("Error: CrUX API requires an API key. Use --api-key or configure GOOGLE_API_KEY.", file=sys.stderr)
sys.exit(1)
result = query_crux(args.url, api_key, form_factor=args.form_factor)
elif args.psi_only:
strategies = ["mobile", "desktop"] if args.strategy == "both" else [args.strategy]
result = {"psi": {}}
for strat in strategies:
result["psi"][strat] = run_pagespeed(args.url, strategy=strat, api_key=api_key)
else:
result = combined_check(args.url, api_key=api_key, strategy=args.strategy, form_factor=args.form_factor)
if args.json:
print(json.dumps(result, indent=2))
else:
# Pretty print summary
if args.crux_only:
_print_crux_summary(result)
elif args.psi_only:
for strat, psi in result.get("psi", {}).items():
_print_psi_summary(psi)
else:
for strat, psi in result.get("psi", {}).items():
_print_psi_summary(psi)
if result.get("crux"):
print()
_print_crux_summary(result["crux"])
# Exit with error code if any errors occurred
if isinstance(result, dict) and result.get("error"):
sys.exit(1)
def _print_psi_summary(psi: dict):
"""Print PSI results in human-readable format."""
if psi.get("error"):
print(f"PSI Error ({psi.get('strategy', '?')}): {psi['error']}")
return
print(f"\n=== PageSpeed Insights ({psi.get('strategy', 'unknown')}) ===")
print(f"URL: {psi.get('url')}")
print(f"Timestamp: {psi.get('analysis_timestamp', 'N/A')}")
scores = psi.get("lighthouse_scores", {})
if scores:
print("\nLighthouse Scores:")
for cat, score in scores.items():
print(f" {cat}: {score}/100")
lab = psi.get("lab_metrics", {})
if lab:
print("\nLab Metrics:")
for metric_id, data in lab.items():
print(f" {metric_id}: {data.get('display', data.get('value'))}")
opps = psi.get("opportunities", [])
if opps:
print("\nTop Opportunities:")
for opp in opps[:5]:
print(f" - {opp['title']} (save ~{opp['savings_ms']}ms)")
failed = psi.get("failed_audits", [])
if failed:
print(f"\nFailed/Warning Audits ({len(failed)}):")
for a in failed[:10]:
score_pct = f"{a['score']:.0%}" if a['score'] is not None else "?"
print(f" [{score_pct}] {a['title']} {a.get('display', '')}")
diags = psi.get("diagnostics", [])
notable_diags = [d for d in diags if d.get("score") is not None and d["score"] < 0.9]
if notable_diags:
print(f"\nDiagnostics (needs attention):")
for d in notable_diags[:5]:
score_pct = f"{d['score']:.0%}" if d['score'] is not None else "info"
print(f" [{score_pct}] {d['title']}: {d.get('display', '')}")
seo = psi.get("seo_audits", [])
seo_failed = [a for a in seo if not a.get("pass")]
if seo_failed:
print(f"\nSEO Issues ({len(seo_failed)}):")
for a in seo_failed:
print(f" [FAIL] {a['title']}")
elif seo:
print(f"\nSEO: All {len(seo)} checks passed")
a11y = psi.get("accessibility_audits", [])
if a11y:
print(f"\nAccessibility Issues ({len(a11y)}):")
for a in a11y[:5]:
print(f" [{a['score']:.0%}] {a['title']}")
passed = psi.get("passed_audits_count", 0)
if passed:
print(f"\nPassed: {passed} audits")
def _print_crux_summary(crux: dict):
"""Print CrUX results in human-readable format."""
if crux.get("error"):
print(f"CrUX Error: {crux['error']}")
return
print(f"=== CrUX Field Data ({crux.get('form_factor', 'ALL')}) ===")
print(f"Target: {crux.get('target')}")
if crux.get("note"):
print(f"Note: {crux['note']}")
cp = crux.get("collection_period", {})
if cp:
print(f"Period: {cp.get('first')} to {cp.get('last')}")
metrics = crux.get("metrics", {})
if metrics:
print("\nCore Web Vitals (p75):")
for name, data in metrics.items():
label = data.get("label", name)
p75 = data.get("p75")
unit = data.get("unit", "")
rating = data.get("rating", "?")
good = data.get("good_threshold")
rating_icon = {"good": "GOOD", "needs-improvement": "NEEDS IMPROVEMENT", "poor": "POOR"}.get(rating, "?")
if name == "cumulative_layout_shift":
print(f" {label}: {p75:.3f} [{rating_icon}] (threshold: <={good})")
else:
print(f" {label}: {p75}{unit} [{rating_icon}] (threshold: <={good}{unit})")
dist = data.get("distribution")
if dist:
print(f" Good: {dist['good']}% | NI: {dist['needs_improvement']}% | Poor: {dist['poor']}%")
if __name__ == "__main__":
main()
scripts/requirements.lock
#
# This file is autogenerated by pip-compile with Python 3.14
# by the following command:
#
# pip-compile --allow-unsafe --generate-hashes --output-file=skills/blog-google/scripts/requirements.lock --strip-extras skills/blog-google/scripts/requirements.txt
#
brotli==1.2.0 \
--hash=sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24 \
--hash=sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f \
--hash=sha256:09ac247501d1909e9ee47d309be760c89c990defbb2e0240845c892ea5ff0de4 \
--hash=sha256:0bbd5b5ccd157ae7913750476d48099aaf507a79841c0d04a9db4415b14842de \
--hash=sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c \
--hash=sha256:14ef29fc5f310d34fc7696426071067462c9292ed98b5ff5a27ac70a200e5470 \
--hash=sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744 \
--hash=sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a \
--hash=sha256:1b557b29782a643420e08d75aea889462a4a8796e9a6cf5621ab05a3f7da8ef2 \
--hash=sha256:1b71754d5b6eda54d16fbbed7fce2d8bc6c052a1b91a35c320247946ee103502 \
--hash=sha256:1ce223652fd4ed3eb2b7f78fbea31c52314baecfac68db44037bb4167062a937 \
--hash=sha256:1e68cdf321ad05797ee41d1d09169e09d40fdf51a725bb148bff892ce04583d7 \
--hash=sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca \
--hash=sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6 \
--hash=sha256:2881416badd2a88a7a14d981c103a52a23a276a553a8aacc1346c2ff47c8dc17 \
--hash=sha256:29b7e6716ee4ea0c59e3b241f682204105f7da084d6254ec61886508efeb43bc \
--hash=sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b \
--hash=sha256:2d39b54b968f4b49b5e845758e202b1035f948b0561ff5e6385e855c96625971 \
--hash=sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe \
--hash=sha256:3173e1e57cebb6d1de186e46b5680afbd82fd4301d7b2465beebe83ed317066d \
--hash=sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac \
--hash=sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd \
--hash=sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84 \
--hash=sha256:3b90b767916ac44e93a8e28ce6adf8d551e43affb512f2377c732d486ac6514e \
--hash=sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18 \
--hash=sha256:3ebe801e0f4e56d17cd386ca6600573e3706ce1845376307f5d2cbd32149b69a \
--hash=sha256:3f3c908bcc404c90c77d5a073e55271a0a498f4e0756e48127c35d91cf155947 \
--hash=sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a \
--hash=sha256:465a0d012b3d3e4f1d6146ea019b5c11e3e87f03d1676da1cc3833462e672fb0 \
--hash=sha256:4735a10f738cb5516905a121f32b24ce196ab82cfc1e4ba2e3ad1b371085fd46 \
--hash=sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48 \
--hash=sha256:50b1b799f45da91292ffaa21a473ab3a3054fa78560e8ff67082a185274431c8 \
--hash=sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5 \
--hash=sha256:5732eff8973dd995549a18ecbd8acd692ac611c5c0bb3f59fa3541ae27b33be3 \
--hash=sha256:598e88c736f63a0efec8363f9eb34e5b5536b7b6b1821e401afcb501d881f59a \
--hash=sha256:640fe199048f24c474ec6f3eae67c48d286de12911110437a36a87d7c89573a6 \
--hash=sha256:66c02c187ad250513c2f4fce973ef402d22f80e0adce734ee4e4efd657b6cb64 \
--hash=sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c \
--hash=sha256:6be67c19e0b0c56365c6a76e393b932fb0e78b3b56b711d180dd7013cb1fd984 \
--hash=sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21 \
--hash=sha256:71a66c1c9be66595d628467401d5976158c97888c2c9379c034e1e2312c5b4f5 \
--hash=sha256:7274942e69b17f9cef76691bcf38f2b2d4c8a5f5dba6ec10958363dcb3308a0a \
--hash=sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b \
--hash=sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7 \
--hash=sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b \
--hash=sha256:7ad8cec81f34edf44a1c6a7edf28e7b7806dfb8886e371d95dcf789ccd4e4982 \
--hash=sha256:7e9053f5fb4e0dfab89243079b3e217f2aea4085e4d58c5c06115fc34823707f \
--hash=sha256:7fa18d65a213abcfbb2f6cafbb4c58863a8bd6f2103d65203c520ac117d1944b \
--hash=sha256:81da1b229b1889f25adadc929aeb9dbc4e922bd18561b65b08dd9343cfccca84 \
--hash=sha256:82676c2781ecf0ab23833796062786db04648b7aae8be139f6b8065e5e7b1518 \
--hash=sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d \
--hash=sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae \
--hash=sha256:865cedc7c7c303df5fad14a57bc5db1d4f4f9b2b4d0a7523ddd206f00c121a16 \
--hash=sha256:88ef7d55b7bcf3331572634c3fd0ed327d237ceb9be6066810d39020a3ebac7a \
--hash=sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f \
--hash=sha256:8d4f47f284bdd28629481c97b5f29ad67544fa258d9091a6ed1fda47c7347cd1 \
--hash=sha256:92edab1e2fd6cd5ca605f57d4545b6599ced5dea0fd90b2bcdf8b247a12bd190 \
--hash=sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7 \
--hash=sha256:95db242754c21a88a79e01504912e537808504465974ebb92931cfca2510469e \
--hash=sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e \
--hash=sha256:96fbe82a58cdb2f872fa5d87dedc8477a12993626c446de794ea025bbda625ea \
--hash=sha256:99cfa69813d79492f0e5d52a20fd18395bc82e671d5d40bd5a91d13e75e468e8 \
--hash=sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3 \
--hash=sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab \
--hash=sha256:9fe11467c42c133f38d42289d0861b6b4f9da31e8087ca2c0d7ebb4543625526 \
--hash=sha256:a1778532b978d2536e79c05dac2d8cd857f6c55cd0c95ace5b03740824e0e2f1 \
--hash=sha256:a387225a67f619bf16bd504c37655930f910eb03675730fc2ad69d3d8b5e7e92 \
--hash=sha256:a56ef534b66a749759ebd091c19c03ef81eb8cd96f0d1d16b59127eaf1b97a12 \
--hash=sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03 \
--hash=sha256:ac27a70bda257ae3f380ec8310b0a06680236bea547756c277b5dfe55a2452a8 \
--hash=sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d \
--hash=sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28 \
--hash=sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036 \
--hash=sha256:b232029d100d393ae3c603c8ffd7e3fe6f798c5e28ddca5feabb8e8fdb732997 \
--hash=sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44 \
--hash=sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8 \
--hash=sha256:b908d1a7b28bc72dfb743be0d4d3f8931f8309f810af66c906ae6cd4127c93cb \
--hash=sha256:ba76177fd318ab7b3b9bf6522be5e84c2ae798754b6cc028665490f6e66b5533 \
--hash=sha256:bba6e7e6cfe1e6cb6eb0b7c2736a6059461de1fa2c0ad26cf845de6c078d16c8 \
--hash=sha256:c0d6770111d1879881432f81c369de5cde6e9467be7c682a983747ec800544e2 \
--hash=sha256:c16ab1ef7bb55651f5836e8e62db1f711d55b82ea08c3b8083ff037157171a69 \
--hash=sha256:c1702888c9f3383cc2f09eb3e88b8babf5965a54afb79649458ec7c3c7a63e96 \
--hash=sha256:c25332657dee6052ca470626f18349fc1fe8855a56218e19bd7a8c6ad4952c49 \
--hash=sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f \
--hash=sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63 \
--hash=sha256:d206a36b4140fbb5373bf1eb73fb9de589bb06afd0d22376de23c5e91d0ab35f \
--hash=sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888 \
--hash=sha256:d8c05b1dfb61af28ef37624385b0029df902ca896a639881f594060b30ffc9a7 \
--hash=sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a \
--hash=sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3 \
--hash=sha256:e80a28f2b150774844c8b454dd288be90d76ba6109670fe33d7ff54d96eb5cb8 \
--hash=sha256:e813da3d2d865e9793ef681d3a6b66fa4b7c19244a45b817d0cceda67e615990 \
--hash=sha256:e85190da223337a6b7431d92c799fca3e2982abd44e7b8dec69938dcc81c8e9e \
--hash=sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161 \
--hash=sha256:eda5a6d042c698e28bda2507a89b16555b9aa954ef1d750e1c20473481aff675 \
--hash=sha256:ef87b8ab2704da227e83a246356a2b179ef826f550f794b2c52cddb4efbd0196 \
--hash=sha256:f16dace5e4d3596eaeb8af334b4d2c820d34b8278da633ce4a00020b2eac981c \
--hash=sha256:f8d635cafbbb0c61327f942df2e3f474dde1cff16c3cd0580564774eaba1ee13 \
--hash=sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361 \
--hash=sha256:ff09cd8c5eec3b9d02d2408db41be150d8891c5566addce57513bf546e3d6c6d
# via fonttools
certifi==2026.4.22 \
--hash=sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a \
--hash=sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580
# via requests
cffi==2.0.0 \
--hash=sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb \
--hash=sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b \
--hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \
--hash=sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9 \
--hash=sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44 \
--hash=sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2 \
--hash=sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c \
--hash=sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75 \
--hash=sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65 \
--hash=sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e \
--hash=sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a \
--hash=sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e \
--hash=sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25 \
--hash=sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a \
--hash=sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe \
--hash=sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b \
--hash=sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91 \
--hash=sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592 \
--hash=sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187 \
--hash=sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c \
--hash=sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1 \
--hash=sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94 \
--hash=sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba \
--hash=sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb \
--hash=sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165 \
--hash=sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529 \
--hash=sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca \
--hash=sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c \
--hash=sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6 \
--hash=sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c \
--hash=sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0 \
--hash=sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743 \
--hash=sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63 \
--hash=sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5 \
--hash=sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5 \
--hash=sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4 \
--hash=sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d \
--hash=sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b \
--hash=sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93 \
--hash=sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205 \
--hash=sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27 \
--hash=sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512 \
--hash=sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d \
--hash=sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c \
--hash=sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037 \
--hash=sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26 \
--hash=sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322 \
--hash=sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb \
--hash=sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c \
--hash=sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8 \
--hash=sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4 \
--hash=sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414 \
--hash=sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9 \
--hash=sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664 \
--hash=sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9 \
--hash=sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775 \
--hash=sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739 \
--hash=sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc \
--hash=sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062 \
--hash=sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe \
--hash=sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9 \
--hash=sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92 \
--hash=sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5 \
--hash=sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13 \
--hash=sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d \
--hash=sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26 \
--hash=sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f \
--hash=sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495 \
--hash=sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b \
--hash=sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6 \
--hash=sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c \
--hash=sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef \
--hash=sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5 \
--hash=sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18 \
--hash=sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad \
--hash=sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3 \
--hash=sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7 \
--hash=sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5 \
--hash=sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534 \
--hash=sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49 \
--hash=sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2 \
--hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5 \
--hash=sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453 \
--hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf
# via
# cryptography
# weasyprint
charset-normalizer==3.4.7 \
--hash=sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc \
--hash=sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c \
--hash=sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67 \
--hash=sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4 \
--hash=sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0 \
--hash=sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c \
--hash=sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5 \
--hash=sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444 \
--hash=sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153 \
--hash=sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9 \
--hash=sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01 \
--hash=sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217 \
--hash=sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b \
--hash=sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c \
--hash=sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a \
--hash=sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83 \
--hash=sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5 \
--hash=sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7 \
--hash=sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb \
--hash=sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c \
--hash=sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1 \
--hash=sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42 \
--hash=sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab \
--hash=sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df \
--hash=sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e \
--hash=sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207 \
--hash=sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18 \
--hash=sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734 \
--hash=sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38 \
--hash=sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110 \
--hash=sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18 \
--hash=sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44 \
--hash=sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d \
--hash=sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48 \
--hash=sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e \
--hash=sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5 \
--hash=sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d \
--hash=sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53 \
--hash=sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790 \
--hash=sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c \
--hash=sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b \
--hash=sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116 \
--hash=sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d \
--hash=sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10 \
--hash=sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6 \
--hash=sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2 \
--hash=sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776 \
--hash=sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a \
--hash=sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265 \
--hash=sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008 \
--hash=sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943 \
--hash=sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374 \
--hash=sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246 \
--hash=sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e \
--hash=sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5 \
--hash=sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616 \
--hash=sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15 \
--hash=sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41 \
--hash=sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960 \
--hash=sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752 \
--hash=sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e \
--hash=sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72 \
--hash=sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7 \
--hash=sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8 \
--hash=sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b \
--hash=sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4 \
--hash=sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545 \
--hash=sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706 \
--hash=sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366 \
--hash=sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb \
--hash=sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a \
--hash=sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e \
--hash=sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00 \
--hash=sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f \
--hash=sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a \
--hash=sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1 \
--hash=sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66 \
--hash=sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356 \
--hash=sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319 \
--hash=sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4 \
--hash=sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad \
--hash=sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d \
--hash=sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5 \
--hash=sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7 \
--hash=sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0 \
--hash=sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686 \
--hash=sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34 \
--hash=sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49 \
--hash=sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c \
--hash=sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1 \
--hash=sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e \
--hash=sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60 \
--hash=sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0 \
--hash=sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274 \
--hash=sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d \
--hash=sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0 \
--hash=sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae \
--hash=sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f \
--hash=sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d \
--hash=sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe \
--hash=sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3 \
--hash=sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393 \
--hash=sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1 \
--hash=sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af \
--hash=sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44 \
--hash=sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00 \
--hash=sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c \
--hash=sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3 \
--hash=sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7 \
--hash=sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd \
--hash=sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e \
--hash=sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b \
--hash=sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8 \
--hash=sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259 \
--hash=sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859 \
--hash=sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46 \
--hash=sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30 \
--hash=sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b \
--hash=sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46 \
--hash=sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24 \
--hash=sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a \
--hash=sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24 \
--hash=sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc \
--hash=sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215 \
--hash=sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063 \
--hash=sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832 \
--hash=sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6 \
--hash=sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79 \
--hash=sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464
# via requests
contourpy==1.3.3 \
--hash=sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69 \
--hash=sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc \
--hash=sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880 \
--hash=sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a \
--hash=sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8 \
--hash=sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc \
--hash=sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470 \
--hash=sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5 \
--hash=sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263 \
--hash=sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b \
--hash=sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5 \
--hash=sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381 \
--hash=sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3 \
--hash=sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4 \
--hash=sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e \
--hash=sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f \
--hash=sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772 \
--hash=sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286 \
--hash=sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42 \
--hash=sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301 \
--hash=sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77 \
--hash=sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7 \
--hash=sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411 \
--hash=sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1 \
--hash=sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9 \
--hash=sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a \
--hash=sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b \
--hash=sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db \
--hash=sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6 \
--hash=sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620 \
--hash=sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989 \
--hash=sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea \
--hash=sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67 \
--hash=sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5 \
--hash=sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d \
--hash=sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36 \
--hash=sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99 \
--hash=sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1 \
--hash=sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e \
--hash=sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b \
--hash=sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8 \
--hash=sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d \
--hash=sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7 \
--hash=sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7 \
--hash=sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339 \
--hash=sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1 \
--hash=sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659 \
--hash=sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4 \
--hash=sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f \
--hash=sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20 \
--hash=sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36 \
--hash=sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb \
--hash=sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d \
--hash=sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8 \
--hash=sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0 \
--hash=sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b \
--hash=sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7 \
--hash=sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe \
--hash=sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77 \
--hash=sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497 \
--hash=sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd \
--hash=sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1 \
--hash=sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216 \
--hash=sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13 \
--hash=sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae \
--hash=sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae \
--hash=sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77 \
--hash=sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3 \
--hash=sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f \
--hash=sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff \
--hash=sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9 \
--hash=sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a
# via matplotlib
cryptography==47.0.0 \
--hash=sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7 \
--hash=sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27 \
--hash=sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd \
--hash=sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7 \
--hash=sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001 \
--hash=sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4 \
--hash=sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca \
--hash=sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0 \
--hash=sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe \
--hash=sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93 \
--hash=sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475 \
--hash=sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe \
--hash=sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515 \
--hash=sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10 \
--hash=sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7 \
--hash=sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92 \
--hash=sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829 \
--hash=sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8 \
--hash=sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52 \
--hash=sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b \
--hash=sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc \
--hash=sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c \
--hash=sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63 \
--hash=sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac \
--hash=sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31 \
--hash=sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7 \
--hash=sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1 \
--hash=sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203 \
--hash=sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7 \
--hash=sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769 \
--hash=sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923 \
--hash=sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74 \
--hash=sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b \
--hash=sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb \
--hash=sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab \
--hash=sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76 \
--hash=sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f \
--hash=sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7 \
--hash=sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973 \
--hash=sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0 \
--hash=sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8 \
--hash=sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310 \
--hash=sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b \
--hash=sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318 \
--hash=sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab \
--hash=sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8 \
--hash=sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa \
--hash=sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50 \
--hash=sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736
# via google-auth
cssselect2==0.9.0 \
--hash=sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563 \
--hash=sha256:759aa22c216326356f65e62e791d66160a0f9c91d1424e8d8adc5e74dddfc6fb
# via weasyprint
cycler==0.12.1 \
--hash=sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30 \
--hash=sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c
# via matplotlib
fonttools==4.62.1 \
--hash=sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04 \
--hash=sha256:0b3ae47e8636156a9accff64c02c0924cbebad62854c4a6dbdc110cd5b4b341a \
--hash=sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9 \
--hash=sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392 \
--hash=sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82 \
--hash=sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d \
--hash=sha256:1c5c25671ce8805e0d080e2ffdeca7f1e86778c5cbfbeae86d7f866d8830517b \
--hash=sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e \
--hash=sha256:268abb1cb221e66c014acc234e872b7870d8b5d4657a83a8f4205094c32d2416 \
--hash=sha256:2d850f66830a27b0d498ee05adb13a3781637b1826982cd7e2b3789ef0cc71ae \
--hash=sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069 \
--hash=sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9 \
--hash=sha256:40975849bac44fb0b9253d77420c6d8b523ac4dcdcefeff6e4d706838a5b80f7 \
--hash=sha256:486f32c8047ccd05652aba17e4a8819a3a9d78570eb8a0e3b4503142947880ed \
--hash=sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800 \
--hash=sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e \
--hash=sha256:5a648bde915fba9da05ae98856987ca91ba832949a9e2888b48c47ef8b96c5a9 \
--hash=sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b \
--hash=sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1 \
--hash=sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe \
--hash=sha256:6acb4109f8bee00fec985c8c7afb02299e35e9c94b57287f3ea542f28bd0b0a7 \
--hash=sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd \
--hash=sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056 \
--hash=sha256:7bca7a1c1faf235ffe25d4f2e555246b4750220b38de8261d94ebc5ce8a23c23 \
--hash=sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae \
--hash=sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260 \
--hash=sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974 \
--hash=sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87 \
--hash=sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24 \
--hash=sha256:942b03094d7edbb99bdf1ae7e9090898cad7bf9030b3d21f33d7072dbcb51a53 \
--hash=sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936 \
--hash=sha256:9dde91633f77fa576879a0c76b1d89de373cae751a98ddf0109d54e173b40f14 \
--hash=sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42 \
--hash=sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c \
--hash=sha256:a5d8825e1140f04e6c99bb7d37a9e31c172f3bc208afbe02175339e699c710e1 \
--hash=sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca \
--hash=sha256:ad5cca75776cd453b1b035b530e943334957ae152a36a88a320e779d61fc980c \
--hash=sha256:b4e0fcf265ad26e487c56cb12a42dffe7162de708762db951e1b3f755319507d \
--hash=sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a \
--hash=sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782 \
--hash=sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c \
--hash=sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a \
--hash=sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79 \
--hash=sha256:c9b9e288b4da2f64fd6180644221749de651703e8d0c16bd4b719533a3a7d6e3 \
--hash=sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7 \
--hash=sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d \
--hash=sha256:e8514f4924375f77084e81467e63238b095abda5107620f49421c368a6017ed2 \
--hash=sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4 \
--hash=sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68 \
--hash=sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca
# via
# matplotlib
# weasyprint
google-ads==31.4.0 \
--hash=sha256:038e502608afc1e88888e2e4ae367bc7a7878b5c3206910753c6a089f0b463cc \
--hash=sha256:210a7f6a7b5d40ef544090216dceeb90c9d2e7fba51c72329a690c9e5bb94474
# via -r skills/blog-google/scripts/requirements.txt
google-analytics-data==0.23.0 \
--hash=sha256:8799b8829ca175e11385008aedb9f2f0aaf5c78c2b69f7570712ee46b190b08a \
--hash=sha256:ba21edbe7f2aa61424651b11b06f81fa4ceed5db5581ef2ca2af0d189b77c0bd
# via -r skills/blog-google/scripts/requirements.txt
google-api-core==2.30.3 \
--hash=sha256:a85761ba72c444dad5d611c2220633480b2b6be2521eca69cca2dbb3ffd6bfe8 \
--hash=sha256:e601a37f148585319b26db36e219df68c5d07b6382cff2d580e83404e44d641b
# via
# google-ads
# google-analytics-data
# google-api-python-client
google-api-python-client==2.194.0 \
--hash=sha256:61eaaac3b8fc8fdf11c08af87abc3d1342d1b37319cc1b57405f86ef7697e717 \
--hash=sha256:db92647bd1a90f40b79c9618461553c2b20b6a43ce7395fa6de07132dc14f023
# via -r skills/blog-google/scripts/requirements.txt
google-auth==2.55.1 \
--hash=sha256:eada68dfd52b3b81191827601e2a0c3fa12540c818534b630ddc5355769c3995 \
--hash=sha256:fb2d9b730f2c9b8d326ec8d7222f21aef2ead15bf0513793d6442485d87af0a1
# via
# -r skills/blog-google/scripts/requirements.txt
# google-analytics-data
# google-api-core
# google-api-python-client
# google-auth-httplib2
# google-auth-oauthlib
google-auth-httplib2==0.3.1 \
--hash=sha256:0af542e815784cb64159b4469aa5d71dd41069ba93effa006e1916b1dcd88e55 \
--hash=sha256:682356a90ef4ba3d06548c37e9112eea6fc00395a11b0303a644c1a86abc275c
# via
# -r skills/blog-google/scripts/requirements.txt
# google-api-python-client
google-auth-oauthlib==1.3.1 \
--hash=sha256:14c22c7b3dd3d06dbe44264144409039465effdd1eef94f7ce3710e486cc4bfa \
--hash=sha256:1a139ef23f1318756805b0e95f655c238bffd29655329a2978218248da4ee7f8
# via
# -r skills/blog-google/scripts/requirements.txt
# google-ads
googleapis-common-protos==1.74.0 \
--hash=sha256:57971e4eeeba6aad1163c1f0fc88543f965bb49129b8bb55b2b7b26ecab084f1 \
--hash=sha256:702216f78610bb510e3f12ac3cafd281b7ac45cc5d86e90ad87e4d301a3426b5
# via
# google-ads
# google-api-core
# grpcio-status
grpcio==1.80.0 \
--hash=sha256:00168469238b022500e486c1c33916acf2f2a9b2c022202cf8a1885d2e3073c1 \
--hash=sha256:02e64bb0bb2da14d947a49e6f120a75e947250aebe65f9629b62bb1f5c14e6e9 \
--hash=sha256:05d55e1798756282cddd52d56c896b3e7d673e3a8798c2f1cd05ba249a3bb4de \
--hash=sha256:09e5e478b3d14afd23f12e49e8b44c8684ac3c5f08561c43a5b9691c54d136ab \
--hash=sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921 \
--hash=sha256:1b97cd29a8eda100b559b455331c487a80915b6ea6bd91cf3e89836c4ee8d957 \
--hash=sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f \
--hash=sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257 \
--hash=sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d \
--hash=sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05 \
--hash=sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd \
--hash=sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e \
--hash=sha256:33eb763f18f006dc7fee1e69831d38d23f5eccd15b2e0f92a13ee1d9242e5e02 \
--hash=sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae \
--hash=sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f \
--hash=sha256:3cb8130ba457d2aa09fa6b7c3ed6b6e4e6a2685fce63cb803d479576c4d80e21 \
--hash=sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e \
--hash=sha256:43168871f170d1e4ed16ae03d10cd21efa29f190e710a624cee7e5ae07da6f4f \
--hash=sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1 \
--hash=sha256:4560cf0e86514595dbbd330cd65b7afad4b5c4b8c4905c041cfffa138d45e6fd \
--hash=sha256:46c2390b59d67f84e882694d489f5b45707c657832d7934859ceb8c33f467069 \
--hash=sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411 \
--hash=sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14 \
--hash=sha256:50a9871536d71c4fba24ee856abc03a87764570f0c457dd8db0b4018f379fed9 \
--hash=sha256:51b4a7189b0bef2aa30adce3c78f09c83526cf3dddb24c6a96555e3b97340440 \
--hash=sha256:52d143637e3872633fc7dd7c3c6a1c84e396b359f3a72e215f8bf69fd82084fc \
--hash=sha256:5c07e82e822e1161354e32da2662f741a4944ea955f9f580ec8fb409dd6f6060 \
--hash=sha256:627fb7312171cdc52828bd6fac8d7028ff2a64b89f1957b6f3416caa2218d141 \
--hash=sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6 \
--hash=sha256:7b641fc3f1dc647bfd80bd713addc68f6d145956f64677e56d9ebafc0bd72388 \
--hash=sha256:8502122a3cc1714038e39a0b071acb1207ca7844208d5ea0d091317555ee7106 \
--hash=sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140 \
--hash=sha256:886457a7768e408cdce226ad1ca67d2958917d306523a0e21e1a2fdaa75c9c9c \
--hash=sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f \
--hash=sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7 \
--hash=sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0 \
--hash=sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294 \
--hash=sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f \
--hash=sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff \
--hash=sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f \
--hash=sha256:a361c20ec1ccd3c3953d20fb6d7b4125093bdd10dff44c5e2bbb39e58917cedc \
--hash=sha256:a72d84ad0514db063e21887fbacd1fd7acb4d494a564cae22227cd45c7fbf199 \
--hash=sha256:aacdfb4ed3eb919ca997504d27e03d5dba403c85130b8ed450308590a738f7a4 \
--hash=sha256:ba0915d51fd4ced2db5ff719f84e270afe0e2d4c45a7bdb1e8d036e4502928c2 \
--hash=sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7 \
--hash=sha256:bac1d573dfa84ce59a5547073e28fa7326d53352adda6912e362da0b917fcef4 \
--hash=sha256:c51bf8ac4575af2e0678bccfb07e47321fc7acb5049b4482832c5c195e04e13a \
--hash=sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0 \
--hash=sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193 \
--hash=sha256:ce1794f4ea6cc3ca29463f42d665c32ba1b964b48958a66497917fe9069f26e6 \
--hash=sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de \
--hash=sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f \
--hash=sha256:dc053420fc75749c961e2a4c906398d7c15725d36ccc04ae6d16093167223b58 \
--hash=sha256:deb10a1528473c11f72a0939eed36d83e847d7cbb63e8cc5611fb7a912d38614 \
--hash=sha256:dfab85db094068ff42e2a3563f60ab3dddcc9d6488a35abf0132daec13209c8a \
--hash=sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50 \
--hash=sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad \
--hash=sha256:ec0a592e926071b4abad50c1495cd0d0d513324b3ff5e7267067c33ba27506e4 \
--hash=sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9 \
--hash=sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2 \
--hash=sha256:f7691a6788ad9196872f95716df5bc643ebba13c97140b7a5ee5c8e75d1dea81
# via
# google-ads
# google-analytics-data
# google-api-core
# grpcio-status
grpcio-status==1.80.0 \
--hash=sha256:4b56990363af50dbf2c2ebb80f1967185c07d87aa25aa2bea45ddb75fc181dbe \
--hash=sha256:df73802a4c89a3ea88aa2aff971e886fccce162bc2e6511408b3d67a144381cd
# via
# google-ads
# google-api-core
httplib2==0.31.2 \
--hash=sha256:385e0869d7397484f4eab426197a4c020b606edd43372492337c0b4010ae5d24 \
--hash=sha256:dbf0c2fa3862acf3c55c078ea9c0bc4481d7dc5117cae71be9514912cf9f8349
# via
# google-api-python-client
# google-auth-httplib2
idna==3.13 \
--hash=sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242 \
--hash=sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3
# via requests
kiwisolver==1.5.0 \
--hash=sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9 \
--hash=sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679 \
--hash=sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0 \
--hash=sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8 \
--hash=sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276 \
--hash=sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96 \
--hash=sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e \
--hash=sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac \
--hash=sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f \
--hash=sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a \
--hash=sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15 \
--hash=sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7 \
--hash=sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368 \
--hash=sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02 \
--hash=sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9 \
--hash=sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681 \
--hash=sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57 \
--hash=sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27 \
--hash=sha256:295d9ffe712caa9f8a3081de8d32fc60191b4b51c76f02f951fd8407253528f4 \
--hash=sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920 \
--hash=sha256:32cc0a5365239a6ea0c6ed461e8838d053b57e397443c0ca894dcc8e388d4374 \
--hash=sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3 \
--hash=sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa \
--hash=sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23 \
--hash=sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859 \
--hash=sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb \
--hash=sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d \
--hash=sha256:41024ed50e44ab1a60d3fe0a9d15a4ccc9f5f2b1d814ff283c8d01134d5b81bc \
--hash=sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581 \
--hash=sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c \
--hash=sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099 \
--hash=sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05 \
--hash=sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9 \
--hash=sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd \
--hash=sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc \
--hash=sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796 \
--hash=sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303 \
--hash=sha256:51e8c4084897de9f05898c2c2a39af6318044ae969d46ff7a34ed3f96274adca \
--hash=sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314 \
--hash=sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489 \
--hash=sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57 \
--hash=sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1 \
--hash=sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797 \
--hash=sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021 \
--hash=sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db \
--hash=sha256:62f59da443c4f4849f73a51a193b1d9d258dcad0c41bc4d1b8fb2bcc04bfeb22 \
--hash=sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028 \
--hash=sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083 \
--hash=sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65 \
--hash=sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588 \
--hash=sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0 \
--hash=sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a \
--hash=sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1 \
--hash=sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c \
--hash=sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac \
--hash=sha256:86e0287879f75621ae85197b0877ed2f8b7aa57b511c7331dce2eb6f4de7d476 \
--hash=sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53 \
--hash=sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3 \
--hash=sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4 \
--hash=sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615 \
--hash=sha256:8f9baf6f0a6e7571c45c8863010b45e837c3ee1c2c77fcd6ef423be91b21fedb \
--hash=sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18 \
--hash=sha256:9190426b7aa26c5229501fa297b8d0653cfd3f5a36f7990c264e157cbf886b3b \
--hash=sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1 \
--hash=sha256:94eff26096eb5395136634622515b234ecb6c9979824c1f5004c6e3c3c85ccd2 \
--hash=sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c \
--hash=sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac \
--hash=sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d \
--hash=sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf \
--hash=sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2 \
--hash=sha256:b83af57bdddef03c01a9138034c6ff03181a3028d9a1003b301eb1a55e161a3f \
--hash=sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f \
--hash=sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4 \
--hash=sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9 \
--hash=sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e \
--hash=sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737 \
--hash=sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b \
--hash=sha256:bf4679a3d71012a7c2bf360e5cd878fbd5e4fcac0896b56393dec239d81529ed \
--hash=sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3 \
--hash=sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7 \
--hash=sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08 \
--hash=sha256:c8277104ded0a51e699c8c3aff63ce2c56d4ed5519a5f73e0fd7057f959a2b9e \
--hash=sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902 \
--hash=sha256:cc0b66c1eec9021353a4b4483afb12dfd50e3669ffbb9152d6842eb34c7e29fd \
--hash=sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6 \
--hash=sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310 \
--hash=sha256:cff8e5383db4989311f99e814feeb90c4723eb4edca425b9d5d9c3fefcdd9537 \
--hash=sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554 \
--hash=sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e \
--hash=sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87 \
--hash=sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a \
--hash=sha256:d5cd5189fc2b6a538b75ae45433140c4823463918f7b1617c31e68b085c0022c \
--hash=sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79 \
--hash=sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e \
--hash=sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16 \
--hash=sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1 \
--hash=sha256:dd952e03bfbb096cfe2dd35cd9e00f269969b67536cb4370994afc20ff2d0875 \
--hash=sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd \
--hash=sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0 \
--hash=sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9 \
--hash=sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646 \
--hash=sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657 \
--hash=sha256:ebae99ed6764f2b5771c522477b311be313e8841d2e0376db2b10922daebbba4 \
--hash=sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232 \
--hash=sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819 \
--hash=sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384 \
--hash=sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309 \
--hash=sha256:f42c23db5d1521218a3276bb08666dcb662896a0be7347cba864eca45ff64ede \
--hash=sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2 \
--hash=sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203 \
--hash=sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7 \
--hash=sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df \
--hash=sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c \
--hash=sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167 \
--hash=sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3 \
--hash=sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09 \
--hash=sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398
# via matplotlib
matplotlib==3.10.9 \
--hash=sha256:09218df8a93712bd6ea133e83a153c755448cf7868316c531cffcc43f69d1cc9 \
--hash=sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42 \
--hash=sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d \
--hash=sha256:1872fb212a05b729e649754a72d5da61d03e0554d76e80303b6f83d1d2c0552b \
--hash=sha256:1aa972116abb4c9d201bf245620b433726cb6856f3bef6a78f776a00f5c92d37 \
--hash=sha256:1e7698ac9868428e84d2c967424803b2472ff7167d9d6590d4204ed775343c3b \
--hash=sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456 \
--hash=sha256:3225f4e1edcb8c86c884ddf79ebe20ecd0a67d30188f279897554ccd8fded4dc \
--hash=sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f \
--hash=sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6 \
--hash=sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2 \
--hash=sha256:3fc0364dfbe1d07f6d15c5ebd0c5bf89e126916e5a8667dd4a7a6e84c36653d4 \
--hash=sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320 \
--hash=sha256:42fb814efabe95c06c1994d8ab5a8385f43a249e23badd3ba931d4308e5bca20 \
--hash=sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf \
--hash=sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c \
--hash=sha256:51bf0ddbdc598e060d46c16b5590708f81a1624cefbaaf62f6a81bf9285b8c80 \
--hash=sha256:56fc0bd271b00025c6edfdc7c2dcd247372c8e1544971d62e1dc7c17367e8bf9 \
--hash=sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716 \
--hash=sha256:6640f75af2c6148293caa0a2b39dd806a492dd66c8a8b04035813e33d0fd2585 \
--hash=sha256:68cfdcede415f7c8f5577b03303dd94526cdb6d11036cecdc205e08733b2d2bb \
--hash=sha256:6b63d9c7c769b88ab81e10dc86e4e0607cf56817b9f9e6cf24b2a5f1693b8e38 \
--hash=sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4 \
--hash=sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2 \
--hash=sha256:77210dce9cb8153dffc967efaae990543392563d5a376d4dd8539bebcb0ed217 \
--hash=sha256:7a8d66a55def891c33147ba3ba9bfcabf0b526a43764c818acbb4525e5ed0838 \
--hash=sha256:82368699727bfb7b0182e1aa13082e3c08e092fa1a25d3e1fd92405bff96f6d4 \
--hash=sha256:82834c3c292d24d3a8aae77cd2d20019de69d692a34a970e4fdb8d33e2ea3dda \
--hash=sha256:8e436d155fa8a3399dc62683f8f5d0e2e50d25d0144a73edd73f82eec8f4abfb \
--hash=sha256:8f3bcac1ca5ed000a6f4337d47ba67dfddf37ed6a46c15fd7f014997f7bf865f \
--hash=sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f \
--hash=sha256:985f2238880e2e69093f588f5fe2e46771747febf0649f3cf7f7b7480875317f \
--hash=sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c \
--hash=sha256:a5a6104ed666402ba5106d7f36e0e0cdca4e8d7fa4d39708ca88019e2835a2eb \
--hash=sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b \
--hash=sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285 \
--hash=sha256:ae2f11957b27ce53497dd4d7b235c4d4f1faf383dfb39d0c5beb833bff883294 \
--hash=sha256:b049278ddce116aaa1c1377ebf58adea909132dfce0281cf7e3a1ea9fc2e2c65 \
--hash=sha256:b1b745c489cd1a77a0dc1120a05dc87af9798faebc913601feb8c73d89bf2d1e \
--hash=sha256:b2b9516251cb89ff618d757daec0e2ed1bf21248013844a853d87ef85ab3081d \
--hash=sha256:b580440f1ff81a0e34122051a3dfabb7e4b7f9e380629929bde0eff9af72165f \
--hash=sha256:ba7b3b8ef09eab7df0e86e9ae086faa433efbfbdb46afcb3aa16aabf779469a8 \
--hash=sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39 \
--hash=sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6 \
--hash=sha256:d730e984eddf56974c3e72b6129c7ca462ac38dc624338f4b0b23eb23ecba00f \
--hash=sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf \
--hash=sha256:d843374407c4017a6403b59c6c81606773d136f3259d5b6da3131bc814542cc2 \
--hash=sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe \
--hash=sha256:de2445a0c6690d21b7eb6ce071cebad6d40a2e9bdf10d039074a96ba19797b99 \
--hash=sha256:dfca0129678bd56379db26c52b5d77ed7de314c047492fbdc763aa7501710cfb \
--hash=sha256:e9fae004b941b23ff2edcf1567a857ed77bafc8086ffa258190462328434faf8 \
--hash=sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1 \
--hash=sha256:f4399f64b3e94cd500195490972ae1ee81170df1636fa15364d157d5bdd7b921 \
--hash=sha256:f76e640a5268850bfda54b5131b1b1941cc685e42c5fa98ed9f2d64038308cba \
--hash=sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358
# via -r skills/blog-google/scripts/requirements.txt
numpy==2.4.4 \
--hash=sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed \
--hash=sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50 \
--hash=sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959 \
--hash=sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827 \
--hash=sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd \
--hash=sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233 \
--hash=sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc \
--hash=sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b \
--hash=sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7 \
--hash=sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e \
--hash=sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a \
--hash=sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d \
--hash=sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3 \
--hash=sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e \
--hash=sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb \
--hash=sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a \
--hash=sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0 \
--hash=sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e \
--hash=sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113 \
--hash=sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103 \
--hash=sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93 \
--hash=sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af \
--hash=sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5 \
--hash=sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7 \
--hash=sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392 \
--hash=sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c \
--hash=sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4 \
--hash=sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40 \
--hash=sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf \
--hash=sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44 \
--hash=sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b \
--hash=sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5 \
--hash=sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e \
--hash=sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74 \
--hash=sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0 \
--hash=sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e \
--hash=sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec \
--hash=sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015 \
--hash=sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d \
--hash=sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d \
--hash=sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842 \
--hash=sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150 \
--hash=sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8 \
--hash=sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a \
--hash=sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed \
--hash=sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f \
--hash=sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008 \
--hash=sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e \
--hash=sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0 \
--hash=sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e \
--hash=sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f \
--hash=sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a \
--hash=sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40 \
--hash=sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7 \
--hash=sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83 \
--hash=sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d \
--hash=sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c \
--hash=sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871 \
--hash=sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502 \
--hash=sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252 \
--hash=sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8 \
--hash=sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115 \
--hash=sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f \
--hash=sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e \
--hash=sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d \
--hash=sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0 \
--hash=sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119 \
--hash=sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e \
--hash=sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db \
--hash=sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121 \
--hash=sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d \
--hash=sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e
# via
# contourpy
# matplotlib
oauthlib==3.3.1 \
--hash=sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9 \
--hash=sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1
# via requests-oauthlib
packaging==26.2 \
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
# via matplotlib
pillow==12.2.0 \
--hash=sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9 \
--hash=sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5 \
--hash=sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987 \
--hash=sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9 \
--hash=sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b \
--hash=sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f \
--hash=sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd \
--hash=sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e \
--hash=sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e \
--hash=sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe \
--hash=sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795 \
--hash=sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601 \
--hash=sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1 \
--hash=sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed \
--hash=sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea \
--hash=sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5 \
--hash=sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97 \
--hash=sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453 \
--hash=sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98 \
--hash=sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa \
--hash=sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b \
--hash=sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d \
--hash=sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705 \
--hash=sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8 \
--hash=sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024 \
--hash=sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0 \
--hash=sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286 \
--hash=sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150 \
--hash=sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2 \
--hash=sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3 \
--hash=sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b \
--hash=sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f \
--hash=sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463 \
--hash=sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940 \
--hash=sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166 \
--hash=sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed \
--hash=sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f \
--hash=sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795 \
--hash=sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780 \
--hash=sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7 \
--hash=sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1 \
--hash=sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5 \
--hash=sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295 \
--hash=sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b \
--hash=sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354 \
--hash=sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60 \
--hash=sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65 \
--hash=sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005 \
--hash=sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c \
--hash=sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be \
--hash=sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5 \
--hash=sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06 \
--hash=sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae \
--hash=sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c \
--hash=sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c \
--hash=sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612 \
--hash=sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e \
--hash=sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab \
--hash=sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808 \
--hash=sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f \
--hash=sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e \
--hash=sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909 \
--hash=sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec \
--hash=sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe \
--hash=sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50 \
--hash=sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4 \
--hash=sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f \
--hash=sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff \
--hash=sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5 \
--hash=sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb \
--hash=sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414 \
--hash=sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1 \
--hash=sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032 \
--hash=sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76 \
--hash=sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136 \
--hash=sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e \
--hash=sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c \
--hash=sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3 \
--hash=sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea \
--hash=sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f \
--hash=sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104 \
--hash=sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176 \
--hash=sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24 \
--hash=sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3 \
--hash=sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4 \
--hash=sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed \
--hash=sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43 \
--hash=sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421 \
--hash=sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7 \
--hash=sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06 \
--hash=sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5
# via
# matplotlib
# weasyprint
proto-plus==1.27.2 \
--hash=sha256:6432f75893d3b9e70b9c412f1d2f03f65b11fb164b793d14ae2ca01821d22718 \
--hash=sha256:b2adde53adadf75737c44d3dcb0104fde65250dfc83ad59168b4aa3e574b6a24
# via
# google-ads
# google-analytics-data
# google-api-core
protobuf==6.33.6 \
--hash=sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326 \
--hash=sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901 \
--hash=sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3 \
--hash=sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a \
--hash=sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135 \
--hash=sha256:bd56799fb262994b2c2faa1799693c95cc2e22c62f56fb43af311cae45d26f0e \
--hash=sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3 \
--hash=sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2 \
--hash=sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593 \
--hash=sha256:f443a394af5ed23672bc6c486be138628fbe5c651ccbc536873d7da23d1868cf
# via
# google-ads
# google-analytics-data
# google-api-core
# googleapis-common-protos
# grpcio-status
# proto-plus
pyasn1==0.6.3 \
--hash=sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf \
--hash=sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde
# via pyasn1-modules
pyasn1-modules==0.4.2 \
--hash=sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a \
--hash=sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6
# via google-auth
pycparser==3.0 \
--hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \
--hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
# via cffi
pydyf==0.12.1 \
--hash=sha256:ea25b4e1fe7911195cb57067560daaa266639184e8335365cc3ee5214e7eaadc \
--hash=sha256:fbd7e759541ac725c29c506612003de393249b94310ea78ae44cb1d04b220095
# via weasyprint
pyparsing==3.3.2 \
--hash=sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d \
--hash=sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc
# via
# httplib2
# matplotlib
pyphen==0.17.2 \
--hash=sha256:3a07fb017cb2341e1d9ff31b8634efb1ae4dc4b130468c7c39dd3d32e7c3affd \
--hash=sha256:f60647a9c9b30ec6c59910097af82bc5dd2d36576b918e44148d8b07ef3b4aa3
# via weasyprint
python-dateutil==2.9.0.post0 \
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
# via matplotlib
pyyaml==6.0.3 \
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
--hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
--hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \
--hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
--hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \
--hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
--hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \
--hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \
--hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \
--hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
--hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \
--hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \
--hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \
--hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \
--hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
--hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \
--hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \
--hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \
--hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \
--hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \
--hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \
--hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
--hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
--hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
--hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
--hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
--hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \
--hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \
--hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \
--hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \
--hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \
--hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \
--hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \
--hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \
--hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \
--hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
--hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
--hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
--hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
--hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \
--hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \
--hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \
--hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
--hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
--hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \
--hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \
--hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
--hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \
--hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \
--hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \
--hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \
--hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
--hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
--hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \
--hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
--hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \
--hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \
--hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \
--hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \
--hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
--hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \
--hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
--hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
--hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
--hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
--hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
--hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \
--hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
# via google-ads
requests==2.34.2 \
--hash=sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0 \
--hash=sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed
# via
# -r skills/blog-google/scripts/requirements.txt
# google-api-core
# requests-oauthlib
requests-oauthlib==2.0.0 \
--hash=sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36 \
--hash=sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9
# via google-auth-oauthlib
six==1.17.0 \
--hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
# via python-dateutil
tinycss2==1.5.1 \
--hash=sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661 \
--hash=sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957
# via
# cssselect2
# weasyprint
tinyhtml5==2.1.0 \
--hash=sha256:60a50ec3d938a37e491efa01af895853060943dcebb5627de5b10d188b338a67 \
--hash=sha256:6e11cfff38515834268daf89d5f85bbde0b6dd02e8d9e212d1385c2289b89f0a
# via weasyprint
typing-extensions==4.15.0 \
--hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \
--hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548
# via grpcio
uritemplate==4.2.0 \
--hash=sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e \
--hash=sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686
# via google-api-python-client
urllib3==2.6.3 \
--hash=sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed \
--hash=sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4
# via requests
weasyprint==68.1 \
--hash=sha256:4dc3ba63c68bbbce3e9617cb2226251c372f5ee90a8a484503b1c099da9cf5be \
--hash=sha256:d3b752049b453a5c95edb27ce78d69e9319af5a34f257fa0f4c738c701b4184e
# via -r skills/blog-google/scripts/requirements.txt
webencodings==0.5.1 \
--hash=sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78 \
--hash=sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923
# via
# cssselect2
# tinycss2
# tinyhtml5
zopfli==0.4.1 \
--hash=sha256:02086247dd12fda929f9bfe8b3962b6bcdbfc8c82e99255aebcf367867cf0760 \
--hash=sha256:07a5cdc5d1aaa6c288c5d9f5a5383042ba743641abf8e2fd898dcad622d8a38e \
--hash=sha256:27823dc1161a4031d1c25925fd45d9868ec0cbc7692341830a7dcfa25063662c \
--hash=sha256:2f992ac7d83cbddd889e1813ace576cbc91a05d5d7a0a21b366e2e5f492e7707 \
--hash=sha256:4238d4d746d1095e29c9125490985e0c12ffd3654f54a24af551e2391e936d54 \
--hash=sha256:5a4c22b6161f47f5bd34637dbaee6735abd287cd64e0d1ce28ef1871bf625f4b \
--hash=sha256:84a31ba9edc921b1d3a4449929394a993888f32d70de3a3617800c428a947b9b \
--hash=sha256:a899eca405662a23ae75054affa3517a060362eae1185d3d791c86a50153c4dd \
--hash=sha256:a93c2ecafff372de6c0aa2212eff18a75f6c71a100372fee7b4b129cc0b6f9a7 \
--hash=sha256:cb136a74d14a4ecfae29cb0fdecece58a6c115abc9a74c12bc6ac62e80f229d7 \
--hash=sha256:d7bcee1b189d64ec33d1e05cfa1b6a1268c29329c382f6ca1bd6245b04925c57 \
--hash=sha256:fdfb7ce9f5de37a5b2f75dd2642fd7717956ef2a72e0387302a36d382440db07
# via fonttools
scripts/requirements.txt
# Blog Google Skill Dependencies
# Installed in the skill's local .venv via run.py
#
# For reproducible installs with hash verification, use the lock file:
# pip install --require-hashes -r requirements.lock
# This requirements.txt declares acceptable version ranges; the lock file
# pins exact versions + sha256 hashes for every transitive dep (closes
# audit VULN-006 supply-chain detection gap).
# Core Google API client
google-api-python-client>=2.100.0,<3.0.0
google-auth>=2.53.0,<3.0.0
google-auth-oauthlib>=1.3.1,<2.0.0
google-auth-httplib2>=0.2.0,<1.0.0
# GA4 Data API
google-analytics-data>=0.22.0,<1.0.0
# Google Ads Keyword Planner
# Google Ads API v25 requires Python client 31.2.0 or newer.
google-ads>=31.2.0,<32.0.0
# HTTP requests (PSI, CrUX, NLP)
requests>=2.34.2,<3.0.0
# Report generation (optional - graceful fallback if unavailable)
matplotlib>=3.8.0,<4.0.0
weasyprint>=61.0,<70.0
scripts/run.py
#!/usr/bin/env python3
"""
Universal runner for Blog Google skill scripts
Ensures all scripts run with the correct virtual environment
"""
import os
import sys
import subprocess
import hashlib
from pathlib import Path
def get_venv_python():
"""Get the virtual environment Python executable"""
skill_dir = Path(__file__).parent.parent
venv_dir = skill_dir / ".venv"
if os.name == 'nt': # Windows
venv_python = venv_dir / "Scripts" / "python.exe"
else: # Unix/Linux/Mac
venv_python = venv_dir / "bin" / "python"
return venv_python
def ensure_venv():
"""Ensure virtual environment exists"""
skill_dir = Path(__file__).parent.parent
venv_dir = skill_dir / ".venv"
setup_script = skill_dir / "scripts" / "setup_environment.py"
lock_file = skill_dir / "scripts" / "requirements.lock"
requirements_file = skill_dir / "scripts" / "requirements.txt"
stamp_file = venv_dir / ".requirements.stamp"
source = lock_file if lock_file.exists() else requirements_file
expected_stamp = hashlib.sha256(source.read_bytes()).hexdigest() if source.exists() else None
current_stamp = stamp_file.read_text().strip() if stamp_file.exists() else None
# Check if venv exists
if not venv_dir.exists() or (expected_stamp and current_stamp != expected_stamp):
print("First-time setup: Creating virtual environment...")
print(" This may take a minute...")
# Run setup with system Python
result = subprocess.run([sys.executable, str(setup_script)])
if result.returncode != 0:
print("Failed to set up environment")
sys.exit(1)
print("Environment ready!")
return get_venv_python()
def main():
"""Main runner"""
if len(sys.argv) < 2:
print("Usage: python run.py <script_name> [args...]")
print("\nAvailable scripts:")
print(" google_auth.py - Credential management and auth setup")
print(" pagespeed_check.py - PageSpeed Insights + CrUX field data")
print(" crux_history.py - 25-week CWV trend history")
print(" youtube_search.py - YouTube video search and details")
print(" nlp_analyze.py - NLP entity extraction and sentiment")
print(" gsc_query.py - Search Console performance data")
print(" gsc_inspect.py - URL Inspection API")
print(" indexing_notify.py - Indexing API notifications")
print(" ga4_report.py - GA4 organic traffic reports")
print(" keyword_planner.py - Google Ads Keyword Planner")
print(" google_report.py - PDF/HTML performance reports")
sys.exit(1)
script_name = sys.argv[1]
script_args = sys.argv[2:]
# Handle both "scripts/script.py" and "script.py" formats
if script_name.startswith('scripts/'):
script_name = script_name[8:] # len('scripts/') = 8
# Ensure .py extension
if not script_name.endswith('.py'):
script_name += '.py'
# Get script path
skill_dir = Path(__file__).parent.parent
scripts_dir = (skill_dir / "scripts").resolve()
script_path = (scripts_dir / script_name).resolve()
try:
script_path.relative_to(scripts_dir)
except ValueError:
print(f"Script path escapes scripts directory: {script_name}")
sys.exit(1)
if not script_path.is_file():
print(f"Script not found: {script_name}")
print(f" Skill directory: {skill_dir}")
print(f" Looked for: {script_path}")
sys.exit(1)
# Ensure venv exists and get Python executable
venv_python = ensure_venv()
# Build command
cmd = [str(venv_python), str(script_path)] + script_args
# Run the script
try:
result = subprocess.run(cmd)
sys.exit(result.returncode)
except KeyboardInterrupt:
print("\nInterrupted by user")
sys.exit(130)
except Exception as e:
print(f"Error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
scripts/setup_environment.py
#!/usr/bin/env python3
"""
Environment Setup for Blog Google Skill
Manages virtual environment and dependencies automatically
"""
import os
import sys
import subprocess
import venv
import hashlib
import json
from pathlib import Path
class SkillEnvironment:
"""Manages skill-specific virtual environment"""
def __init__(self):
self.skill_dir = Path(__file__).parent.parent
self.venv_dir = self.skill_dir / ".venv"
# Prefer the lock file when present (hash-verified install). Falls
# back to the loose requirements.txt for environments that haven't
# generated a lock yet (closes audit VULN-006).
self.lock_file = self.skill_dir / "scripts" / "requirements.lock"
self.requirements_file = self.skill_dir / "scripts" / "requirements.txt"
self.stamp_file = self.venv_dir / ".requirements.stamp"
if os.name == 'nt':
self.venv_python = self.venv_dir / "Scripts" / "python.exe"
self.venv_pip = self.venv_dir / "Scripts" / "pip.exe"
else:
self.venv_python = self.venv_dir / "bin" / "python"
self.venv_pip = self.venv_dir / "bin" / "pip"
def ensure_venv(self) -> bool:
"""Ensure virtual environment exists and is set up"""
if self.is_in_skill_venv():
return True
if not self.venv_dir.exists():
print(f"Creating virtual environment in {self.venv_dir.name}/")
try:
venv.create(self.venv_dir, with_pip=True)
except Exception as e:
print(f"Failed to create venv: {e}")
return False
# Use lock file when available (hash-verified, reproducible).
# Fall back to requirements.txt only if no lock present.
if self.lock_file.exists():
install_args = ["install", "--require-hashes", "-r", str(self.lock_file)]
install_label = "lock file (hash-verified)"
elif self.requirements_file.exists():
install_args = ["install", "-r", str(self.requirements_file)]
install_label = "requirements.txt (no hash verification)"
else:
print("No requirements.txt or requirements.lock found; skipping install")
return True
print(f"Installing dependencies from {install_label}...")
try:
subprocess.run(
[str(self.venv_pip)] + install_args,
check=True, capture_output=True, text=True,
)
self.write_dependency_stamp()
print("Dependencies installed")
return True
except subprocess.CalledProcessError as e:
print(f"Failed to install dependencies: {e}")
return False
def is_in_skill_venv(self) -> bool:
"""Check if running in the skill's venv"""
if hasattr(sys, 'real_prefix') or (
hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix
):
return Path(sys.prefix) == self.venv_dir
return False
def get_python_executable(self) -> str:
"""Get the correct Python executable"""
if self.venv_python.exists():
return str(self.venv_python)
return sys.executable
def dependency_source(self) -> Path | None:
if self.lock_file.exists():
return self.lock_file
if self.requirements_file.exists():
return self.requirements_file
return None
def dependency_stamp(self) -> str | None:
source = self.dependency_source()
if not source:
return None
return hashlib.sha256(source.read_bytes()).hexdigest()
def dependencies_current(self) -> bool:
expected = self.dependency_stamp()
if not expected or not self.stamp_file.exists():
return False
try:
return self.stamp_file.read_text().strip() == expected
except OSError:
return False
def write_dependency_stamp(self) -> None:
stamp = self.dependency_stamp()
if stamp:
self.stamp_file.write_text(stamp)
def main():
"""Main entry point for environment setup"""
import argparse
parser = argparse.ArgumentParser(description='Setup Blog Google skill environment')
parser.add_argument('--check', action='store_true', help='Check if environment is set up')
parser.add_argument('--json', action='store_true', help='Output structured JSON')
args = parser.parse_args()
env = SkillEnvironment()
if args.check:
status = {
"venv_exists": env.venv_dir.exists(),
"python": env.get_python_executable(),
"dependencies_current": env.dependencies_current(),
"dependency_source": str(env.dependency_source()) if env.dependency_source() else None,
}
if args.json:
print(json.dumps(status, indent=2))
return 0 if status["venv_exists"] and status["dependencies_current"] else 1
if env.venv_dir.exists():
print(f"Virtual environment exists: {env.venv_dir}")
print(f" Python: {env.get_python_executable()}")
print(f" Dependencies current: {'yes' if env.dependencies_current() else 'no'}")
else:
print("No virtual environment found")
return
if env.ensure_venv():
if args.json:
print(json.dumps({
"status": "success",
"venv": str(env.venv_dir),
"python": env.get_python_executable(),
"dependencies_current": env.dependencies_current(),
}, indent=2))
return 0
print(f"\nEnvironment ready!")
print(f" Virtual env: {env.venv_dir}")
print(f" Python: {env.get_python_executable()}")
else:
if args.json:
print(json.dumps({"status": "error", "venv": str(env.venv_dir)}, indent=2))
return 1
print("\nEnvironment setup failed")
return 1
if __name__ == "__main__":
sys.exit(main() or 0)
scripts/youtube_search.py
#!/usr/bin/env python3
"""
YouTube Data API v3 - Search, video details, and channel data for editorial use.
Use the results to evaluate whether a video is relevant, accurate, useful, and
eligible for the intended page. The script does not predict or award ranking,
readiness, authority, or citation outcomes.
Usage:
python youtube_search.py search "claude code seo"
python youtube_search.py video dQw4w9WgXcQ --json
python youtube_search.py channel UCxxxxxx --json
"""
import argparse
import json
import sys
from typing import Optional
try:
from googleapiclient.discovery import build
except ImportError:
print(
"Error: google-api-python-client required. "
"Install with: pip install google-api-python-client",
file=sys.stderr,
)
sys.exit(1)
try:
from google_auth import get_api_key
except ImportError:
import os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from google_auth import get_api_key
# YouTube Data API v3 quota costs:
# search.list = 100 units, videos.list = 1 unit, channels.list = 1 unit
# Default quota: 10,000 units/day = ~100 searches or ~10,000 video lookups
YOUTUBE_API_SERVICE = "youtube"
YOUTUBE_API_VERSION = "v3"
def _build_youtube_service(api_key: Optional[str] = None):
"""Build the YouTube Data API v3 service."""
key = api_key or get_api_key()
if not key:
return None
try:
return build(YOUTUBE_API_SERVICE, YOUTUBE_API_VERSION, developerKey=key)
except Exception as e:
print(f"Error building YouTube service: {e}", file=sys.stderr)
return None
def search_videos(
query: str,
max_results: int = 10,
order: str = "relevance",
api_key: Optional[str] = None,
) -> dict:
"""
Search YouTube for videos matching a query.
Args:
query: Search query string.
max_results: Max results (1-50, default 10).
order: Sort order: relevance, date, rating, viewCount, title.
api_key: Optional API key override.
Returns:
Dictionary with videos list and metadata.
"""
result = {"query": query, "videos": [], "total_results": 0, "error": None}
service = _build_youtube_service(api_key)
if not service:
result["error"] = "No API key. Set GOOGLE_API_KEY or add 'api_key' to config."
return result
try:
response = service.search().list(
q=query,
part="snippet",
type="video",
maxResults=min(max_results, 50),
order=order,
).execute()
result["total_results"] = response.get("pageInfo", {}).get("totalResults", 0)
# Get video IDs for statistics
video_ids = []
snippets = {}
for item in response.get("items", []):
vid = item["id"].get("videoId")
if vid:
video_ids.append(vid)
snippets[vid] = item.get("snippet", {})
# Fetch statistics for all videos in one call (1 unit)
if video_ids:
stats_response = service.videos().list(
id=",".join(video_ids),
part="statistics,contentDetails",
).execute()
stats_map = {}
for item in stats_response.get("items", []):
stats_map[item["id"]] = {
"views": int(item.get("statistics", {}).get("viewCount", 0)),
"likes": int(item.get("statistics", {}).get("likeCount", 0)),
"comments": int(item.get("statistics", {}).get("commentCount", 0)),
"duration": item.get("contentDetails", {}).get("duration", ""),
}
for vid in video_ids:
snip = snippets.get(vid, {})
stats = stats_map.get(vid, {})
result["videos"].append({
"video_id": vid,
"title": snip.get("title", ""),
"channel": snip.get("channelTitle", ""),
"channel_id": snip.get("channelId", ""),
"published": snip.get("publishedAt", ""),
"description": snip.get("description", "")[:300],
"thumbnail": snip.get("thumbnails", {}).get("high", {}).get("url", ""),
"views": stats.get("views", 0),
"likes": stats.get("likes", 0),
"comments": stats.get("comments", 0),
"duration": stats.get("duration", ""),
"url": f"https://www.youtube.com/watch?v={vid}",
})
except Exception as e:
error_str = str(e)
if "403" in error_str:
result["error"] = (
"YouTube Data API access denied. Ensure the API is enabled "
"in your GCP project (APIs & Services > Library > YouTube Data API v3)."
)
elif "429" in error_str:
result["error"] = "YouTube API quota exceeded (10,000 units/day). Search costs 100 units."
else:
result["error"] = f"YouTube API error: {e}"
return result
def get_video_details(
video_id: str,
api_key: Optional[str] = None,
) -> dict:
"""
Get detailed information about a specific YouTube video.
Args:
video_id: YouTube video ID.
api_key: Optional API key override.
Returns:
Dictionary with video details, statistics, and top comments.
"""
result = {"video_id": video_id, "details": None, "comments": [], "error": None}
service = _build_youtube_service(api_key)
if not service:
result["error"] = "No API key configured."
return result
try:
# Video details (1 unit)
response = service.videos().list(
id=video_id,
part="snippet,statistics,contentDetails,topicDetails",
).execute()
items = response.get("items", [])
if not items:
result["error"] = f"Video not found: {video_id}"
return result
item = items[0]
snip = item.get("snippet", {})
stats = item.get("statistics", {})
content = item.get("contentDetails", {})
topics = item.get("topicDetails", {})
result["details"] = {
"title": snip.get("title", ""),
"channel": snip.get("channelTitle", ""),
"channel_id": snip.get("channelId", ""),
"published": snip.get("publishedAt", ""),
"description": snip.get("description", ""),
"tags": snip.get("tags", []),
"category_id": snip.get("categoryId", ""),
"duration": content.get("duration", ""),
"definition": content.get("definition", ""),
"caption": content.get("caption", "false"),
"views": int(stats.get("viewCount", 0)),
"likes": int(stats.get("likeCount", 0)),
"comments_count": int(stats.get("commentCount", 0)),
"favorites": int(stats.get("favoriteCount", 0)),
"topic_categories": topics.get("topicCategories", []),
"url": f"https://www.youtube.com/watch?v={video_id}",
}
# Top comments (1 unit)
try:
comments_response = service.commentThreads().list(
videoId=video_id,
part="snippet",
maxResults=10,
order="relevance",
textFormat="plainText",
).execute()
for thread in comments_response.get("items", []):
comment = thread.get("snippet", {}).get("topLevelComment", {}).get("snippet", {})
result["comments"].append({
"author": comment.get("authorDisplayName", ""),
"text": comment.get("textDisplay", "")[:500],
"likes": comment.get("likeCount", 0),
"published": comment.get("publishedAt", ""),
})
except Exception:
pass # Comments may be disabled
except Exception as e:
result["error"] = f"YouTube API error: {e}"
return result
def get_channel_info(
channel_id: str,
api_key: Optional[str] = None,
) -> dict:
"""
Get channel information.
Args:
channel_id: YouTube channel ID.
api_key: Optional API key override.
Returns:
Dictionary with channel details.
"""
result = {"channel_id": channel_id, "channel": None, "error": None}
service = _build_youtube_service(api_key)
if not service:
result["error"] = "No API key configured."
return result
try:
response = service.channels().list(
id=channel_id,
part="snippet,statistics,brandingSettings",
).execute()
items = response.get("items", [])
if not items:
result["error"] = f"Channel not found: {channel_id}"
return result
item = items[0]
snip = item.get("snippet", {})
stats = item.get("statistics", {})
result["channel"] = {
"title": snip.get("title", ""),
"description": snip.get("description", "")[:500],
"custom_url": snip.get("customUrl", ""),
"published": snip.get("publishedAt", ""),
"country": snip.get("country", ""),
"subscribers": int(stats.get("subscriberCount", 0)),
"videos": int(stats.get("videoCount", 0)),
"views": int(stats.get("viewCount", 0)),
"thumbnail": snip.get("thumbnails", {}).get("high", {}).get("url", ""),
}
except Exception as e:
result["error"] = f"YouTube API error: {e}"
return result
def main():
parser = argparse.ArgumentParser(
description="YouTube Data API v3 - Search and video analysis for SEO"
)
parser.add_argument(
"command",
choices=["search", "video", "channel"],
help="Command: search, video (details), channel (info)",
)
parser.add_argument("query", help="Search query, video ID, or channel ID")
parser.add_argument("--limit", type=int, default=10, help="Max results for search (default: 10)")
parser.add_argument(
"--order",
choices=["relevance", "date", "rating", "viewCount", "title"],
default="relevance",
help="Sort order for search (default: relevance)",
)
parser.add_argument("--api-key", help="API key override")
parser.add_argument("--json", "-j", action="store_true", help="Output as JSON")
args = parser.parse_args()
if args.command == "search":
result = search_videos(args.query, max_results=args.limit, order=args.order, api_key=args.api_key)
elif args.command == "video":
result = get_video_details(args.query, api_key=args.api_key)
elif args.command == "channel":
result = get_channel_info(args.query, api_key=args.api_key)
if result.get("error"):
print(f"Error: {result['error']}", file=sys.stderr)
if not args.json:
sys.exit(1)
if args.json:
print(json.dumps(result, indent=2))
else:
if args.command == "search":
print(f"=== YouTube Search: {args.query} ===")
print(f"Results: {result.get('total_results', 0):,}")
for i, v in enumerate(result.get("videos", []), 1):
print(f"\n {i}. {v['title']}")
print(f" {v['channel']} | {v['views']:,} views | {v['likes']:,} likes | {v['duration']}")
print(f" {v['url']}")
elif args.command == "video":
d = result.get("details", {})
if d:
print(f"=== {d.get('title')} ===")
print(f"Channel: {d.get('channel')}")
print(f"Views: {d.get('views', 0):,} | Likes: {d.get('likes', 0):,} | Comments: {d.get('comments_count', 0):,}")
print(f"Published: {d.get('published', '')[:10]} | Duration: {d.get('duration')}")
tags = d.get("tags", [])
if tags:
print(f"Tags: {', '.join(tags[:10])}")
comments = result.get("comments", [])
if comments:
print(f"\nTop Comments ({len(comments)}):")
for c in comments[:5]:
print(f" [{c['likes']} likes] {c['author']}: {c['text'][:100]}")
elif args.command == "channel":
ch = result.get("channel", {})
if ch:
print(f"=== {ch.get('title')} ===")
print(f"Subscribers: {ch.get('subscribers', 0):,} | Videos: {ch.get('videos', 0):,} | Views: {ch.get('views', 0):,}")
if __name__ == "__main__":
main()
SKILL.md
---
name: blog-google
description: >
Google API integration for blog performance: PageSpeed Insights, CrUX Core Web
Vitals with 25-week history, Search Console performance, URL Inspection, Indexing
API, GA4 organic traffic, NLP entity analysis for E-E-A-T, YouTube video search
for embedding, and Google Ads Keyword Planner. Progressive feature availability
based on credential tier (API key, OAuth/service account, GA4, Ads). Shares
config with claude-seo at ~/.config/claude-seo/google-api.json. Use when user
says "google data", "page speed", "core web vitals", "search console",
"indexation", "GA4", "keyword research", "nlp entities", "blog performance",
"youtube search", "google api setup".
user-invokable: true
argument-hint: "[setup|pagespeed|crux|crux-history|gsc|inspect|index|ga4|nlp|youtube|keywords|report|quotas] [url|property|query]"
license: MIT
metadata:
author: AgriciDaniel
version: "2.2.0"
category: blog
---
# Blog Google: Google API Data for Blog Performance
Direct access to Google's SEO APIs for blog performance analysis. Provides real
Chrome user metrics, indexation status, search performance, entity analysis, YouTube
video discovery, keyword volumes, and PDF/HTML performance reports.
Most integrations have no usage fee within their documented quotas. Cloud
Natural Language requires billing and can incur charges after its free monthly
tier. Google Ads requires an eligible account and developer token. Never enable
billing or make a paid request without explicit user approval.
## Prerequisites
**Always check credentials before running any command:**
```bash
python3 skills/blog-google/scripts/run.py google_auth --check --json
```
**Config file:** `~/.config/claude-seo/google-api.json` (shared with claude-seo)
```json
{
"api_key": "YOUR_GOOGLE_API_KEY",
"oauth_client_path": "/path/to/client_secret.json",
"default_property": "sc-domain:example.com",
"ga4_property_id": "properties/123456789",
"ads_developer_token": "...",
"ads_customer_id": "123-456-7890",
"ads_login_customer_id": "123-456-7890"
}
```
If missing, read `references/auth-setup.md` and walk the user through setup.
### Credential Tiers
| Tier | Detection | Available Commands |
|------|-----------|-------------------|
| **0** (API Key) | `api_key` present | `pagespeed`, `crux`, `crux-history`, `youtube`, `nlp` |
| **1** (OAuth/SA) | + OAuth token or service account | Tier 0 + `gsc`, `inspect`, `index` |
| **2** (Full) | + `ga4_property_id` configured | Tier 1 + `ga4` |
| **3** (Ads) | + `ads_developer_token` + `ads_customer_id` | Tier 2 + `keywords` |
Always communicate the detected tier before running commands.
## Quick Reference
| Command | What it does | Tier |
|---------|-------------|------|
| `/blog google setup` | Check/configure API credentials |: |
| `/blog google pagespeed <url>` | PSI Lighthouse + CrUX field data | 0 |
| `/blog google crux <url>` | CrUX field data only (p75 metrics) | 0 |
| `/blog google crux-history <url>` | 25-week CWV trend analysis | 0 |
| `/blog google youtube <query>` | YouTube video search (views, likes, duration) | 0 |
| `/blog google nlp <url-or-text>` | NLP entity extraction + sentiment | 0 |
| `/blog google gsc <property>` | Search Console: clicks, impressions, CTR, position | 1 |
| `/blog google inspect <url>` | URL Inspection: index status, canonical | 1 |
| `/blog google index <url>` | Submit URL to Indexing API | 1 |
| `/blog google ga4 [property-id]` | GA4 organic traffic report | 2 |
| `/blog google keywords <seed>` | Keyword ideas from Google Ads Keyword Planner | 3 |
| `/blog google report <type>` | PDF/HTML performance report |: |
| `/blog google quotas` | Show rate limits for all APIs |: |
---
## PageSpeed + CrUX
### `/blog google pagespeed <url>`
Combined Lighthouse lab data + CrUX field data for a published blog post.
**Script:** `python3 skills/blog-google/scripts/run.py pagespeed_check <url> --json`
**Reference:** `references/api-reference.md`
Output merges lab scores (point-in-time Lighthouse) with field data (28-day
Chrome user metrics). CrUX tries URL-level first, falls back to origin-level.
### `/blog google crux <url>`
CrUX field data only (no Lighthouse run). Faster.
**Script:** `python3 skills/blog-google/scripts/run.py pagespeed_check <url> --crux-only --json`
### `/blog google crux-history <url>`
25-week CrUX History trends. Shows whether CWV metrics are improving, stable, or degrading.
**Script:** `python3 skills/blog-google/scripts/run.py crux_history <url> --json`
---
## Search Console
### `/blog google gsc <property>`
Search Analytics: clicks, impressions, CTR, position for last 28 days.
**Script:** `python3 skills/blog-google/scripts/run.py gsc_query --property <property> --json`
**Default:** 28 days, dimensions=query,page, type=web, limit=1000.
Includes quick-win detection: queries at position 4-10 with high impressions.
The dedicated Search Console generative-AI reports are a gradual, subset
rollout in the Search Console UI. They have separate Search and Discover views;
the Search view covers AI Overviews and AI Mode. Do not promise clicks, queries,
or API retrieval from these dedicated views. Until Google documents an API,
report that capability as `SKIPPED` or unavailable and point the user to the UI.
Google's July 29 Search Central announcement says Search Console platform
properties for Instagram, TikTok, X, and YouTube are globally available. The
current Help Center still says gradual rollout. Report this as a Google-source
conflict, verify availability in the user's account, and do not claim that
`/blog google gsc` retrieves these platform reports through the current API.
### `/blog google inspect <url>`
URL Inspection: real indexation status from Google.
**Script:** `python3 skills/blog-google/scripts/run.py gsc_inspect <url> --json`
Returns: verdict (PASS/FAIL), coverage state, robots.txt status, indexing state,
page fetch state, canonical selection, mobile usability, rich results.
After a canonicalization fix, Google may retain the URL in a duplicate cluster
for up to two weeks. If the implementation is now correct and the fix is within
that window, report `PENDING_REEVALUATION` rather than an immediate failure.
Search Console's Request Indexing feature is quota-limited; reserve it for
important URLs.
For batch inspection: `python3 skills/blog-google/scripts/run.py gsc_inspect --batch <file> --json`
---
## Indexing API
### `/blog google index <url>`
Notify Google of a URL update through the Indexing API.
**Script:** `python3 skills/blog-google/scripts/run.py indexing_notify <url> --json`
**Reference:** `references/api-reference.md`
The Indexing API is officially for JobPosting and BroadcastEvent/VideoObject pages.
Always inform the user of this restriction. Daily quota: 200 publish requests.
Do not present it as a general-purpose replacement for URL Inspection's Request
Indexing feature.
For batch: `python3 skills/blog-google/scripts/run.py indexing_notify --batch <file> --json`
---
## GA4 Traffic
### `/blog google ga4 [property-id]`
Organic traffic report: daily sessions, users, pageviews, bounce rate, engagement.
**Script:** `python3 skills/blog-google/scripts/run.py ga4_report --property <id> --json`
**Default:** 28 days, filtered to Organic Search channel group.
For top landing pages: `python3 skills/blog-google/scripts/run.py ga4_report --property <id> --report top-pages --json`
---
## YouTube (Video Discovery)
YouTube research can add useful, relevant media and distribution context. Any
third-party visibility correlation is observational, not a Google ranking or
citation requirement. Free, API key only. Used by blog-write and blog-rewrite
for video embedding.
### `/blog google youtube <query>`
Search YouTube for videos relevant to a blog topic.
**Script:** `python3 skills/blog-google/scripts/run.py youtube_search search "<query>" --json`
**Quota:** 100 units per search (10,000 units/day free).
Returns: title, channel, views, likes, duration, description, tags.
For video details + comments: `python3 skills/blog-google/scripts/run.py youtube_search video <video_id> --json`
---
## NLP Content Analysis
Google's entity and sentiment analysis can support topic and editorial review.
It does not expose ranking-system scores, and E-E-A-T is not a numeric Google
ranking factor.
### `/blog google nlp <url-or-text>`
Full NLP analysis: entities, sentiment, content classification.
**Script:** `python3 skills/blog-google/scripts/run.py nlp_analyze --url <url> --json`
**Free tier:** 5,000 units/month. Requires billing enabled on GCP project.
For entity extraction only: `python3 skills/blog-google/scripts/run.py nlp_analyze --url <url> --features entities --json`
---
## Keyword Research (Google Ads)
Gold-standard keyword volume data. Requires Google Ads account (Tier 3).
### `/blog google keywords <seed>`
Generate keyword ideas from seed terms for blog topic research.
**Script:** `python3 skills/blog-google/scripts/run.py keyword_planner ideas "<seed>" --json`
For volume lookup: `python3 skills/blog-google/scripts/run.py keyword_planner volume "<kw1>,<kw2>" --json`
---
## Reports
### `/blog google report <type>`
Generate a PDF/HTML report with charts and tables.
**Script:** `python3 skills/blog-google/scripts/run.py google_report --type <type> --data <json> --domain <domain> --format pdf`
| Type | Input | Output |
|------|-------|--------|
| `cwv-audit` | PSI + CrUX + CrUX History data | Core Web Vitals audit with gauges, timelines |
| `gsc-performance` | GSC query data | Search Console report with query tables |
| `indexation` | Batch inspection data | Indexation status with coverage donut |
| `full` | All data combined | Comprehensive Google SEO report |
**Note:** PDF generation requires system libraries: `sudo apt install libpango1.0-dev libcairo2-dev`.
Falls back to HTML if WeasyPrint is unavailable or PDF rendering fails.
---
## Rate Limits
| API | Per-Minute | Per-Day | Auth |
|-----|-----------|---------|------|
| PSI v5 | 240 QPM | 25,000 QPD | API Key |
| CrUX + History | 150 QPM (shared) | Unlimited | API Key |
| GSC Search Analytics | 1,200 QPM/site | 30M QPD | Service Account |
| GSC URL Inspection | 600 QPM | 2,000 QPD/site | Service Account |
| Indexing API | 380 RPM | 200 publish/day | Service Account |
| GA4 Data API | 10 concurrent (50 for 360) | 200K Core Tokens/day (2M for 360) | Service Account |
| YouTube Data |: | 10,000 units/day | API Key |
| NLP API |: | 5,000 units/month | API Key (billing) |
Read `references/rate-limits-quotas.md` for detailed quota management.
## Blog Workflow Integration
This skill is both user-invocable (`/blog google pagespeed`) and callable
internally by other blog sub-skills:
- **blog-seo-check**: Runs PSI + CrUX on published post URL for live CWV data
- **blog-rewrite**: NLP entity analysis to identify E-E-A-T entity gaps
- **blog-geo**: GSC performance data for real search appearance insights
- **blog-audit**: Batch CWV + indexation checks across all published blog URLs
- **blog-write / blog-rewrite**: YouTube search for video embedding
Falls back gracefully when credentials are not configured.
## Report Templates
Use the bundled templates when a workflow requests a durable human-readable
report. Keep unavailable account data marked `SKIPPED`; never fill an empty
section with estimated metrics.
- `assets/templates/cwv-audit-report.md` for PageSpeed and CrUX evidence.
- `assets/templates/gsc-performance-report.md` for Search Analytics exports.
- `assets/templates/indexation-status-report.md` for URL Inspection evidence.
## Technical Notes
- INP replaced FID on March 12, 2024. Never reference FID.
- CLS values from CrUX are string-encoded (e.g., "0.05"). Scripts handle parsing.
- CrUX 404 = insufficient Chrome traffic, not an auth error.
- Search Analytics data has 2-3 day lag.
- Indexing API is officially for JobPosting/BroadcastEvent pages only.
- Most integrations have no usage fee within quota. Cloud Natural Language
requires billing and can incur charges; Google Ads requires account and
developer-token access.
- Read `references/search-currentness.md` before diagnosing a named update,
canonical change, Discover visibility, Google generative-AI reporting,
platform properties, Preferred Sources, AMP, or crawler byte-limit issue.
- A named update's dates do not prove what caused an individual site's change.
Wait one full week after rollout before comparing data, and separate Web,
Image, Video, and News performance.
- Googlebot processes only the first 2MB of supported files and first 64MB of
PDFs. Keep critical metadata and primary content before the cutoff.
## Error Handling
| Scenario | Action |
|----------|--------|
| No credentials configured | Run `/blog google setup`. List Tier 0 commands (API key only). |
| Service account lacks GSC access | Add `client_email` to GSC > Settings > Users > Add. |
| CrUX data unavailable (404) | Insufficient Chrome traffic. Use PSI lab data as fallback. |
| GA4 property not found | Find property ID in GA4 Admin > Property Details. |
| Indexing API quota exceeded | 200/day limit. Prioritize most important URLs. |
| Rate limit (429) | Wait and retry with exponential backoff. |