From 17c0f5f801a8f07b6f22ec3ffbadd37cda57fa79 Mon Sep 17 00:00:00 2001 From: Sion Smith Date: Thu, 4 Jun 2026 13:35:17 +0100 Subject: [PATCH] fix: align Semrush API and release workflows --- .github/workflows/auto-tag.yml | 19 ++-- .github/workflows/release.yml | 29 +++++- .github/workflows/test.yml | 4 +- README.md | 46 +++++---- src/api/client.rs | 155 ++++++++++++++++++++++++++++- src/api/columns.rs | 68 ++++++++++++- src/api/csv_parser.rs | 9 ++ src/api/v1_backlinks.rs | 47 ++++++--- src/api/v3_analytics.rs | 82 +++++++++------- src/api/v3_trends.rs | 30 ++---- src/api/v4_local.rs | 115 ++++++++++++++-------- src/api/v4_projects.rs | 37 +++---- src/batch/recipe.rs | 3 +- src/cli/account.rs | 4 +- src/cli/backlink.rs | 4 +- src/cli/batch.rs | 2 +- src/cli/domain.rs | 7 +- src/cli/keyword.rs | 3 +- src/cli/local.rs | 38 +++++++- src/cli/mod.rs | 6 +- src/cli/overview.rs | 2 +- src/cli/project.rs | 4 +- src/cli/trends.rs | 21 ++-- src/main.rs | 172 ++++++++++++++++++++++----------- 24 files changed, 647 insertions(+), 260 deletions(-) diff --git a/.github/workflows/auto-tag.yml b/.github/workflows/auto-tag.yml index 488ba28..1c6a9f4 100644 --- a/.github/workflows/auto-tag.yml +++ b/.github/workflows/auto-tag.yml @@ -14,13 +14,18 @@ jobs: steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: - fetch-depth: 2 - persist-credentials: false + fetch-depth: 0 - name: Check version bump id: version run: | - OLD_VERSION=$(git diff HEAD~1 HEAD -- Cargo.toml | grep '^-version' | head -1 | sed 's/.*"\(.*\)".*/\1/' || echo "") + set -euo pipefail + BEFORE="${{ github.event.before }}" + if [ "$BEFORE" != "0000000000000000000000000000000000000000" ] && git cat-file -e "${BEFORE}:Cargo.toml" 2>/dev/null; then + OLD_VERSION=$(git show "${BEFORE}:Cargo.toml" | grep '^version' | head -1 | sed 's/.*"\(.*\)".*/\1/') + else + OLD_VERSION="" + fi NEW_VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)".*/\1/') if [ -n "$OLD_VERSION" ] && [ "$OLD_VERSION" != "$NEW_VERSION" ]; then echo "changed=true" >> "$GITHUB_OUTPUT" @@ -33,7 +38,9 @@ jobs: if: steps.version.outputs.changed == 'true' id: tag-check run: | - if git rev-parse "v${{ steps.version.outputs.version }}" >/dev/null 2>&1; then + set -euo pipefail + git fetch --tags --force + if git rev-parse -q --verify "refs/tags/v${{ steps.version.outputs.version }}" >/dev/null; then echo "exists=true" >> "$GITHUB_OUTPUT" else echo "exists=false" >> "$GITHUB_OUTPUT" @@ -41,11 +48,9 @@ jobs: - name: Create and push tag if: steps.version.outputs.changed == 'true' && steps.tag-check.outputs.exists == 'false' - env: - GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} run: | + set -euo pipefail git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@github.com/${{ github.repository }}.git" git tag -a "v${{ steps.version.outputs.version }}" -m "Release v${{ steps.version.outputs.version }}" git push origin "v${{ steps.version.outputs.version }}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6950d92..f4a32a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,11 @@ permissions: on: push: tags: - - v[0-9]+.* + - "v*.*.*" + +concurrency: + group: release-${{ github.ref_name }} + cancel-in-progress: false jobs: create-release: @@ -16,8 +20,12 @@ jobs: - name: Extract changelog run: | + set -euo pipefail VERSION="${GITHUB_REF_NAME#v}" - awk "/^## \\[${VERSION}\\]/{found=1; next} /^## \\[/{if(found) exit} found{print}" CHANGELOG.md > release_notes.md || echo "Release ${GITHUB_REF_NAME}" > release_notes.md + awk "/^## \\[${VERSION}\\]/{found=1; next} /^## \\[/{if(found) exit} found{print}" CHANGELOG.md > release_notes.md + if [ ! -s release_notes.md ]; then + echo "Release ${GITHUB_REF_NAME}" > release_notes.md + fi - name: Create GitHub release env: @@ -25,7 +33,8 @@ jobs: run: | gh release create "$GITHUB_REF_NAME" \ --title "$GITHUB_REF_NAME" \ - --notes-file release_notes.md + --notes-file release_notes.md \ + --verify-tag upload-assets: needs: create-release @@ -53,11 +62,12 @@ jobs: - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - name: Build - run: cargo build --release --target ${{ matrix.target }} + run: cargo build --locked --release --target ${{ matrix.target }} --bin semrush - name: Package (unix) if: runner.os != 'Windows' run: | + set -euo pipefail cd target/${{ matrix.target }}/release tar czf ../../../semrush-${{ matrix.target }}.tar.gz semrush @@ -72,6 +82,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + set -euo pipefail gh release upload "$GITHUB_REF_NAME" semrush-${{ matrix.target }}.* --clobber homebrew: @@ -83,6 +94,11 @@ jobs: HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} run: | set -euo pipefail + if [ -z "${HOMEBREW_TAP_TOKEN}" ]; then + echo "HOMEBREW_TAP_TOKEN is not configured; skipping Homebrew tap update." + exit 0 + fi + VERSION="${GITHUB_REF_NAME#v}" BASE_URL="https://github.com/osodevops/semrush-cli/releases/download/${GITHUB_REF_NAME}" @@ -129,10 +145,15 @@ jobs: sed -i 's/^ //' semrush.rb git clone "https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/osodevops/homebrew-tap.git" tap + mkdir -p tap/Formula cp semrush.rb tap/Formula/semrush.rb cd tap git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add Formula/semrush.rb + if git diff --cached --quiet; then + echo "Homebrew formula already up to date." + exit 0 + fi git commit -m "semrush ${VERSION}" git push diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b926e7f..0ebac29 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,7 +35,7 @@ jobs: toolchain: stable components: clippy - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - - run: cargo clippy --all-targets -- -D warnings + - run: cargo clippy --locked --all-targets -- -D warnings test: name: Test @@ -49,7 +49,7 @@ jobs: with: toolchain: stable - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - - run: cargo test --all-targets + - run: cargo test --locked --all-targets audit: name: Security Audit diff --git a/README.md b/README.md index 3880a53..f8490df 100644 --- a/README.md +++ b/README.md @@ -224,31 +224,31 @@ semrush backlink indexed-pages # Indexed pages semrush backlink competitors # Backlink competitors semrush backlink compare # Compare targets semrush backlink batch # Bulk overview -semrush backlink authority-score # Authority score -semrush backlink categories # Category distribution -semrush backlink category-profile # Category profile +semrush backlink authority-score # Authority score profile +semrush backlink category-profile # Referring-domain categories +semrush backlink categories # Domain category distribution semrush backlink history # Historical data ``` ### Traffic Trends ```bash -semrush trends summary [DOMAIN2] # Visits, bounce rate, pages/visit -semrush trends daily # Daily traffic data -semrush trends weekly # Weekly traffic data -semrush trends sources # Traffic source breakdown -semrush trends destinations # Outgoing traffic destinations -semrush trends geo # Geographic distribution -semrush trends subdomains # Subdomain traffic -semrush trends top-pages # Top pages by traffic -semrush trends rank # Traffic rank -semrush trends categories # Category breakdown -semrush trends conversion # Conversion data +semrush trends summary # Visits, bounce rate, pages/visit +semrush trends daily # Daily traffic data +semrush trends weekly # Weekly traffic data +semrush trends sources # Traffic source breakdown +semrush trends destinations # Outgoing traffic destinations +semrush trends geo # Geographic distribution +semrush trends subdomains # Subdomain traffic +semrush trends top-pages # Top pages by traffic +semrush trends rank # Traffic rank +semrush trends categories # Category breakdown +semrush trends conversion # Conversion data ``` -### Project Management (v4 API) +### Project Management -Requires OAuth2 token (`SEMRUSH_OAUTH_TOKEN` env var): +Requires `SEMRUSH_API_KEY`: ```bash semrush project list @@ -258,15 +258,19 @@ semrush project update --name "New Name" semrush project delete ``` -### Local SEO (v4 API) +### Local SEO ```bash +# Listing Management uses SEMRUSH_API_KEY semrush local listing list -semrush local listing get +semrush local listing get semrush local listing create --json '{"name": "Business"}' + +# Map Rank Tracker uses SEMRUSH_OAUTH_TOKEN semrush local map-rank campaigns semrush local map-rank keywords -semrush local map-rank heatmap +semrush local map-rank heatmap --keyword-id --cid +semrush local map-rank competitors --keyword-id --report-date ``` ### Utility @@ -298,8 +302,8 @@ semrush completions # Generate shell completions (bash/zsh/fish) | Variable | Description | |----------|-------------| -| `SEMRUSH_API_KEY` | API key (required for v3 endpoints) | -| `SEMRUSH_OAUTH_TOKEN` | OAuth2 token (required for v4 endpoints) | +| `SEMRUSH_API_KEY` | API key for SEO, Trends, Projects, and Listing Management endpoints | +| `SEMRUSH_OAUTH_TOKEN` | OAuth2 bearer token for Map Rank Tracker endpoints | | `SEMRUSH_DATABASE` | Default regional database | | `SEMRUSH_OUTPUT` | Default output format | diff --git a/src/api/client.rs b/src/api/client.rs index 0c2b9a3..a8c5e4f 100644 --- a/src/api/client.rs +++ b/src/api/client.rs @@ -58,6 +58,18 @@ impl SemrushClient { csv_parser::parse_csv_response(&body) } + /// Execute a v1 Backlinks API request with repeated query params. + pub async fn v1_backlinks_pairs( + &self, + report_type: &str, + params: &[(String, String)], + ) -> Result, AppError> { + let body = self + .request_csv_pairs(V1_BACKLINKS_BASE, report_type, params) + .await?; + csv_parser::parse_csv_response(&body) + } + /// Execute a v3 Trends API request and return parsed JSON rows. /// Trends API returns CSV like other v3 endpoints. pub async fn v3_trends( @@ -92,6 +104,21 @@ impl SemrushClient { self.request_with_retry(base_url, &query).await } + async fn request_csv_pairs( + &self, + base_url: &str, + report_type: &str, + params: &[(String, String)], + ) -> Result { + let mut query: Vec<(String, String)> = vec![ + ("type".to_string(), report_type.to_string()), + ("key".to_string(), self.api_key.clone()), + ]; + query.extend(params.iter().cloned()); + + self.request_with_retry(base_url, &query).await + } + async fn request_with_retry( &self, url: &str, @@ -186,7 +213,131 @@ impl SemrushClient { &self.api_key } - // ── v4 JSON methods (OAuth2 bearer token auth) ───────────── + // ── JSON API methods ─────────────────────────────────────── + + pub async fn json_get_with_key(&self, url: &str) -> Result { + self.limiter.until_ready().await; + let response = self + .http + .get(url) + .query(&[("key", self.api_key.as_str())]) + .send() + .await?; + self.handle_json_response(response).await + } + + pub async fn json_post_with_key( + &self, + url: &str, + body: &serde_json::Value, + ) -> Result { + self.limiter.until_ready().await; + let response = self + .http + .post(url) + .query(&[("key", self.api_key.as_str())]) + .json(body) + .send() + .await?; + self.handle_json_response(response).await + } + + pub async fn json_put_with_key( + &self, + url: &str, + body: &serde_json::Value, + ) -> Result { + self.limiter.until_ready().await; + let response = self + .http + .put(url) + .query(&[("key", self.api_key.as_str())]) + .json(body) + .send() + .await?; + self.handle_json_response(response).await + } + + pub async fn json_delete_with_key(&self, url: &str) -> Result { + self.limiter.until_ready().await; + let response = self + .http + .delete(url) + .query(&[("key", self.api_key.as_str())]) + .send() + .await?; + + let status = response.status(); + if status == reqwest::StatusCode::NO_CONTENT || status.is_success() { + return Ok(serde_json::json!({"status": "deleted"})); + } + self.handle_json_response(response).await + } + + pub async fn json_get_with_apikey_header( + &self, + url: &str, + ) -> Result { + self.limiter.until_ready().await; + let response = self + .http + .get(url) + .header("Authorization", format!("Apikey {}", self.api_key)) + .send() + .await?; + self.handle_json_response(response).await + } + + pub async fn json_post_with_apikey_header( + &self, + url: &str, + body: &serde_json::Value, + ) -> Result { + self.limiter.until_ready().await; + let response = self + .http + .post(url) + .header("Authorization", format!("Apikey {}", self.api_key)) + .json(body) + .send() + .await?; + self.handle_json_response(response).await + } + + pub async fn json_put_with_apikey_header( + &self, + url: &str, + body: &serde_json::Value, + ) -> Result { + self.limiter.until_ready().await; + let response = self + .http + .put(url) + .header("Authorization", format!("Apikey {}", self.api_key)) + .json(body) + .send() + .await?; + self.handle_json_response(response).await + } + + pub async fn json_delete_with_apikey_header( + &self, + url: &str, + ) -> Result { + self.limiter.until_ready().await; + let response = self + .http + .delete(url) + .header("Authorization", format!("Apikey {}", self.api_key)) + .send() + .await?; + + let status = response.status(); + if status == reqwest::StatusCode::NO_CONTENT || status.is_success() { + return Ok(serde_json::json!({"status": "deleted"})); + } + self.handle_json_response(response).await + } pub async fn v4_json_get( &self, @@ -260,7 +411,7 @@ impl SemrushClient { if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { return Err(AppError::AuthFailed { - message: "OAuth2 token invalid or expired. Run `semrush account auth setup-oauth`." + message: "Semrush API authorization failed. Check your API key or OAuth2 token." .to_string(), }); } diff --git a/src/api/columns.rs b/src/api/columns.rs index f0f2cd4..9e54e8d 100644 --- a/src/api/columns.rs +++ b/src/api/columns.rs @@ -14,6 +14,15 @@ static COLUMN_MAP: LazyLock> = LazyLock::new ("Td", "trends"), ("In", "intent"), ("Fk", "featured_keyword"), + ("Keyword", "keyword"), + ("Search Volume", "search_volume"), + ("CPC", "cpc"), + ("Competition", "competition"), + ("Number of Results", "results_count"), + ("Trends", "trends"), + ("Intent", "intent"), + ("Keyword Difficulty", "keyword_difficulty"), + ("Keyword Difficulty Index", "keyword_difficulty"), // Domain columns ("Dn", "domain"), ("Rk", "rank"), @@ -26,6 +35,16 @@ static COLUMN_MAP: LazyLock> = LazyLock::new ("Sh", "pla_keywords"), ("Sv", "pla_uniques"), ("FKn", "featured_snippet_keywords"), + ("Domain", "domain"), + ("Rank", "rank"), + ("Organic Keywords", "organic_keywords"), + ("Organic Traffic", "organic_traffic"), + ("Organic Cost", "organic_cost"), + ("Adwords Keywords", "paid_keywords"), + ("Adwords Traffic", "paid_traffic"), + ("Adwords Cost", "paid_cost"), + ("PLA keywords", "pla_keywords"), + ("PLA uniques", "pla_uniques"), // Position columns ("Po", "position"), ("Pp", "previous_position"), @@ -34,7 +53,27 @@ static COLUMN_MAP: LazyLock> = LazyLock::new ("Tr", "traffic_percent"), ("Tc", "traffic_cost"), ("Tg", "timestamp"), + ("Position", "position"), + ("Previous Position", "previous_position"), + ("Position Difference", "position_difference"), + ("Url", "url"), + ("URL", "url"), + ("Traffic (%)", "traffic_percent"), + ("Traffic Cost", "traffic_cost"), // Backlink columns + ("ascore", "authority_score"), + ("domain_ascore", "authority_score"), + ("total", "total_backlinks"), + ("urls_num", "referring_urls"), + ("ipclassc_num", "referring_ip_classes"), + ("sponsored_num", "sponsored_links"), + ("ugc_num", "ugc_links"), + ("category_name", "category"), + ("rating", "rating"), + ("matches_num", "matching_referring_domains"), + ("neighbour", "competitor"), + ("similarity", "similarity"), + ("common_refdomains", "common_referring_domains"), ("source_url", "source_url"), ("source_title", "source_title"), ("target_url", "target_url"), @@ -63,6 +102,11 @@ static COLUMN_MAP: LazyLock> = LazyLock::new ("frames_num", "frame_links"), ("score", "authority_score"), // Domain comparison + ("P0", "domain_0_position"), + ("P1", "domain_1_position"), + ("P2", "domain_2_position"), + ("P3", "domain_3_position"), + ("P4", "domain_4_position"), ("Np", "common_keywords"), ("Nm", "missing_keywords"), // Ads @@ -70,18 +114,40 @@ static COLUMN_MAP: LazyLock> = LazyLock::new ("Ds", "ad_description"), ("Vu", "visible_url"), ("Dt", "date"), + ("date", "date"), + ("Title", "ad_title"), + ("Description", "ad_description"), + ("Visible Url", "visible_url"), + ("Date", "date"), // PLA / Shopping ("St", "product_title"), ("Sp", "product_price"), ("Sn", "shop_name"), + ("Product Title", "product_title"), + ("Product Price", "product_price"), + ("Shop", "shop_name"), // Traffic / Trends ("visits", "visits"), ("users", "unique_visitors"), + ("target", "target"), + ("display_date", "display_date"), + ("device_type", "device_type"), + ("country", "country"), + ("traffic", "traffic"), + ("traffic_share", "traffic_share"), + ("traffic_diff", "traffic_diff"), + ("channel", "channel"), + ("traffic_type", "traffic_type"), + ("rank", "rank"), + ("domain", "domain"), + ("conversion", "conversion"), ("pages_per_visit", "pages_per_visit"), ("bounce_rate", "bounce_rate"), ("avg_visit_duration", "avg_visit_duration"), + ("time_on_site", "avg_visit_duration"), // Overview ("Db", "database"), + ("Database", "database"), ]) }); @@ -118,7 +184,7 @@ pub fn default_columns(report_type: &str) -> &'static str { "domain_shopping_shopping" => "Dn,Np,Sh,Sv", "domain_organic_unique" => "Ur,Tr,Tc", "domain_organic_subdomains" => "Dn,Or,Ot,Oc", - "domain_domains" => "Ph,Nq,Kd,Co,Dn", + "domain_domains" => "Ph,P0,P1,P2,P3,P4,Nr,Cp,Nq,Kd,Co,Td", "phrase_this" => "Ph,Nq,Cp,Co,Nr,Td,Kd,In", "phrase_all" => "Db,Ph,Nq,Cp,Co,Nr,Kd", "phrase_these" => "Ph,Nq,Cp,Co,Nr,Td,Kd,In", diff --git a/src/api/csv_parser.rs b/src/api/csv_parser.rs index a7251d8..1704891 100644 --- a/src/api/csv_parser.rs +++ b/src/api/csv_parser.rs @@ -125,6 +125,15 @@ mod tests { assert_eq!(rows[0]["cpc"], 2.45); } + #[test] + fn test_parse_csv_human_headers() { + let csv = "Keyword;Search Volume;CPC\nrust programming;12100;2.45"; + let rows = parse_csv_response(csv).unwrap(); + assert_eq!(rows[0]["keyword"], "rust programming"); + assert_eq!(rows[0]["search_volume"], 12100); + assert_eq!(rows[0]["cpc"], 2.45); + } + #[test] fn test_parse_csv_empty() { let rows = parse_csv_response("").unwrap(); diff --git a/src/api/v1_backlinks.rs b/src/api/v1_backlinks.rs index 114bf13..142f5a6 100644 --- a/src/api/v1_backlinks.rs +++ b/src/api/v1_backlinks.rs @@ -30,7 +30,7 @@ pub async fn overview( params.insert("target_type".to_string(), target_type.to_string()); params.insert( "export_columns".to_string(), - "backlinks_num,domains_num,ips_num,follows_num,nofollows_num,texts_num,images_num,forms_num,frames_num,score".to_string(), + "ascore,total,domains_num,urls_num,ips_num,ipclassc_num,follows_num,nofollows_num,sponsored_num,ugc_num,texts_num,images_num,forms_num,frames_num".to_string(), ); client.v1_backlinks("backlinks_overview", ¶ms).await } @@ -186,15 +186,17 @@ pub async fn compare( client: &SemrushClient, targets: &[String], target_type: &str, + limit: u32, + offset: u32, ) -> Result, AppError> { - let mut params = HashMap::new(); - params.insert("targets".to_string(), targets.join(",")); - params.insert("target_type".to_string(), target_type.to_string()); - params.insert( + let mut params = backlink_targets_params(targets, target_type); + params.push(( "export_columns".to_string(), - "target,backlinks_num,domains_num,ips_num,follows_num,nofollows_num,score".to_string(), - ); - client.v1_backlinks("backlinks_matrix", ¶ms).await + "domain,domain_ascore,matches_num,backlinks_num".to_string(), + )); + params.push(("display_limit".to_string(), limit.to_string())); + params.push(("display_offset".to_string(), offset.to_string())); + client.v1_backlinks_pairs("backlinks_matrix", ¶ms).await } pub async fn batch( @@ -202,14 +204,14 @@ pub async fn batch( targets: &[String], target_type: &str, ) -> Result, AppError> { - let mut params = HashMap::new(); - params.insert("targets".to_string(), targets.join(",")); - params.insert("target_type".to_string(), target_type.to_string()); - params.insert( + let mut params = backlink_targets_params(targets, target_type); + params.push(( "export_columns".to_string(), - "target,backlinks_num,domains_num,ips_num,score".to_string(), - ); - client.v1_backlinks("backlinks_comparison", ¶ms).await + "target,target_type,ascore,backlinks_num,domains_num,ips_num,follows_num,nofollows_num,texts_num,images_num,forms_num,frames_num".to_string(), + )); + client + .v1_backlinks_pairs("backlinks_comparison", ¶ms) + .await } pub async fn authority_score( @@ -234,6 +236,10 @@ pub async fn categories( let mut params = HashMap::new(); params.insert("target".to_string(), target.to_string()); params.insert("target_type".to_string(), target_type.to_string()); + params.insert( + "export_columns".to_string(), + "category_name,rating".to_string(), + ); client.v1_backlinks("backlinks_categories", ¶ms).await } @@ -244,7 +250,7 @@ pub async fn category_profile( limit: u32, offset: u32, ) -> Result, AppError> { - let params = base_params(target, target_type, "", limit, offset); + let params = base_params(target, target_type, "category_name,rating", limit, offset); client .v1_backlinks("backlinks_categories_profile", ¶ms) .await @@ -266,3 +272,12 @@ pub async fn history( ); client.v1_backlinks("backlinks_historical", ¶ms).await } + +fn backlink_targets_params(targets: &[String], target_type: &str) -> Vec<(String, String)> { + let mut params = Vec::with_capacity(targets.len() * 2); + for target in targets { + params.push(("targets[]".to_string(), target.clone())); + params.push(("target_types[]".to_string(), target_type.to_string())); + } + params +} diff --git a/src/api/v3_analytics.rs b/src/api/v3_analytics.rs index 7292783..97d30fc 100644 --- a/src/api/v3_analytics.rs +++ b/src/api/v3_analytics.rs @@ -89,18 +89,8 @@ pub async fn domain_organic( if let Some(s) = sort { params.insert("display_sort".to_string(), s.to_string()); } - for (i, f) in filters.iter().enumerate() { - params.insert( - format!( - "display_filter{}", - if i == 0 { - String::new() - } else { - format!("_{i}") - } - ), - f.clone(), - ); + if !filters.is_empty() { + params.insert("display_filter".to_string(), filters.join("|")); } client.v3_analytics("domain_organic", ¶ms).await @@ -125,18 +115,8 @@ pub async fn domain_paid( if let Some(s) = sort { params.insert("display_sort".to_string(), s.to_string()); } - for (i, f) in filters.iter().enumerate() { - params.insert( - format!( - "display_filter{}", - if i == 0 { - String::new() - } else { - format!("_{i}") - } - ), - f.clone(), - ); + if !filters.is_empty() { + params.insert("display_filter".to_string(), filters.join("|")); } client.v3_analytics("domain_adwords", ¶ms).await } @@ -316,22 +296,54 @@ pub async fn domain_compare( columns::default_columns("domain_domains"), ); - // domain_domains expects domains as: domains=d1|d2|d3|d4|d5 - let domains_str = domains.join("|"); - params.insert("domains".to_string(), domains_str); + let domain_type = match comparison_type.unwrap_or("organic") { + "paid" | "adwords" | "ads" => "ad", + _ => "or", + }; - if let Some(m) = mode { - params.insert("display_filter".to_string(), format!("+|Se|{m}")); - } - if let Some(t) = comparison_type { - // organic or paid - let sign = if t == "paid" { "+" } else { "*" }; - params.insert("sign".to_string(), sign.to_string()); - } + params.insert( + "domains".to_string(), + build_domain_comparison(domains, mode.unwrap_or("shared"), domain_type), + ); client.v3_analytics("domain_domains", ¶ms).await } +fn build_domain_comparison(domains: &[String], mode: &str, domain_type: &str) -> String { + let mut parts = Vec::with_capacity(domains.len()); + + match mode { + "unique" | "exclusive" => { + for (i, domain) in domains.iter().enumerate() { + parts.push(format!( + "{}|{domain_type}|{domain}", + if i == 0 { "*" } else { "-" } + )); + } + } + "missing" | "untapped" => { + for domain in domains.iter().skip(1) { + parts.push(format!("*|{domain_type}|{domain}")); + } + if let Some(primary) = domains.first() { + parts.push(format!("-|{domain_type}|{primary}")); + } + } + "all" => { + for domain in domains { + parts.push(format!("+|{domain_type}|{domain}")); + } + } + _ => { + for domain in domains { + parts.push(format!("*|{domain_type}|{domain}")); + } + } + } + + parts.join("|") +} + // ── Keyword reports ──────────────────────────────────────────── pub async fn keyword_overview( diff --git a/src/api/v3_trends.rs b/src/api/v3_trends.rs index 5fe482b..97c1aa9 100644 --- a/src/api/v3_trends.rs +++ b/src/api/v3_trends.rs @@ -26,14 +26,12 @@ pub async fn summary( country: Option<&str>, device: Option<&str>, date: Option<&str>, - limit: u32, ) -> Result, AppError> { let mut params = HashMap::new(); - params.insert("target".to_string(), targets.join(",")); - params.insert("display_limit".to_string(), limit.to_string()); + params.insert("targets".to_string(), targets.join(",")); params.insert( "export_columns".to_string(), - "target,visits,users,pages_per_visit,bounce_rate,avg_visit_duration".to_string(), + "target,visits,users,pages_per_visit,bounce_rate,time_on_site".to_string(), ); add_common(&mut params, country, device, date); client.v3_trends("summary", ¶ms).await @@ -42,8 +40,7 @@ pub async fn summary( pub async fn daily( client: &SemrushClient, target: &str, - date_from: Option<&str>, - date_to: Option<&str>, + date: Option<&str>, forecast: bool, country: Option<&str>, device: Option<&str>, @@ -52,13 +49,10 @@ pub async fn daily( params.insert("target".to_string(), target.to_string()); params.insert( "export_columns".to_string(), - "date,visits,users,pages_per_visit,bounce_rate,avg_visit_duration".to_string(), + "display_date,visits,users,pages_per_visit,bounce_rate,time_on_site".to_string(), ); - if let Some(f) = date_from { - params.insert("date_from".to_string(), f.to_string()); - } - if let Some(t) = date_to { - params.insert("date_to".to_string(), t.to_string()); + if let Some(dt) = date { + params.insert("display_date".to_string(), dt.to_string()); } if forecast { params.insert("include_forecasted_items".to_string(), "true".to_string()); @@ -70,8 +64,7 @@ pub async fn daily( pub async fn weekly( client: &SemrushClient, target: &str, - date_from: Option<&str>, - date_to: Option<&str>, + date: Option<&str>, forecast: bool, country: Option<&str>, device: Option<&str>, @@ -80,13 +73,10 @@ pub async fn weekly( params.insert("target".to_string(), target.to_string()); params.insert( "export_columns".to_string(), - "date,visits,users,pages_per_visit,bounce_rate,avg_visit_duration".to_string(), + "display_date,visits,users,pages_per_visit,bounce_rate,time_on_site".to_string(), ); - if let Some(f) = date_from { - params.insert("date_from".to_string(), f.to_string()); - } - if let Some(t) = date_to { - params.insert("date_to".to_string(), t.to_string()); + if let Some(dt) = date { + params.insert("display_date".to_string(), dt.to_string()); } if forecast { params.insert("include_forecasted_items".to_string(), "true".to_string()); diff --git a/src/api/v4_local.rs b/src/api/v4_local.rs index 9904f2b..c580dda 100644 --- a/src/api/v4_local.rs +++ b/src/api/v4_local.rs @@ -1,66 +1,51 @@ use crate::api::client::SemrushClient; use crate::error::AppError; -const LISTING_BASE: &str = "https://api.semrush.com/management/v1/listings"; -const MAP_RANK_BASE: &str = "https://api.semrush.com/management/v1/map-rank"; +const LISTING_BASE: &str = "https://api.semrush.com/apis/v4/local/v1/locations"; +const MAP_RANK_BASE: &str = "https://api.semrush.com/apis/v4/map-rank-tracker/v0"; // ── Listing Management ───────────────────────────────────────── -pub async fn listing_list( - client: &SemrushClient, - oauth_token: &str, -) -> Result, AppError> { - let response = client.v4_json_get(LISTING_BASE, oauth_token).await?; - match response { - serde_json::Value::Array(arr) => Ok(arr), - serde_json::Value::Object(map) => { - if let Some(serde_json::Value::Array(arr)) = map.get("data") { - Ok(arr.clone()) - } else { - Ok(vec![serde_json::Value::Object(map)]) - } - } - other => Ok(vec![other]), - } +pub async fn listing_list(client: &SemrushClient) -> Result, AppError> { + let response = client.json_get_with_apikey_header(LISTING_BASE).await?; + Ok(json_rows(response)) } pub async fn listing_get( client: &SemrushClient, - oauth_token: &str, location_id: &str, ) -> Result, AppError> { let url = format!("{LISTING_BASE}/{location_id}"); - let response = client.v4_json_get(&url, oauth_token).await?; + let response = client.json_get_with_apikey_header(&url).await?; Ok(vec![response]) } pub async fn listing_create( client: &SemrushClient, - oauth_token: &str, body: &serde_json::Value, ) -> Result, AppError> { - let response = client.v4_json_post(LISTING_BASE, oauth_token, body).await?; + let response = client + .json_post_with_apikey_header(LISTING_BASE, body) + .await?; Ok(vec![response]) } pub async fn listing_update( client: &SemrushClient, - oauth_token: &str, location_id: &str, body: &serde_json::Value, ) -> Result, AppError> { let url = format!("{LISTING_BASE}/{location_id}"); - let response = client.v4_json_patch(&url, oauth_token, body).await?; + let response = client.json_put_with_apikey_header(&url, body).await?; Ok(vec![response]) } pub async fn listing_delete( client: &SemrushClient, - oauth_token: &str, location_id: &str, ) -> Result, AppError> { let url = format!("{LISTING_BASE}/{location_id}"); - client.v4_json_delete(&url, oauth_token).await?; + client.json_delete_with_apikey_header(&url).await?; Ok(vec![serde_json::json!({"deleted": location_id})]) } @@ -72,47 +57,95 @@ pub async fn map_rank_campaigns( ) -> Result, AppError> { let url = format!("{MAP_RANK_BASE}/campaigns"); let response = client.v4_json_get(&url, oauth_token).await?; - match response { - serde_json::Value::Array(arr) => Ok(arr), - other => Ok(vec![other]), - } + Ok(json_rows(response)) } pub async fn map_rank_keywords( client: &SemrushClient, oauth_token: &str, campaign_id: &str, + report_date: Option<&str>, ) -> Result, AppError> { let url = format!("{MAP_RANK_BASE}/campaigns/{campaign_id}/keywords"); + let url = with_query(&url, &[("reportDate", report_date)])?; let response = client.v4_json_get(&url, oauth_token).await?; - match response { - serde_json::Value::Array(arr) => Ok(arr), - other => Ok(vec![other]), - } + Ok(json_rows(response)) } pub async fn map_rank_heatmap( client: &SemrushClient, oauth_token: &str, campaign_id: &str, + keyword_id: &str, + cid: Option<&str>, + place_ids: &[String], + report_date: Option<&str>, ) -> Result, AppError> { + if cid.is_none() && place_ids.is_empty() { + return Err(AppError::InvalidParams { + message: "Map Rank heatmap requires either --cid or --place-ids.".to_string(), + }); + } + let url = format!("{MAP_RANK_BASE}/campaigns/{campaign_id}/heatmap"); - let response = client.v4_json_get(&url, oauth_token).await?; - match response { - serde_json::Value::Array(arr) => Ok(arr), - other => Ok(vec![other]), + let mut params = vec![ + ("keywordId", Some(keyword_id)), + ("cid", cid), + ("reportDate", report_date), + ]; + let place_ids_joined = place_ids.join(","); + if !place_ids_joined.is_empty() { + params.push(("placeIds", Some(place_ids_joined.as_str()))); } + let url = with_query(&url, ¶ms)?; + let response = client.v4_json_get(&url, oauth_token).await?; + Ok(json_rows(response)) } pub async fn map_rank_competitors( client: &SemrushClient, oauth_token: &str, campaign_id: &str, + keyword_id: &str, + report_date: &str, ) -> Result, AppError> { - let url = format!("{MAP_RANK_BASE}/campaigns/{campaign_id}/competitors"); + let url = format!("{MAP_RANK_BASE}/campaigns/{campaign_id}/top-competitors"); + let url = with_query( + &url, + &[ + ("keywordId", Some(keyword_id)), + ("reportDate", Some(report_date)), + ], + )?; let response = client.v4_json_get(&url, oauth_token).await?; + Ok(json_rows(response)) +} + +fn json_rows(response: serde_json::Value) -> Vec { match response { - serde_json::Value::Array(arr) => Ok(arr), - other => Ok(vec![other]), + serde_json::Value::Array(arr) => arr, + serde_json::Value::Object(map) => { + if let Some(serde_json::Value::Array(arr)) = map.get("data") { + arr.clone() + } else { + vec![serde_json::Value::Object(map)] + } + } + other => vec![other], + } +} + +fn with_query(url: &str, params: &[(&str, Option<&str>)]) -> Result { + let mut parsed = reqwest::Url::parse(url).map_err(|e| AppError::InvalidParams { + message: format!("Invalid API URL: {e}"), + })?; + { + let mut pairs = parsed.query_pairs_mut(); + for (key, value) in params { + if let Some(value) = value { + pairs.append_pair(key, value); + } + } } + Ok(parsed.to_string()) } diff --git a/src/api/v4_projects.rs b/src/api/v4_projects.rs index 05572cb..212933a 100644 --- a/src/api/v4_projects.rs +++ b/src/api/v4_projects.rs @@ -3,11 +3,8 @@ use crate::error::AppError; const V4_PROJECTS_BASE: &str = "https://api.semrush.com/management/v1/projects"; -pub async fn list( - client: &SemrushClient, - oauth_token: &str, -) -> Result, AppError> { - let response = client.v4_json_get(V4_PROJECTS_BASE, oauth_token).await?; +pub async fn list(client: &SemrushClient) -> Result, AppError> { + let response = client.json_get_with_key(V4_PROJECTS_BASE).await?; // Response is either an array or an object with a "data" field match response { @@ -25,17 +22,15 @@ pub async fn list( pub async fn get( client: &SemrushClient, - oauth_token: &str, project_id: &str, ) -> Result, AppError> { let url = format!("{V4_PROJECTS_BASE}/{project_id}"); - let response = client.v4_json_get(&url, oauth_token).await?; + let response = client.json_get_with_key(&url).await?; Ok(vec![response]) } pub async fn create( client: &SemrushClient, - oauth_token: &str, name: &str, domain: &str, ) -> Result, AppError> { @@ -43,37 +38,35 @@ pub async fn create( "project_name": name, "url": domain, }); - let url = V4_PROJECTS_BASE; - let response = client.v4_json_post(url, oauth_token, &body).await?; + let response = client.json_post_with_key(V4_PROJECTS_BASE, &body).await?; Ok(vec![response]) } pub async fn update( client: &SemrushClient, - oauth_token: &str, project_id: &str, - name: Option<&str>, + name: &str, ) -> Result, AppError> { let mut body = serde_json::Map::new(); - if let Some(n) = name { - body.insert( - "project_name".to_string(), - serde_json::Value::String(n.to_string()), - ); - } - let url = format!("{V4_PROJECTS_BASE}/{project_id}"); + body.insert( + "project_id".to_string(), + serde_json::Value::String(project_id.to_string()), + ); + body.insert( + "project_name".to_string(), + serde_json::Value::String(name.to_string()), + ); let response = client - .v4_json_patch(&url, oauth_token, &serde_json::Value::Object(body)) + .json_put_with_key(V4_PROJECTS_BASE, &serde_json::Value::Object(body)) .await?; Ok(vec![response]) } pub async fn delete( client: &SemrushClient, - oauth_token: &str, project_id: &str, ) -> Result, AppError> { let url = format!("{V4_PROJECTS_BASE}/{project_id}"); - client.v4_json_delete(&url, oauth_token).await?; + client.json_delete_with_key(&url).await?; Ok(vec![serde_json::json!({"deleted": project_id})]) } diff --git a/src/batch/recipe.rs b/src/batch/recipe.rs index fef8b3c..9c5d7e1 100644 --- a/src/batch/recipe.rs +++ b/src/batch/recipe.rs @@ -239,8 +239,7 @@ async fn execute_step( .map(|s| s.trim().to_string()) .collect(); let country = get_str("country"); - crate::api::v3_trends::summary(client, &targets, country.as_deref(), None, None, limit) - .await? + crate::api::v3_trends::summary(client, &targets, country.as_deref(), None, None).await? } _ => { return Err(AppError::InvalidParams { diff --git a/src/cli/account.rs b/src/cli/account.rs index 69a4665..1b5aa1a 100644 --- a/src/cli/account.rs +++ b/src/cli/account.rs @@ -1,6 +1,6 @@ use clap::Subcommand; -#[derive(Subcommand)] +#[derive(Debug, Subcommand)] pub enum AccountCommand { /// Check API unit balance Balance, @@ -12,7 +12,7 @@ pub enum AccountCommand { }, } -#[derive(Subcommand)] +#[derive(Debug, Subcommand)] pub enum AuthCommand { /// Set up API key authentication Setup, diff --git a/src/cli/backlink.rs b/src/cli/backlink.rs index 41ab1e2..e602870 100644 --- a/src/cli/backlink.rs +++ b/src/cli/backlink.rs @@ -1,6 +1,6 @@ use clap::Subcommand; -#[derive(Subcommand)] +#[derive(Debug, Subcommand)] pub enum BacklinkCommand { /// Get backlink overview metrics (total backlinks, referring domains, authority score) Overview { @@ -83,6 +83,7 @@ pub enum BacklinkCommand { /// Compare backlink profiles of multiple targets Compare { /// Targets to compare + #[arg(required = true, num_args = 2..)] targets: Vec, #[arg(long, default_value = "root_domain")] target_type: String, @@ -90,6 +91,7 @@ pub enum BacklinkCommand { /// Batch comparison of multiple targets (up to 200) Batch { + #[arg(required = true, num_args = 1..=200)] targets: Vec, #[arg(long, default_value = "root_domain")] target_type: String, diff --git a/src/cli/batch.rs b/src/cli/batch.rs index bdf4d24..94870d7 100644 --- a/src/cli/batch.rs +++ b/src/cli/batch.rs @@ -1,6 +1,6 @@ use clap::Subcommand; -#[derive(Subcommand)] +#[derive(Debug, Subcommand)] pub enum BatchCommand { /// Execute a TOML batch recipe Run { diff --git a/src/cli/domain.rs b/src/cli/domain.rs index 1ebde21..08b4908 100644 --- a/src/cli/domain.rs +++ b/src/cli/domain.rs @@ -1,6 +1,6 @@ use clap::Subcommand; -#[derive(Subcommand)] +#[derive(Debug, Subcommand)] pub enum DomainCommand { /// Get domain overview metrics (rank, traffic, keywords count) Overview { @@ -90,7 +90,8 @@ pub enum DomainCommand { /// Compare domains (keyword gap analysis) Compare { - /// Domains to compare (2-5) + /// Domains to compare (2-5). The first domain is treated as the primary domain. + #[arg(required = true, num_args = 2..=5)] domains: Vec, /// Comparison mode: shared, all, unique, untapped, missing, exclusive @@ -103,7 +104,7 @@ pub enum DomainCommand { }, } -#[derive(Subcommand)] +#[derive(Debug, Subcommand)] pub enum CompetitorsCommand { /// Organic search competitors Organic { domain: String }, diff --git a/src/cli/keyword.rs b/src/cli/keyword.rs index f1b458c..82c0cb3 100644 --- a/src/cli/keyword.rs +++ b/src/cli/keyword.rs @@ -1,6 +1,6 @@ use clap::Subcommand; -#[derive(Subcommand)] +#[derive(Debug, Subcommand)] pub enum KeywordCommand { /// Get keyword metrics: search volume, CPC, difficulty, competition, intent Overview { @@ -15,6 +15,7 @@ pub enum KeywordCommand { /// Get metrics for multiple keywords at once (up to 100) Batch { /// Keywords to analyze + #[arg(required = true, num_args = 1..=100)] phrases: Vec, }, diff --git a/src/cli/local.rs b/src/cli/local.rs index de220ef..fe013ce 100644 --- a/src/cli/local.rs +++ b/src/cli/local.rs @@ -1,6 +1,6 @@ use clap::Subcommand; -#[derive(Subcommand)] +#[derive(Debug, Subcommand)] pub enum LocalCommand { /// Listing Management — manage local business listings Listing { @@ -16,7 +16,7 @@ pub enum LocalCommand { }, } -#[derive(Subcommand)] +#[derive(Debug, Subcommand)] pub enum ListingCommand { /// List all locations List, @@ -51,7 +51,7 @@ pub enum ListingCommand { }, } -#[derive(Subcommand)] +#[derive(Debug, Subcommand)] pub enum MapRankCommand { /// List Map Rank Tracker campaigns Campaigns, @@ -60,17 +60,45 @@ pub enum MapRankCommand { Keywords { /// Campaign ID campaign_id: String, + + /// Report date to query, if not using the latest report + #[arg(long)] + report_date: Option, }, - /// Get heatmap data for a campaign + /// Get heatmap data for a campaign keyword Heatmap { /// Campaign ID campaign_id: String, + + /// Keyword ID + #[arg(long)] + keyword_id: String, + + /// Google business CID. Either --cid or --place-ids is required. + #[arg(long)] + cid: Option, + + /// Google Place IDs. Either --cid or --place-ids is required. + #[arg(long, value_delimiter = ',')] + place_ids: Vec, + + /// Report date to query, if not using the latest report + #[arg(long)] + report_date: Option, }, - /// Get competitors for a campaign + /// Get competitors for a campaign keyword Competitors { /// Campaign ID campaign_id: String, + + /// Keyword ID + #[arg(long)] + keyword_id: String, + + /// Report date in ISO-8601 format + #[arg(long)] + report_date: String, }, } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index a859ec3..3401f87 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -10,7 +10,7 @@ pub mod trends; use clap::{Parser, Subcommand}; -#[derive(Parser)] +#[derive(Debug, Parser)] #[command( name = "semrush", version, @@ -68,7 +68,7 @@ pub struct Cli { pub command: Commands, } -#[derive(Subcommand)] +#[derive(Debug, Subcommand)] pub enum Commands { /// Domain analytics — overview, organic, paid, competitors, etc. Domain { @@ -137,7 +137,7 @@ pub enum Commands { }, } -#[derive(Subcommand)] +#[derive(Debug, Subcommand)] pub enum CacheCommand { /// Clear all cached responses Clear, diff --git a/src/cli/overview.rs b/src/cli/overview.rs index 69b14f7..164ab6b 100644 --- a/src/cli/overview.rs +++ b/src/cli/overview.rs @@ -1,6 +1,6 @@ use clap::Subcommand; -#[derive(Subcommand)] +#[derive(Debug, Subcommand)] pub enum OverviewCommand { /// Get Semrush Rank — top domains by visibility Rank, diff --git a/src/cli/project.rs b/src/cli/project.rs index 62a2b5b..3d15e71 100644 --- a/src/cli/project.rs +++ b/src/cli/project.rs @@ -1,6 +1,6 @@ use clap::Subcommand; -#[derive(Subcommand)] +#[derive(Debug, Subcommand)] pub enum ProjectCommand { /// List all projects List, @@ -29,7 +29,7 @@ pub enum ProjectCommand { /// New project name #[arg(long)] - name: Option, + name: String, }, /// Delete a project diff --git a/src/cli/trends.rs b/src/cli/trends.rs index 0a73b58..f41a226 100644 --- a/src/cli/trends.rs +++ b/src/cli/trends.rs @@ -1,10 +1,11 @@ use clap::Subcommand; -#[derive(Subcommand)] +#[derive(Debug, Subcommand)] pub enum TrendsCommand { /// Get traffic summary for one or more domains (up to 200) Summary { /// Domains to analyze (comma-separated or multiple args) + #[arg(required = true, num_args = 1..=200)] targets: Vec, /// Device type: desktop, mobile @@ -25,13 +26,9 @@ pub enum TrendsCommand { /// Domain to analyze target: String, - /// Start date (YYYY-MM-DD) - #[arg(long)] - date_from: Option, - - /// End date (YYYY-MM-DD) - #[arg(long)] - date_to: Option, + /// Month to report (YYYY-MM-01) + #[arg(long, alias = "date-from")] + date: Option, /// Include forecasted data #[arg(long)] @@ -48,11 +45,9 @@ pub enum TrendsCommand { Weekly { target: String, - #[arg(long)] - date_from: Option, - - #[arg(long)] - date_to: Option, + /// Month to report (YYYY-MM-01) + #[arg(long, alias = "date-from")] + date: Option, #[arg(long)] forecast: bool, diff --git a/src/main.rs b/src/main.rs index 75473d0..4932390 100644 --- a/src/main.rs +++ b/src/main.rs @@ -104,15 +104,20 @@ async fn main() { return; } - // All remaining executable API commands need an API key - let api_key = match config.resolve_api_key(cli.api_key.as_deref()) { - Some(key) => key, - None => { - AppError::AuthFailed { - message: "No API key provided. Set SEMRUSH_API_KEY, use --api-key, or run `semrush account auth setup`.".to_string(), + let api_key = if command_requires_api_key(&cli.command) { + match config.resolve_api_key(cli.api_key.as_deref()) { + Some(key) => key, + None => { + AppError::AuthFailed { + message: "No API key provided. Set SEMRUSH_API_KEY, use --api-key, or run `semrush account auth setup`.".to_string(), + } + .print_and_exit(); } - .print_and_exit(); } + } else { + config + .resolve_api_key(cli.api_key.as_deref()) + .unwrap_or_default() }; let client = api::client::SemrushClient::new(api_key, config.rate_limit.requests_per_second); @@ -208,8 +213,17 @@ async fn run_api_command( /// Build a deterministic cache key string from command params (excluding API key). fn build_cache_key(cli: &Cli, report_type_key: &str) -> String { format!( - "{}|db={}|limit={}|offset={}", - report_type_key, cli.database, cli.limit, cli.offset + "{}|db={}|limit={}|offset={}|command={:?}", + report_type_key, cli.database, cli.limit, cli.offset, cli.command + ) +} + +fn command_requires_api_key(command: &Commands) -> bool { + !matches!( + command, + Commands::Local { + command: cli::local::LocalCommand::MapRank { .. } + } ) } @@ -639,7 +653,7 @@ async fn execute_backlink( BacklinkCommand::Compare { targets, target_type, - } => api::v1_backlinks::compare(client, targets, target_type).await, + } => api::v1_backlinks::compare(client, targets, target_type, cli.limit, cli.offset).await, BacklinkCommand::Batch { targets, @@ -709,14 +723,12 @@ async fn execute_trends( country.as_deref(), device.as_deref(), date.as_deref(), - cli.limit, ) .await } TrendsCommand::Daily { target, - date_from, - date_to, + date, forecast, country, device, @@ -724,8 +736,7 @@ async fn execute_trends( api::v3_trends::daily( client, target, - date_from.as_deref(), - date_to.as_deref(), + date.as_deref(), *forecast, country.as_deref(), device.as_deref(), @@ -734,8 +745,7 @@ async fn execute_trends( } TrendsCommand::Weekly { target, - date_from, - date_to, + date, forecast, country, device, @@ -743,8 +753,7 @@ async fn execute_trends( api::v3_trends::weekly( client, target, - date_from.as_deref(), - date_to.as_deref(), + date.as_deref(), *forecast, country.as_deref(), device.as_deref(), @@ -885,25 +894,16 @@ async fn execute_project( ) -> Result, AppError> { use cli::project::ProjectCommand; - // v4 APIs need OAuth2 token — check env var for now - let oauth_token = std::env::var("SEMRUSH_OAUTH_TOKEN").map_err(|_| AppError::AuthFailed { - message: "OAuth2 token required for v4 API. Set SEMRUSH_OAUTH_TOKEN or run `semrush account auth setup-oauth`.".to_string(), - })?; - match command { - ProjectCommand::List => api::v4_projects::list(client, &oauth_token).await, - ProjectCommand::Get { project_id } => { - api::v4_projects::get(client, &oauth_token, project_id).await - } + ProjectCommand::List => api::v4_projects::list(client).await, + ProjectCommand::Get { project_id } => api::v4_projects::get(client, project_id).await, ProjectCommand::Create { name, domain } => { - api::v4_projects::create(client, &oauth_token, name, domain).await + api::v4_projects::create(client, name, domain).await } ProjectCommand::Update { project_id, name } => { - api::v4_projects::update(client, &oauth_token, project_id, name.as_deref()).await - } - ProjectCommand::Delete { project_id } => { - api::v4_projects::delete(client, &oauth_token, project_id).await + api::v4_projects::update(client, project_id, name).await } + ProjectCommand::Delete { project_id } => api::v4_projects::delete(client, project_id).await, } } @@ -913,42 +913,81 @@ async fn execute_local( ) -> Result, AppError> { use cli::local::{ListingCommand, LocalCommand, MapRankCommand}; - let oauth_token = std::env::var("SEMRUSH_OAUTH_TOKEN").map_err(|_| AppError::AuthFailed { - message: "OAuth2 token required for v4 API. Set SEMRUSH_OAUTH_TOKEN or run `semrush account auth setup-oauth`.".to_string(), - })?; - match command { LocalCommand::Listing { command } => match command { - ListingCommand::List => api::v4_local::listing_list(client, &oauth_token).await, + ListingCommand::List => api::v4_local::listing_list(client).await, ListingCommand::Get { location_id } => { - api::v4_local::listing_get(client, &oauth_token, location_id).await + api::v4_local::listing_get(client, location_id).await } ListingCommand::Create { json } => { let body = parse_json_input(json.as_deref())?; - api::v4_local::listing_create(client, &oauth_token, &body).await + api::v4_local::listing_create(client, &body).await } ListingCommand::Update { location_id, json } => { let body = parse_json_input(json.as_deref())?; - api::v4_local::listing_update(client, &oauth_token, location_id, &body).await + api::v4_local::listing_update(client, location_id, &body).await } ListingCommand::Delete { location_id } => { - api::v4_local::listing_delete(client, &oauth_token, location_id).await + api::v4_local::listing_delete(client, location_id).await } }, - LocalCommand::MapRank { command } => match command { - MapRankCommand::Campaigns => { - api::v4_local::map_rank_campaigns(client, &oauth_token).await - } - MapRankCommand::Keywords { campaign_id } => { - api::v4_local::map_rank_keywords(client, &oauth_token, campaign_id).await - } - MapRankCommand::Heatmap { campaign_id } => { - api::v4_local::map_rank_heatmap(client, &oauth_token, campaign_id).await - } - MapRankCommand::Competitors { campaign_id } => { - api::v4_local::map_rank_competitors(client, &oauth_token, campaign_id).await + LocalCommand::MapRank { command } => { + let oauth_token = + std::env::var("SEMRUSH_OAUTH_TOKEN").map_err(|_| AppError::AuthFailed { + message: "OAuth2 token required for Map Rank Tracker. Set SEMRUSH_OAUTH_TOKEN." + .to_string(), + })?; + + match command { + MapRankCommand::Campaigns => { + api::v4_local::map_rank_campaigns(client, &oauth_token).await + } + MapRankCommand::Keywords { + campaign_id, + report_date, + } => { + api::v4_local::map_rank_keywords( + client, + &oauth_token, + campaign_id, + report_date.as_deref(), + ) + .await + } + MapRankCommand::Heatmap { + campaign_id, + keyword_id, + cid, + place_ids, + report_date, + } => { + api::v4_local::map_rank_heatmap( + client, + &oauth_token, + campaign_id, + keyword_id, + cid.as_deref(), + place_ids, + report_date.as_deref(), + ) + .await + } + MapRankCommand::Competitors { + campaign_id, + keyword_id, + report_date, + } => { + api::v4_local::map_rank_competitors( + client, + &oauth_token, + campaign_id, + keyword_id, + report_date, + ) + .await + } } - }, + } } } @@ -1079,3 +1118,26 @@ async fn handle_account(command: &cli::account::AccountCommand, _cli: &Cli, conf }, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cache_key_includes_command_arguments() { + let first = Cli::try_parse_from(["semrush", "domain", "overview", "example.com"]).unwrap(); + let second = Cli::try_parse_from(["semrush", "domain", "overview", "example.org"]).unwrap(); + + assert_ne!( + build_cache_key(&first, "domain_overview"), + build_cache_key(&second, "domain_overview") + ); + } + + #[test] + fn map_rank_commands_do_not_require_api_key() { + let cli = Cli::try_parse_from(["semrush", "local", "map-rank", "campaigns"]).unwrap(); + + assert!(!command_requires_api_key(&cli.command)); + } +}