pi-chrome-search 2.0.0 → 2.1.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.
@@ -19,12 +19,15 @@
19
19
  */
20
20
 
21
21
  import type { Config } from "../config/index.ts";
22
+ import { resolveConfigPath } from "../config/env.ts";
23
+ import { avitoEngine } from "./avito.ts";
22
24
  import { duckduckgoEngine } from "./duckduckgo.ts";
23
25
  import { googleEngine } from "./google.ts";
24
26
  import { yandexEngine } from "./yandex.ts";
25
27
  import { WebSearchError } from "../runtime/error-codes.ts";
26
28
  import {
27
29
  ENGINE_IDS,
30
+ isPrimaryEngine,
28
31
  type Engine,
29
32
  type EngineId,
30
33
  type EngineRegistry,
@@ -45,6 +48,7 @@ export const DEFAULT_ENGINES: EngineMap = Object.freeze({
45
48
  google: googleEngine,
46
49
  duckduckgo: duckduckgoEngine,
47
50
  yandex: yandexEngine,
51
+ avito: avitoEngine,
48
52
  });
49
53
 
50
54
  /**
@@ -84,13 +88,16 @@ function missingEngine(id: EngineId): WebSearchError {
84
88
  }
85
89
 
86
90
  function disabledEngine(id: EngineId): WebSearchError {
91
+ // S9.2: the hint names the real file and the real key, because "enable it in
92
+ // config" sends the agent hunting. A special engine (avito) ships disabled, so
93
+ // this is the first thing a user of it sees.
87
94
  return new WebSearchError({
88
95
  code: "E_UNSUPPORTED",
89
96
  engine: id,
90
97
  stage: "resolve",
91
98
  retryable: false,
92
99
  message: `engine "${id}" is disabled in config`,
93
- hint: `set config.engines.${id}.enabled = true (or pick another engine); /web-doctor shows the current state`,
100
+ hint: `set config.engines.${id}.enabled = true in ${resolveConfigPath(process.env)} (or pick another engine); /web-doctor shows the current state`,
94
101
  });
95
102
  }
96
103
 
@@ -122,8 +129,10 @@ class Registry implements EngineRegistry {
122
129
 
123
130
  select(requested: EngineSelection): EngineId[] {
124
131
  const usable = new Set(this.enabled());
125
- // `auto` is the canonical order by construction: `enabled()` walks ENGINE_IDS.
126
- if (requested === "auto") return this.enabled();
132
+ // `auto` is the canonical order by construction: `enabled()` walks ENGINE_IDS,
133
+ // and the primary filter is what keeps a special engine (avito) out of it
134
+ // (SPEC S4.7/D11). A caller that wants it names it.
135
+ if (requested === "auto") return this.enabled().filter(isPrimaryEngine);
127
136
 
128
137
  const list = Array.isArray(requested) ? requested : [requested];
129
138
  const selected: EngineId[] = [];
@@ -9,10 +9,40 @@
9
9
 
10
10
  import type { CdpPage } from "../cdp/types.ts";
11
11
 
12
- export type EngineId = "google" | "duckduckgo" | "yandex";
12
+ /**
13
+ * Primary engines answer a general web query. Canonical order; `auto` keeps this
14
+ * order and yandex stays last by policy (SPEC §15 D10).
15
+ */
16
+ export const PRIMARY_ENGINES = ["google", "duckduckgo", "yandex"] as const;
17
+
18
+ /**
19
+ * Special engines answer one site, not the web (SPEC §15 D11). They are never
20
+ * part of `auto`: a marketplace listing is not an answer to "search the web",
21
+ * so a caller must name the engine (`engines: "avito"`) to get it.
22
+ */
23
+ export const SPECIAL_ENGINES = ["avito"] as const;
13
24
 
14
- /** Canonical order. `auto` keeps this order; yandex stays last by policy (D10). */
15
- export const ENGINE_IDS = ["google", "duckduckgo", "yandex"] as const satisfies readonly EngineId[];
25
+ export type PrimaryEngineId = (typeof PRIMARY_ENGINES)[number];
26
+ export type SpecialEngineId = (typeof SPECIAL_ENGINES)[number];
27
+ export type EngineId = PrimaryEngineId | SpecialEngineId;
28
+
29
+ /** Every id the contract knows, primary first. `auto` walks the primary part only. */
30
+ export const ENGINE_IDS = [...PRIMARY_ENGINES, ...SPECIAL_ENGINES] as const satisfies readonly EngineId[];
31
+
32
+ export type EngineClass = "primary" | "special";
33
+
34
+ /** Primary/special is data, so `auto` and the tool's docs cannot drift apart. */
35
+ export const ENGINE_CLASS: Readonly<Record<EngineId, EngineClass>> = Object.freeze({
36
+ google: "primary",
37
+ duckduckgo: "primary",
38
+ yandex: "primary",
39
+ avito: "special",
40
+ });
41
+
42
+ /** True when `auto` may pick this engine (SPEC S4.7). */
43
+ export function isPrimaryEngine(id: EngineId): boolean {
44
+ return ENGINE_CLASS[id] === "primary";
45
+ }
16
46
 
17
47
  export interface Query {
18
48
  text: string;
@@ -42,6 +72,17 @@ export interface SerpItem {
42
72
  * resolved link (S3.5, S7.4).
43
73
  */
44
74
  unresolved?: boolean;
75
+ /**
76
+ * Short facts the card shows around the snippet, in display order: price,
77
+ * availability, rating, location, listing date (SPEC §15 D13).
78
+ *
79
+ * Additive and optional on purpose: a SERP that does not surface them leaves
80
+ * this absent, and an adapter never invents one. Google already prints
81
+ * `103 500,00 ₽ · В наличии · 5,0 ★` under some results; Avito prints price,
82
+ * location and date on every card. The renderer joins them after the snippet
83
+ * and caps the line (`COMPACT_CAPS.factsMaxChars`).
84
+ */
85
+ facts?: readonly string[];
45
86
  }
46
87
 
47
88
  /**
@@ -121,6 +162,15 @@ export interface RawSerp {
121
162
  pageCarried: boolean;
122
163
  /** True only for the keyless HTTP fallback (SPEC §6.5); never for a real SERP. */
123
164
  degraded?: boolean;
165
+ /**
166
+ * One adapter-authored line about the page the caller must not have to guess
167
+ * (S7.6/S7.7): Avito answers a no-match query with "similar listings", and
168
+ * saying so is the difference between a labelled fallback and a silent lie.
169
+ *
170
+ * A fixed adapter constant, never page text — a SERP cannot inject prose into
171
+ * the output through it.
172
+ */
173
+ notice?: string;
124
174
  timings: SerpTimings;
125
175
  }
126
176
 
@@ -171,6 +221,12 @@ export interface EngineInteraction {
171
221
  minCount?: number;
172
222
  /** Optional typing profile; without it `humanize: "typing"` uses the generic delays. */
173
223
  typeProfile?: EngineTypeProfile;
224
+ /**
225
+ * Per-query home page, when the engine's home is parameterized (SPEC D12:
226
+ * Avito's typed search starts on `https://www.avito.ru/<location>/`, so the
227
+ * configured region must reach the warm-up page too). Absent means `homeUrl`.
228
+ */
229
+ homeUrlFor?(q: Query): string;
174
230
  /**
175
231
  * Where the typed path starts (P10-C). `home` (default) opens the engine home
176
232
  * page and types into its box. `serp` navigates the SERP URL first and types
@@ -216,11 +272,14 @@ export type ArgumentSupport = "yes" | "flag";
216
272
  export const SUPPORT_MATRIX: Readonly<
217
273
  Record<SupportArgument, Readonly<Record<EngineId, ArgumentSupport>>>
218
274
  > = Object.freeze({
219
- n: Object.freeze({ google: "yes", duckduckgo: "yes", yandex: "yes" }),
275
+ n: Object.freeze({ google: "yes", duckduckgo: "yes", yandex: "yes", avito: "yes" }),
220
276
  // DuckDuckGo has no pagination in this adapter: page > 1 is served as page 1
221
277
  // and must be reported as `ignored.page`, never silently dropped (S4.2, S7.1).
222
- page: Object.freeze({ google: "yes", duckduckgo: "flag", yandex: "yes" }),
223
- summary: Object.freeze({ google: "yes", duckduckgo: "yes", yandex: "yes" }),
278
+ // Avito paginates with `&p=` (verified live: the page-2 link carries `p=2`).
279
+ page: Object.freeze({ google: "yes", duckduckgo: "flag", yandex: "yes", avito: "yes" }),
280
+ // Avito has no native AI block at all (SPEC D12): asking is reported as
281
+ // `ignored.summary`, never answered with an invented summary.
282
+ summary: Object.freeze({ google: "yes", duckduckgo: "yes", yandex: "yes", avito: "flag" }),
224
283
  });
225
284
 
226
285
  export interface EngineRegistry {
@@ -29,6 +29,7 @@
29
29
 
30
30
  import type { CdpPage } from "../cdp/types.ts";
31
31
  import { waitForMarker } from "../cdp/humanize.ts";
32
+ import { asFacts } from "../extract/facts.ts";
32
33
  import { normalizeUrl } from "../extract/urls.ts";
33
34
  import { systemClock } from "../runtime/clock.ts";
34
35
  import { fail } from "../runtime/errors.ts";
@@ -134,6 +135,8 @@ export interface YandexExtractedItem {
134
135
  title: string;
135
136
  url: string;
136
137
  snippet: string;
138
+ /** Card facts (S7.7): a shop rating and its review count, when shown. */
139
+ facts?: string[];
137
140
  }
138
141
 
139
142
  export interface YandexExtractedAi {
@@ -543,11 +546,33 @@ export function extractFromDocument(doc: Document, arg: YandexExtractionArg): Ya
543
546
  organics += 1;
544
547
 
545
548
  let snippet = "";
549
+ const facts: string[] = [];
546
550
  if (card !== null) {
547
- const snippetElement = card.querySelector(".OrganicTextContentSpan");
551
+ // `.OrganicTextContent` is today's layout (`yandex-rating`), the
552
+ // `…Span` wrapper is the older one (`yandex-neuro`): the old selector alone
553
+ // returned empty snippets on a live SERP.
554
+ const snippetElement = card.querySelector(".OrganicTextContentSpan, .OrganicTextContent");
548
555
  if (snippetElement !== null) snippet = normalize(snippetElement.textContent);
556
+
557
+ /**
558
+ * A shop rating, not a product rating: the card prints "Рейтинг 4,4 из 5"
559
+ * and "414K отзывов на магазин". Both strings are copied, never
560
+ * recomposed, so the label that says what the rating is about survives
561
+ * into the output (S7.7: report, do not invent).
562
+ */
563
+ const label = card.querySelector('[class*="EReviewsLabel"]');
564
+ if (label !== null) {
565
+ const ratingNode = label.querySelector(".A11yHidden");
566
+ const rating = ratingNode === null ? "" : normalize(ratingNode.textContent);
567
+ if (rating !== "") facts.push(rating);
568
+ const reviewsNode = label.querySelector('[class*="EReviewsLabel-Reviews"]');
569
+ const reviews = reviewsNode === null ? "" : normalize(reviewsNode.textContent);
570
+ if (reviews !== "") facts.push(reviews);
571
+ }
572
+ }
573
+ if (results.length < limit) {
574
+ results.push({ title, url, snippet, ...(facts.length === 0 ? {} : { facts }) });
549
575
  }
550
- if (results.length < limit) results.push({ title, url, snippet });
551
576
  }
552
577
 
553
578
  /**
@@ -728,7 +753,8 @@ function asExtraction(value: unknown): { results: SerpItem[]; ai: AiSummary | un
728
753
  const normalized = normalizeUrl(url);
729
754
  if (normalized === null || seenUrls.has(normalized)) continue;
730
755
  seenUrls.add(normalized);
731
- results.push({ title, url: normalized, snippet });
756
+ const facts = asFacts(rawItem.facts);
757
+ results.push({ title, url: normalized, snippet, ...(facts.length === 0 ? {} : { facts }) });
732
758
  }
733
759
 
734
760
  let ai: AiSummary | undefined;
@@ -25,11 +25,13 @@
25
25
  * "ai": { "text": "…", "sources": ["a.com"] }, // omitted when absent
26
26
  * "results": [
27
27
  * { "title": "…", "url": "…", "domain": "…", "snippet": "…", "engine": "google",
28
- * "unresolved": true }
28
+ * "unresolved": true, "facts": ["23 990 ₽", "Самара"] }
29
29
  * // "engine" only when the caller mixed engines; "unresolved" only for an
30
- * // opaque Google /goto wrapper that stayed unresolved (S3.5, S7.4)
30
+ * // opaque Google /goto wrapper that stayed unresolved (S3.5, S7.4); "facts"
31
+ * // only when the card showed price/rating/location (SPEC §15 D13)
31
32
  * ],
32
33
  * "pageCarried": true,
34
+ * "notice": "no exact match: …", // only when the engine answered with a fallback
33
35
  * "ignored": { "page": "duckduckgo" }, // only when an argument was not honoured
34
36
  * "degraded": true, // only when true
35
37
  * "cached": true, // only when true
@@ -65,6 +67,12 @@ export const COMPACT_CAPS = {
65
67
  maxN: 10,
66
68
  /** Results per domain before the domain cap starts deferring them. */
67
69
  perDomainLimit: 2,
70
+ /**
71
+ * Card facts joined on one line (`23 990 ₽ · Самара · Вчера`, SPEC §15 D13).
72
+ * A fact line is display sugar: it never displaces the snippet, and it is cut
73
+ * here so an adapter cannot flood the output with page text.
74
+ */
75
+ factsMaxChars: 120,
68
76
  } as const;
