@adobe/mysticat-shared-seo-client 1.5.1 → 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,15 @@
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
+
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)
8
+
9
+ ### Features
10
+
11
+ * **seo-client:** add quality filters to broken-backlinks Semrush call (404+410, follow, text, no lostlinks) ([#1652](https://github.com/adobe/spacecat-shared/issues/1652)) ([875d92c](https://github.com/adobe/spacecat-shared/commit/875d92c4ceb306b5010118e29287bc81906e980e))
12
+
1
13
  ## [@adobe/mysticat-shared-seo-client-v1.5.1](https://github.com/adobe/spacecat-shared/compare/@adobe/mysticat-shared-seo-client-v1.5.0...@adobe/mysticat-shared-seo-client-v1.5.1) (2026-05-29)
2
14
 
3
15
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/mysticat-shared-seo-client",
3
- "version": "1.5.1",
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
  *
@@ -744,25 +910,49 @@ export default class SeoClient {
744
910
  const epLinks = ENDPOINTS.brokenBacklinks;
745
911
  const effectiveLimit = getLimit(limit, 100);
746
912
 
747
- // Step 1: Find broken (404) target pages, sorted by referring domains
748
- const { body: pagesBody, fullAuditRef } = await this.sendRawRequest({
913
+ // Step 1: Find broken target pages 404 and 410 in parallel (Semrush does not support OR
914
+ // within a single display_filter, so we issue two requests and merge the results).
915
+ const pageParams = (code) => ({
749
916
  type: epPages.type,
750
917
  target: url,
751
918
  target_type: 'domain',
752
919
  export_columns: epPages.columns,
753
920
  display_limit: effectiveLimit,
754
921
  display_filter: buildFilter([{
755
- sign: '+', field: 'responsecode', op: 'Eq', value: '404',
922
+ sign: '+', field: 'responsecode', op: 'Eq', value: code,
756
923
  }]),
757
924
  ...epPages.defaultParams,
758
- }, epPages.path);
759
- const brokenPages = parseCsvResponse(pagesBody);
925
+ });
926
+
927
+ const [{ body: pages404Body, fullAuditRef }, { body: pages410Body }] = await Promise.all([
928
+ this.sendRawRequest(pageParams('404'), epPages.path),
929
+ this.sendRawRequest(pageParams('410'), epPages.path),
930
+ ]);
931
+
932
+ // Merge, deduplicate by source_url, re-sort by domains_num descending, cap at limit.
933
+ const seenUrls = new Set();
934
+ const brokenPages = [
935
+ ...parseCsvResponse(pages404Body),
936
+ ...parseCsvResponse(pages410Body),
937
+ ]
938
+ .filter((p) => {
939
+ if (seenUrls.has(p.source_url)) {
940
+ return false;
941
+ }
942
+ seenUrls.add(p.source_url);
943
+ return true;
944
+ })
945
+ .sort((a, b) => Number(b.domains_num) - Number(a.domains_num))
946
+ .slice(0, effectiveLimit);
760
947
 
761
948
  if (brokenPages.length === 0) {
762
949
  return { result: { backlinks: [] }, fullAuditRef };
763
950
  }
764
951
 
765
- // Step 2: For each broken page, fetch the top backlink by authority score.
952
+ // Step 2: For each broken page, fetch the top backlink by authority score with quality filters:
953
+ // - follow links only (nofollow links carry no SEO value)
954
+ // - text links only (more actionable than image/frame links)
955
+ // - exclude lostlinks (only active broken backlinks, not already-removed ones)
766
956
  const brokenUrls = brokenPages.map((p) => p.source_url);
767
957
  const linkResults = await this.fanOut(brokenUrls, (brokenUrl) => this.sendRawRequest({
768
958
  type: epLinks.type,
@@ -770,6 +960,17 @@ export default class SeoClient {
770
960
  target_type: 'url',
771
961
  export_columns: epLinks.columns,
772
962
  display_limit: 1,
963
+ display_filter: buildFilter([
964
+ {
965
+ sign: '+', field: 'type', op: '', value: 'follow',
966
+ },
967
+ {
968
+ sign: '+', field: 'type', op: '', value: 'text',
969
+ },
970
+ {
971
+ sign: '-', field: 'type', op: '', value: 'lostlink',
972
+ },
973
+ ]),
773
974
  ...epLinks.defaultParams,
774
975
  }, epLinks.path), 'getBrokenBacklinks');
775
976
 
@@ -806,3 +1007,11 @@ export default class SeoClient {
806
1007
  return STUB_RESPONSE;
807
1008
  }
808
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,