@cliwant/mcp-sam-gov 0.3.0 → 1.0.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/LICENSE +21 -21
- package/README.ja.md +48 -16
- package/README.ko.md +48 -16
- package/README.md +226 -64
- package/dist/attachments.d.ts +63 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +576 -0
- package/dist/attachments.js.map +1 -0
- package/dist/bls.d.ts +267 -0
- package/dist/bls.d.ts.map +1 -0
- package/dist/bls.js +1549 -0
- package/dist/bls.js.map +1 -0
- package/dist/cache.d.ts +2 -0
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js +4 -0
- package/dist/cache.js.map +1 -1
- package/dist/census.d.ts +182 -0
- package/dist/census.d.ts.map +1 -0
- package/dist/census.js +576 -0
- package/dist/census.js.map +1 -0
- package/dist/ckan.d.ts +141 -0
- package/dist/ckan.d.ts.map +1 -0
- package/dist/ckan.js +393 -0
- package/dist/ckan.js.map +1 -0
- package/dist/clinicaltrials.d.ts +180 -0
- package/dist/clinicaltrials.d.ts.map +1 -0
- package/dist/clinicaltrials.js +730 -0
- package/dist/clinicaltrials.js.map +1 -0
- package/dist/cms.d.ts +140 -0
- package/dist/cms.d.ts.map +1 -0
- package/dist/cms.js +482 -0
- package/dist/cms.js.map +1 -0
- package/dist/coerce.d.ts +32 -0
- package/dist/coerce.d.ts.map +1 -0
- package/dist/coerce.js +50 -0
- package/dist/coerce.js.map +1 -0
- package/dist/datagov-catalog.d.ts +84 -0
- package/dist/datagov-catalog.d.ts.map +1 -0
- package/dist/datagov-catalog.js +233 -0
- package/dist/datagov-catalog.js.map +1 -0
- package/dist/datagov.d.ts +146 -0
- package/dist/datagov.d.ts.map +1 -0
- package/dist/datagov.js +689 -0
- package/dist/datagov.js.map +1 -0
- package/dist/datagovKey.d.ts +36 -0
- package/dist/datagovKey.d.ts.map +1 -0
- package/dist/datagovKey.js +60 -0
- package/dist/datagovKey.js.map +1 -0
- package/dist/datasource.d.ts +313 -0
- package/dist/datasource.d.ts.map +1 -0
- package/dist/datasource.js +551 -0
- package/dist/datasource.js.map +1 -0
- package/dist/disclosure.d.ts +55 -0
- package/dist/disclosure.d.ts.map +1 -0
- package/dist/disclosure.js +57 -0
- package/dist/disclosure.js.map +1 -0
- package/dist/ecfr.d.ts +4 -2
- package/dist/ecfr.d.ts.map +1 -1
- package/dist/ecfr.js +74 -1
- package/dist/ecfr.js.map +1 -1
- package/dist/echo.d.ts +143 -0
- package/dist/echo.d.ts.map +1 -0
- package/dist/echo.js +424 -0
- package/dist/echo.js.map +1 -0
- package/dist/edgar.d.ts +377 -0
- package/dist/edgar.d.ts.map +1 -0
- package/dist/edgar.js +2418 -0
- package/dist/edgar.js.map +1 -0
- package/dist/errors.d.ts +26 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +69 -1
- package/dist/errors.js.map +1 -1
- package/dist/fac.d.ts +180 -0
- package/dist/fac.d.ts.map +1 -0
- package/dist/fac.js +416 -0
- package/dist/fac.js.map +1 -0
- package/dist/far.d.ts +170 -0
- package/dist/far.d.ts.map +1 -0
- package/dist/far.js +804 -0
- package/dist/far.js.map +1 -0
- package/dist/fdic.d.ts +599 -0
- package/dist/fdic.d.ts.map +1 -0
- package/dist/fdic.js +1624 -0
- package/dist/fdic.js.map +1 -0
- package/dist/federal-register.d.ts +139 -2
- package/dist/federal-register.d.ts.map +1 -1
- package/dist/federal-register.js +419 -3
- package/dist/federal-register.js.map +1 -1
- package/dist/fema.d.ts +181 -0
- package/dist/fema.d.ts.map +1 -0
- package/dist/fema.js +436 -0
- package/dist/fema.js.map +1 -0
- package/dist/fpds.d.ts +108 -0
- package/dist/fpds.d.ts.map +1 -0
- package/dist/fpds.js +519 -0
- package/dist/fpds.js.map +1 -0
- package/dist/gao.d.ts +64 -0
- package/dist/gao.d.ts.map +1 -0
- package/dist/gao.js +640 -0
- package/dist/gao.js.map +1 -0
- package/dist/govinfo.d.ts +111 -0
- package/dist/govinfo.d.ts.map +1 -0
- package/dist/govinfo.js +422 -0
- package/dist/govinfo.js.map +1 -0
- package/dist/grants.d.ts +27 -4
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +111 -6
- package/dist/grants.js.map +1 -1
- package/dist/gsa-csv.d.ts +249 -0
- package/dist/gsa-csv.d.ts.map +1 -0
- package/dist/gsa-csv.js +784 -0
- package/dist/gsa-csv.js.map +1 -0
- package/dist/integrity.d.ts +212 -0
- package/dist/integrity.d.ts.map +1 -0
- package/dist/integrity.js +707 -0
- package/dist/integrity.js.map +1 -0
- package/dist/meta.d.ts +165 -0
- package/dist/meta.d.ts.map +1 -0
- package/dist/meta.js +162 -0
- package/dist/meta.js.map +1 -0
- package/dist/nih.d.ts +117 -0
- package/dist/nih.d.ts.map +1 -0
- package/dist/nih.js +291 -0
- package/dist/nih.js.map +1 -0
- package/dist/nppes.d.ts +157 -0
- package/dist/nppes.d.ts.map +1 -0
- package/dist/nppes.js +648 -0
- package/dist/nppes.js.map +1 -0
- package/dist/nsf.d.ts +176 -0
- package/dist/nsf.d.ts.map +1 -0
- package/dist/nsf.js +554 -0
- package/dist/nsf.js.map +1 -0
- package/dist/nvd.d.ts +176 -0
- package/dist/nvd.d.ts.map +1 -0
- package/dist/nvd.js +912 -0
- package/dist/nvd.js.map +1 -0
- package/dist/ofac.d.ts +205 -0
- package/dist/ofac.d.ts.map +1 -0
- package/dist/ofac.js +919 -0
- package/dist/ofac.js.map +1 -0
- package/dist/pricing.d.ts +110 -0
- package/dist/pricing.d.ts.map +1 -0
- package/dist/pricing.js +843 -0
- package/dist/pricing.js.map +1 -0
- package/dist/sam-gov/client.d.ts +60 -2
- package/dist/sam-gov/client.d.ts.map +1 -1
- package/dist/sam-gov/client.js +320 -54
- package/dist/sam-gov/client.js.map +1 -1
- package/dist/sam-gov/index.d.ts +1 -1
- package/dist/sam-gov/index.d.ts.map +1 -1
- package/dist/sam-gov/index.js +1 -1
- package/dist/sam-gov/index.js.map +1 -1
- package/dist/sam-gov/types.d.ts +24 -0
- package/dist/sam-gov/types.d.ts.map +1 -1
- package/dist/sba.d.ts +72 -0
- package/dist/sba.d.ts.map +1 -0
- package/dist/sba.js +281 -0
- package/dist/sba.js.map +1 -0
- package/dist/server.d.ts +14 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3889 -296
- package/dist/server.js.map +1 -1
- package/dist/snapshot.d.ts +98 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +146 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/socrata.d.ts +157 -0
- package/dist/socrata.d.ts.map +1 -0
- package/dist/socrata.js +448 -0
- package/dist/socrata.js.map +1 -0
- package/dist/treasury.d.ts +143 -0
- package/dist/treasury.d.ts.map +1 -0
- package/dist/treasury.js +436 -0
- package/dist/treasury.js.map +1 -0
- package/dist/usaspending.d.ts +260 -65
- package/dist/usaspending.d.ts.map +1 -1
- package/dist/usaspending.js +1629 -201
- package/dist/usaspending.js.map +1 -1
- package/dist/usitc.d.ts +142 -0
- package/dist/usitc.d.ts.map +1 -0
- package/dist/usitc.js +339 -0
- package/dist/usitc.js.map +1 -0
- package/package.json +24 -2
- package/src/attachments.ts +652 -0
- package/src/bls.ts +1943 -0
- package/src/cache.ts +5 -0
- package/src/census.ts +735 -0
- package/src/ckan.ts +495 -0
- package/src/clinicaltrials.ts +923 -0
- package/src/cms.ts +634 -0
- package/src/coerce.ts +47 -0
- package/src/datagov-catalog.ts +296 -0
- package/src/datagov.ts +907 -0
- package/src/datagovKey.ts +68 -0
- package/src/datasource.ts +721 -0
- package/src/disclosure.ts +61 -0
- package/src/ecfr.ts +98 -1
- package/src/echo.ts +496 -0
- package/src/edgar.ts +3014 -0
- package/src/errors.ts +84 -1
- package/src/fac.ts +529 -0
- package/src/far.ts +1007 -0
- package/src/fdic.ts +2052 -0
- package/src/federal-register.ts +512 -3
- package/src/fema.ts +541 -0
- package/src/fpds.ts +620 -0
- package/src/gao.ts +744 -0
- package/src/govinfo.ts +497 -0
- package/src/grants.ts +139 -7
- package/src/gsa-csv.ts +992 -0
- package/src/integrity.ts +928 -0
- package/src/meta.ts +292 -0
- package/src/nih.ts +375 -0
- package/src/nppes.ts +834 -0
- package/src/nsf.ts +706 -0
- package/src/nvd.ts +1124 -0
- package/src/ofac.ts +1166 -0
- package/src/pricing.ts +1075 -0
- package/src/sam-gov/client.ts +345 -63
- package/src/sam-gov/index.ts +5 -1
- package/src/sam-gov/types.ts +22 -0
- package/src/sba.ts +357 -0
- package/src/server.ts +4551 -328
- package/src/snapshot.ts +192 -0
- package/src/socrata.ts +532 -0
- package/src/treasury.ts +575 -0
- package/src/usaspending.ts +1946 -214
- package/src/usitc.ts +420 -0
package/dist/gao.js
ADDED
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GAO bid-protest lookup (keyless) — recent Comptroller General bid-protest
|
|
3
|
+
* decisions from the public GAO "Legal Products" RSS feed, optionally enriched
|
|
4
|
+
* from each decision's public product page.
|
|
5
|
+
*
|
|
6
|
+
* HONEST SCOPE (the defining constraint of this tool)
|
|
7
|
+
* ---------------------------------------------------
|
|
8
|
+
* Keyless access covers ONLY the RECENT decisions carried by the public
|
|
9
|
+
* Legal-Products RSS feed (a rolling ~25-item window). GAO's faceted historical
|
|
10
|
+
* bid-protest search (by protester / agency / outcome / date across all years,
|
|
11
|
+
* at https://www.gao.gov/legal/bid-protests/search) is WAF-protected against
|
|
12
|
+
* automated clients and is available cleanly only via a PAID third-party API.
|
|
13
|
+
* So this tool NEVER presents "recent" as "all": its `_meta` is ALWAYS
|
|
14
|
+
* `complete:false` + `truncated:true`, `totalAvailable` is `null` (the feed is
|
|
15
|
+
* not a count of all protests), and a top-level `accessNote` + a `_meta` note
|
|
16
|
+
* spell out the boundary. This is an intentional PARTIAL close of a capability
|
|
17
|
+
* that a competitor (Tango) sells as a paid feature.
|
|
18
|
+
*
|
|
19
|
+
* LIVE-VERIFIED 2026-07-03
|
|
20
|
+
* ------------------------
|
|
21
|
+
* - `GET https://www.gao.gov/rss/reportslegal.xml` with a browser UA → HTTP
|
|
22
|
+
* 200, application/rss+xml, ~14.6 KB, 26 <item>s. The feed carries GAO
|
|
23
|
+
* Legal Products — bid-protest DECISIONS *and* other legal products
|
|
24
|
+
* (Congressional Review Act regulatory reviews, legal opinions), so it MUST
|
|
25
|
+
* be filtered down to bid protests.
|
|
26
|
+
* - Each <item> has <title>, <link> (…/products/b-XXXXXX), <description>,
|
|
27
|
+
* <pubDate>, <guid>. A reconsideration reads "FCN Inc.--Reconsideration" →
|
|
28
|
+
* /products/b-424249.2; a costs decision "Accura Engineering--Costs".
|
|
29
|
+
* - Bid-protest items are reliably identified by their DESCRIPTION language
|
|
30
|
+
* ("protest(s)", "protester", "request(s) reconsideration of our decision")
|
|
31
|
+
* — regulatory reviews instead read "GAO reviewed the …'s new rule entitled".
|
|
32
|
+
* Protest B-numbers are the B-4xxxxx series; CRA reviews are B-33xxxx.
|
|
33
|
+
* - `GET https://www.gao.gov/products/b-XXXXXX` (browser UA) is cleanly
|
|
34
|
+
* parseable for protester (+ city/state), contracting agency, decision date,
|
|
35
|
+
* outcome ("We deny/sustain/dismiss the protest"), a solicitation number
|
|
36
|
+
* when present, and a decision-PDF link (/assets/.../NNNNNN.pdf).
|
|
37
|
+
*/
|
|
38
|
+
import { ToolErrorCarrier } from "./errors.js";
|
|
39
|
+
import { getText as getTextPort } from "./datasource.js";
|
|
40
|
+
import { withMeta } from "./meta.js";
|
|
41
|
+
// ─── Shared HTTP ─────────────────────────────────────────────────
|
|
42
|
+
// GAO's edge (Cloudflare/WAF) will 403 a bare client — always send a realistic
|
|
43
|
+
// browser User-Agent. Mirror the shape the rest of the server uses for GAO-ish
|
|
44
|
+
// public HTML/RSS scraping so behavior is consistent.
|
|
45
|
+
const GAO_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36";
|
|
46
|
+
const RSS_URL = "https://www.gao.gov/rss/reportslegal.xml";
|
|
47
|
+
const PRODUCT_BASE = "https://www.gao.gov/products/";
|
|
48
|
+
const SOURCE = "gao.gov Legal Products RSS + decision pages (keyless)";
|
|
49
|
+
/**
|
|
50
|
+
* The mandatory honesty disclaimer. Surfaced BOTH as a top-level `accessNote`
|
|
51
|
+
* in `data` and inside `_meta.notes` so no consumer can miss the scope boundary.
|
|
52
|
+
*/
|
|
53
|
+
const ACCESS_NOTE = "Keyless GAO access covers only RECENT decisions from the public Legal-Products RSS feed (a rolling ~25-item window). GAO's faceted historical protest search (by protester/agency/outcome/date across all years) is WAF-blocked to automated clients and available only via a paid third-party API. Do NOT treat these results as the complete protest history.";
|
|
54
|
+
// Thin LOCAL wrapper (ADR-0013) that injects GAO's WAF-friendly UA + RSS Accept
|
|
55
|
+
// for the tool's two call sites, then delegates to the shared `getText` port
|
|
56
|
+
// (retry defaults true → fetchWithRetry, byte-identical to the former
|
|
57
|
+
// hand-rolled fetcher). `timeoutMs` is preserved as a param default (never
|
|
58
|
+
// overridden at either call site) and passed through explicitly.
|
|
59
|
+
async function getText(url, label, timeoutMs = 15_000) {
|
|
60
|
+
return getTextPort(url, {
|
|
61
|
+
label,
|
|
62
|
+
headers: {
|
|
63
|
+
"User-Agent": GAO_UA,
|
|
64
|
+
Accept: "application/rss+xml, application/xml, text/html;q=0.9, */*;q=0.8",
|
|
65
|
+
},
|
|
66
|
+
timeoutMs,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
// ─── RSS parse (no dependency — string/regex) ─────────────────────
|
|
70
|
+
/** Decode the handful of XML/HTML entities that appear in GAO titles/desc. */
|
|
71
|
+
function decodeEntities(s) {
|
|
72
|
+
return s
|
|
73
|
+
.replace(/</g, "<")
|
|
74
|
+
.replace(/>/g, ">")
|
|
75
|
+
.replace(/"/g, '"')
|
|
76
|
+
.replace(/�?39;/g, "'")
|
|
77
|
+
.replace(/'/g, "'")
|
|
78
|
+
.replace(/'/g, "'")
|
|
79
|
+
// & last so we don't double-decode an already-decoded entity.
|
|
80
|
+
.replace(/&/g, "&")
|
|
81
|
+
.trim();
|
|
82
|
+
}
|
|
83
|
+
/** Pull the inner text of the first <tag>…</tag> in a block (CDATA-aware). */
|
|
84
|
+
function tagText(block, tag) {
|
|
85
|
+
const re = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, "i");
|
|
86
|
+
const m = re.exec(block);
|
|
87
|
+
if (!m || m[1] === undefined)
|
|
88
|
+
return null;
|
|
89
|
+
let inner = m[1];
|
|
90
|
+
const cdata = /^<!\[CDATA\[([\s\S]*?)\]\]>$/.exec(inner.trim());
|
|
91
|
+
if (cdata && cdata[1] !== undefined)
|
|
92
|
+
inner = cdata[1];
|
|
93
|
+
return decodeEntities(inner);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Extract the canonical B-number from a product URL or guid. The URL path is
|
|
97
|
+
* lower-cased and may be percent-encoded with a comma joining companion numbers
|
|
98
|
+
* (e.g. `/products/b-424347%2Cb-424347.2`). We take the FIRST b-number, upcase
|
|
99
|
+
* it to the conventional `B-424347.2` form, and return the companions too.
|
|
100
|
+
*/
|
|
101
|
+
function bNumbersFromUrl(url) {
|
|
102
|
+
let decoded = url;
|
|
103
|
+
try {
|
|
104
|
+
decoded = decodeURIComponent(url);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
/* keep raw on malformed escapes */
|
|
108
|
+
}
|
|
109
|
+
const matches = decoded.match(/b-[0-9][0-9a-z.\-]*/gi) ?? [];
|
|
110
|
+
const norm = matches.map((b) => {
|
|
111
|
+
// Upcase the leading "b-" and any trailing "-O.M." style suffix letters.
|
|
112
|
+
return b.replace(/^b-/i, "B-").toUpperCase();
|
|
113
|
+
});
|
|
114
|
+
const unique = [...new Set(norm)];
|
|
115
|
+
return { primary: unique[0] ?? null, all: unique };
|
|
116
|
+
}
|
|
117
|
+
/** Parse the RSS feed into raw items (every legal product, unfiltered). */
|
|
118
|
+
function parseFeed(xml) {
|
|
119
|
+
const items = [];
|
|
120
|
+
const itemRe = /<item\b[^>]*>([\s\S]*?)<\/item>/gi;
|
|
121
|
+
let m;
|
|
122
|
+
while ((m = itemRe.exec(xml)) !== null) {
|
|
123
|
+
const block = m[1] ?? "";
|
|
124
|
+
const title = tagText(block, "title") ?? "";
|
|
125
|
+
const link = tagText(block, "link") ?? "";
|
|
126
|
+
const description = tagText(block, "description") ?? "";
|
|
127
|
+
const pubDate = tagText(block, "pubDate");
|
|
128
|
+
const guid = tagText(block, "guid");
|
|
129
|
+
// Prefer the link for the B-number; fall back to the guid path.
|
|
130
|
+
const fromLink = bNumbersFromUrl(link);
|
|
131
|
+
const fromGuid = guid ? bNumbersFromUrl(guid) : { primary: null, all: [] };
|
|
132
|
+
const primary = fromLink.primary ?? fromGuid.primary;
|
|
133
|
+
const all = fromLink.all.length ? fromLink.all : fromGuid.all;
|
|
134
|
+
items.push({
|
|
135
|
+
title,
|
|
136
|
+
link,
|
|
137
|
+
description,
|
|
138
|
+
pubDate,
|
|
139
|
+
bNumber: primary,
|
|
140
|
+
companionBNumbers: all,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return items;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Is this feed item a BID-PROTEST decision (vs a CRA regulatory review or a
|
|
147
|
+
* legal opinion)? The most reliable keyless signal is the DESCRIPTION language:
|
|
148
|
+
* a protest reads "…protests…" / "protester" / "request(s) reconsideration of
|
|
149
|
+
* our decision", while a CRA review reads "GAO reviewed the …'s new rule
|
|
150
|
+
* entitled". We also accept the "--Reconsideration" / "--Costs" title suffixes
|
|
151
|
+
* GAO uses on protest follow-ons, and (as a weak positive) the B-4 number
|
|
152
|
+
* series. Regulatory-review language is an explicit NEGATIVE so those never leak.
|
|
153
|
+
*/
|
|
154
|
+
function isBidProtest(item) {
|
|
155
|
+
if (!item.bNumber)
|
|
156
|
+
return false;
|
|
157
|
+
const d = item.description.toLowerCase();
|
|
158
|
+
const t = item.title.toLowerCase();
|
|
159
|
+
// Explicit negatives — CRA / rule reviews and pure legal opinions.
|
|
160
|
+
if (/gao reviewed the .*new rule entitled/.test(d))
|
|
161
|
+
return false;
|
|
162
|
+
if (/\bcongressional review act\b/.test(d) && !/\bprotest/.test(d))
|
|
163
|
+
return false;
|
|
164
|
+
// Strong positives from the decision language.
|
|
165
|
+
if (/\bprotest(s|er|ers|ed|ing)?\b/.test(d))
|
|
166
|
+
return true;
|
|
167
|
+
if (/reconsideration of our decision/.test(d))
|
|
168
|
+
return true;
|
|
169
|
+
if (/request(s)? (for )?reconsideration/.test(d))
|
|
170
|
+
return true;
|
|
171
|
+
// GAO protest follow-on title conventions.
|
|
172
|
+
if (/--reconsideration\b/.test(t))
|
|
173
|
+
return true;
|
|
174
|
+
if (/--costs\b/.test(t))
|
|
175
|
+
return true;
|
|
176
|
+
// Fallback: the B-4xxxxx series is the bid-protest docket range. Only trust it
|
|
177
|
+
// when the description is not clearly a rule review (handled above).
|
|
178
|
+
if (/^B-4\d/.test(item.bNumber))
|
|
179
|
+
return true;
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
/** Strip HTML tags + collapse whitespace from a fragment. */
|
|
183
|
+
function stripHtml(s) {
|
|
184
|
+
return decodeEntities(s.replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
|
|
185
|
+
}
|
|
186
|
+
const MONTHS = {
|
|
187
|
+
jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5,
|
|
188
|
+
jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11,
|
|
189
|
+
};
|
|
190
|
+
/**
|
|
191
|
+
* Normalize a "Jun 22, 2026" / "June 22, 2026" date to ISO (YYYY-MM-DD),
|
|
192
|
+
* parsing in UTC so we never shift a day (a local-timezone `new Date(...)` on a
|
|
193
|
+
* bare date string is midnight LOCAL, which `toISOString()` then rolls back a
|
|
194
|
+
* day in negative-offset zones — LIVE-VERIFIED that bug produced 06-21 for
|
|
195
|
+
* "Jun 22, 2026"). Returns the input unchanged if it can't be parsed.
|
|
196
|
+
*/
|
|
197
|
+
function normalizeDate(human) {
|
|
198
|
+
const m = /^([A-Za-z]{3,9})\.?\s+(\d{1,2}),\s+(\d{4})$/.exec(human.trim());
|
|
199
|
+
if (m && m[1] && m[2] && m[3]) {
|
|
200
|
+
const mon = MONTHS[m[1].slice(0, 3).toLowerCase()];
|
|
201
|
+
if (mon !== undefined) {
|
|
202
|
+
const d = new Date(Date.UTC(Number(m[3]), mon, Number(m[2])));
|
|
203
|
+
if (!Number.isNaN(d.getTime()))
|
|
204
|
+
return d.toISOString().slice(0, 10);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return human;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Extract the contracting agency from a GAO highlights blurb. GAO phrases it
|
|
211
|
+
* two ways (LIVE-VERIFIED 2026-07-03):
|
|
212
|
+
* - "…issued by the <Agency>[, <sub-component>], for …"
|
|
213
|
+
* - "…protests the <Agency>'s issuance of …"
|
|
214
|
+
* We capture the full agency phrase (through a trailing sub-component up to the
|
|
215
|
+
* next clause boundary), then trim. Returns null when no agency is stated.
|
|
216
|
+
*/
|
|
217
|
+
function extractAgency(text) {
|
|
218
|
+
// Bound to the FIRST ~2 sentences of the highlights — the contracting agency
|
|
219
|
+
// is named in the opening clause ("…issued by the <Agency>…"); a later "the
|
|
220
|
+
// Department of Defense during both peace and war" narrative sentence would
|
|
221
|
+
// otherwise be mis-captured (LIVE-VERIFIED that failure on USTRANSCOM).
|
|
222
|
+
const blurb = text.split(/(?<=\.)\s+/).slice(0, 2).join(" ");
|
|
223
|
+
// Anchor tokens that reliably head a federal agency name. "Department of the
|
|
224
|
+
// Air Force" / "Department of Defense" both allow an optional leading "the".
|
|
225
|
+
const AG = "(?:Department of (?:the )?[A-Z][A-Za-z.'’\\- ]+?|National [A-Z][A-Za-z.'’\\- ]+?|United States [A-Z][A-Za-z.'’\\- ]+? Command|U\\.S\\. [A-Z][A-Za-z.'’\\- ]+?|General Services Administration|Environmental Protection Agency|Small Business Administration|[A-Z][A-Za-z.'’\\- ]+? (?:Agency|Administration|Command|Corps|Bureau|Guard|Service))";
|
|
226
|
+
// Clause boundary the agency name ends at: a comma, a period, a bare " for"/
|
|
227
|
+
// " under"/" to"/" in support", or end-of-string. An optional parenthetical
|
|
228
|
+
// acronym ("(USTRANSCOM)") is consumed but not captured.
|
|
229
|
+
const STOP = "(?=,|\\.|\\s+for\\b|\\s+under\\b|\\s+to\\b|\\s+in support\\b|$)";
|
|
230
|
+
// "issued/awarded/conducted/solicited by [the] <Agency>[, U.S. <sub>]".
|
|
231
|
+
const by = new RegExp(`(?:issued|awarded|conducted|solicited|procured)\\s+by\\s+(?:the\\s+)?(${AG}(?:,\\s+U\\.S\\.[A-Za-z.'’&\\- ]+?)?)(?:\\s*\\([A-Z]{2,10}\\))?${STOP}`).exec(blurb);
|
|
232
|
+
if (by && by[1])
|
|
233
|
+
return cleanAgency(by[1]);
|
|
234
|
+
// "protests the <Agency>['s] issuance/award/decision".
|
|
235
|
+
const poss = new RegExp(`protests?\\s+the\\s+(${AG})(?:'s|’s)?\\s+(?:issuance|award|decision|evaluation|cancellation|termination|rejection)`).exec(blurb);
|
|
236
|
+
if (poss && poss[1])
|
|
237
|
+
return cleanAgency(poss[1]);
|
|
238
|
+
// Bare "Department of [the] X[, U.S. sub-component]" fallback in the opening.
|
|
239
|
+
const dept = new RegExp(`\\b(Department of (?:the )?[A-Z][A-Za-z.'’\\- ]+?(?:,\\s+U\\.S\\. [A-Z][A-Za-z.'’\\- ]+?)?)${STOP}`).exec(blurb);
|
|
240
|
+
if (dept && dept[1])
|
|
241
|
+
return cleanAgency(dept[1]);
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
function cleanAgency(s) {
|
|
245
|
+
return s.replace(/\s+/g, " ").trim().replace(/[.,;]+$/, "").trim();
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Classify the protest outcome from the decision text. GAO decisions state the
|
|
249
|
+
* disposition in the Highlights ("We deny the protest", "We sustain…",
|
|
250
|
+
* "We dismiss…") and echo it in the body. Order matters: "sustained in part"
|
|
251
|
+
* still counts as sustained. Returns null if no clear disposition is found.
|
|
252
|
+
*/
|
|
253
|
+
function classifyOutcome(text) {
|
|
254
|
+
const t = text.toLowerCase();
|
|
255
|
+
// Sustain wins over deny when both appear ("sustain in part and deny in part"
|
|
256
|
+
// is a win worth surfacing as sustained).
|
|
257
|
+
if (/\bwe sustain\b/.test(t) || /protest is sustained/.test(t) || /\bsustained in part\b/.test(t))
|
|
258
|
+
return "sustained";
|
|
259
|
+
if (/\bwe deny\b/.test(t) || /protest is denied/.test(t) || /\bdenied in part\b/.test(t))
|
|
260
|
+
return "denied";
|
|
261
|
+
if (/\bwe dismiss\b/.test(t) || /protest is dismissed/.test(t) || /\bdismissed in part\b/.test(t))
|
|
262
|
+
return "dismissed";
|
|
263
|
+
if (/\bwithdrew\b/.test(t) || /protest is withdrawn/.test(t) || /\bwe withdraw\b/.test(t))
|
|
264
|
+
return "withdrawn";
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Best-effort parse of a GAO decision product page. Every field is nullable —
|
|
269
|
+
* GAO's HTML is not a contract, so a missing field is surfaced as null (never
|
|
270
|
+
* fabricated) and disclosed by the caller. `rawHtml` is the fetched page text.
|
|
271
|
+
*/
|
|
272
|
+
function parseDecisionPage(rawHtml, feed) {
|
|
273
|
+
// The GAO product page embeds a JSON-LD / og:description with the Highlights
|
|
274
|
+
// text, and a visible Highlights section. We work over the whole HTML with
|
|
275
|
+
// targeted regexes rather than a DOM parser (no dependency).
|
|
276
|
+
const bodyOnly = rawHtml.slice(Math.max(0, rawHtml.indexOf("</head>")));
|
|
277
|
+
const flat = stripHtml(rawHtml);
|
|
278
|
+
// Highlights: GAO renders the FULL (un-truncated) highlights on the page —
|
|
279
|
+
// the <meta> og:description carries only a TRUNCATED "…" blurb (agency cut
|
|
280
|
+
// off). In the flattened body the real highlights sit right after a DOUBLED
|
|
281
|
+
// "Highlights Highlights" marker (a heading + the body heading), and the body
|
|
282
|
+
// begins with the protester name (LIVE-VERIFIED 2026-07-03). The single word
|
|
283
|
+
// "Highlights" also appears in nav furniture, so we anchor on the doubled
|
|
284
|
+
// marker and stop at the "What GAO Found"/"Recommendations"/"View Decision"
|
|
285
|
+
// section that follows. Fall back to og:description, then the feed.
|
|
286
|
+
let highlights = null;
|
|
287
|
+
const flatHl = /Highlights\s+Highlights\s+([A-Z][\s\S]*?)(?=\s*(?:View Decision|Full Report|Highlights Page|What GAO Recommends|Recommendations for Executive Action|GAO Contacts|Additional Materials)\b|$)/.exec(flat);
|
|
288
|
+
if (flatHl && flatHl[1] && flatHl[1].trim().length > 40) {
|
|
289
|
+
highlights = flatHl[1].replace(/\s+/g, " ").trim();
|
|
290
|
+
}
|
|
291
|
+
// A cleaner (truncated) blurb from the <meta> tags — used to VALIDATE the flat
|
|
292
|
+
// highlights and as a fallback. It always starts with the protester.
|
|
293
|
+
let metaBlurb = null;
|
|
294
|
+
const og = /<meta\s+property="og:description"\s+content="([\s\S]*?)"/i.exec(rawHtml);
|
|
295
|
+
if (og && og[1])
|
|
296
|
+
metaBlurb = decodeEntities(og[1]).trim();
|
|
297
|
+
if (!metaBlurb) {
|
|
298
|
+
const md = /<meta\s+name="description"\s+content="([\s\S]*?)"/i.exec(rawHtml);
|
|
299
|
+
if (md && md[1])
|
|
300
|
+
metaBlurb = decodeEntities(md[1]).trim();
|
|
301
|
+
}
|
|
302
|
+
// If the flat highlights don't start with the same protester lead as the meta
|
|
303
|
+
// blurb, the anchor grabbed the wrong section — discard it and use the blurb.
|
|
304
|
+
if (highlights && metaBlurb) {
|
|
305
|
+
const lead = metaBlurb.replace(/\.\.\.$|…$/, "").slice(0, 18).toLowerCase();
|
|
306
|
+
if (lead.length > 6 && !highlights.slice(0, 40).toLowerCase().includes(lead.slice(0, 10))) {
|
|
307
|
+
highlights = null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (!highlights)
|
|
311
|
+
highlights = metaBlurb;
|
|
312
|
+
if (!highlights && feed)
|
|
313
|
+
highlights = feed.description || null;
|
|
314
|
+
// Outcome: GAO states the disposition in a
|
|
315
|
+
// `<div class="status highlighted-status">We dismiss the protest.</div>`
|
|
316
|
+
// block (LIVE-VERIFIED). Prefer that; else classify over the highlights/flat
|
|
317
|
+
// text. The truncated og:description alone never carries the disposition.
|
|
318
|
+
let outcome = null;
|
|
319
|
+
const statusBlock = /<div class="status[^"]*">\s*([\s\S]*?)\s*<\/div>/i.exec(rawHtml);
|
|
320
|
+
if (statusBlock && statusBlock[1]) {
|
|
321
|
+
outcome = classifyOutcome(stripHtml(statusBlock[1]));
|
|
322
|
+
}
|
|
323
|
+
if (!outcome) {
|
|
324
|
+
outcome = classifyOutcome(`${highlights ?? ""} ${feed?.description ?? ""} ${flat}`);
|
|
325
|
+
}
|
|
326
|
+
// Decision date: no <time datetime> / JSON-LD on these pages — the visible
|
|
327
|
+
// decision date renders as "Jun 22, 2026" in the body (LIVE-VERIFIED). Take
|
|
328
|
+
// the first such date after </head>, normalized to ISO. Fall back to the feed
|
|
329
|
+
// pubDate (handled by the caller when this is null).
|
|
330
|
+
let decisionDate = null;
|
|
331
|
+
const dm = /\b((?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},\s+20\d{2})\b/.exec(bodyOnly.replace(/<[^>]+>/g, " "));
|
|
332
|
+
if (dm && dm[1])
|
|
333
|
+
decisionDate = normalizeDate(dm[1]);
|
|
334
|
+
// Protester + agency parsed from the (un-truncated) highlights.
|
|
335
|
+
let protester = null;
|
|
336
|
+
let agency = null;
|
|
337
|
+
const blurb = highlights ?? feed?.description ?? "";
|
|
338
|
+
if (blurb) {
|
|
339
|
+
// Protester = leading clause up to the first comma OR "protests"/"requests".
|
|
340
|
+
const protMatch = /^(.*?)(?:,|\s+protests\b|\s+requests?\b)/i.exec(blurb);
|
|
341
|
+
if (protMatch && protMatch[1] && protMatch[1].trim().length > 1) {
|
|
342
|
+
protester = protMatch[1].trim();
|
|
343
|
+
}
|
|
344
|
+
agency = extractAgency(blurb);
|
|
345
|
+
}
|
|
346
|
+
// Fallback: parse the agency from the FULL flattened page text (the on-page
|
|
347
|
+
// highlights are un-truncated there even when the <meta> blurb was cut off,
|
|
348
|
+
// and the highlights container markup varies). LIVE-VERIFIED to recover
|
|
349
|
+
// "Department of Homeland Security, U.S. Customs and Border Protection" etc.
|
|
350
|
+
if (!agency)
|
|
351
|
+
agency = extractAgency(flat);
|
|
352
|
+
// A page-level agency facet as a last resort (GAO tags a "Federal agency").
|
|
353
|
+
if (!agency) {
|
|
354
|
+
const facet = /Federal agency<\/[^>]+>\s*<[^>]*>\s*([^<]{3,80}?)\s*</i.exec(rawHtml);
|
|
355
|
+
if (facet && facet[1])
|
|
356
|
+
agency = stripHtml(facet[1]);
|
|
357
|
+
}
|
|
358
|
+
// Solicitation / RFP / RFQ / TOPR number — GAO decisions cite it as
|
|
359
|
+
// "(request for proposals|solicitation|TOPR|task order proposal request)
|
|
360
|
+
// [(RFP)] No. XXXXXXX". Parse over the un-truncated highlights first, then the
|
|
361
|
+
// flat body. Best-effort — null when not stated in the highlights.
|
|
362
|
+
let solicitationNumber = null;
|
|
363
|
+
const solText = `${blurb} ${flat}`;
|
|
364
|
+
const sol = /\b(?:solicitation|request for (?:proposals?|quotations?)|task order proposal request|RF[PQ]|TOPR)\s*(?:\((?:RF[PQ]|TOPR)\)\s*)?No\.?\s*([A-Za-z0-9][A-Za-z0-9-]{4,})/i.exec(solText);
|
|
365
|
+
if (sol && sol[1])
|
|
366
|
+
solicitationNumber = sol[1].replace(/[.,;]$/, "").trim();
|
|
367
|
+
// Decision PDF: GAO links the full report as /assets/<dir>/NNNNNN.pdf.
|
|
368
|
+
let pdfUrl = null;
|
|
369
|
+
const pdf = /href="(\/assets\/[^"]*?\.pdf)"/i.exec(rawHtml) ?? /href="(https:\/\/www\.gao\.gov\/assets\/[^"]*?\.pdf)"/i.exec(rawHtml);
|
|
370
|
+
if (pdf && pdf[1]) {
|
|
371
|
+
pdfUrl = pdf[1].startsWith("http") ? pdf[1] : `https://www.gao.gov${pdf[1]}`;
|
|
372
|
+
}
|
|
373
|
+
const summary = highlights ? highlights.slice(0, 600) : null;
|
|
374
|
+
return { protester, agency, decisionDate, solicitationNumber, outcome, pdfUrl, summary };
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Fetch + parse a single decision by B-number. A 404 → structured not_found; a
|
|
378
|
+
* 429/5xx/network fault stays a retryable upstream error (never a silent empty
|
|
379
|
+
* that reads as "no such protest"). The B-number is normalized to the
|
|
380
|
+
* `/products/b-xxxxxx` path shape GAO serves.
|
|
381
|
+
*/
|
|
382
|
+
async function fetchDecision(bNumber, feed) {
|
|
383
|
+
const norm = bNumber.trim();
|
|
384
|
+
const primaryPath = norm.toLowerCase().replace(/^b-?/, "b-");
|
|
385
|
+
// Candidate URLs, in priority order. GAO reconsiderations are served at a
|
|
386
|
+
// COMMA-JOINED path (e.g. /products/b-424347,b-424347.2) — the primary
|
|
387
|
+
// B-number alone 404s (LIVE-VERIFIED 2026-07-03). So:
|
|
388
|
+
// 1. the feed's exact <link> (already carries the comma-joined path), then
|
|
389
|
+
// 2. the primary B-number path, then
|
|
390
|
+
// 3. a companion-joined path built from the feed's B-numbers.
|
|
391
|
+
const candidates = [];
|
|
392
|
+
if (feed?.link)
|
|
393
|
+
candidates.push(feed.link);
|
|
394
|
+
candidates.push(`${PRODUCT_BASE}${encodeURIComponent(primaryPath)}`);
|
|
395
|
+
if (feed && feed.companionBNumbers.length > 1) {
|
|
396
|
+
const joined = feed.companionBNumbers.map((b) => b.toLowerCase()).join(",");
|
|
397
|
+
candidates.push(`${PRODUCT_BASE}${joined}`);
|
|
398
|
+
}
|
|
399
|
+
// De-dup while preserving order.
|
|
400
|
+
const urls = [...new Set(candidates)];
|
|
401
|
+
let html = null;
|
|
402
|
+
let usedUrl = urls[0];
|
|
403
|
+
let lastNotFound = null;
|
|
404
|
+
for (const u of urls) {
|
|
405
|
+
try {
|
|
406
|
+
html = await getText(u, `gao:product:${primaryPath}`);
|
|
407
|
+
usedUrl = u;
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
catch (e) {
|
|
411
|
+
if (e instanceof ToolErrorCarrier) {
|
|
412
|
+
if (e.toolError.kind === "not_found") {
|
|
413
|
+
// Try the next candidate — a 404 on the primary path is expected for
|
|
414
|
+
// reconsiderations served at the comma-joined path.
|
|
415
|
+
lastNotFound = e;
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
// A 429/5xx/network fault is retryable — surface it, never swallow.
|
|
419
|
+
throw e;
|
|
420
|
+
}
|
|
421
|
+
throw new ToolErrorCarrier({
|
|
422
|
+
kind: "upstream_unavailable",
|
|
423
|
+
message: `GAO decision fetch failed for '${bNumber}': ${e instanceof Error ? e.message : String(e)}`,
|
|
424
|
+
retryable: true,
|
|
425
|
+
upstreamEndpoint: `products/${primaryPath}`,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
if (html === null) {
|
|
430
|
+
// Every candidate 404'd → a genuine "no such decision".
|
|
431
|
+
throw new ToolErrorCarrier({
|
|
432
|
+
kind: "not_found",
|
|
433
|
+
message: `No GAO decision found for B-number '${bNumber}' (tried ${urls.length} path form(s)). Verify the B-number (e.g. via gao_protest_lookup without bNumber to list recent protests).`,
|
|
434
|
+
retryable: false,
|
|
435
|
+
upstreamEndpoint: lastNotFound?.toolError.upstreamEndpoint ?? `products/${primaryPath}`,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
const fields = parseDecisionPage(html, feed);
|
|
439
|
+
return {
|
|
440
|
+
bNumber: norm.toUpperCase(),
|
|
441
|
+
protester: fields.protester ?? feedProtester(feed),
|
|
442
|
+
agency: fields.agency,
|
|
443
|
+
decisionDate: fields.decisionDate ?? feedDate(feed),
|
|
444
|
+
solicitationNumber: fields.solicitationNumber,
|
|
445
|
+
outcome: fields.outcome,
|
|
446
|
+
title: feed?.title ?? fields.protester ?? norm.toUpperCase(),
|
|
447
|
+
decisionUrl: usedUrl,
|
|
448
|
+
pdfUrl: fields.pdfUrl,
|
|
449
|
+
summary: fields.summary ?? feed?.description ?? null,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
/** A protester name derived from the feed title (company name) when present. */
|
|
453
|
+
function feedProtester(feed) {
|
|
454
|
+
if (!feed)
|
|
455
|
+
return null;
|
|
456
|
+
// Feed titles ARE the protester (minus the "--Reconsideration"/"--Costs" tag).
|
|
457
|
+
const t = feed.title.replace(/--(?:reconsideration|costs).*$/i, "").trim();
|
|
458
|
+
return t || null;
|
|
459
|
+
}
|
|
460
|
+
/** ISO/human decision date from the feed pubDate (RFC-822) when present. */
|
|
461
|
+
function feedDate(feed) {
|
|
462
|
+
if (!feed?.pubDate)
|
|
463
|
+
return null;
|
|
464
|
+
const d = new Date(feed.pubDate);
|
|
465
|
+
if (Number.isNaN(d.getTime()))
|
|
466
|
+
return feed.pubDate;
|
|
467
|
+
return d.toISOString().slice(0, 10);
|
|
468
|
+
}
|
|
469
|
+
/** Bounded-concurrency map (be polite to GAO's edge — no unbounded fan-out). */
|
|
470
|
+
async function mapWithConcurrency(items, limit, fn) {
|
|
471
|
+
const results = new Array(items.length);
|
|
472
|
+
let next = 0;
|
|
473
|
+
const workers = new Array(Math.min(limit, items.length)).fill(0).map(async () => {
|
|
474
|
+
while (true) {
|
|
475
|
+
const i = next++;
|
|
476
|
+
if (i >= items.length)
|
|
477
|
+
break;
|
|
478
|
+
results[i] = await fn(items[i], i);
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
await Promise.all(workers);
|
|
482
|
+
return results;
|
|
483
|
+
}
|
|
484
|
+
// ─── Public tool ─────────────────────────────────────────────────
|
|
485
|
+
export async function gaoProtestLookup(args) {
|
|
486
|
+
const outcomeFilter = args.outcome && args.outcome !== "any" ? args.outcome : null;
|
|
487
|
+
const limit = Math.min(50, Math.max(1, Math.floor(args.limit ?? 20)));
|
|
488
|
+
const enrich = args.enrich ?? true;
|
|
489
|
+
// ── Direct-by-B-number path (bypass the feed) ──────────────────────
|
|
490
|
+
if (args.bNumber) {
|
|
491
|
+
const decision = await fetchDecision(args.bNumber);
|
|
492
|
+
const notes = [ACCESS_NOTE];
|
|
493
|
+
const fieldsUnavailable = missingFields([decision]);
|
|
494
|
+
return withMeta({ accessNote: ACCESS_NOTE, decisions: [decision] }, {
|
|
495
|
+
source: SOURCE,
|
|
496
|
+
keylessMode: true,
|
|
497
|
+
// Even a direct lookup is a partial view of protest history.
|
|
498
|
+
complete: false,
|
|
499
|
+
truncated: true,
|
|
500
|
+
returned: 1,
|
|
501
|
+
totalAvailable: null,
|
|
502
|
+
filtersApplied: ["bNumber(direct)"],
|
|
503
|
+
filtersDropped: [],
|
|
504
|
+
fieldsUnavailable,
|
|
505
|
+
notes,
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
// ── Feed path ──────────────────────────────────────────────────────
|
|
509
|
+
const xml = await getText(RSS_URL, "gao:rss");
|
|
510
|
+
const rawItems = parseFeed(xml);
|
|
511
|
+
const protests = rawItems.filter(isBidProtest);
|
|
512
|
+
// Client-side filters over the feed items (case-insensitive substring).
|
|
513
|
+
const filtersApplied = [];
|
|
514
|
+
let filtered = protests;
|
|
515
|
+
const agencyQ = args.agency?.trim().toLowerCase();
|
|
516
|
+
const protesterQ = args.protester?.trim().toLowerCase();
|
|
517
|
+
const solQ = args.solicitationNumber?.trim().toLowerCase();
|
|
518
|
+
if (agencyQ) {
|
|
519
|
+
filtersApplied.push("agency(client-side)");
|
|
520
|
+
filtered = filtered.filter((i) => i.description.toLowerCase().includes(agencyQ) ||
|
|
521
|
+
i.title.toLowerCase().includes(agencyQ));
|
|
522
|
+
}
|
|
523
|
+
if (protesterQ) {
|
|
524
|
+
filtersApplied.push("protester(client-side)");
|
|
525
|
+
filtered = filtered.filter((i) => i.title.toLowerCase().includes(protesterQ) ||
|
|
526
|
+
i.description.toLowerCase().includes(protesterQ));
|
|
527
|
+
}
|
|
528
|
+
if (solQ) {
|
|
529
|
+
filtersApplied.push("solicitationNumber(client-side)");
|
|
530
|
+
filtered = filtered.filter((i) => i.description.toLowerCase().includes(solQ));
|
|
531
|
+
}
|
|
532
|
+
// Bound the number of items we enrich (be polite + fast). Outcome filtering
|
|
533
|
+
// requires the decision page, so when an outcome filter is set we enrich the
|
|
534
|
+
// candidate set then filter; otherwise we honor `limit` up front.
|
|
535
|
+
const candidates = outcomeFilter ? filtered : filtered.slice(0, limit);
|
|
536
|
+
let decisions;
|
|
537
|
+
let enrichFailures = 0;
|
|
538
|
+
let enrichedCount = 0;
|
|
539
|
+
if (enrich) {
|
|
540
|
+
const enriched = await mapWithConcurrency(candidates, 4, async (item) => {
|
|
541
|
+
try {
|
|
542
|
+
const dec = await fetchDecision(item.bNumber, item);
|
|
543
|
+
return { dec, ok: true };
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
// Tolerate a failed enrich: fall back to feed-level fields, disclose it.
|
|
547
|
+
return {
|
|
548
|
+
dec: feedOnlyDecision(item),
|
|
549
|
+
ok: false,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
enrichedCount = enriched.filter((e) => e.ok).length;
|
|
554
|
+
enrichFailures = enriched.filter((e) => !e.ok).length;
|
|
555
|
+
decisions = enriched.map((e) => e.dec);
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
decisions = candidates.map(feedOnlyDecision);
|
|
559
|
+
}
|
|
560
|
+
// Apply the outcome filter now that we (may) have parsed outcomes, then cap.
|
|
561
|
+
const filtersDropped = [];
|
|
562
|
+
if (outcomeFilter) {
|
|
563
|
+
if (enrich) {
|
|
564
|
+
filtersApplied.push("outcome(from decision page)");
|
|
565
|
+
decisions = decisions.filter((d) => d.outcome === outcomeFilter);
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
// Can't determine outcome without enrichment → don't silently pretend to.
|
|
569
|
+
filtersDropped.push("outcome(requires enrichment; enrich=false)");
|
|
570
|
+
}
|
|
571
|
+
decisions = decisions.slice(0, limit);
|
|
572
|
+
}
|
|
573
|
+
const fieldsUnavailable = missingFields(decisions);
|
|
574
|
+
const notes = [ACCESS_NOTE];
|
|
575
|
+
if (enrich && enrichFailures > 0) {
|
|
576
|
+
notes.push(`${enrichFailures} decision(s) returned feed-level fields only; per-decision page enrichment failed (agency/outcome/solicitation may be null for those).`);
|
|
577
|
+
}
|
|
578
|
+
if (!enrich) {
|
|
579
|
+
notes.push("enrich=false: only RSS feed-level fields were returned (protester/title/date/summary). Agency, outcome, solicitation number, and the decision PDF require the per-decision page — call again with enrich=true or a specific bNumber.");
|
|
580
|
+
}
|
|
581
|
+
if (outcomeFilter && !enrich) {
|
|
582
|
+
notes.push("The outcome filter was NOT applied because it requires reading each decision page (enrich=false). Results are unfiltered on outcome.");
|
|
583
|
+
}
|
|
584
|
+
return withMeta({ accessNote: ACCESS_NOTE, decisions }, {
|
|
585
|
+
source: SOURCE,
|
|
586
|
+
keylessMode: true,
|
|
587
|
+
// ALWAYS incomplete — the feed is a recent window, never the full history.
|
|
588
|
+
complete: false,
|
|
589
|
+
truncated: true,
|
|
590
|
+
returned: decisions.length,
|
|
591
|
+
totalAvailable: null,
|
|
592
|
+
filtersApplied,
|
|
593
|
+
filtersDropped,
|
|
594
|
+
fieldsUnavailable,
|
|
595
|
+
enrichedCount: enrich ? enrichedCount : undefined,
|
|
596
|
+
notes,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
/** Build a feed-only decision (no page fetch) from a feed item. */
|
|
600
|
+
function feedOnlyDecision(item) {
|
|
601
|
+
return {
|
|
602
|
+
bNumber: (item.bNumber ?? "").toUpperCase(),
|
|
603
|
+
protester: feedProtester(item),
|
|
604
|
+
agency: null,
|
|
605
|
+
decisionDate: feedDate(item),
|
|
606
|
+
solicitationNumber: null,
|
|
607
|
+
outcome: null,
|
|
608
|
+
title: item.title,
|
|
609
|
+
decisionUrl: item.link || `${PRODUCT_BASE}${(item.bNumber ?? "").toLowerCase()}`,
|
|
610
|
+
pdfUrl: null,
|
|
611
|
+
summary: item.description || null,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Which decision fields came back null across the returned set — surfaced in
|
|
616
|
+
* `_meta.fieldsUnavailable` so the AI knows these are "not parseable / not in
|
|
617
|
+
* this source", not "no data exists". A field is listed only if it is null on
|
|
618
|
+
* at least one returned decision.
|
|
619
|
+
*/
|
|
620
|
+
function missingFields(decisions) {
|
|
621
|
+
if (decisions.length === 0)
|
|
622
|
+
return [];
|
|
623
|
+
const keys = [
|
|
624
|
+
"agency",
|
|
625
|
+
"outcome",
|
|
626
|
+
"solicitationNumber",
|
|
627
|
+
"pdfUrl",
|
|
628
|
+
"decisionDate",
|
|
629
|
+
"protester",
|
|
630
|
+
];
|
|
631
|
+
const out = [];
|
|
632
|
+
for (const k of keys) {
|
|
633
|
+
if (decisions.some((d) => d[k] === null))
|
|
634
|
+
out.push(k);
|
|
635
|
+
}
|
|
636
|
+
// Historical-search limitation is structural, not per-field — always noted.
|
|
637
|
+
out.push("historicalFacetedSearch(WAF-blocked; paid API only)");
|
|
638
|
+
return out;
|
|
639
|
+
}
|
|
640
|
+
//# sourceMappingURL=gao.js.map
|