69
77
 
70
78
  export interface CompactOptions {
@@ -99,6 +107,8 @@ export interface CompactItem {
99
107
  engine?: EngineId;
100
108
  /** The URL is an opaque `/goto` wrapper that stayed unresolved (S3.5, S7.4). */
101
109
  unresolved?: boolean;
110
+ /** Card facts (price, location, date) rendered after the snippet; already capped. */
111
+ facts?: readonly string[];
102
112
  }
103
113
 
104
114
  export interface CompactAi {
@@ -118,6 +128,8 @@ export interface CompactResult {
118
128
  pageCarried: boolean;
119
129
  degraded: boolean;
120
130
  cached: boolean;
131
+ /** Adapter-authored caveat about the page (`no exact match: …`); rendered as `note: `. */
132
+ notice?: string;
121
133
  /** Markdown render (always produced). */
122
134
  text: string;
123
135
  /** JSON render; see the schema above. */
@@ -137,6 +149,7 @@ interface MergedItem {
137
149
  snippet: string;
138
150
  engine?: EngineId;
139
151
  unresolved?: boolean;
152
+ facts?: readonly string[];
140
153
  }
141
154
 
142
155
  interface Opts {
@@ -182,6 +195,30 @@ const AI_MIN_BLOCK = AI_NEWLINE + AI_PREFIX.length + 1;
182
195
  */
183
196
  const AI_SOURCES_MAX_SHARE = 0.25;
184
197
  const PAGE_LINE = "\npage: more results available";
198
+ /** Card facts are one line, joined the way a SERP card groups them. */
199
+ const FACTS_SEPARATOR = " · ";
200
+ /** Adapter notice is one short line (`note: …`), never a paragraph. */
201
+ const NOTICE_MAX_CHARS = 160;
202
+
203
+ /**
204
+ * Cap a card's facts as a joined line by dropping whole facts from the tail.
205
+ * A truncated price ("23 9…") would be a wrong price, and a wrong price is worse
206
+ * than a missing one, so facts are never cut mid-way.
207
+ */
208
+ function capFacts(facts: readonly string[] | undefined): string[] | undefined {
209
+ if (facts === undefined) return undefined;
210
+ const kept: string[] = [];
211
+ let width = 0;
212
+ for (const raw of facts) {
213
+ const fact = collapse(raw);
214
+ if (fact === "") continue;
215
+ const extra = kept.length === 0 ? fact.length : fact.length + FACTS_SEPARATOR.length;
216
+ if (width + extra > COMPACT_CAPS.factsMaxChars) break;
217
+ kept.push(fact);
218
+ width += extra;
219
+ }
220
+ return kept.length === 0 ? undefined : kept;
221
+ }
185
222
 
186
223
  function clampInt(value: number | undefined, fallback: number, min: number, max: number): number {
187
224
  if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
@@ -361,6 +398,9 @@ function renderItem(item: CompactItem, index: number): string {
361
398
  const marker = item.unresolved === true ? " (unresolved)" : "";
362
399
  const lines = [`${index + 1}. ${heading}`, ` ${item.url}${marker}`];
363
400
  if (item.snippet) lines.push(` ${item.snippet}`);
401
+ if (item.facts !== undefined && item.facts.length > 0) {
402
+ lines.push(` ${item.facts.join(FACTS_SEPARATOR)}`);
403
+ }
364
404
  return lines.join("\n");
365
405
  }
366
406
 
@@ -373,6 +413,7 @@ function jsonItem(item: CompactItem): Record<string, unknown> {
373
413
  };
374
414
  if (item.engine) out.engine = item.engine;
375
415
  if (item.unresolved === true) out.unresolved = true;
416
+ if (item.facts !== undefined && item.facts.length > 0) out.facts = [...item.facts];
376
417
  return out;
377
418
  }
378
419
 
@@ -418,6 +459,13 @@ export function compactSerps(serps: readonly RawSerp[], options: CompactOptions)
418
459
  const engineLabel = contributing.length === 0 ? "none" : contributing.join("+");
419
460
  const query = collapse(input[0]?.query ?? "");
420
461
  const degraded = input.some((serp) => serp.degraded === true);
462
+ // Adapter-authored notice (S7.6): "Avito answered with similar listings". One
463
+ // line, first non-empty wins, capped — a page cannot push prose through it
464
+ // because adapters pass a constant, not page text.
465
+ const notice = truncate(
466
+ collapse(input.find((serp) => typeof serp.notice === "string" && serp.notice !== "")?.notice ?? ""),
467
+ NOTICE_MAX_CHARS,
468
+ );
421
469
 
422
470
  const merged = new Map<string, MergedItem>();
423
471
  for (const serp of input) {
@@ -430,6 +478,7 @@ export function compactSerps(serps: readonly RawSerp[], options: CompactOptions)
430
478
 
431
479
  const title = truncate(collapse(raw.title), COMPACT_CAPS.titleMaxChars);
432
480
  const snippet = truncate(collapse(raw.snippet), opts.snippetMaxChars);
481
+ const facts = capFacts(raw.facts);
433
482
  const engine = raw.engine ?? serp.engine;
434
483
  const existing = merged.get(normalized);
435
484
 
@@ -441,14 +490,16 @@ export function compactSerps(serps: readonly RawSerp[], options: CompactOptions)
441
490
  snippet,
442
491
  engine,
443
492
  ...(raw.unresolved === true ? { unresolved: true } : {}),
493
+ ...(facts === undefined ? {} : { facts }),
444
494
  });
445
495
  continue;
446
496
  }
447
497
 
448
- // First non-empty title wins; the longest capped snippet wins (ties keep
449
- // the earlier engine, so the outcome does not depend on map internals).
498
+ // First non-empty title/facts win; the longest capped snippet wins (ties
499
+ // keep the earlier engine, so the outcome does not depend on map internals).
450
500
  if (!existing.title && title) existing.title = title;
451
501
  if (snippet.length > existing.snippet.length) existing.snippet = snippet;
502
+ if (existing.facts === undefined && facts !== undefined) existing.facts = facts;
452
503
  if (raw.unresolved === true) existing.unresolved = true;
453
504
  }
454
505
  }
