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.
- package/README.md +18 -3
- package/package.json +4 -3
- package/src/config/defaults.ts +20 -1
- package/src/config/doc.ts +3 -0
- package/src/config/schema.ts +2 -0
- package/src/engines/avito.ts +552 -0
- package/src/engines/google.ts +87 -2
- package/src/engines/index.ts +12 -3
- package/src/engines/types.ts +65 -6
- package/src/engines/yandex.ts +29 -3
- package/src/extract/compact.ts +61 -5
- package/src/extract/facts.ts +39 -0
- package/src/runtime/doctor.ts +26 -3
- package/src/runtime/limiter.ts +5 -5
- package/src/runtime/panel.ts +2 -2
- package/src/tools/web_search.ts +39 -7
package/src/engines/index.ts
CHANGED
|
@@ -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
|
-
|
|
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[] = [];
|
package/src/engines/types.ts
CHANGED
|
@@ -9,10 +9,40 @@
|
|
|
9
9
|
|
|
10
10
|
import type { CdpPage } from "../cdp/types.ts";
|
|
11
11
|
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
export
|
|
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
|
-
|
|
223
|
-
|
|
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 {
|
package/src/engines/yandex.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
package/src/extract/compact.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/runtime/doctor.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/runtime/limiter.ts
CHANGED
|
@@ -29,11 +29,11 @@ interface EngineCounters {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
function emptyCounters(): Record<EngineId, EngineCounters> {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 {
|
package/src/runtime/panel.ts
CHANGED
|
@@ -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
|
|
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<
|
|
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 {
|
package/src/tools/web_search.ts
CHANGED
|
@@ -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
|
|
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([
|
|
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 = {
|
|
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,
|