recker 1.0.75 → 1.0.76-next.dfaea9d

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.
@@ -0,0 +1,107 @@
1
+ import type { SearchTransport } from '../search/google.js';
2
+ import type { SeoReport } from './types.js';
3
+ export type KeywordCampaignSource = 'discovered' | 'preset';
4
+ export type CampaignResultPlacement = 'ad' | 'organic' | 'unknown';
5
+ export interface KeywordCampaignSeedInput {
6
+ keyword: string;
7
+ source?: KeywordCampaignSource;
8
+ sourcePage?: string;
9
+ weight?: number;
10
+ }
11
+ export interface KeywordCampaignSeed {
12
+ keyword: string;
13
+ normalizedKeyword: string;
14
+ source: KeywordCampaignSource;
15
+ sourcePage?: string;
16
+ weight: number;
17
+ }
18
+ export interface KeywordCampaignOptions {
19
+ targetUrl: string;
20
+ discoveredKeywords?: KeywordCampaignSeedInput[] | string[];
21
+ presetKeywords?: string[];
22
+ minKeywordLength?: number;
23
+ maxQueries?: number;
24
+ maxResultsPerQuery?: number;
25
+ transport?: SearchTransport;
26
+ timeout?: number;
27
+ country?: string;
28
+ gl?: string;
29
+ hl?: string;
30
+ minWeight?: number;
31
+ }
32
+ export interface KeywordCampaignCompetitorResult {
33
+ domain: string;
34
+ rank: number;
35
+ placement: CampaignResultPlacement;
36
+ placementHint?: string;
37
+ url: string;
38
+ title: string;
39
+ }
40
+ export interface KeywordCampaignResult {
41
+ keyword: string;
42
+ source: KeywordCampaignSource;
43
+ sourcePage?: string;
44
+ sourceWeight: number;
45
+ found: boolean;
46
+ bestPosition: number | null;
47
+ totalChecked: number;
48
+ matchedUrl?: string;
49
+ matchedTitle?: string;
50
+ matchedDisplayUrl?: string;
51
+ placement: CampaignResultPlacement;
52
+ placementHint?: string;
53
+ searchUrl: string;
54
+ searchTransport: SearchTransport;
55
+ competitors: KeywordCampaignCompetitorResult[];
56
+ }
57
+ export interface KeywordCampaignPageStats {
58
+ pageUrl: string;
59
+ tracked: number;
60
+ found: number;
61
+ avgPosition: number | null;
62
+ top3: number;
63
+ top10: number;
64
+ }
65
+ export interface KeywordCampaignSummary {
66
+ queriesRequested: number;
67
+ queriesExecuted: number;
68
+ queriesFound: number;
69
+ avgTopPosition: number | null;
70
+ top3Count: number;
71
+ top10Count: 0 | number;
72
+ topOrganicCompetitors: KeywordCampaignCompetitorSummary[];
73
+ topPaidCompetitors: KeywordCampaignCompetitorSummary[];
74
+ competitorCoverage: {
75
+ organicUniqueDomains: number;
76
+ paidUniqueDomains: number;
77
+ };
78
+ }
79
+ export interface KeywordCampaignCompetitorSummary {
80
+ domain: string;
81
+ organicQueries: number;
82
+ paidQueries: number;
83
+ bestOrganicRank: number | null;
84
+ bestPaidRank: number | null;
85
+ matchedKeywords: number;
86
+ }
87
+ export interface CampaignActivitySignal {
88
+ active: boolean;
89
+ confidence: 'high' | 'medium' | 'low';
90
+ evidence: string[];
91
+ }
92
+ export interface KeywordCampaignReport {
93
+ targetUrl: string;
94
+ targetDomain: string;
95
+ results: KeywordCampaignResult[];
96
+ summary: KeywordCampaignSummary;
97
+ pageComparison: KeywordCampaignPageStats[];
98
+ campaign: CampaignActivitySignal;
99
+ }
100
+ export interface KeywordCampaignExtractionOptions {
101
+ maxKeywords?: number;
102
+ minKeywordLength?: number;
103
+ }
104
+ export declare function extractKeywordCampaignSeedsFromReport(report: SeoReport, options?: KeywordCampaignExtractionOptions & {
105
+ sourcePage?: string;
106
+ }): KeywordCampaignSeed[];
107
+ export declare function analyzeKeywordCampaign(options: KeywordCampaignOptions): Promise<KeywordCampaignReport>;
@@ -0,0 +1,380 @@
1
+ import { searchGoogleAdvanced } from '../search/google.js';
2
+ function normalizeKeyword(value) {
3
+ return value
4
+ .toLowerCase()
5
+ .replace(/[^\p{L}\p{N}\s-]+/gu, ' ')
6
+ .replace(/\s+/g, ' ')
7
+ .trim();
8
+ }
9
+ function normalizeHost(target) {
10
+ try {
11
+ const parsed = new URL(target);
12
+ return parsed.hostname.replace(/^www\./, '').toLowerCase();
13
+ }
14
+ catch {
15
+ return target.replace(/^www\./, '').toLowerCase();
16
+ }
17
+ }
18
+ function clampNumber(value, fallback) {
19
+ if (value === undefined || !Number.isFinite(value) || value <= 0)
20
+ return fallback;
21
+ return Math.max(1, Math.floor(value));
22
+ }
23
+ function normalizePlacement(value) {
24
+ if (value === 'ad')
25
+ return 'ad';
26
+ if (value === 'organic')
27
+ return 'organic';
28
+ return 'unknown';
29
+ }
30
+ function toSeedArray(seed = []) {
31
+ return seed.map((item) => {
32
+ if (typeof item === 'string') {
33
+ return {
34
+ keyword: item,
35
+ normalizedKeyword: normalizeKeyword(item),
36
+ source: 'preset',
37
+ weight: 1,
38
+ };
39
+ }
40
+ return {
41
+ keyword: item.keyword,
42
+ normalizedKeyword: normalizeKeyword(item.keyword),
43
+ source: item.source ?? 'discovered',
44
+ sourcePage: item.sourcePage,
45
+ weight: item.weight ?? 1,
46
+ };
47
+ });
48
+ }
49
+ export function extractKeywordCampaignSeedsFromReport(report, options = {}) {
50
+ const maxKeywords = options.maxKeywords ?? 10;
51
+ const minKeywordLength = options.minKeywordLength ?? 2;
52
+ return (report.keywords?.topKeywords ?? [])
53
+ .map((item) => ({
54
+ keyword: item.word,
55
+ normalizedKeyword: normalizeKeyword(item.word),
56
+ source: 'discovered',
57
+ sourcePage: options.sourcePage,
58
+ weight: Math.max(1, Math.round(item.count)),
59
+ }))
60
+ .filter(seed => seed.keyword.trim().length >= minKeywordLength
61
+ && seed.normalizedKeyword.length >= minKeywordLength
62
+ && seed.weight > 0)
63
+ .slice(0, maxKeywords);
64
+ }
65
+ function sortSeedsByWeight(a, b) {
66
+ if (a.weight === b.weight) {
67
+ return a.normalizedKeyword.localeCompare(b.normalizedKeyword);
68
+ }
69
+ return b.weight - a.weight;
70
+ }
71
+ function mergeKeywordSeeds(discovered, preset, minKeywordLength = 2, minWeight = 1) {
72
+ const merged = new Map();
73
+ const upsert = (seed) => {
74
+ if (!seed.normalizedKeyword || seed.normalizedKeyword.length < minKeywordLength)
75
+ return;
76
+ if (seed.weight < minWeight)
77
+ return;
78
+ const existing = merged.get(seed.normalizedKeyword);
79
+ if (!existing) {
80
+ merged.set(seed.normalizedKeyword, seed);
81
+ return;
82
+ }
83
+ if (existing.source === 'preset' && seed.source === 'discovered') {
84
+ merged.set(seed.normalizedKeyword, seed);
85
+ return;
86
+ }
87
+ if (seed.source === existing.source && seed.weight > existing.weight) {
88
+ merged.set(seed.normalizedKeyword, seed);
89
+ }
90
+ };
91
+ for (const seed of discovered)
92
+ upsert(seed);
93
+ for (const seed of preset)
94
+ upsert(seed);
95
+ return [...merged.values()].sort(sortSeedsByWeight);
96
+ }
97
+ function buildEmptyReport(targetUrl, targetDomain) {
98
+ return {
99
+ targetUrl,
100
+ targetDomain,
101
+ results: [],
102
+ summary: {
103
+ queriesRequested: 0,
104
+ queriesExecuted: 0,
105
+ queriesFound: 0,
106
+ avgTopPosition: null,
107
+ top3Count: 0,
108
+ top10Count: 0,
109
+ topOrganicCompetitors: [],
110
+ topPaidCompetitors: [],
111
+ competitorCoverage: {
112
+ organicUniqueDomains: 0,
113
+ paidUniqueDomains: 0,
114
+ },
115
+ },
116
+ pageComparison: [],
117
+ campaign: {
118
+ active: false,
119
+ confidence: 'low',
120
+ evidence: [],
121
+ },
122
+ };
123
+ }
124
+ function dedupeDomainResults(results, targetDomain) {
125
+ const seen = new Set();
126
+ const domainResults = [];
127
+ for (const result of results) {
128
+ if (!result.domain || result.domain === targetDomain)
129
+ continue;
130
+ if (seen.has(result.domain))
131
+ continue;
132
+ seen.add(result.domain);
133
+ domainResults.push({
134
+ domain: result.domain,
135
+ rank: result.rank,
136
+ placement: result.placement,
137
+ placementHint: result.placementHint,
138
+ url: result.url,
139
+ title: result.title,
140
+ });
141
+ }
142
+ return domainResults;
143
+ }
144
+ function trackCompetitors(candidates, keyword, targetDomain, trackers) {
145
+ for (const competitor of candidates) {
146
+ if (competitor.domain === targetDomain)
147
+ continue;
148
+ const current = trackers.get(competitor.domain) ?? {
149
+ organicQueries: 0,
150
+ paidQueries: 0,
151
+ bestOrganicRank: null,
152
+ bestPaidRank: null,
153
+ keywords: new Set(),
154
+ };
155
+ if (competitor.placement === 'ad') {
156
+ current.paidQueries += 1;
157
+ if (current.bestPaidRank === null || competitor.rank < current.bestPaidRank) {
158
+ current.bestPaidRank = competitor.rank;
159
+ }
160
+ }
161
+ else {
162
+ current.organicQueries += 1;
163
+ if (current.bestOrganicRank === null || competitor.rank < current.bestOrganicRank) {
164
+ current.bestOrganicRank = competitor.rank;
165
+ }
166
+ }
167
+ current.keywords.add(keyword);
168
+ trackers.set(competitor.domain, current);
169
+ }
170
+ }
171
+ function finalizeCompetitorSummary(trackers) {
172
+ const ranked = [...trackers.entries()].map(([domain, tracker]) => ({
173
+ domain,
174
+ organicQueries: tracker.organicQueries,
175
+ paidQueries: tracker.paidQueries,
176
+ bestOrganicRank: tracker.bestOrganicRank,
177
+ bestPaidRank: tracker.bestPaidRank,
178
+ matchedKeywords: tracker.keywords.size,
179
+ score: (tracker.organicQueries * 2) + (tracker.paidQueries * 3),
180
+ }));
181
+ const scoreSort = (a, b) => b.score - a.score || a.domain.localeCompare(b.domain);
182
+ const topOrganic = ranked
183
+ .filter((entry) => entry.organicQueries > 0)
184
+ .sort(scoreSort)
185
+ .map(({ score: _ignored, ...entry }) => entry)
186
+ .slice(0, 10);
187
+ const topPaid = ranked
188
+ .filter((entry) => entry.paidQueries > 0)
189
+ .sort(scoreSort)
190
+ .map(({ score: _ignored, ...entry }) => entry)
191
+ .slice(0, 10);
192
+ return {
193
+ topOrganic,
194
+ topPaid,
195
+ coverage: {
196
+ organicUniqueDomains: ranked.filter((entry) => entry.organicQueries > 0).length,
197
+ paidUniqueDomains: ranked.filter((entry) => entry.paidQueries > 0).length,
198
+ },
199
+ };
200
+ }
201
+ export async function analyzeKeywordCampaign(options) {
202
+ const targetUrl = options.targetUrl.trim();
203
+ const targetDomain = normalizeHost(targetUrl);
204
+ const queryLimit = clampNumber(options.maxQueries, 20);
205
+ const defaultCampaignResultLimit = 25;
206
+ const searchResultLimit = Math.max(clampNumber(options.maxResultsPerQuery, defaultCampaignResultLimit), 20);
207
+ const minKeywordLength = options.minKeywordLength ?? 2;
208
+ const minWeight = Math.max(1, options.minWeight ?? 1);
209
+ const discovered = toSeedArray(options.discoveredKeywords ?? []);
210
+ const preset = (options.presetKeywords ?? []).map((keyword) => ({
211
+ keyword,
212
+ normalizedKeyword: normalizeKeyword(keyword),
213
+ source: 'preset',
214
+ weight: 1,
215
+ }));
216
+ const seeds = mergeKeywordSeeds(discovered, preset, minKeywordLength, minWeight).slice(0, queryLimit);
217
+ const report = buildEmptyReport(targetUrl, targetDomain);
218
+ if (seeds.length === 0 || !targetDomain) {
219
+ report.summary.queriesRequested = 0;
220
+ return report;
221
+ }
222
+ const pageBuckets = new Map();
223
+ const competitorTrackers = new Map();
224
+ const campaignEvidence = [];
225
+ report.summary.queriesRequested = seeds.length;
226
+ for (const seed of seeds) {
227
+ const query = seed.keyword;
228
+ try {
229
+ const response = await searchGoogleAdvanced(query, {
230
+ num: searchResultLimit,
231
+ transport: options.transport,
232
+ timeout: options.timeout,
233
+ country: options.country,
234
+ gl: options.gl,
235
+ hl: options.hl,
236
+ });
237
+ report.summary.queriesExecuted += 1;
238
+ const parsedResults = response.results.map((result) => ({
239
+ ...result,
240
+ domain: normalizeHost(result.url),
241
+ placement: normalizePlacement(result.placement),
242
+ }));
243
+ const matched = parsedResults.find((result) => {
244
+ return result.domain && result.domain === targetDomain;
245
+ });
246
+ const competitorCandidates = dedupeDomainResults(parsedResults.map((result) => ({
247
+ domain: result.domain,
248
+ rank: result.rank,
249
+ placement: result.placement,
250
+ placementHint: result.placementHint,
251
+ url: result.url,
252
+ title: result.title,
253
+ })), targetDomain);
254
+ trackCompetitors(competitorCandidates, seed.normalizedKeyword, targetDomain, competitorTrackers);
255
+ const searchResult = {
256
+ keyword: query,
257
+ source: seed.source,
258
+ sourcePage: seed.sourcePage,
259
+ sourceWeight: seed.weight,
260
+ found: Boolean(matched),
261
+ bestPosition: matched?.rank ?? null,
262
+ totalChecked: parsedResults.length,
263
+ matchedUrl: matched?.url,
264
+ matchedTitle: matched?.title,
265
+ matchedDisplayUrl: matched?.displayedUrl,
266
+ placement: matched ? normalizePlacement(matched.placement) : 'unknown',
267
+ placementHint: matched?.placementHint,
268
+ searchUrl: response.searchUrl,
269
+ searchTransport: response.transport.used,
270
+ competitors: competitorCandidates,
271
+ };
272
+ if (searchResult.found && searchResult.bestPosition !== null) {
273
+ const position = searchResult.bestPosition;
274
+ report.summary.queriesFound += 1;
275
+ if (position <= 3)
276
+ report.summary.top3Count += 1;
277
+ if (position <= 10)
278
+ report.summary.top10Count += 1;
279
+ if (searchResult.placement === 'ad') {
280
+ campaignEvidence.push(`${query} aparece como anúncio em posição #${position}`);
281
+ }
282
+ else {
283
+ campaignEvidence.push(`${query} aparece orgânico em posição #${position}`);
284
+ }
285
+ const pageKey = seed.sourcePage ?? (seed.source === 'preset' ? 'preset-queries' : 'unknown');
286
+ const bucket = pageBuckets.get(pageKey) ?? {
287
+ tracked: 0,
288
+ found: 0,
289
+ totalPosition: 0,
290
+ positions: [],
291
+ top3: 0,
292
+ top10: 0,
293
+ };
294
+ bucket.tracked += 1;
295
+ bucket.found += 1;
296
+ bucket.totalPosition += position;
297
+ bucket.positions.push(position);
298
+ if (position <= 3)
299
+ bucket.top3 += 1;
300
+ if (position <= 10)
301
+ bucket.top10 += 1;
302
+ pageBuckets.set(pageKey, bucket);
303
+ }
304
+ else {
305
+ const pageKey = seed.sourcePage ?? (seed.source === 'preset' ? 'preset-queries' : 'unknown');
306
+ const bucket = pageBuckets.get(pageKey) ?? {
307
+ tracked: 0,
308
+ found: 0,
309
+ totalPosition: 0,
310
+ positions: [],
311
+ top3: 0,
312
+ top10: 0,
313
+ };
314
+ bucket.tracked += 1;
315
+ pageBuckets.set(pageKey, bucket);
316
+ }
317
+ report.results.push(searchResult);
318
+ }
319
+ catch {
320
+ const pageKey = seed.sourcePage ?? (seed.source === 'preset' ? 'preset-queries' : 'unknown');
321
+ const bucket = pageBuckets.get(pageKey) ?? {
322
+ tracked: 0,
323
+ found: 0,
324
+ totalPosition: 0,
325
+ positions: [],
326
+ top3: 0,
327
+ top10: 0,
328
+ };
329
+ bucket.tracked += 1;
330
+ pageBuckets.set(pageKey, bucket);
331
+ report.summary.queriesExecuted += 1;
332
+ report.results.push({
333
+ keyword: seed.keyword,
334
+ source: seed.source,
335
+ sourcePage: seed.sourcePage,
336
+ sourceWeight: seed.weight,
337
+ found: false,
338
+ bestPosition: null,
339
+ totalChecked: searchResultLimit,
340
+ placement: 'unknown',
341
+ searchUrl: '',
342
+ searchTransport: options.transport ?? 'undici',
343
+ competitors: [],
344
+ });
345
+ campaignEvidence.push(`Falha ao buscar keyword "${seed.keyword}"`);
346
+ }
347
+ }
348
+ const foundPositions = report.results
349
+ .filter((entry) => entry.found && entry.bestPosition !== null)
350
+ .map((entry) => entry.bestPosition);
351
+ if (foundPositions.length > 0) {
352
+ report.summary.avgTopPosition = Math.round(foundPositions.reduce((acc, pos) => acc + pos, 0) / foundPositions.length);
353
+ }
354
+ const hasTop3 = foundPositions.some((position) => position <= 3);
355
+ const hasTop10 = foundPositions.some((position) => position <= 10);
356
+ report.campaign = {
357
+ active: foundPositions.some((position) => position <= 10),
358
+ confidence: hasTop3 ? 'high' : hasTop10 ? 'medium' : foundPositions.length > 0 ? 'low' : 'low',
359
+ evidence: campaignEvidence,
360
+ };
361
+ const competitorSummary = finalizeCompetitorSummary(competitorTrackers);
362
+ report.summary.topOrganicCompetitors = competitorSummary.topOrganic;
363
+ report.summary.topPaidCompetitors = competitorSummary.topPaid;
364
+ report.summary.competitorCoverage = competitorSummary.coverage;
365
+ report.pageComparison = [...pageBuckets.entries()].map(([pageUrl, bucket]) => ({
366
+ pageUrl,
367
+ tracked: bucket.tracked,
368
+ found: bucket.found,
369
+ avgPosition: bucket.found > 0 ? Math.round(bucket.totalPosition / bucket.found) : null,
370
+ top3: bucket.top3,
371
+ top10: bucket.top10,
372
+ })).sort((a, b) => {
373
+ if (a.avgPosition === null)
374
+ return 1;
375
+ if (b.avgPosition === null)
376
+ return -1;
377
+ return a.avgPosition - b.avgPosition;
378
+ });
379
+ return report;
380
+ }
@@ -38,12 +38,15 @@ export interface GoogleSearchAdvancedOptions {
38
38
  headers?: HeadersInit;
39
39
  includeRawHtml?: boolean;
40
40
  }
