Catch Up on Bidding Projects

Site login.onlineplanservice.comTask catch-up-bidding-projectsVersion v4Updated Aug 5, 2026Category procurement

Search authenticated bidding projects for demolition, abatement, and multiprime-related work, then retrieve complete project details, contacts, and document links by plan number or bid package. This skill was captured from a live agent session on login.onlineplanservice.com and publishes here verbatim, exactly as an agent receives it.

NoteSelectors and URL schemes drift as sites change. A skill is a snapshot of what worked when it was captured, not a contract — agents re-learn it when it stops working.

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

  1. Ensure an authenticated session exists for login.onlineplanservice.com. If navigation redirects to /Login or /Login.aspx, use the site's normal login form with credentials supplied at runtime only. Observed selectors are input[type='text'][id*='UserName' i], input[type='password'], and input[type='submit'][value*='Login' i]; never store credentials in this skill.
  2. For broad catch-up reviews, navigate directly to https://login.onlineplanservice.com/projectgrid?mode=biddingprojects and run the following extractor in one evaluate() 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 };
})();
  1. 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=1 Select the matching result and read its exact opsplannum, BidPackageID or equivalent, and any available home-prefix/status fields. Never invent BidPackageID.

  2. 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 as DOCLOC and the portion after the year prefix as PUNIQ; prefer values returned by the search/detail record. A related filter URL is https://login.onlineplanservice.com/filter.aspx?BidPackageID={BidPackageID}&projectnum={opsplannum}&bx={HOMEPR}&bxup={plan-suffix}, but it is only an intermediate route; go directly to /detail after identifiers are known.

  3. On the loaded /detail page, 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)] },
  };
})();
  1. 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=1 is 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 opsplannum exactly equals the requested plan number.
  • The full detail page requires opaque BidPackageID plus HOMEPR, STATUS, DOCLOC, and PUNIQ; 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 /Login when unauthenticated.
  • Detail-page document links may be ordinary anchors or labels containing plan/specification/addendum terms; inspect links if classification misses one.
  • Contact data can appear in free text or table cells, so return normalized tables and email/phone/contact-line candidates.
  • Count reports the server-side result count and Projects contains 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.

Call it

GET https://production-sfo.browserless.io/skills?token=TOKEN-HERE&domain=login.onlineplanservice.com&task=catch-up-bidding-projects