灵猴市集Linghou Market

中国电建采购公告搜索

中国电建powerchina-bid-search

用途 搜索中国电建阳光采购公告,返回公告标题、发布时间、公告类型、项目编号、金额或限价、采购人或招标人、截止或开标时间等可从公开接口或页面上下文取得的字段。 执行前提 需要浏览器能访问 https://bid.powerchina.cn/。脚本优先请求中国电建阳光采购的公开公告列表接口;如果接口策略变化、跨域限制、Cookie 状态异常或页面结构变化,可能…

搜索采购公告招投标公开读取
版本1.0.0
扫描passed
更新2026-06-28
超时30000ms

匹配范围

*://bid.powerchina.cn/*

排除范围

未声明

能力声明

dom.read

代码

源码已展开
1return await (async () => {2  const input = typeof params === "object" && params ? params : {};3  const query = cleanText(input.query || "");4  if (!query) {5    throw new Error("缺少参数 query:请输入要搜索的中国电建采购公告关键词。");6  }7  const limit = Math.max(1, Math.min(toInteger(input.limit, 20), 50));8 9  const rows = [];10  let apiError = "";11  try {12    rows.push(...(await searchApi(query, limit)));13  } catch (error) {14    apiError = cleanText(error && error.message ? error.message : String(error || ""));15  }16 17  if (rows.length === 0) {18    rows.push(...extractDomRows(document, query, location.href));19  }20 21  const records = normalizeRecords(dedupe(rows), query).slice(0, limit);22  if (records.length > 0) {23    return records.map((record, index) => ({ rank: index + 1, ...record }));24  }25 26  const pageText = cleanText(document.body ? document.body.innerText : "");27  if (/请先登录|未登录|登录后|验证码|人机验证|权限不足|无权限/.test(pageText)) {28    throw new Error("中国电建阳光采购页面要求登录、权限或人机验证,无法作为公开读取脚本继续执行。");29  }30  if (apiError) {31    throw new Error(`中国电建采购公告公开接口和页面解析均未返回结果:${apiError}。可能是接口策略或页面结构变化。`);32  }33  return [];34 35  function toInteger(value, fallback) {36    if (value === undefined || value === null || value === "") return fallback;37    const text = String(value).trim();38    if (!/^\d+$/.test(text)) {39      throw new Error(`limit 必须是正整数:${text}`);40    }41    const parsed = Number(text);42    if (!Number.isSafeInteger(parsed) || parsed < 1) {43      throw new Error(`limit 超出范围:${text}`);44    }45    return parsed;46  }47 48  function cleanText(value) {49    return typeof value === "string" ? value.replace(/\s+/g, " ").trim() : "";50  }51 52  function normalizeDate(value) {53    const text = cleanText(value);54    const match = text.match(/(20\d{2})[.\-/年](\d{1,2})[.\-/月](\d{1,2})/);55    if (!match) return "";56    return `${match[1]}-${String(match[2]).padStart(2, "0")}-${String(match[3]).padStart(2, "0")}`;57  }58 59  function absolutize(href, base) {60    if (!href) return "";61    try {62      return new URL(href, base || location.href).toString();63    } catch {64      return "";65    }66  }67 68  async function searchApi(keyword, size) {69    const response = await fetch("https://bid.powerchina.cn/newcbs/recpro-newmember/BidAnnouncementSummary/list", {70      method: "POST",71      credentials: "include",72      headers: {73        "Content-Type": "application/json;charset=utf-8",74        Accept: "application/json, text/plain, */*",75      },76      body: JSON.stringify({77        pageNum: 1,78        pageSize: Math.max(20, Math.min(100, Math.max(size * 3, size))),79        announcementType: "招采公告",80        companyType: "3",81        keyWords: keyword,82        time: Date.now(),83      }),84    });85    if (!response.ok) {86      throw new Error(`HTTP ${response.status}`);87    }88    const data = await response.json();89    if ((data.code ?? 200) !== 200) {90      throw new Error(`code=${data.code ?? "unknown"} msg=${cleanText(data.msg || "")}`);91    }92    const list = Array.isArray(data.rows) ? data.rows : [];93    return list94      .map((row) => {95        const id = cleanText(String(row.id || ""));96        const title = cleanText(row.title || row.announcementTitle || row.name || "");97        if (!id || !title) return null;98        const contextText = cleanText(99          [100            row.announcementType,101            row.titleTypeName,102            row.projectCode,103            row.projectNo,104            row.projectAmount,105            row.budgetAmount,106            row.source,107            row.publishTime,108            row.registrationDeadline,109            row.submissionDeadline,110            row.bidOpenTime,111          ]112            .filter(Boolean)113            .join(" | "),114        );115        return {116          title,117          url: `https://bid.powerchina.cn/newcbs/recpro-newmember/BidAnnouncementSummary/getInfo/${encodeURIComponent(id)}`,118          date: normalizeDate(cleanText(row.publishTime || row.bidOpenTime || row.submissionDeadline || "")),119          contextText,120          raw: row,121        };122      })123      .filter(Boolean);124  }125 126  function extractDomRows(root, keyword, baseUrl) {127    const tokenParts = keyword128      .split(/\s+/)129      .map((item) => item.toLowerCase())130      .filter(Boolean);131    const hints = ["招标", "采购", "公告", "项目", "中标", "成交", "询价", "竞价", "比选", "投标", "notice", "tender", "procurement", "bidding"];132    const selectors = [133      "table tbody tr",134      "table tr",135      "ul li",136      "ol li",137      "article",138      "section",139      ".list li",140      ".notice li",141      '[class*="list"] li',142      '[class*="notice"] li',143      '[class*="item"]',144      '[class*="row"]',145    ];146    const candidates = [];147    const seenNodes = new Set();148    for (const selector of selectors) {149      for (const node of Array.from(root.querySelectorAll(selector))) {150        const text = cleanText(node.innerText || node.textContent || "");151        if (!text || text.length < 8 || seenNodes.has(node)) continue;152        const lower = text.toLowerCase();153        const hasDate = /(20\d{2})[.\-/年](\d{1,2})[.\-/月](\d{1,2})/.test(text);154        const hasHint = hints.some((hint) => lower.includes(hint));155        const hasQuery = tokenParts.length === 0 || tokenParts.some((part) => lower.includes(part));156        if (!hasDate && !hasHint && !hasQuery) continue;157        seenNodes.add(node);158        candidates.push(node);159      }160    }161    const items = [];162    for (const node of candidates) {163      const contextText = cleanText(node.innerText || node.textContent || "");164      const lower = contextText.toLowerCase();165      const hasHint = hints.some((hint) => lower.includes(hint));166      const hasQuery = tokenParts.length === 0 || tokenParts.some((part) => lower.includes(part));167      if (!hasHint && !hasQuery) continue;168      for (const anchor of Array.from(node.querySelectorAll("a[href]"))) {169        const title = cleanText(anchor.textContent || "");170        const url = absolutize(anchor.getAttribute("href") || anchor.href || "", baseUrl);171        if (!title || title.length < 4 || !url || !/powerchina\.cn/i.test(url)) continue;172        items.push({173          title,174          url,175          date: normalizeDate(contextText),176          contextText,177        });178      }179    }180    return items;181  }182 183  function dedupe(items) {184    const output = [];185    const seen = new Set();186    for (const item of items) {187      const title = cleanText(item.title);188      const url = cleanText(item.url);189      const key = `${title}\t${url}`;190      if (!title || !url || seen.has(key)) continue;191      seen.add(key);192      output.push({ ...item, title, url, contextText: cleanText(item.contextText), date: normalizeDate(item.date || item.contextText) });193    }194    return output;195  }196 197  function normalizeRecords(items, keyword) {198    return items199      .map((item) => {200        const contextText = cleanText(item.contextText);201        const raw = item.raw && typeof item.raw === "object" ? item.raw : {};202        const contentType = detectContentType(item.title, contextText, item.url, raw);203        return {204          title: item.title,205          url: item.url,206          publish_time: normalizeDate(item.date || raw.publishTime || contextText),207          content_type: contentType,208          project_code: firstClean(raw.projectCode, raw.projectNo, extractByPattern(contextText, /(?:项目编号|招标编号|采购编号|项目编码|项目代码|编号)\s*[::]\s*([A-Za-z0-9\-_/]{4,80})/i)),209          budget_or_limit: firstClean(raw.projectAmount, raw.budgetAmount, raw.purchaseAmount, extractByPattern(contextText, /(?:预算(?:金额)?|控制价|最高限价|限价|采购金额|合同估算价|金额)\s*[::]\s*([^\n,。;|]{2,100})/i)),210          project_owner: firstClean(raw.tenderer, raw.purchaser, raw.companyName, extractByPattern(contextText, /(?:招标人|采购人|业主|建设单位|项目单位)\s*[::]\s*([^\n,。;|]{2,80})/i)),211          deadline_or_open_time: firstClean(raw.registrationDeadline, raw.submissionDeadline, raw.bidOpenTime, extractByPattern(contextText, /(?:报名截止时间|投标截止时间|开标时间|响应文件递交截止时间|截止时间|开标日期)\s*[::]\s*([^\n,。;|]{2,100})/i)),212          summary: contextText.slice(0, 220),213        };214      })215      .filter((item) => isUsefulRecord(item, keyword));216  }217 218  function firstClean(...values) {219    for (const value of values) {220      const text = cleanText(value === undefined || value === null ? "" : String(value));221      if (text) return text;222    }223    return "";224  }225 226  function extractByPattern(text, pattern) {227    const match = cleanText(text).match(pattern);228    return match && match[1] ? cleanText(match[1]) : "";229  }230 231  function detectContentType(title, contextText, url, raw) {232    const rawType = firstClean(raw.announcementType, raw.titleTypeName, raw.typeName);233    if (rawType) return rawType;234    const haystack = `${title} ${contextText} ${url}`.toLowerCase();235    if (/中标|成交|结果|候选人|中选|定标|评标|award|winner/.test(haystack)) return "结果公告";236    if (/招标|采购|询价|比选|公告|竞争性|邀请|投标|tender|procurement|notice/.test(haystack)) return "招采公告";237    return "公告";238  }239 240  function isUsefulRecord(item, keyword) {241    const text = `${item.title} ${item.summary} ${item.url}`.toLowerCase();242    const tokens = keyword243      .split(/\s+/)244      .map((part) => part.toLowerCase())245      .filter(Boolean);246    if (tokens.length > 0 && !tokens.some((part) => text.includes(part))) return false;247    if (/\/$|\/search(?:[?#]|$)|\/index(?:[?#]|$)|\/old(?:\/|$)|\/en(?:\/|$)|\/zh(?:\/|$)/i.test(item.url)) return false;248    if (/^(english|中文|chinese|language|home|首页|搜索|search)$/i.test(item.title)) return false;249    return true;250  }251})();