@@ -518,6 +569,7 @@ export function compactSerps(serps: readonly RawSerp[], options: CompactOptions)
518
569
 
519
570
  const render = (items: CompactItem[], summary: CompactAi | undefined, state: AiState): string => {
520
571
  const head = [headerFor(items, state)];
572
+ if (notice !== "") head.push(`note: ${notice}`);
521
573
  if (summary) head.push(aiLine(summary));
522
574
  let text = head.join("\n");
523
575
  if (items.length > 0) text += `\n\n${items.map(renderItem).join("\n")}`;
@@ -534,12 +586,14 @@ export function compactSerps(serps: readonly RawSerp[], options: CompactOptions)
534
586
  };
535
587
  if (mixed || perEngine) out.engine = item.engine;
536
588
  if (item.unresolved === true) out.unresolved = true;
589
+ if (item.facts !== undefined && item.facts.length > 0) out.facts = item.facts;
537
590
  return out;
538
591
  };
539
592
 
540
593
  const outputMaxChars = opts.outputMaxChars;
541
594
  const fallbackCap = Math.min(opts.snippetMaxChars, MIN_SNIPPET);
542
595
  const pageSuffixLen = pageCarried ? PAGE_LINE.length : 0;
596
+ const noticeLen = notice === "" ? 0 : `\nnote: ${notice}`.length;
543
597
 