41
+ export type GoogleSearchResultPlacement = 'ad' | 'organic' | 'unknown';
41
42
  export interface GoogleSearchResult {
42
43
  rank: number;
43
44
  title: string;
44
45
  url: string;
45
46
  snippet?: string;
46
47
  displayedUrl?: string;
48
+ placement?: GoogleSearchResultPlacement;
49
+ placementHint?: string;
47
50
  source?: string;
48
51
  }
49
52
  export interface SearchTransportDetails {
@@ -23,6 +23,29 @@ const GOOGLE_RESULT_LINK_SELECTORS = [
23
23
  'a[href^="http://www.google.com/url?"]',
24
24
  ];
25
25
  const GOOGLE_RESULT_CONTAINER_SELECTORS = '[data-hveid], [data-ved], div[class*="g"], div[class*="MjjY"], div[class*="tF2Cxc"], [class*="xpd"]';
26
+ const GOOGLE_AD_CONTAINER_CLASS_HINTS = [
27
+ 'ad',
28
+ 'ads',
29
+ 'sponsored',
30
+ 'ad_cx',
31
+ 'pla',
32
+ 'shopping',
33
+ 'uEierd',
34
+ ];
35
+ const GOOGLE_AD_TEXT_HINTS = [
36
+ 'anúncio',
37
+ 'anuncio',
38
+ 'sponsored',
39
+ 'patrocinado',
40
+ 'patrocinada',
41
+ 'patrocínio',
42
+ 'publi',
43
+ 'publicidade',
44
+ 'ad',
45
+ 'ads',
46
+ 'anúncios',
47
+ 'anunciante',
48
+ ];
26
49
  const COUNTRY_CODE_PATTERN = /^[a-z]{2}$/;
27
50
  const COUNTRY_ALIASES = {
28
51
  us: 'us',
@@ -348,6 +371,117 @@ function looksLikeSnippet(text, title) {
348
371
  return false;
349
372
  return true;
350
373
  }
374
+ function escapeRegex(value) {
375
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
376
+ }
377
+ function hasAdHintText(value) {
378
+ if (!value)
379
+ return false;
380
+ const normalized = cleanText(value).toLowerCase();
381
+ if (!normalized)
382
+ return false;
383
+ return GOOGLE_AD_TEXT_HINTS.some((hint) => {
384
+ const pattern = new RegExp(`(^|\\W)${escapeRegex(hint)}(\\W|$)`, 'i');
385
+ return pattern.test(normalized);
386
+ });
387
+ }
388
+ function hasAdClassHint(className) {
389
+ if (!className)
390
+ return false;
391
+ const normalized = cleanText(className).toLowerCase();
392
+ if (!normalized)
393
+ return false;
394
+ return GOOGLE_AD_CONTAINER_CLASS_HINTS.some((hint) => {
395
+ const classes = normalized.split(/\s+/);
396
+ return classes.some((token) => token === hint || token.startsWith(`${hint}-`));
397
+ });
398
+ }
399
+ function detectResultPlacement(anchor, container) {
400
+ const isScrapeElementLike = (value) => {
401
+ if (value === null || value === undefined)
402
+ return false;
403
+ if (typeof value !== 'object')
404
+ return false;
405
+ const candidate = value;
406
+ return 'length' in candidate
407
+ && typeof candidate.length === 'number'
408
+ && typeof candidate.attrs === 'function';
409
+ };
410
+ const isResultContainer = (node) => {
411
+ const tag = (() => {
412
+ const raw = node;
413
+ if (typeof raw.tagName === 'function')
414
+ return raw.tagName().toLowerCase();
415
+ if (typeof raw.tagName === 'string')
416
+ return raw.tagName.toLowerCase();
417
+ return '';
418
+ })();
419
+ return tag !== 'body' && tag !== 'html';
420
+ };
421
+ const anchorParent = typeof anchor.parent === 'function' ? anchor.parent() : undefined;
422
+ const containerParent = typeof container.parent === 'function' ? container.parent() : undefined;
423
+ const anchorNext = typeof anchor.next === 'function' ? anchor.next() : undefined;
424
+ const anchorPrev = typeof anchor.prev === 'function' ? anchor.prev() : undefined;
425
+ const checkList = [
426
+ anchor,
427
+ anchorParent,
428
+ container,
429
+ containerParent,
430
+ anchorNext,
431
+ anchorPrev,
432
+ ].filter((node) => {
433
+ if (!isScrapeElementLike(node) || node.length === 0)
434
+ return false;
435
+ return isResultContainer(node);
436
+ });
437
+ for (const node of checkList) {
438
+ const attributes = node.attrs();
439
+ const className = node.attr('class');
440
+ const dataAttributeKeys = Object.keys(attributes).filter((key) => {
441
+ const normalized = key.toLowerCase();
442
+ return normalized === 'data-text-ad'
443
+ || normalized === 'data-rw'
444
+ || normalized === 'data-snc'
445
+ || normalized.startsWith('data-ad-')
446
+ || normalized === 'data-ved'
447
+ || normalized === 'data-pcu';
448
+ });
449
+ if (dataAttributeKeys.length > 0) {
450
+ return {
451
+ placement: 'ad',
452
+ placementHint: cleanText(`${dataAttributeKeys.join(' ')} ${node.text()}`.slice(0, 120)),
453
+ };
454
+ }
455
+ const dataAttributes = Object.entries(attributes)
456
+ .filter(([key]) => key.startsWith('data-'))
457
+ .map(([, value]) => value)
458
+ .filter((value) => typeof value === 'string');
459
+ const text = cleanText(node.text());
460
+ const dataText = dataAttributes.join(' ');
461
+ if (hasAdHintText(node.attr('data-ved'))) {
462
+ return {
463
+ placement: 'ad',
464
+ placementHint: cleanText(`${text} ${dataText}`.slice(0, 120)),
465
+ };
466
+ }
467
+ if (hasAdClassHint(className) || hasAdHintText(dataText)) {
468
+ return {
469
+ placement: 'ad',
470
+ placementHint: cleanText(`${className} ${dataText}`.slice(0, 120)),
471
+ };
472
+ }
473
+ if (hasAdHintText(text)) {
474
+ return {
475
+ placement: 'ad',
476
+ placementHint: text,
477
+ };
478
+ }
479
+ }
480
+ return {
481
+ placement: 'organic',
482
+ placementHint: undefined,
483
+ };
484
+ }
351
485
  function parseResultStats(text) {
352
486
  const normalized = text.replace(/,/g, '');
353
487
  const match = normalized.match(/([0-9]+)\s*(?:result|resultado)/i);
@@ -380,6 +514,7 @@ function parseSearchPage(html, options) {
380
514
  if (!titleText)
381
515
  continue;
382
516
  const resultContainer = anchor.parents(GOOGLE_RESULT_CONTAINER_SELECTORS).first();
517
+ const placement = detectResultPlacement(anchor, resultContainer);
383
518
  const snippet = (() => {
384
519
  for (const selector of GOOGLE_RESULT_SNIPPET_SELECTOR_ORDER) {
385
520
  const snippetNode = resultContainer.find(selector).first();
@@ -407,6 +542,8 @@ function parseSearchPage(html, options) {
407
542
  url: resultUrl,
408
543
  snippet,
409
544
  displayedUrl: extractDisplayedUrl(resultUrl, anchor.text()),
545
+ placement: placement.placement,
546
+ placementHint: placement.placementHint,
410
547
  };
411
548
  results.push(item);
412
549
  seen.add(resultUrl);
@@ -1,3 +1,3 @@
1
1
  export { searchGoogleAdvanced } from './google.js';
2
- export type { GoogleSearchAdvancedOptions, GoogleSearchResult, GoogleSearchResponse, SearchTransportDetails, } from './google.js';
2
+ export type { GoogleSearchAdvancedOptions, GoogleSearchResult, GoogleSearchResultPlacement, GoogleSearchResponse, SearchTransportDetails, } from './google.js';
3
3
  export type { SearchTransport } from './google.js';
@@ -15,3 +15,5 @@ export { parseSitemap, validateSitemap, discoverSitemaps, fetchAndValidateSitema
15
15
  export type { SitemapUrl, SitemapIndex, SitemapParseResult, SitemapValidationIssue, SitemapValidationResult, } from './validators/sitemap.js';
16
16
  export { parseLlmsTxt, validateLlmsTxt, fetchAndValidateLlmsTxt, generateLlmsTxtTemplate, } from './validators/llms-txt.js';
17
17
  export type { LlmsTxtLink, LlmsTxtSection, LlmsTxtParseResult, LlmsTxtValidationIssue, LlmsTxtValidationResult, } from './validators/llms-txt.js';
18
+ export { analyzeKeywordCampaign, extractKeywordCampaignSeedsFromReport, } from './keyword-campaign.js';
19
+ export type { CampaignActivitySignal, CampaignResultPlacement, KeywordCampaignCompetitorResult, KeywordCampaignCompetitorSummary, KeywordCampaignExtractionOptions, KeywordCampaignOptions, KeywordCampaignPageStats, KeywordCampaignReport, KeywordCampaignResult, KeywordCampaignSeed, KeywordCampaignSeedInput, KeywordCampaignSource, KeywordCampaignSummary, } from './keyword-campaign.js';
package/dist/seo/index.js CHANGED
@@ -6,3 +6,4 @@ export { generateSeoFilename, resolveOutputPath, writeReport, formatReportForJso
6
6
  export { parseRobotsTxt, validateRobotsTxt, isPathAllowed, fetchAndValidateRobotsTxt, } from './validators/robots.js';
7
7
  export { parseSitemap, validateSitemap, discoverSitemaps, fetchAndValidateSitemap, } from './validators/sitemap.js';
8
8
  export { parseLlmsTxt, validateLlmsTxt, fetchAndValidateLlmsTxt, generateLlmsTxtTemplate, } from './validators/llms-txt.js';
9
+ export { analyzeKeywordCampaign, extractKeywordCampaignSeedsFromReport, } from './keyword-campaign.js';