Purpose
Find bidding projects relevant to demolition, abatement, or multiprime/trade-package work, returning bid dates and core project metadata without manually paging through the project grid. When plan numbers or bid-package names are available, resolve the matching project and retrieve its complete detail page, including contact information and document links.
When to Use
Use for recurring bid-catchup reviews or any request to identify bidding projects matching demolition, abatement, asbestos, multiprime, or related construction-package terminology. Search terms are configurable; overlapping results are deduplicated. Use the detail workflow when the caller requests full information for a selected project, supplies a plan number, or names a bid package such as a trade package or school-project package.
Workflow
- Ensure an authenticated session exists for
login.onlineplanservice.com. If navigation redirects to/Loginor/Login.aspx, use the site's normal login form with credentials supplied at runtime only. Observed selectors areinput[type='text'][id*='UserName' i],input[type='password'], andinput[type='submit'][value*='Login' i]; never store credentials in this skill. - For broad catch-up reviews, navigate directly to
https://login.onlineplanservice.com/projectgrid?mode=biddingprojectsand run the following extractor in oneevaluate()call on the loaded page. It queries the authenticated JSON endpoint, searches all configured terms, follows pagination, deduplicates overlapping results, and sorts by bid date ascending:
(async () => {
const terms = [
"demolition",
"abatement",
"asbestos",
"multiple prime",
"multi-prime",
"modernization",
"increment",
"bid package",
"trade package",
"sub-bids",
"prime contract",
"reconstruction",
"renovation and demolition",
];
const endpoint = "/ajax_grid_datasource.aspx";
const normalize = (p) => ({
projectName: String(p.projectname || "").trim(),
location: String(p.location || "").trim(),
bidDate: String(p.biddate || "").trim(),
planNumber: String(p.opsplannum || "").trim(),
dateReceived: String(p.datereceived || "").trim(),
estimatedCost: String(p.estimatedcost || p.EstCost || "").trim(),
raw: p,
});
const search = async (term) => {
const rows = [],
signatures = new Set();
let page = 1,
count = 0;
while (page <= 100) {
const r = await fetch(
`${endpoint}?mode=biddingprojects&search=${encodeURIComponent(term)}&page=${page}&sort=biddate&dir=ASC`,
{ credentials: "include" },
);
if (!r.ok) throw Error(`HTTP ${r.status} for ${term}`);
const d = await r.json();
count = Number(d.Count) || count;
const pr = Array.isArray(d.Projects) ? d.Projects.map(normalize) : [];
const sig = JSON.stringify(pr);
if (!pr.length || signatures.has(sig)) break;
signatures.add(sig);
rows.push(...pr);
if (count && rows.length >= count) break;
page++;
}
return { count, rows };
};
const byTerm = {},
merged = new Map();
for (const term of terms) {
const result = await search(term);
byTerm[term] = result;
for (const row of result.rows) {
const key = [row.planNumber, row.projectName, row.location, row.bidDate].join(
"|",
);
if (!merged.has(key)) merged.set(key, { ...row, matchedTerms: [term] });
else if (!merged.get(key).matchedTerms.includes(term))
merged.get(key).matchedTerms.push(term);
}
}
const projects = [...merged.values()].sort(
(a, b) =>
a.bidDate.localeCompare(b.bidDate) ||
a.projectName.localeCompare(b.projectName),
);
return { projects, byTerm };
})();For a named package or selected project, resolve opaque identifiers before constructing a detail URL. Search the authenticated endpoint with the package name, project name, or exact plan number:
/ajax_grid_datasource.aspx?mode=biddingprojects&search={query}&page=1Select the matching result and read its exactopsplannum,BidPackageIDor equivalent, and any available home-prefix/status fields. Never inventBidPackageID.Construct the detail URL directly once identifiers are known. The observed form is:
https://login.onlineplanservice.com/detail?idwebDB=True&BidPackageID={BidPackageID}&HOMEPR={HOMEPR}&STATUS={STATUS}&DOCLOC={opsplannum}&PUNIQ={plan-suffix}For plan numbers shaped like{HOMEPR}{two-digit-year}-{suffix}, use the full plan number asDOCLOCand the portion after the year prefix asPUNIQ; prefer values returned by the search/detail record. A related filter URL ishttps://login.onlineplanservice.com/filter.aspx?BidPackageID={BidPackageID}&projectnum={opsplannum}&bx={HOMEPR}&bxup={plan-suffix}, but it is only an intermediate route; go directly to/detailafter identifiers are known.On the loaded
/detailpage, run this extractor:
(() => {
const clean = (s) =>
String(s || "")
.replace(/\s+/g, " ")
.trim();
const abs = (a) => {
try {
return new URL(a, location.href).href;
} catch {
return "";
}
};
const links = [...document.querySelectorAll("a[href]")]
.map((a) => ({
text: clean(a.innerText || a.textContent),
href: abs(a.getAttribute("href")),
}))
.filter((x) => x.href && !/facebook|twitter|linkedin/i.test(x.href));
const documents = links.filter((x) =>
/document|download|plan|spec|addend|bid|pdf|docx?|xlsx?|zip/i.test(
`${x.text} ${x.href}`,
),
);
const tables = [...document.querySelectorAll("table")]
.map((table, tableIndex) => ({
tableIndex,
rows: [...table.querySelectorAll("tr")]
.map((tr) =>
[...tr.querySelectorAll("th,td")].map((c) =>
clean(c.innerText || c.textContent),
),
)
.filter((r) => r.some(Boolean)),
}))
.filter((t) => t.rows.length);
const text = clean(document.body.innerText);
const emails = [
...new Set(
(text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi) || []).map(clean),
),
];
const phones = [
...new Set(
(text.match(/(?:\+?1[ .-]?)?(?:\(?\d{3}\)?[ .-]?)\d{3}[ .-]\d{4}/g) || []).map(
clean,
),
),
];
const contactLines = text
.split(/\n+/)
.map(clean)
.filter(
(x) =>
/contact|email|phone|tel|fax|architect|owner|engineer|procurement|representative/i.test(
x,
) && x.length < 500,
);
return {
url: location.href,
title: clean(document.title),
text,
tables,
links,
documents,
contacts: { emails, phones, lines: [...new Set(contactLines)] },
};
})();- For plan-number-only enrichment without page navigation, pass exact plan numbers to this authenticated JSON extractor:
(async () => {
const planNumbers = [/* replace with requested plan numbers */];
const details = {};
for (const planNumber of planNumbers) {
const id = String(planNumber).trim();
if (!id) continue;
try {
const r = await fetch(
"/ajax_grid_datasource.aspx?mode=biddingprojects&search=" +
encodeURIComponent(id) +
"&page=1",
{ credentials: "include" },
);
const text = await r.text();
if (!r.ok || text.trim().startsWith("<")) {
details[id] = { error: "authentication or endpoint response was not JSON" };
continue;
}
const data = JSON.parse(text);
const p = (Array.isArray(data.Projects) ? data.Projects : []).find(
(row) => String(row.opsplannum || "").trim() === id,
);
if (!p) {
details[id] = {
error: "plan number not found",
resultCount: Number(data.Count) || 0,
};
continue;
}
const full = {};
for (const [k, v] of Object.entries(p)) {
if (v === null || v === undefined || v === "") continue;
if (typeof v === "string") {
const n = v.replace(/\s+/g, " ").trim();
if (n) full[k] = n;
} else if (v !== false) full[k] = v;
}
details[id] = full;
} catch (e) {
details[id] = { error: String(e).slice(0, 160) };
}
}
return { details };
})();Optional modifiers: replace terms with caller-supplied terminology; omit terms only when a different vocabulary is explicitly requested. The endpoint's sort=biddate&dir=ASC can be changed to another supported grid sort. For detail retrieval, use plan numbers and identifiers from search output or caller data; no opaque IDs are guessed.
Site-Specific Gotchas
- The login form is at
/Login.aspx; authenticate before navigating to project-grid, filter, detail, or JSON endpoints. Credentials are runtime inputs and must not be saved. - The project grid is available at
/projectgrid?mode=biddingprojects;welcome=1is not required for the data request. - Project data is supplied by the same-origin authenticated endpoint
/ajax_grid_datasource.aspx?mode=biddingprojects&search={term}&page={page}&sort=biddate&dir=ASC, not necessarily embedded in the initial HTML. - The same endpoint supports exact plan-number searches. Always select the row whose
opsplannumexactly equals the requested plan number. - The full detail page requires opaque
BidPackageIDplusHOMEPR,STATUS,DOCLOC, andPUNIQ; resolve them from search results or caller-provided URLs rather than guessing. /filter.aspx?BidPackageID={BidPackageID}&projectnum={opsplannum}&bx={HOMEPR}&bxup={plan-suffix}may redirect to/Loginwhen unauthenticated.- Detail-page document links may be ordinary anchors or labels containing plan/specification/addendum terms; inspect
linksif classification misses one. - Contact data can appear in free text or table cells, so return normalized tables and email/phone/contact-line candidates.
Countreports the server-side result count andProjectscontains page rows. Continue until the count is reached or no new rows are returned.- Full-detail records may contain description, prebid conference, addenda, estimate, owner, architect, bid time, and date received. Preserve all nonempty returned fields.
Expected Output
For a broad review, return {projects,byTerm} with deduplicated projects sorted by ascending bid date; each project includes normalized metadata, raw server fields, and matchedTerms.
For JSON detail enrichment, return {details}, mapping each requested exact plan number to its complete nonempty project record or a clear error.
For rendered bid-package details, return the extractor object containing canonical detail url, page title and text, structured tables, non-social links, likely documents, and contacts with email, phone, and contact-line arrays.