544
598
  // Results floor: the exact chars needed for the selected items at MIN_SNIPPET.
545
599
  // Titles are already capped by the merge step and URLs are full, so this is an
@@ -551,7 +605,7 @@ export function compactSerps(serps: readonly RawSerp[], options: CompactOptions)
551
605
  // `headerFor(selected.length, …)` is the widest header still possible: dropping
552
606
  // tail items only shortens the count, and `ai:lead` (7) is the widest AI label.
553
607
  const headerReserve =
554
- headerFor(selected.map((item) => toItem(item, fallbackCap)), "lead").length + pageSuffixLen;
608
+ headerFor(selected.map((item) => toItem(item, fallbackCap)), "lead").length + pageSuffixLen + noticeLen;
555
609
  const aiBudget = Math.max(
556
610
  0,
557
611
  Math.min(opts.summaryMaxChars, outputMaxChars - headerReserve - resultsFloor),
@@ -617,6 +671,7 @@ export function compactSerps(serps: readonly RawSerp[], options: CompactOptions)
617
671
  }
618
672
  if (aiOut) data.ai = { text: aiOut.text, sources: [...aiOut.sources], state: "answer" };
619
673
  if (aiState === "lead") data.aiState = "lead";
674
+ if (notice !== "") data.notice = notice;
620
675
  if (degraded) data.degraded = true;
