@adobe/mysticat-shared-seo-client 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## [@adobe/mysticat-shared-seo-client-v1.7.0](https://github.com/adobe/spacecat-shared/compare/@adobe/mysticat-shared-seo-client-v1.6.0...@adobe/mysticat-shared-seo-client-v1.7.0) (2026-07-17)
2
+
3
+ ### Features
4
+
5
+ * **seo-client:** add Semrush broken-links v2 endpoint with OAuth2 and priority scoring ([#1821](https://github.com/adobe/spacecat-shared/issues/1821)) ([9a7b81f](https://github.com/adobe/spacecat-shared/commit/9a7b81f7af5c5e39dd740a0fada7fde8d49a213d))
6
+
1
7
  ## [@adobe/mysticat-shared-seo-client-v1.6.0](https://github.com/adobe/spacecat-shared/compare/@adobe/mysticat-shared-seo-client-v1.5.1...@adobe/mysticat-shared-seo-client-v1.6.0) (2026-06-08)
2
8
 
3
9
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/mysticat-shared-seo-client",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Shared modules of the SpaceCat Services - SEO Client",
5
5
  "type": "module",
6
6
  "engines": {
package/src/client.js CHANGED
@@ -30,6 +30,19 @@ const MAX_PAID_KEYWORDS_FETCH = 10000;
30
30
  const RATE_LIMIT_BASE_DELAY_MS = 1000;
31
31
  const MAX_RETRIES = 4;
32
32
 
33
+ const BROKEN_LINKS_URL = 'https://api.semrush.com/apis/v4/backlinks-external/v0/broken-links';
34
+ const SEMRUSH_TOKEN_URL = 'https://api.semrush.com/apis/v4-raw/auth/v0/oauth2/access_token';
35
+ const TOKEN_REFRESH_BUFFER_MS = 60 * 1000; // refresh 60s before expiry
36
+ const DEFAULT_TOKEN_TTL_MS = 5 * 60 * 1000; // Semrush tokens live ~5 min
37
+ const semrushTokenCache = new Map(); // key: clientId → { token, expiresAt }
38
+ const LOW_VALUE_HOSTS = [
39
+ 'search.yahoo.com', 'bing.com', 'search.brave.com', 'duckduckgo.com',
40
+ 'yandex.com', 'yandex.ru', 'baidu.com', 'web.archive.org',
41
+ 'webcache.googleusercontent.com', 'cache.google.com',
42
+ 'translate.google.com', 'translate.googleusercontent.com',
43
+ 'sites.google.com',
44
+ ];
45
+
33
46
  /**
34
47
  * Major SEO provider databases by search volume. Used as the default fan-out
35
48
  * set for getTopPages to aggregate traffic across top global markets.
@@ -64,8 +77,16 @@ export default class SeoClient {
64
77
  }
65
78
 
66
79
  static createFrom(context) {
67
- const { SEO_API_BASE_URL: apiBaseUrl, SEO_API_KEY: apiKey } = context.env;
68
- return new SeoClient({ apiBaseUrl, apiKey }, fetch, context.log);
80
+ const {
81
+ SEO_API_BASE_URL: apiBaseUrl,
82
+ SEO_API_KEY: apiKey,
83
+ SEMRUSH_CLIENT_ID: semrushClientId,
84
+ SEMRUSH_CLIENT_SECRET: semrushClientSecret,
85
+ SEMRUSH_BROKEN_LINKS_SCOPE: semrushScope,
86
+ } = context.env;
87
+ return new SeoClient({
88
+ apiBaseUrl, apiKey, semrushClientId, semrushClientSecret, semrushScope,
89
+ }, fetch, context.log);
69
90
  }
70
91
 
71
92
  constructor(config, fetchAPI, log = console) {
@@ -87,6 +108,9 @@ export default class SeoClient {
87
108
  this.apiKey = apiKey;
88
109
  this.fetchAPI = fetchAPI;
89
110
  this.log = log;
111
+ this.semrushClientId = config.semrushClientId || null;
112
+ this.semrushClientSecret = config.semrushClientSecret || null;
113
+ this.semrushScope = config.semrushScope || null;
90
114
  }
91
115
 
92
116
  /**
@@ -708,6 +732,148 @@ export default class SeoClient {
708
732
  };
709
733
  }
710
734
 
735
+ hasNewBrokenBacklinksEndpoint() {
736
+ return !!(this.semrushClientId && this.semrushClientSecret && this.semrushScope);
737
+ }
738
+
739
+ async _getSemrushToken() {
740
+ const cacheKey = this.semrushClientId;
741
+ const now = Date.now();
742
+ const cached = semrushTokenCache.get(cacheKey);
743
+ if (cached && now < cached.expiresAt) {
744
+ return cached.token;
745
+ }
746
+
747
+ const body = new URLSearchParams({
748
+ grant_type: 'client_credentials',
749
+ client_id: this.semrushClientId,
750
+ client_secret: this.semrushClientSecret,
751
+ scope: this.semrushScope,
752
+ });
753
+ const r = await this.fetchAPI(SEMRUSH_TOKEN_URL, {
754
+ method: 'POST',
755
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
756
+ body,
757
+ });
758
+ const json = await r.json();
759
+ if (!json.access_token) {
760
+ throw new Error(`Semrush OAuth token request failed (HTTP ${r.status}): ${json.error || 'unknown'}`);
761
+ }
762
+
763
+ const ttlMs = json.expires_in
764
+ ? Math.max((Number(json.expires_in) * 1000) - TOKEN_REFRESH_BUFFER_MS, 1000)
765
+ : DEFAULT_TOKEN_TTL_MS - TOKEN_REFRESH_BUFFER_MS;
766
+ semrushTokenCache.set(cacheKey, { token: json.access_token, expiresAt: now + ttlMs });
767
+
768
+ return json.access_token;
769
+ }
770
+
771
+ static computePriorityScore(row) {
772
+ const pageNorm = (row.page_score ?? 0) / 100;
773
+ const domainNorm = (row.domain_score ?? 0) / 100;
774
+ let recency = 0;
775
+ if (row.first_seen_at) {
776
+ const daysSince = (Date.now() - new Date(row.first_seen_at).getTime())
777
+ / (1000 * 60 * 60 * 24);
778
+ if (daysSince <= 30) {
779
+ recency = 1.0;
780
+ } else if (daysSince <= 90) {
781
+ recency = 0.75;
782
+ } else if (daysSince <= 180) {
783
+ recency = 0.5;
784
+ } else if (daysSince <= 365) {
785
+ recency = 0.25;
786
+ } else {
787
+ recency = 0.1;
788
+ }
789
+ }
790
+ return Math.round(((0.50 * pageNorm) + (0.40 * domainNorm) + (0.10 * recency)) * 1000) / 1000;
791
+ }
792
+
793
+ async getBrokenBacklinksV2(url, limit = 50) {
794
+ if (!hasText(url)) {
795
+ throw new Error(`Invalid URL: ${url}`);
796
+ }
797
+ if (!this.hasNewBrokenBacklinksEndpoint()) {
798
+ throw new Error('Missing Semrush OAuth2 credentials (SEMRUSH_CLIENT_ID, SEMRUSH_CLIENT_SECRET, SEMRUSH_BROKEN_LINKS_SCOPE)');
799
+ }
800
+
801
+ const effectiveLimit = getLimit(limit, 100);
802
+ const FETCH_LIMIT = 100;
803
+
804
+ const token = await this._getSemrushToken();
805
+
806
+ const notLike = LOW_VALUE_HOSTS.map((h) => `AND source_url NOT LIKE '%${h}%'`).join(' ');
807
+ const filter = `is_nofollow=false AND is_lost=false AND response_code=200 AND is_image=false AND is_ugc=false AND domain_score>=50 ${notLike}`;
808
+
809
+ const params = new URLSearchParams({
810
+ url,
811
+ scope: 'ROOT_DOMAIN',
812
+ limit: String(FETCH_LIMIT),
813
+ order_by: 'domain_score',
814
+ direction: 'desc',
815
+ filter,
816
+ limit_by_field: 'target_url',
817
+ limit_by_limit: '1',
818
+ });
819
+
820
+ const requestUrl = `${BROKEN_LINKS_URL}?${params}`;
821
+ const fullAuditRef = requestUrl;
822
+
823
+ const r = await this.fetchAPI(requestUrl, {
824
+ headers: { Authorization: `Bearer ${token}` },
825
+ });
826
+
827
+ if (!r.ok) {
828
+ const bodyText = await r.text();
829
+ throw new Error(`Semrush broken-links endpoint HTTP ${r.status}: ${bodyText.slice(0, 500)}`);
830
+ }
831
+
832
+ const { data } = await r.json();
833
+
834
+ const scored = (data || [])
835
+ .map((row) => ({ ...row, score: SeoClient.computePriorityScore(row) }))
836
+ .sort((a, b) => b.score - a.score)
837
+ .slice(0, effectiveLimit);
838
+
839
+ const total = scored.length;
840
+ scored.forEach((row, i) => {
841
+ const pct = (i + 1) / total;
842
+ let relativeLabel;
843
+ if (pct <= 0.20) {
844
+ relativeLabel = 'High';
845
+ } else if (pct <= 0.50) {
846
+ relativeLabel = 'Medium';
847
+ } else {
848
+ relativeLabel = 'Low';
849
+ }
850
+ // eslint-disable-next-line no-param-reassign
851
+ row.relativeLabel = relativeLabel;
852
+ });
853
+
854
+ const backlinks = scored.map((row) => ({
855
+ title: row.source_title || null,
856
+ url_from: row.source_url,
857
+ url_to: row.target_url,
858
+ traffic_domain: row.domain_score ?? null,
859
+ page_score: row.page_score ?? null,
860
+ domain_score: row.domain_score ?? null,
861
+ first_seen_at: row.first_seen_at ?? null,
862
+ last_seen_at: row.last_seen_at ?? null,
863
+ source_domain: row.source_domain ?? null,
864
+ anchor: row.anchor ?? null,
865
+ is_nofollow: row.is_nofollow ?? null,
866
+ is_lost: row.is_lost ?? null,
867
+ response_code: row.response_code ?? null,
868
+ priority_score: row.score,
869
+ priority_label: row.relativeLabel,
870
+ }));
871
+
872
+ this.log.info(`SEO broken-links v2: url=${url} fetched=${(data || []).length} returned=${backlinks.length}`);
873
+
874
+ return { result: { backlinks }, fullAuditRef };
875
+ }
876
+
711
877
  /**
712
878
  * Retrieves broken backlinks for a domain — links pointing to pages that return 404.
713
879
  *
@@ -841,3 +1007,11 @@ export default class SeoClient {
841
1007
  return STUB_RESPONSE;
842
1008
  }
843
1009
  }
1010
+
1011
+ export function clearSemrushTokenCache(clientId) {
1012
+ if (clientId) {
1013
+ semrushTokenCache.delete(clientId);
1014
+ } else {
1015
+ semrushTokenCache.clear();
1016
+ }
1017
+ }
package/src/index.js CHANGED
@@ -13,7 +13,9 @@
13
13
  import SeoClient from './client.js';
14
14
 
15
15
  export default SeoClient;
16
- export { fetch, BIG_MARKETS, getDatabases } from './client.js';
16
+ export {
17
+ fetch, BIG_MARKETS, getDatabases, clearSemrushTokenCache,
18
+ } from './client.js';
17
19
  export { ENDPOINTS } from './endpoints.js';
18
20
  export {
19
21
  buildQueryParams, parseCsvResponse, coerceValue, getLimit,