@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 +12 -0
- package/package.json +1 -1
- package/src/client.js +217 -8
- package/src/index.js +3 -1
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
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,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
|
|
748
|
-
|
|
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:
|
|
922
|
+
sign: '+', field: 'responsecode', op: 'Eq', value: code,
|
|
756
923
|
}]),
|
|
757
924
|
...epPages.defaultParams,
|
|
758
|
-
}
|
|
759
|
-
|
|
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 {
|
|
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,
|