621
676
  if (opts.cached) data.cached = true;
622
677
 
@@ -634,6 +689,7 @@ export function compactSerps(serps: readonly RawSerp[], options: CompactOptions)
634
689
  resultsChars,
635
690
  truncated,
636
691
  };
692
+ if (notice !== "") result.notice = notice;
637
693
  if (aiOut) result.ai = aiOut;
638
694
  return result;
639
695
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Card facts (SPEC S7.7, §15 D13): the short strings a SERP card shows *outside*
3
+ * its snippet — a price, an availability, a rating, a place, an age.
4
+ *
5
+ * Why one module: three adapters (avito, google, yandex) report facts, and an
6
+ * adapter must not be able to disagree about how many a card may carry or how
7
+ * long one may be. The display cap (one joined line, 120 chars) lives in
8
+ * `compact.ts`; this is the *input* cap, applied to untrusted page output before
9
+ * it becomes a `SerpItem`.
10
+ *
11
+ * Facts are copied from the page, never composed from numbers we found: "От
12
+ * 4 057,00 ₽" and "Рейтинг 4,4 из 5" are the page's own words, so a fact cannot
13
+ * claim more than the SERP did. A fact is dropped whole when it does not fit —
14
+ * half a price is worse than no price.
15
+ */
16
+
17
+ /** A card with more than four facts is reporting a table, not a card. */
18
+ export const FACT_MAX_ITEMS = 4;
19
+
20
+ /** Longer than this is prose that belongs in the snippet (57 + margin). */
21
+ export const FACT_MAX_CHARS = 60;
22
+
23
+ /**
24
+ * Guard for the TS side of the page boundary: the DOM is untrusted even when we
25
+ * wrote the extractor. Non-strings, blanks and over-long fragments are dropped;
26
+ * the count and the length are capped.
27
+ */
28
+ export function asFacts(value: unknown): string[] {
29
+ if (!Array.isArray(value)) return [];
30
+ const facts: string[] = [];
31
+ for (const raw of value) {
32
+ if (typeof raw !== "string") continue;
33
+ const fact = raw.replace(/[\s\u00a0\u202f]+/g, " ").trim();
34
+ if (fact === "") continue;
35
+ facts.push(fact.slice(0, FACT_MAX_CHARS));
36
+ if (facts.length >= FACT_MAX_ITEMS) break;
37
+ }
38
+ return facts;
39
+ }
@@ -69,8 +69,18 @@ const WALL_KINDS = ["captcha", "consent", "sorry", "blocked"] as const;
69
69
  * `skipped` means "not exercised" (caller asked for no probe, or CDP was down),
