@adobe/mysticat-shared-seo-client 1.6.0 → 1.8.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 +12 -0
- package/package.json +1 -1
- package/src/client.js +180 -2
- package/src/index.js +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [@adobe/mysticat-shared-seo-client-v1.8.0](https://github.com/adobe/spacecat-shared/compare/@adobe/mysticat-shared-seo-client-v1.7.0...@adobe/mysticat-shared-seo-client-v1.8.0) (2026-07-22)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* **seo-client:** add getBrokenBacklinksV2 with priority scoring and fix 404 handling (SITES-46957, SITES-47209) ([#1837](https://github.com/adobe/spacecat-shared/issues/1837)) ([1bfebc5](https://github.com/adobe/spacecat-shared/commit/1bfebc50b715fdecccfc5e4919eac36d38049176))
|
|
6
|
+
|
|
7
|
+
## [@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)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **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))
|
|
12
|
+
|
|
1
13
|
## [@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
14
|
|
|
3
15
|
### Features
|
package/package.json
CHANGED
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 {
|
|
68
|
-
|
|
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,152 @@ 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
|
+
if (r.status === 404) {
|
|
829
|
+
this.log.info(`Semrush broken-links returned 404 for ${url} — no data found, returning empty result`);
|
|
830
|
+
return { result: { backlinks: [], totalCount: 0 }, fullAuditRef };
|
|
831
|
+
}
|
|
832
|
+
const bodyText = await r.text();
|
|
833
|
+
throw new Error(`Semrush broken-links endpoint HTTP ${r.status}: ${bodyText.slice(0, 500)}`);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const { data } = await r.json();
|
|
837
|
+
|
|
838
|
+
const scored = (data || [])
|
|
839
|
+
.map((row) => ({ ...row, score: SeoClient.computePriorityScore(row) }))
|
|
840
|
+
.sort((a, b) => b.score - a.score)
|
|
841
|
+
.slice(0, effectiveLimit);
|
|
842
|
+
|
|
843
|
+
const total = scored.length;
|
|
844
|
+
scored.forEach((row, i) => {
|
|
845
|
+
const pct = (i + 1) / total;
|
|
846
|
+
let relativeLabel;
|
|
847
|
+
if (pct <= 0.20) {
|
|
848
|
+
relativeLabel = 'High';
|
|
849
|
+
} else if (pct <= 0.50) {
|
|
850
|
+
relativeLabel = 'Medium';
|
|
851
|
+
} else {
|
|
852
|
+
relativeLabel = 'Low';
|
|
853
|
+
}
|
|
854
|
+
// eslint-disable-next-line no-param-reassign
|
|
855
|
+
row.relativeLabel = relativeLabel;
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
const backlinks = scored.map((row) => ({
|
|
859
|
+
title: row.source_title || null,
|
|
860
|
+
url_from: row.source_url,
|
|
861
|
+
url_to: row.target_url,
|
|
862
|
+
traffic_domain: row.domain_score ?? null,
|
|
863
|
+
page_score: row.page_score ?? null,
|
|
864
|
+
domain_score: row.domain_score ?? null,
|
|
865
|
+
first_seen_at: row.first_seen_at ?? null,
|
|
866
|
+
last_seen_at: row.last_seen_at ?? null,
|
|
867
|
+
source_domain: row.source_domain ?? null,
|
|
868
|
+
anchor: row.anchor ?? null,
|
|
869
|
+
is_nofollow: row.is_nofollow ?? null,
|
|
870
|
+
is_lost: row.is_lost ?? null,
|
|
871
|
+
response_code: row.response_code ?? null,
|
|
872
|
+
priority_score: row.score,
|
|
873
|
+
priority_label: row.relativeLabel,
|
|
874
|
+
}));
|
|
875
|
+
|
|
876
|
+
this.log.info(`SEO broken-links v2: url=${url} fetched=${(data || []).length} returned=${backlinks.length}`);
|
|
877
|
+
|
|
878
|
+
return { result: { backlinks }, fullAuditRef };
|
|
879
|
+
}
|
|
880
|
+
|
|
711
881
|
/**
|
|
712
882
|
* Retrieves broken backlinks for a domain — links pointing to pages that return 404.
|
|
713
883
|
*
|
|
@@ -841,3 +1011,11 @@ export default class SeoClient {
|
|
|
841
1011
|
return STUB_RESPONSE;
|
|
842
1012
|
}
|
|
843
1013
|
}
|
|
1014
|
+
|
|
1015
|
+
export function clearSemrushTokenCache(clientId) {
|
|
1016
|
+
if (clientId) {
|
|
1017
|
+
semrushTokenCache.delete(clientId);
|
|
1018
|
+
} else {
|
|
1019
|
+
semrushTokenCache.clear();
|
|
1020
|
+
}
|
|
1021
|
+
}
|
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 {
|
|
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,
|