@oh-my-pi/pi-coding-agent 17.1.1 → 17.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/dist/cli.js +6317 -4085
- package/dist/types/cli/bench-cli.d.ts +1 -0
- package/dist/types/config/model-resolver.d.ts +6 -3
- package/dist/types/config/settings-schema.d.ts +4 -0
- package/dist/types/launch/broker-list-order.test.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -6
- package/dist/types/modes/types.d.ts +8 -5
- package/dist/types/modes/utils/ui-helpers.d.ts +1 -6
- package/dist/types/task/executor.d.ts +3 -1
- package/dist/types/task/structured-subagent.d.ts +3 -0
- package/dist/types/task/types.d.ts +11 -11
- package/dist/types/thinking.d.ts +13 -0
- package/dist/types/tools/auto-generated-guard.d.ts +5 -2
- package/dist/types/tools/eval-format/index.d.ts +7 -0
- package/dist/types/tools/eval-format/javascript.d.ts +2 -0
- package/dist/types/tools/eval-format/julia.d.ts +2 -0
- package/dist/types/tools/eval-format/python.d.ts +2 -0
- package/dist/types/tools/eval-format/ruby.d.ts +2 -0
- package/dist/types/tools/memory-render.d.ts +1 -4
- package/dist/types/tools/resolve.d.ts +7 -0
- package/dist/types/tools/todo.d.ts +4 -1
- package/dist/types/web/search/providers/base.d.ts +16 -0
- package/dist/types/web/search/providers/brave.d.ts +2 -0
- package/dist/types/web/search/providers/firecrawl.d.ts +2 -0
- package/dist/types/web/search/providers/gemini.d.ts +3 -0
- package/dist/types/web/search/providers/jina.d.ts +2 -0
- package/dist/types/web/search/providers/kagi.d.ts +2 -0
- package/dist/types/web/search/providers/kimi.d.ts +2 -0
- package/dist/types/web/search/providers/parallel.d.ts +2 -0
- package/dist/types/web/search/providers/perplexity.d.ts +3 -0
- package/dist/types/web/search/providers/searxng.d.ts +10 -0
- package/dist/types/web/search/providers/tavily.d.ts +8 -0
- package/dist/types/web/search/query.d.ts +190 -0
- package/package.json +12 -12
- package/src/cli/bench-cli.ts +12 -1
- package/src/cli/web-search-cli.ts +7 -0
- package/src/config/model-resolver.ts +17 -6
- package/src/config/settings-schema.ts +5 -0
- package/src/debug/raw-sse-buffer.ts +157 -30
- package/src/edit/hashline/filesystem.ts +1 -1
- package/src/edit/modes/patch.ts +1 -1
- package/src/export/share.ts +4 -3
- package/src/launch/broker-list-order.test.ts +89 -0
- package/src/launch/broker.ts +49 -8
- package/src/modes/controllers/input-controller.ts +8 -8
- package/src/modes/interactive-mode.ts +60 -5
- package/src/modes/types.ts +9 -4
- package/src/modes/utils/ui-helpers.ts +7 -8
- package/src/prompts/system/resolve-device-reminder.md +1 -1
- package/src/prompts/system/workflow-notice.md +1 -1
- package/src/prompts/tools/ast-edit.md +2 -1
- package/src/prompts/tools/bash.md +1 -0
- package/src/prompts/tools/eval.md +2 -2
- package/src/prompts/tools/task.md +5 -2
- package/src/prompts/tools/web-search.md +2 -0
- package/src/sdk.ts +8 -4
- package/src/session/agent-session.ts +14 -0
- package/src/session/queued-messages.ts +7 -1
- package/src/session/stream-guards.ts +1 -1
- package/src/task/executor.ts +17 -9
- package/src/task/index.ts +14 -34
- package/src/task/structured-subagent.ts +4 -0
- package/src/task/types.ts +15 -13
- package/src/thinking.ts +27 -0
- package/src/tools/ast-edit.ts +4 -1
- package/src/tools/auto-generated-guard.ts +18 -5
- package/src/tools/bash.ts +13 -0
- package/src/tools/browser/render.ts +2 -1
- package/src/tools/eval-format/index.ts +24 -0
- package/src/tools/eval-format/javascript.ts +952 -0
- package/src/tools/eval-format/julia.ts +446 -0
- package/src/tools/eval-format/python.ts +544 -0
- package/src/tools/eval-format/ruby.ts +380 -0
- package/src/tools/eval-render.ts +12 -6
- package/src/tools/memory-render.ts +11 -2
- package/src/tools/render-utils.ts +8 -3
- package/src/tools/resolve.ts +10 -1
- package/src/tools/todo.ts +58 -6
- package/src/tools/write.ts +1 -1
- package/src/web/search/index.ts +28 -5
- package/src/web/search/providers/anthropic.ts +62 -4
- package/src/web/search/providers/base.ts +16 -0
- package/src/web/search/providers/brave.ts +30 -3
- package/src/web/search/providers/codex.ts +14 -1
- package/src/web/search/providers/duckduckgo.ts +23 -1
- package/src/web/search/providers/ecosia.ts +5 -1
- package/src/web/search/providers/exa.ts +28 -1
- package/src/web/search/providers/firecrawl.ts +37 -3
- package/src/web/search/providers/gemini.ts +12 -2
- package/src/web/search/providers/google.ts +2 -1
- package/src/web/search/providers/jina.ts +31 -8
- package/src/web/search/providers/kagi.ts +10 -1
- package/src/web/search/providers/kimi.ts +16 -1
- package/src/web/search/providers/mojeek.ts +14 -1
- package/src/web/search/providers/parallel.ts +48 -2
- package/src/web/search/providers/perplexity.ts +94 -6
- package/src/web/search/providers/searxng.ts +145 -9
- package/src/web/search/providers/startpage.ts +8 -2
- package/src/web/search/providers/synthetic.ts +7 -1
- package/src/web/search/providers/tavily.ts +52 -3
- package/src/web/search/providers/tinyfish.ts +6 -1
- package/src/web/search/providers/xai.ts +49 -2
- package/src/web/search/providers/zai.ts +19 -1
- package/src/web/search/query.ts +850 -0
|
@@ -0,0 +1,850 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured web-search query parsing.
|
|
3
|
+
*
|
|
4
|
+
* Agents habitually embed Google-style directives in search queries —
|
|
5
|
+
* `site:`, `before:`/`after:`, `inurl:`, `filetype:`, quoted phrases, `OR`
|
|
6
|
+
* groups, `-exclusions` — regardless of whether the backing engine parses
|
|
7
|
+
* them. This module turns a raw query into a {@link StructuredQuery} so each
|
|
8
|
+
* provider can:
|
|
9
|
+
*
|
|
10
|
+
* 1. map constraints onto native API parameters where they exist (Perplexity
|
|
11
|
+
* `search_domain_filter`, Tavily `include_domains`, Exa date bounds, …),
|
|
12
|
+
* 2. rebuild a query string containing only the syntax the target engine
|
|
13
|
+
* understands ({@link formatQuery}), and
|
|
14
|
+
* 3. post-filter returned sources leniently ({@link applyQueryConstraints}):
|
|
15
|
+
* a constraint dimension that would eliminate every result is dropped and
|
|
16
|
+
* reported rather than returning nothing.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { SearchSource } from "./types";
|
|
20
|
+
|
|
21
|
+
/** One free-text token of the query (everything that is not a recognized directive). */
|
|
22
|
+
export interface QueryTerm {
|
|
23
|
+
/** Term text without quotes or operator prefixes. */
|
|
24
|
+
text: string;
|
|
25
|
+
/** Quoted exact phrase (`"like this"`) or verbatim-required (`+term`). */
|
|
26
|
+
phrase?: boolean;
|
|
27
|
+
/** Excluded via `-term` or `NOT term`. */
|
|
28
|
+
negated?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* OR-group id. Terms sharing an id are alternatives (`a OR b`); terms
|
|
31
|
+
* without a group are implicitly AND-ed. Groups are always contiguous
|
|
32
|
+
* runs in {@link StructuredQuery.terms}.
|
|
33
|
+
*/
|
|
34
|
+
group?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A raw query decomposed into free text plus every recognized constraint.
|
|
39
|
+
*
|
|
40
|
+
* All list fields are always present (possibly empty) so consumers can map
|
|
41
|
+
* over them without null checks. Values are stored as typed by the user
|
|
42
|
+
* except for normalization noted per field.
|
|
43
|
+
*/
|
|
44
|
+
export interface StructuredQuery {
|
|
45
|
+
/** Original query string, verbatim. */
|
|
46
|
+
raw: string;
|
|
47
|
+
/**
|
|
48
|
+
* Free-text remainder with all recognized directives removed; phrases
|
|
49
|
+
* stay quoted, exclusions keep `-`, OR groups keep `OR`. Empty when the
|
|
50
|
+
* query was directives only — use {@link formatQuery} for a never-empty
|
|
51
|
+
* engine query.
|
|
52
|
+
*/
|
|
53
|
+
text: string;
|
|
54
|
+
/** Ordered free-text terms (phrases, exclusions, OR groups). */
|
|
55
|
+
terms: QueryTerm[];
|
|
56
|
+
/** `site:`/`domain:`/`host:` includes — any-of. Lowercased, scheme stripped, may carry a path (`github.com/anthropics`). */
|
|
57
|
+
sites: string[];
|
|
58
|
+
/** `-site:` exclusions, same normalization as {@link sites}. */
|
|
59
|
+
excludedSites: string[];
|
|
60
|
+
/** `inurl:`/`url:`/`allinurl:` substrings — all must appear in the URL. */
|
|
61
|
+
inUrl: string[];
|
|
62
|
+
/** `-inurl:` substrings — none may appear in the URL. */
|
|
63
|
+
excludedInUrl: string[];
|
|
64
|
+
/** `intitle:`/`title:`/`allintitle:` substrings — all must appear in the title. */
|
|
65
|
+
inTitle: string[];
|
|
66
|
+
/** `-intitle:` substrings — none may appear in the title. */
|
|
67
|
+
excludedInTitle: string[];
|
|
68
|
+
/** `intext:`/`inbody:`/`inanchor:`/`allintext:` body substrings. Not post-filterable (snippets are partial); query-building only. */
|
|
69
|
+
inText: string[];
|
|
70
|
+
/** `-intext:` body exclusions. Query-building only. */
|
|
71
|
+
excludedInText: string[];
|
|
72
|
+
/** `filetype:`/`ext:` extensions — any-of. Lowercased, no leading dot. */
|
|
73
|
+
filetypes: string[];
|
|
74
|
+
/** `-filetype:`/`-ext:` extensions — none may match. */
|
|
75
|
+
excludedFiletypes: string[];
|
|
76
|
+
/** Inclusive lower publish-date bound from `after:`/`since:`, ISO `YYYY-MM-DD`. */
|
|
77
|
+
after?: string;
|
|
78
|
+
/** Exclusive upper publish-date bound from `before:`/`until:`, ISO `YYYY-MM-DD`. */
|
|
79
|
+
before?: string;
|
|
80
|
+
/** Language code from `lang:`/`language:`, lowercased (e.g. `en`, `en-us`). */
|
|
81
|
+
lang?: string;
|
|
82
|
+
/** True when any directive or boolean operator was recognized. */
|
|
83
|
+
hasDirectives: boolean;
|
|
84
|
+
/** True when any post-filterable constraint is set (sites, url/title terms, filetypes, date bounds). */
|
|
85
|
+
hasConstraints: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Query-syntax capabilities of a target engine, used by {@link formatQuery}
|
|
90
|
+
* to decide which parsed features are re-emitted as query text. Everything
|
|
91
|
+
* defaults to `false`: the zero-value produces plain keywords suitable for
|
|
92
|
+
* natural-language APIs.
|
|
93
|
+
*/
|
|
94
|
+
export interface QuerySyntax {
|
|
95
|
+
/** Emit `"quoted phrases"`. */
|
|
96
|
+
phrases?: boolean;
|
|
97
|
+
/** Emit `-term` exclusions (negated terms are dropped otherwise). */
|
|
98
|
+
negation?: boolean;
|
|
99
|
+
/** Emit `OR` between alternatives (groups are flattened to keywords otherwise). */
|
|
100
|
+
or?: boolean;
|
|
101
|
+
/** Emit `site:`/`-site:`. */
|
|
102
|
+
site?: boolean;
|
|
103
|
+
/** Emit `inurl:`/`-inurl:`. */
|
|
104
|
+
inUrl?: boolean;
|
|
105
|
+
/** Emit `intitle:`/`-intitle:`. */
|
|
106
|
+
inTitle?: boolean;
|
|
107
|
+
/** Emit `intext:`/`-intext:`. */
|
|
108
|
+
inText?: boolean;
|
|
109
|
+
/** Emit `filetype:`/`-filetype:`. */
|
|
110
|
+
filetype?: boolean;
|
|
111
|
+
/** Emit `before:`/`after:` ISO date bounds. */
|
|
112
|
+
dateRange?: boolean;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Full Google-style syntax: engines that parse the classic operator set (Google, Startpage, Ecosia, Brave, Kagi, Mojeek, SearXNG…). */
|
|
116
|
+
export const GOOGLE_QUERY_SYNTAX: QuerySyntax = {
|
|
117
|
+
phrases: true,
|
|
118
|
+
negation: true,
|
|
119
|
+
or: true,
|
|
120
|
+
site: true,
|
|
121
|
+
inUrl: true,
|
|
122
|
+
inTitle: true,
|
|
123
|
+
inText: true,
|
|
124
|
+
filetype: true,
|
|
125
|
+
dateRange: true,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** Result of {@link applyQueryConstraints}. */
|
|
129
|
+
export interface ConstraintFilterResult {
|
|
130
|
+
/** Sources surviving the lenient filter — never empty when the input was non-empty. */
|
|
131
|
+
sources: SearchSource[];
|
|
132
|
+
/**
|
|
133
|
+
* Directive renderings (`site:arxiv.org`, `before:2024-01-01`, …) of the
|
|
134
|
+
* constraint dimensions that matched zero sources and were therefore
|
|
135
|
+
* relaxed instead of enforced.
|
|
136
|
+
*/
|
|
137
|
+
dropped: string[];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const DIRECTIVE_PATTERN = /^([+-]?)([a-z][a-z-]*):(.*)$/i;
|
|
141
|
+
|
|
142
|
+
type AllMode = "inTitle" | "inUrl" | "inText";
|
|
143
|
+
|
|
144
|
+
interface RawToken {
|
|
145
|
+
text: string;
|
|
146
|
+
/** Entire token was a quoted phrase. */
|
|
147
|
+
quoted: boolean;
|
|
148
|
+
/** Directive value was quoted (`intitle:"a b"`). */
|
|
149
|
+
quotedValue?: boolean;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isQuote(ch: string): boolean {
|
|
153
|
+
return ch === '"' || ch === "\u201c" || ch === "\u201d";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Unicode-aware whitespace (agents paste NBSP and friends). */
|
|
157
|
+
const WHITESPACE = /\s/;
|
|
158
|
+
|
|
159
|
+
/** Split a raw query into whitespace-delimited tokens, honoring quoted spans and standalone parens. */
|
|
160
|
+
function tokenize(raw: string): RawToken[] {
|
|
161
|
+
const tokens: RawToken[] = [];
|
|
162
|
+
const n = raw.length;
|
|
163
|
+
let i = 0;
|
|
164
|
+
while (i < n) {
|
|
165
|
+
const ch = raw[i];
|
|
166
|
+
if (WHITESPACE.test(ch)) {
|
|
167
|
+
i++;
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (isQuote(ch)) {
|
|
171
|
+
let j = i + 1;
|
|
172
|
+
let buf = "";
|
|
173
|
+
while (j < n && !isQuote(raw[j])) {
|
|
174
|
+
buf += raw[j];
|
|
175
|
+
j++;
|
|
176
|
+
}
|
|
177
|
+
if (buf.trim().length > 0) tokens.push({ text: buf.trim(), quoted: true });
|
|
178
|
+
i = j + 1;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
// Bare word; a quote directly after `name:` swallows the quoted span
|
|
182
|
+
// into the same token (`intitle:"budget tips"`).
|
|
183
|
+
let buf = "";
|
|
184
|
+
let quotedValue = false;
|
|
185
|
+
while (i < n && !WHITESPACE.test(raw[i])) {
|
|
186
|
+
const c = raw[i];
|
|
187
|
+
if (isQuote(c) && buf.endsWith(":")) {
|
|
188
|
+
let j = i + 1;
|
|
189
|
+
while (j < n && !isQuote(raw[j])) {
|
|
190
|
+
buf += raw[j];
|
|
191
|
+
j++;
|
|
192
|
+
}
|
|
193
|
+
quotedValue = true;
|
|
194
|
+
i = j + 1;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (isQuote(c)) break; // `foo"bar` — stop the word, let the quote start a phrase
|
|
198
|
+
buf += c;
|
|
199
|
+
i++;
|
|
200
|
+
}
|
|
201
|
+
if (buf.length > 0) tokens.push({ text: buf, quoted: false, quotedValue });
|
|
202
|
+
}
|
|
203
|
+
return splitParens(tokens);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Split leading `(` and unbalanced trailing `)` into standalone tokens so
|
|
208
|
+
* `(react OR vue)` parses while `site:wikipedia.org/Foo_(bar)` stays whole.
|
|
209
|
+
*/
|
|
210
|
+
function splitParens(tokens: RawToken[]): RawToken[] {
|
|
211
|
+
const out: RawToken[] = [];
|
|
212
|
+
for (const tok of tokens) {
|
|
213
|
+
if (tok.quoted || tok.quotedValue) {
|
|
214
|
+
out.push(tok);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
let text = tok.text;
|
|
218
|
+
while (text.startsWith("(")) {
|
|
219
|
+
out.push({ text: "(", quoted: false });
|
|
220
|
+
text = text.slice(1);
|
|
221
|
+
}
|
|
222
|
+
let trailing = 0;
|
|
223
|
+
while (text.endsWith(")")) {
|
|
224
|
+
// Only strip parens that do not close an opener inside the word.
|
|
225
|
+
const body = text.slice(0, -1);
|
|
226
|
+
let depth = 0;
|
|
227
|
+
for (const c of body) {
|
|
228
|
+
if (c === "(") depth++;
|
|
229
|
+
else if (c === ")") depth--;
|
|
230
|
+
}
|
|
231
|
+
if (depth > 0) break;
|
|
232
|
+
text = body;
|
|
233
|
+
trailing++;
|
|
234
|
+
}
|
|
235
|
+
if (text.length > 0) out.push({ text, quoted: false });
|
|
236
|
+
for (let k = 0; k < trailing; k++) out.push({ text: ")", quoted: false });
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Convert year/month/day parts to a validated ISO date, or undefined. */
|
|
242
|
+
function isoDate(year: number, month: number, day: number): string | undefined {
|
|
243
|
+
if (year < 1000 || year > 9999 || month < 1 || month > 12 || day < 1 || day > 31) return undefined;
|
|
244
|
+
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Parse a `before:`/`after:` value into ISO `YYYY-MM-DD`.
|
|
249
|
+
* Accepts `YYYY`, `YYYY-MM`, `YYYY-MM-DD` (also `/` and `.` separators) and
|
|
250
|
+
* `MM/DD/YYYY` (day-first assumed when the first field exceeds 12).
|
|
251
|
+
* Bare years/months resolve to the first day of the period, matching
|
|
252
|
+
* Google's `after:2024` ≙ `after:2024-01-01` semantics.
|
|
253
|
+
*/
|
|
254
|
+
export function parseDateValue(value: string): string | undefined {
|
|
255
|
+
const t = value.trim();
|
|
256
|
+
let m = /^(\d{4})(?:[-/.](\d{1,2})(?:[-/.](\d{1,2}))?)?$/.exec(t);
|
|
257
|
+
if (m) return isoDate(Number(m[1]), m[2] ? Number(m[2]) : 1, m[3] ? Number(m[3]) : 1);
|
|
258
|
+
m = /^(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})$/.exec(t);
|
|
259
|
+
if (m) {
|
|
260
|
+
let month = Number(m[1]);
|
|
261
|
+
let day = Number(m[2]);
|
|
262
|
+
if (month > 12 && day <= 12) [month, day] = [day, month];
|
|
263
|
+
return isoDate(Number(m[3]), month, day);
|
|
264
|
+
}
|
|
265
|
+
return undefined;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Lowercase a `site:` value and strip scheme, `*.` wildcard, and trailing slash/dot. */
|
|
269
|
+
function normalizeSite(value: string): string {
|
|
270
|
+
let site = value.trim().toLowerCase();
|
|
271
|
+
site = site.replace(/^[a-z][a-z0-9+.-]*:\/\//, "");
|
|
272
|
+
if (site.startsWith("*.")) site = site.slice(2);
|
|
273
|
+
site = site.replace(/[/.]+$/, "");
|
|
274
|
+
return site;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Directive names mapped to their canonical field. */
|
|
278
|
+
const DIRECTIVE_FIELDS: Record<
|
|
279
|
+
string,
|
|
280
|
+
"site" | "inUrl" | "inTitle" | "inText" | "filetype" | "before" | "after" | "lang"
|
|
281
|
+
> = {
|
|
282
|
+
site: "site",
|
|
283
|
+
domain: "site",
|
|
284
|
+
host: "site",
|
|
285
|
+
inurl: "inUrl",
|
|
286
|
+
url: "inUrl",
|
|
287
|
+
intitle: "inTitle",
|
|
288
|
+
title: "inTitle",
|
|
289
|
+
intext: "inText",
|
|
290
|
+
inbody: "inText",
|
|
291
|
+
inanchor: "inText",
|
|
292
|
+
filetype: "filetype",
|
|
293
|
+
ext: "filetype",
|
|
294
|
+
before: "before",
|
|
295
|
+
until: "before",
|
|
296
|
+
after: "after",
|
|
297
|
+
since: "after",
|
|
298
|
+
lang: "lang",
|
|
299
|
+
language: "lang",
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
/** `allin*:` directives that capture every following plain term. */
|
|
303
|
+
const ALL_MODES: Record<string, AllMode> = {
|
|
304
|
+
allintitle: "inTitle",
|
|
305
|
+
allinurl: "inUrl",
|
|
306
|
+
allintext: "inText",
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
/** True for operator/paren tokens and recognized directives — anything a bare `name:` must not adopt as its value. */
|
|
310
|
+
function isReservedToken(text: string): boolean {
|
|
311
|
+
if (
|
|
312
|
+
text === "(" ||
|
|
313
|
+
text === ")" ||
|
|
314
|
+
text === "OR" ||
|
|
315
|
+
text === "AND" ||
|
|
316
|
+
text === "NOT" ||
|
|
317
|
+
text === "|" ||
|
|
318
|
+
text === "||" ||
|
|
319
|
+
text === "&&" ||
|
|
320
|
+
text === "!"
|
|
321
|
+
) {
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
const m = DIRECTIVE_PATTERN.exec(text);
|
|
325
|
+
if (!m) return false;
|
|
326
|
+
const name = m[2].toLowerCase();
|
|
327
|
+
return DIRECTIVE_FIELDS[name] !== undefined || ALL_MODES[name] !== undefined;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Parse a raw query into a {@link StructuredQuery}.
|
|
332
|
+
*
|
|
333
|
+
* Lenient by construction: unknown `name:value` tokens (URLs, `C:\paths`,
|
|
334
|
+
* `TS2345:`, jargon) stay in the free text verbatim, and a directive with an
|
|
335
|
+
* unparseable value (`before:someday`) degrades to a plain term instead of
|
|
336
|
+
* being dropped.
|
|
337
|
+
*/
|
|
338
|
+
export function parseSearchQuery(raw: string): StructuredQuery {
|
|
339
|
+
const q: StructuredQuery = {
|
|
340
|
+
raw,
|
|
341
|
+
text: "",
|
|
342
|
+
terms: [],
|
|
343
|
+
sites: [],
|
|
344
|
+
excludedSites: [],
|
|
345
|
+
inUrl: [],
|
|
346
|
+
excludedInUrl: [],
|
|
347
|
+
inTitle: [],
|
|
348
|
+
excludedInTitle: [],
|
|
349
|
+
inText: [],
|
|
350
|
+
excludedInText: [],
|
|
351
|
+
filetypes: [],
|
|
352
|
+
excludedFiletypes: [],
|
|
353
|
+
hasDirectives: false,
|
|
354
|
+
hasConstraints: false,
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const tokens = tokenize(raw);
|
|
358
|
+
let negateNext = false;
|
|
359
|
+
let orPending = false;
|
|
360
|
+
let lastWasTerm = false;
|
|
361
|
+
let groupSeq = 0;
|
|
362
|
+
let allMode: AllMode | undefined;
|
|
363
|
+
|
|
364
|
+
const pushConstraint = (
|
|
365
|
+
field: "site" | "inUrl" | "inTitle" | "inText" | "filetype",
|
|
366
|
+
value: string,
|
|
367
|
+
negated: boolean,
|
|
368
|
+
): void => {
|
|
369
|
+
q.hasDirectives = true;
|
|
370
|
+
orPending = false;
|
|
371
|
+
lastWasTerm = false;
|
|
372
|
+
const v = value.trim();
|
|
373
|
+
if (!v) return;
|
|
374
|
+
switch (field) {
|
|
375
|
+
case "site": {
|
|
376
|
+
const site = normalizeSite(v);
|
|
377
|
+
if (site) (negated ? q.excludedSites : q.sites).push(site);
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
case "inUrl":
|
|
381
|
+
(negated ? q.excludedInUrl : q.inUrl).push(v);
|
|
382
|
+
break;
|
|
383
|
+
case "inTitle":
|
|
384
|
+
(negated ? q.excludedInTitle : q.inTitle).push(v);
|
|
385
|
+
break;
|
|
386
|
+
case "inText":
|
|
387
|
+
(negated ? q.excludedInText : q.inText).push(v);
|
|
388
|
+
break;
|
|
389
|
+
case "filetype": {
|
|
390
|
+
const ext = v.toLowerCase().replace(/^\.+/, "");
|
|
391
|
+
if (ext) (negated ? q.excludedFiletypes : q.filetypes).push(ext);
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
const pushTerm = (text: string, phrase: boolean): void => {
|
|
398
|
+
const negated = negateNext;
|
|
399
|
+
negateNext = false;
|
|
400
|
+
if (allMode && !negated) {
|
|
401
|
+
pushConstraint(allMode, text, false);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (allMode && negated) {
|
|
405
|
+
pushConstraint(allMode, text, true);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const term: QueryTerm = { text };
|
|
409
|
+
if (phrase) term.phrase = true;
|
|
410
|
+
if (negated) term.negated = true;
|
|
411
|
+
if (orPending && lastWasTerm) {
|
|
412
|
+
const prev = q.terms[q.terms.length - 1];
|
|
413
|
+
if (prev) {
|
|
414
|
+
prev.group ??= ++groupSeq;
|
|
415
|
+
term.group = prev.group;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
orPending = false;
|
|
419
|
+
lastWasTerm = true;
|
|
420
|
+
q.terms.push(term);
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
for (let idx = 0; idx < tokens.length; idx++) {
|
|
424
|
+
const tok = tokens[idx];
|
|
425
|
+
|
|
426
|
+
if (tok.quoted) {
|
|
427
|
+
pushTerm(tok.text, true);
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Boolean operators and grouping parens.
|
|
432
|
+
if (tok.text === "(" || tok.text === ")") continue;
|
|
433
|
+
if (tok.text === "OR" || tok.text === "|" || tok.text === "||") {
|
|
434
|
+
orPending = true;
|
|
435
|
+
q.hasDirectives = true;
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if (tok.text === "AND" || tok.text === "&&") {
|
|
439
|
+
q.hasDirectives = true;
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if (tok.text === "NOT" || tok.text === "!") {
|
|
443
|
+
negateNext = true;
|
|
444
|
+
q.hasDirectives = true;
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
if (tok.text === "-" || tok.text === "+") {
|
|
448
|
+
// The tokenizer splits `-"exact phrase"` into `-` + phrase; carry the negation over.
|
|
449
|
+
if (tok.text === "-" && tokens[idx + 1]?.quoted) negateNext = true;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const match = DIRECTIVE_PATTERN.exec(tok.text);
|
|
454
|
+
const name = match?.[2].toLowerCase();
|
|
455
|
+
const allMatch = name ? ALL_MODES[name] : undefined;
|
|
456
|
+
const field = name ? DIRECTIVE_FIELDS[name] : undefined;
|
|
457
|
+
|
|
458
|
+
if (match && allMatch) {
|
|
459
|
+
allMode = allMatch;
|
|
460
|
+
q.hasDirectives = true;
|
|
461
|
+
// `allintitle:budget tips` — inline value plus every following term.
|
|
462
|
+
const inline = match[3].trim();
|
|
463
|
+
if (inline) pushConstraint(allMatch, inline, match[1] === "-");
|
|
464
|
+
orPending = false;
|
|
465
|
+
lastWasTerm = false;
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (match && field) {
|
|
470
|
+
let value = match[3].trim();
|
|
471
|
+
// `site: example.com` — lenient: adopt the next plain token as the value.
|
|
472
|
+
if (!value) {
|
|
473
|
+
const next = tokens[idx + 1];
|
|
474
|
+
if (next && (next.quoted || !isReservedToken(next.text))) {
|
|
475
|
+
value = next.text.trim();
|
|
476
|
+
idx++;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (!value) {
|
|
480
|
+
q.hasDirectives = true;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
const negated = match[1] === "-" || negateNext;
|
|
484
|
+
negateNext = false;
|
|
485
|
+
switch (field) {
|
|
486
|
+
case "before":
|
|
487
|
+
case "after": {
|
|
488
|
+
const iso = parseDateValue(value);
|
|
489
|
+
if (!iso) {
|
|
490
|
+
pushTerm(tok.text, false);
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
if (field === "before") q.before = iso;
|
|
494
|
+
else q.after = iso;
|
|
495
|
+
q.hasDirectives = true;
|
|
496
|
+
orPending = false;
|
|
497
|
+
lastWasTerm = false;
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
case "lang":
|
|
501
|
+
q.lang = value.toLowerCase();
|
|
502
|
+
q.hasDirectives = true;
|
|
503
|
+
orPending = false;
|
|
504
|
+
lastWasTerm = false;
|
|
505
|
+
break;
|
|
506
|
+
default:
|
|
507
|
+
pushConstraint(field, value, negated);
|
|
508
|
+
}
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Plain term with optional +/- prefix.
|
|
513
|
+
let text = tok.text;
|
|
514
|
+
if (text.startsWith("-") && text.length > 1) {
|
|
515
|
+
negateNext = true;
|
|
516
|
+
q.hasDirectives = true;
|
|
517
|
+
text = text.replace(/^-+/, "");
|
|
518
|
+
if (!text) continue;
|
|
519
|
+
// `-site:x` arrives pre-split only when written `- site:x`; re-check directive.
|
|
520
|
+
const negMatch = DIRECTIVE_PATTERN.exec(text);
|
|
521
|
+
const negName = negMatch?.[2].toLowerCase();
|
|
522
|
+
const negField = negName ? DIRECTIVE_FIELDS[negName] : undefined;
|
|
523
|
+
if (negMatch && negField && negField !== "before" && negField !== "after" && negField !== "lang") {
|
|
524
|
+
negateNext = false;
|
|
525
|
+
pushConstraint(negField, negMatch[3].trim(), true);
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
pushTerm(text, false);
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
if (text.startsWith("+") && text.length > 1) {
|
|
532
|
+
// Legacy Google `+term`: verbatim/required — treat as an exact phrase.
|
|
533
|
+
pushTerm(text.slice(1), true);
|
|
534
|
+
q.hasDirectives = true;
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
pushTerm(text, false);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
q.text = renderTerms(q.terms, { phrases: true, negation: true, or: true });
|
|
541
|
+
q.hasConstraints =
|
|
542
|
+
q.sites.length > 0 ||
|
|
543
|
+
q.excludedSites.length > 0 ||
|
|
544
|
+
q.inUrl.length > 0 ||
|
|
545
|
+
q.excludedInUrl.length > 0 ||
|
|
546
|
+
q.inTitle.length > 0 ||
|
|
547
|
+
q.excludedInTitle.length > 0 ||
|
|
548
|
+
q.filetypes.length > 0 ||
|
|
549
|
+
q.excludedFiletypes.length > 0 ||
|
|
550
|
+
q.before !== undefined ||
|
|
551
|
+
q.after !== undefined;
|
|
552
|
+
return q;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Quote a directive value when it contains whitespace. */
|
|
556
|
+
function quoteValue(value: string): string {
|
|
557
|
+
return /\s/.test(value) ? `"${value}"` : value;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** Render the free-text terms per the target syntax. */
|
|
561
|
+
function renderTerms(terms: readonly QueryTerm[], syntax: QuerySyntax): string {
|
|
562
|
+
const parts: string[] = [];
|
|
563
|
+
for (let i = 0; i < terms.length; i++) {
|
|
564
|
+
const term = terms[i];
|
|
565
|
+
if (term.group !== undefined && syntax.or) {
|
|
566
|
+
const members: string[] = [];
|
|
567
|
+
let j = i;
|
|
568
|
+
for (; j < terms.length && terms[j].group === term.group; j++) {
|
|
569
|
+
const rendered = renderTerm(terms[j], syntax);
|
|
570
|
+
if (rendered) members.push(rendered);
|
|
571
|
+
}
|
|
572
|
+
i = j - 1;
|
|
573
|
+
if (members.length > 1) parts.push(`(${members.join(" OR ")})`);
|
|
574
|
+
else if (members.length === 1) parts.push(members[0]);
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
const rendered = renderTerm(term, syntax);
|
|
578
|
+
if (rendered) parts.push(rendered);
|
|
579
|
+
}
|
|
580
|
+
return parts.join(" ");
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function renderTerm(term: QueryTerm, syntax: QuerySyntax): string | undefined {
|
|
584
|
+
if (term.negated && !syntax.negation) return undefined;
|
|
585
|
+
const body = term.phrase && syntax.phrases ? `"${term.text}"` : term.text;
|
|
586
|
+
return term.negated ? `-${body}` : body;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Rebuild a query string for an engine with the given {@link QuerySyntax}.
|
|
591
|
+
*
|
|
592
|
+
* Constraints whose syntax the engine lacks are omitted (the caller maps
|
|
593
|
+
* them onto API parameters or relies on {@link applyQueryConstraints}).
|
|
594
|
+
* Never returns an empty string for a non-empty input: a directives-only
|
|
595
|
+
* query falls back to the constraint values as keywords, then to `raw` — an
|
|
596
|
+
* engine searching *something* beats an empty-query error.
|
|
597
|
+
*/
|
|
598
|
+
export function formatQuery(q: StructuredQuery, syntax: QuerySyntax = {}): string {
|
|
599
|
+
const parts: string[] = [];
|
|
600
|
+
const text = renderTerms(q.terms, syntax);
|
|
601
|
+
if (text) parts.push(text);
|
|
602
|
+
|
|
603
|
+
if (syntax.site) {
|
|
604
|
+
if (q.sites.length > 1 && syntax.or) parts.push(`(${q.sites.map(s => `site:${s}`).join(" OR ")})`);
|
|
605
|
+
else parts.push(...q.sites.map(s => `site:${s}`));
|
|
606
|
+
parts.push(...q.excludedSites.map(s => `-site:${s}`));
|
|
607
|
+
}
|
|
608
|
+
if (syntax.inUrl) {
|
|
609
|
+
parts.push(...q.inUrl.map(v => `inurl:${quoteValue(v)}`));
|
|
610
|
+
parts.push(...q.excludedInUrl.map(v => `-inurl:${quoteValue(v)}`));
|
|
611
|
+
}
|
|
612
|
+
if (syntax.inTitle) {
|
|
613
|
+
parts.push(...q.inTitle.map(v => `intitle:${quoteValue(v)}`));
|
|
614
|
+
parts.push(...q.excludedInTitle.map(v => `-intitle:${quoteValue(v)}`));
|
|
615
|
+
}
|
|
616
|
+
if (syntax.inText) {
|
|
617
|
+
parts.push(...q.inText.map(v => `intext:${quoteValue(v)}`));
|
|
618
|
+
parts.push(...q.excludedInText.map(v => `-intext:${quoteValue(v)}`));
|
|
619
|
+
}
|
|
620
|
+
if (syntax.filetype) {
|
|
621
|
+
if (q.filetypes.length > 1 && syntax.or) parts.push(`(${q.filetypes.map(f => `filetype:${f}`).join(" OR ")})`);
|
|
622
|
+
else parts.push(...q.filetypes.map(f => `filetype:${f}`));
|
|
623
|
+
parts.push(...q.excludedFiletypes.map(f => `-filetype:${f}`));
|
|
624
|
+
}
|
|
625
|
+
if (syntax.dateRange) {
|
|
626
|
+
if (q.after) parts.push(`after:${q.after}`);
|
|
627
|
+
if (q.before) parts.push(`before:${q.before}`);
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
let result = parts.join(" ").trim();
|
|
631
|
+
if (!result) {
|
|
632
|
+
// Directives-only query and no directive syntax: search the constraint
|
|
633
|
+
// values as plain keywords so the engine still gets a meaningful query.
|
|
634
|
+
const fallback = [...q.sites, ...q.inTitle, ...q.inUrl, ...q.inText, ...q.filetypes];
|
|
635
|
+
result = fallback.join(" ").trim();
|
|
636
|
+
}
|
|
637
|
+
return result || q.raw.trim();
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Build the engine query for a credential-free HTML engine (Google,
|
|
642
|
+
* Startpage, DuckDuckGo, Ecosia, Mojeek, SearXNG, and the Public Web
|
|
643
|
+
* fan-out over them).
|
|
644
|
+
*
|
|
645
|
+
* Canonicalizes directives via {@link formatQuery} with the engine's
|
|
646
|
+
* {@link QuerySyntax} (default: full Google syntax), after demoting the
|
|
647
|
+
* operators that zero-match across the scraper set: engines only match
|
|
648
|
+
* `site:` against a bare domain (a path yields zero results everywhere),
|
|
649
|
+
* and DuckDuckGo ignores `inurl:` entirely — so either operator silently
|
|
650
|
+
* empties the result set. The raw URL as a plain term matches fine, so
|
|
651
|
+
* bare-domain `site:` filters are kept while path-carrying `site:` and all
|
|
652
|
+
* `inurl:` values become plain keywords; the demotion is structural (before
|
|
653
|
+
* formatting), so OR-grouped and quoted directives are covered. Negated
|
|
654
|
+
* forms (`-site:`, `-inurl:`) pass through untouched — demoting them would
|
|
655
|
+
* invert an exclusion into a search term; the pipeline post-filter
|
|
656
|
+
* ({@link applyQueryConstraints}) enforces every demoted or unsupported
|
|
657
|
+
* constraint on the returned sources. Directive-free queries pass through
|
|
658
|
+
* byte-identical.
|
|
659
|
+
*/
|
|
660
|
+
export function formatScraperQuery(
|
|
661
|
+
query: string,
|
|
662
|
+
parsedQuery?: StructuredQuery,
|
|
663
|
+
syntax: QuerySyntax = GOOGLE_QUERY_SYNTAX,
|
|
664
|
+
): string {
|
|
665
|
+
const parsed = parsedQuery ?? parseSearchQuery(query);
|
|
666
|
+
if (!parsed.hasDirectives) return query;
|
|
667
|
+
const demoted = [...parsed.sites.filter(site => site.includes("/")), ...parsed.inUrl];
|
|
668
|
+
const downgraded: StructuredQuery = {
|
|
669
|
+
...parsed,
|
|
670
|
+
sites: parsed.sites.filter(site => !site.includes("/")),
|
|
671
|
+
inUrl: [],
|
|
672
|
+
terms: [...parsed.terms, ...demoted.map(text => ({ text }))],
|
|
673
|
+
};
|
|
674
|
+
return formatQuery(downgraded, syntax);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** Hostname (lowercased) and pathname of a URL, or undefined when unparsable. */
|
|
678
|
+
function hostAndPath(url: string): { host: string; path: string } | undefined {
|
|
679
|
+
try {
|
|
680
|
+
const u = new URL(url);
|
|
681
|
+
return { host: u.hostname.toLowerCase(), path: u.pathname };
|
|
682
|
+
} catch {
|
|
683
|
+
return undefined;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* `site:` matcher: exact host or subdomain of `site`; when `site` carries a
|
|
689
|
+
* path (`github.com/anthropics`), the URL path must start with it.
|
|
690
|
+
*/
|
|
691
|
+
export function matchesSite(url: string, site: string): boolean {
|
|
692
|
+
const parsed = hostAndPath(url);
|
|
693
|
+
if (!parsed) return false;
|
|
694
|
+
const slash = site.indexOf("/");
|
|
695
|
+
const siteHost = slash === -1 ? site : site.slice(0, slash);
|
|
696
|
+
const sitePath = slash === -1 ? "" : site.slice(slash);
|
|
697
|
+
if (parsed.host !== siteHost && !parsed.host.endsWith(`.${siteHost}`)) return false;
|
|
698
|
+
if (sitePath && !parsed.path.toLowerCase().startsWith(sitePath.toLowerCase())) return false;
|
|
699
|
+
return true;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** `filetype:` matcher: URL pathname ends with `.ext`. */
|
|
703
|
+
function matchesFiletype(url: string, ext: string): boolean {
|
|
704
|
+
const parsed = hostAndPath(url);
|
|
705
|
+
if (!parsed) return false;
|
|
706
|
+
return parsed.path.toLowerCase().endsWith(`.${ext}`);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const RELATIVE_AGE_PATTERN = /^(\d+)\s*(minute|min|hour|hr|day|week|month|mo|year|yr|[mhdwy])s?\s+ago$/i;
|
|
710
|
+
|
|
711
|
+
const RELATIVE_UNIT_SECONDS: Record<string, number> = {
|
|
712
|
+
m: 60,
|
|
713
|
+
min: 60,
|
|
714
|
+
minute: 60,
|
|
715
|
+
h: 3600,
|
|
716
|
+
hr: 3600,
|
|
717
|
+
hour: 3600,
|
|
718
|
+
d: 86_400,
|
|
719
|
+
day: 86_400,
|
|
720
|
+
w: 604_800,
|
|
721
|
+
week: 604_800,
|
|
722
|
+
mo: 2_592_000,
|
|
723
|
+
month: 2_592_000,
|
|
724
|
+
y: 31_536_000,
|
|
725
|
+
yr: 31_536_000,
|
|
726
|
+
year: 31_536_000,
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
/** Best-effort publish time (ms epoch) of a source from `ageSeconds`, ISO, or relative dates. */
|
|
730
|
+
function sourceTime(source: SearchSource): number | undefined {
|
|
731
|
+
if (typeof source.ageSeconds === "number" && Number.isFinite(source.ageSeconds)) {
|
|
732
|
+
return Date.now() - source.ageSeconds * 1000;
|
|
733
|
+
}
|
|
734
|
+
if (!source.publishedDate) return undefined;
|
|
735
|
+
const rel = RELATIVE_AGE_PATTERN.exec(source.publishedDate.trim());
|
|
736
|
+
if (rel) {
|
|
737
|
+
const seconds = Number(rel[1]) * (RELATIVE_UNIT_SECONDS[rel[2].toLowerCase()] ?? 0);
|
|
738
|
+
return seconds > 0 ? Date.now() - seconds * 1000 : undefined;
|
|
739
|
+
}
|
|
740
|
+
const parsed = Date.parse(source.publishedDate);
|
|
741
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Strict per-source constraint check: every filterable dimension of `q` must
|
|
746
|
+
* pass. Sources without a resolvable date pass date bounds (a missing date
|
|
747
|
+
* is not proof of violation). For custom provider flows; the standard path
|
|
748
|
+
* is {@link applyQueryConstraints}.
|
|
749
|
+
*/
|
|
750
|
+
export function matchesQueryConstraints(source: SearchSource, q: StructuredQuery): boolean {
|
|
751
|
+
for (const dim of constraintDimensions(q)) {
|
|
752
|
+
if (!dim.pred(source)) return false;
|
|
753
|
+
}
|
|
754
|
+
return true;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
interface ConstraintDimension {
|
|
758
|
+
/** Directive rendering for relaxation notes (`site:arxiv.org`). */
|
|
759
|
+
label: string;
|
|
760
|
+
pred: (source: SearchSource) => boolean;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function constraintDimensions(q: StructuredQuery): ConstraintDimension[] {
|
|
764
|
+
const dims: ConstraintDimension[] = [];
|
|
765
|
+
const lower = (s: string | undefined): string => (s ?? "").toLowerCase();
|
|
766
|
+
|
|
767
|
+
if (q.sites.length > 0) {
|
|
768
|
+
dims.push({
|
|
769
|
+
label: q.sites.map(s => `site:${s}`).join(" OR "),
|
|
770
|
+
pred: src => q.sites.some(site => matchesSite(src.url, site)),
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
if (q.excludedSites.length > 0) {
|
|
774
|
+
dims.push({
|
|
775
|
+
label: q.excludedSites.map(s => `-site:${s}`).join(" "),
|
|
776
|
+
pred: src => !q.excludedSites.some(site => matchesSite(src.url, site)),
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
if (q.inUrl.length > 0) {
|
|
780
|
+
dims.push({
|
|
781
|
+
label: q.inUrl.map(v => `inurl:${v}`).join(" "),
|
|
782
|
+
pred: src => q.inUrl.every(v => lower(src.url).includes(v.toLowerCase())),
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
if (q.excludedInUrl.length > 0) {
|
|
786
|
+
dims.push({
|
|
787
|
+
label: q.excludedInUrl.map(v => `-inurl:${v}`).join(" "),
|
|
788
|
+
pred: src => !q.excludedInUrl.some(v => lower(src.url).includes(v.toLowerCase())),
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
if (q.inTitle.length > 0) {
|
|
792
|
+
dims.push({
|
|
793
|
+
label: q.inTitle.map(v => `intitle:${v}`).join(" "),
|
|
794
|
+
pred: src => q.inTitle.every(v => lower(src.title).includes(v.toLowerCase())),
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
if (q.excludedInTitle.length > 0) {
|
|
798
|
+
dims.push({
|
|
799
|
+
label: q.excludedInTitle.map(v => `-intitle:${v}`).join(" "),
|
|
800
|
+
pred: src => !q.excludedInTitle.some(v => lower(src.title).includes(v.toLowerCase())),
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
if (q.filetypes.length > 0) {
|
|
804
|
+
dims.push({
|
|
805
|
+
label: q.filetypes.map(f => `filetype:${f}`).join(" OR "),
|
|
806
|
+
pred: src => q.filetypes.some(ext => matchesFiletype(src.url, ext)),
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
if (q.excludedFiletypes.length > 0) {
|
|
810
|
+
dims.push({
|
|
811
|
+
label: q.excludedFiletypes.map(f => `-filetype:${f}`).join(" "),
|
|
812
|
+
pred: src => !q.excludedFiletypes.some(ext => matchesFiletype(src.url, ext)),
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
if (q.after !== undefined || q.before !== undefined) {
|
|
816
|
+
const afterMs = q.after !== undefined ? Date.parse(q.after) : undefined;
|
|
817
|
+
const beforeMs = q.before !== undefined ? Date.parse(q.before) : undefined;
|
|
818
|
+
const label = [q.after ? `after:${q.after}` : "", q.before ? `before:${q.before}` : ""].filter(Boolean).join(" ");
|
|
819
|
+
dims.push({
|
|
820
|
+
label,
|
|
821
|
+
pred: src => {
|
|
822
|
+
const time = sourceTime(src);
|
|
823
|
+
if (time === undefined) return true; // undated → cannot prove violation
|
|
824
|
+
if (afterMs !== undefined && time < afterMs) return false;
|
|
825
|
+
if (beforeMs !== undefined && time >= beforeMs) return false;
|
|
826
|
+
return true;
|
|
827
|
+
},
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
return dims;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Lenient post-filter: applies each constraint dimension of `q` in turn,
|
|
835
|
+
* skipping (and reporting) any dimension that would eliminate every
|
|
836
|
+
* remaining source. Guarantees a non-empty result for a non-empty input, so
|
|
837
|
+
* a mis-scoped directive degrades to unfiltered results plus a note instead
|
|
838
|
+
* of a dead search.
|
|
839
|
+
*/
|
|
840
|
+
export function applyQueryConstraints(sources: readonly SearchSource[], q: StructuredQuery): ConstraintFilterResult {
|
|
841
|
+
let current = [...sources];
|
|
842
|
+
const dropped: string[] = [];
|
|
843
|
+
if (current.length === 0) return { sources: current, dropped };
|
|
844
|
+
for (const dim of constraintDimensions(q)) {
|
|
845
|
+
const kept = current.filter(dim.pred);
|
|
846
|
+
if (kept.length > 0) current = kept;
|
|
847
|
+
else dropped.push(dim.label);
|
|
848
|
+
}
|
|
849
|
+
return { sources: current, dropped };
|
|
850
|
+
}
|