70
70
  * which is different from `unavailable` ("tried and could not"). It still keeps
71
71
  * the overall verdict at `degraded`: a doctor with no evidence is not healthy.
72
+ *
73
+ * `disabled` is the one exception: an engine the user turned off (the opt-in
74
+ * avito ships off, SPEC D12) is a config choice, not a fault.
72
75
  */
73
- export type EngineVerdict = "ok" | "wall" | "layout_changed" | "blocked" | "unavailable" | "skipped";
76
+ export type EngineVerdict =
77
+ | "ok"
78
+ | "wall"
79
+ | "layout_changed"
80
+ | "blocked"
81
+ | "unavailable"
82
+ | "skipped"
83
+ | "disabled";
74
84
  export type CdpStatus = "ok" | "unavailable" | "busy" | "error" | "awaiting_allow";
75
85
  export type DoctorVerdict = "ok" | "degraded" | "blocked";
76
86
 
@@ -138,6 +148,11 @@ function probeLocale(engine: EngineId, config: Config): Pick<Query, "lang" | "re
138
148
  const region = resolveProbeLocale(config.engines.yandex.lr);
139
149
  return region === undefined ? {} : { region };
140
150
  }
151
+ case "avito": {
152
+ // `auto` means every region here; the adapter's own default is `all` too.
153
+ const region = resolveProbeLocale(config.engines.avito.location);
154
+ return region === undefined ? {} : { region };
155
+ }
141
156
  }
142
157
  }
143
158
 
@@ -827,7 +842,10 @@ async function engineEntry(
827
842
  ): Promise<DoctorEngine> {
828
843
  const enabled = deps.config.engines[id].enabled;
829
844
  if (!enabled) {
830
- return { id, enabled: false, verdict: "unavailable", note: "disabled in config" };
845
+ // A deliberate config choice, reported as `disabled` so it does not degrade
846
+ // the verdict: the opt-in avito engine ships off (SPEC D12), and a fresh
847
+ // install must not look broken because of it.
848
+ return { id, enabled: false, verdict: "disabled", note: "disabled in config" };
831
849
  }
832
850
 
833
851
  if (!deps.registry.has(id)) {
@@ -948,7 +966,12 @@ async function readArtifacts(artifacts: Artifacts | undefined): Promise<DoctorAr
948
966
 
949
967
  function overallVerdict(cdp: DoctorCdp, engines: DoctorEngine[]): DoctorVerdict {
950
968
  if (cdp.status !== "ok" || cdp.webdriver === true) return "blocked";
951
- if (engines.some((engine) => engine.verdict !== "ok")) return "degraded";
969
+ // `disabled` (an engine the user turned off, e.g. the opt-in avito) is a config
970
+ // choice, not a fault: only a broken, unavailable or probe-skipped engine
971
+ // degrades the report ("probe absence is not health").
972
+ if (engines.some((engine) => engine.verdict !== "ok" && engine.verdict !== "disabled")) {
973
+ return "degraded";
974
+ }
952
975
  return "ok";
953
976
  }
954
977
 
@@ -29,11 +29,11 @@ interface EngineCounters {
29
29
  }
30
30
 
31
31
  function emptyCounters(): Record<EngineId, EngineCounters> {
32
- return {
33
- google: { lastAt: null, today: 0 },
34
- duckduckgo: { lastAt: null, today: 0 },
35
- yandex: { lastAt: null, today: 0 },
36
- };
32
+ // Derived from ENGINE_IDS so a new engine cannot be missing from the counters
33
+ // (SPEC §15 D11: the wiring is data, not a second list to keep in sync).
34
+ const counters = {} as Record<EngineId, EngineCounters>;
35
+ for (const id of ENGINE_IDS) counters[id] = { lastAt: null, today: 0 };
36
+ return counters;
37
37
  }
38
38
 
39
39
  function cancelled(engine: EngineId): WebSearchError {
@@ -29,7 +29,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
29
29
 
30
30
  import type { PageKey } from "../cdp/types.ts";
31
31
  import type { PacingWindow } from "../config/index.ts";
32
- import type { EngineId } from "../engines/types.ts";
32
+ import { ENGINE_IDS, type EngineId } from "../engines/types.ts";
33
33
  import type { Clock } from "./types.ts";
34
34
 
35
35
  /** Highest number of panel paints per second (PLAN-v2 P10-A). */
@@ -136,7 +136,7 @@ const STAGES: ReadonlySet<string> = new Set<PanelStage>([
136
136
  "error",
137
137
  ]);
138
138
 
139
- const ENGINES: ReadonlySet<string> = new Set<EngineId>(["google", "duckduckgo", "yandex"]);
139
+ const ENGINES: ReadonlySet<string> = new Set<string>(ENGINE_IDS);
140
140
 
141
141
  /** Control characters would corrupt the rendered single line; strip, never escape. */
142
142
  function clean(value: string, max: number): string {
@@ -67,17 +67,23 @@ const engineIdSchema = Type.Union([
67
67
  Type.Literal("google"),
68
68
  Type.Literal("duckduckgo"),
69
69
  Type.Literal("yandex"),
70
+ Type.Literal("avito"),
70
71
  ]);
71
72
 
72
73
  /**
73
74
  * SPEC §7.1 / D9. `engines` accepts one id, a list, or `auto`; the array form is
74
- * capped at three because that is every engine the extension supports.
75
+ * capped at `ENGINE_IDS.length` because that is every engine the extension
76
+ * supports.
75
77
  */
76
78
  export const webSearchSchema = Type.Object({
77
79
  q: Type.String({ minLength: 1 }),
78
80
  n: Type.Optional(Type.Integer({ minimum: 1, maximum: 10 })),
79
81
  engines: Type.Optional(
80
- Type.Union([Type.Literal("auto"), engineIdSchema, Type.Array(engineIdSchema, { minItems: 1, maxItems: 3 })]),
82
+ Type.Union([
83
+ Type.Literal("auto"),
84
+ engineIdSchema,
85
+ Type.Array(engineIdSchema, { minItems: 1, maxItems: ENGINE_IDS.length }),
86
+ ]),
81
87
  ),
82
88
  summary: Type.Optional(Type.Boolean()),
83
89
  page: Type.Optional(Type.Integer({ minimum: 1 })),
@@ -192,7 +198,7 @@ export interface WebSearchTool {
192
198
  * every word.
193
199
  */
194
200
  export const WEB_SEARCH_DESCRIPTION =
195
- "Search the web for free in the user's visible Chrome (Google, DuckDuckGo, Yandex) with each engine's native AI summary. Compact results, n<=10. Call web_read on a result URL to get that page as paged Markdown. site:/filetype:/quoted queries pass through; engines: id, list, or auto.";
201
+ "Search the web for free in the user's visible Chrome (Google, DuckDuckGo, Yandex; Avito listings opt-in) with each engine's native AI summary. Compact results, n<=10. Call web_read on a result URL to get that page as paged Markdown. site:/filetype:/quoted queries pass through; engines: id, list, or auto.";
196
202
 
197
203
  /**
198
204
  * SPEC S3.6: `q` is passed verbatim, so narrowing happens in the query. The
@@ -200,6 +206,7 @@ export const WEB_SEARCH_DESCRIPTION =
200
206
  */
201
207
  export const WEB_SEARCH_GUIDELINES: readonly string[] = [
202
208
  "web_search passes q verbatim: use quoted phrases, -term, OR, site:, filetype:; narrow by date with after:/before: on Google or date: on Yandex (there is no date argument).",
209
+ "engines:\"avito\" searches Avito listings (price, location); it is opt-in: enable engines.avito.enabled in web-search.json first, and auto never uses it.",
203
210
  ];
204
211
 
205
212
  /**
@@ -217,8 +224,7 @@ const FALLBACK_CODES: ReadonlySet<ErrorCode> = new Set<ErrorCode>([
217
224
  ]);
218
225
 
219
226
  /** Every defensive validation failure shares this hint: the fix is in the call, not the file. */
220
- const INPUT_HINT =
221
- "check the web_search arguments: q (non-empty), n 1..10, page >= 1, engines auto|google|duckduckgo|yandex";
227
+ const INPUT_HINT = `check the web_search arguments: q (non-empty), n 1..10, page >= 1, engines auto|${ENGINE_IDS.join("|")}`;
222
228
 
223
229
  const UNSUPPORTED_HINT =
224
230
  "no enabled engine matched the request; run /web-doctor (or node src/cli/doctor.ts --json) and check config.engines";
@@ -350,7 +356,13 @@ function resolveLocale(value: string): string | undefined {
350
356
  * parameter rather than inventing one.
351
357
  */
352
358
  function buildQuery(engine: EngineId, input: NormalizedInput, config: Config): Query {
353
- const query: Query = { text: input.q, n: input.n, summary: input.summary };
359
+ const query: Query = {
360
+ text: input.q,
361
+ n: input.n,
362
+ // S4.2 as data: an engine without a native AI block (avito) never receives
363
+ // `summary: true`; the caller sees `ignored.summary` instead (S7.1).
364
+ summary: SUPPORT_MATRIX.summary[engine] === "yes" ? input.summary : false,
365
+ };
354
366
  // S4.2 is data (`SUPPORT_MATRIX`): an engine that cannot paginate never
355
367
  // receives `page`, and the caller is told so through `ignored` below.
356
368
  if (SUPPORT_MATRIX.page[engine] === "yes") query.page = input.page;
@@ -373,6 +385,12 @@ function buildQuery(engine: EngineId, input: NormalizedInput, config: Config): Q
373
385
  if (region !== undefined) query.region = region;
374
386
  break;
375
387
  }
388
+ case "avito": {
389
+ // The region is a URL path slug, not a locale; `auto` means "every
390
+ // region" here, which is the adapter's own default (SPEC D12).
391
+ query.region = resolveLocale(config.engines.avito.location) ?? "all";
392
+ break;
393
+ }
376
394
  }
377
395
  return query;
378
396
  }
@@ -967,7 +985,7 @@ async function runEngineLive(
967
985
  const human = await humanSearch({
968
986
  page,
969
987
  query: query.text,
970
- homeUrl: interaction.homeUrl,
988
+ homeUrl: interaction.homeUrlFor?.(query) ?? interaction.homeUrl,
971
989
  serpUrl: engine.buildUrl(query),
972
990
  boxSelectors: [...interaction.boxSelectors],
973
991
  marker: interaction.marker,
@@ -1209,6 +1227,20 @@ export function createWebSearchTool(
1209
1227
  return failureResult(asFailure(error, "resolve"));
1210
1228
  }
1211
1229
  if (order.length === 0) {
1230
+ // The selection came back empty. When the caller named engines, ask the
1231
+ // registry *why* each one was dropped: a disabled engine (the opt-in avito
1232
+ // ships off, D12) gets its own "set config.engines.avito.enabled" hint
1233
+ // instead of a generic "no enabled engine" (S9.2). `auto` keeps the
1234
+ // generic message: there is no id to explain.
1235
+ const named =
1236
+ input.engines === "auto" ? [] : Array.isArray(input.engines) ? input.engines : [input.engines];
1237
+ for (const id of named) {
1238
+ try {
1239
+ deps.engines.get(id);
1240
+ } catch (error) {
1241
+ return failureResult(asFailure(error, "resolve", id));
1242
+ }
1243
+ }
1212
1244
  return failureResult(
1213
1245
  fail("E_UNSUPPORTED", "resolve", {
1214
1246
  hint: UNSUPPORTED_HINT,