Files
wehub-resource-sync 9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

252 lines
8.0 KiB
JavaScript

/**
* PowerChina search — browser DOM extraction with multi-entry URL probing.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
import {
cleanText,
normalizeDate,
toProcurementSearchRecords,
} from '../jianyu/shared/procurement-contract.js';
import { searchRowsFromEntries } from '../jianyu/shared/china-bid-search.js';
const SEARCH_ENTRIES = [
'https://bid.powerchina.cn/search',
'https://bid.powerchina.cn/',
];
const API_LIST_ENDPOINT = 'https://bid.powerchina.cn/newcbs/recpro-newmember/BidAnnouncementSummary/list';
const API_DETAIL_ENDPOINT = 'https://bid.powerchina.cn/newcbs/recpro-newmember/BidAnnouncementSummary/getInfo';
const API_DEFAULT_ANNOUNCEMENT_TYPE = '招采公告';
const PROCUREMENT_TITLE_HINT = /(公告|招标|采购|中标|成交|项目|notice|tender|bidding)/i;
const NAVIGATION_TITLE_HINT = /^(english|中文|chinese|language|home|首页|搜索|search)$/i;
const RETRYABLE_SEARCH_ERROR_HINT = /(detached while handling command|execution context was destroyed|target closed|cannot find context with specified id)/i;
export function buildSearchCandidates(query) {
const keyword = query.trim();
if (!keyword) return [...SEARCH_ENTRIES];
const encoded = encodeURIComponent(keyword);
return [
`https://bid.powerchina.cn/search?keyword=${encoded}`,
`https://bid.powerchina.cn/search?keywords=${encoded}`,
`https://bid.powerchina.cn/search?q=${encoded}`,
...SEARCH_ENTRIES,
];
}
function dedupeCandidates(items) {
const deduped = [];
const seen = new Set();
for (const item of items) {
const key = `${item.title}\t${item.url}`;
if (seen.has(key)) continue;
seen.add(key);
deduped.push(item);
}
return deduped;
}
function isLikelyNavigationUrl(rawUrl) {
const urlText = cleanText(rawUrl);
if (!urlText) return true;
try {
const parsed = new URL(urlText);
const pathname = parsed.pathname.toLowerCase().replace(/\/+$/, '') || '/';
const hash = cleanText(parsed.hash).toLowerCase();
if (pathname === '/' || pathname === '/index') return true;
if (pathname === '/search') return true;
if (pathname === '/old' || pathname.startsWith('/old/')) return true;
if (pathname === '/en' || pathname.startsWith('/en/')) return true;
if (pathname === '/zh' || pathname.startsWith('/zh/')) return true;
if (hash === '#/' || hash === '#/index' || hash.startsWith('#/search')) return true;
return false;
} catch {
return true;
}
}
function isLikelyNavigationTitle(rawTitle) {
const title = cleanText(rawTitle);
if (!title) return true;
const normalized = title.toLowerCase();
if (NAVIGATION_TITLE_HINT.test(normalized)) return true;
if (normalized.length <= 10 && (normalized === 'en' || normalized === 'zh' || normalized.includes('english'))) {
return true;
}
return false;
}
function filterNavigationRows(items) {
return items.filter((item) => {
const title = cleanText(item.title);
const url = cleanText(item.url);
if (!url || !title) return false;
if (isLikelyNavigationUrl(url)) return false;
if (isLikelyNavigationTitle(title) && !PROCUREMENT_TITLE_HINT.test(title)) return false;
return true;
});
}
export function buildApiDetailUrl(id) {
const normalizedId = cleanText(id);
if (!normalizedId) return '';
return `${API_DETAIL_ENDPOINT}/${encodeURIComponent(normalizedId)}`;
}
function toApiCandidate(row) {
const id = cleanText(row.id);
const title = cleanText(row.title);
if (!id || !title) return null;
const url = buildApiDetailUrl(id);
if (!url) return null;
const contextText = cleanText([
row.announcementType,
row.titleTypeName,
row.source,
row.publishTime,
row.registrationDeadline,
row.submissionDeadline,
row.bidOpenTime,
].filter(Boolean).join(' | '));
const date = normalizeDate(cleanText(row.publishTime || row.bidOpenTime || row.submissionDeadline || ''));
return {
title,
url,
date,
contextText,
};
}
async function searchRowsFromApi(query, limit) {
const keyword = cleanText(query);
const pageSize = Math.max(20, Math.min(100, Math.max(limit * 3, limit)));
const payload = {
pageNum: 1,
pageSize,
announcementType: API_DEFAULT_ANNOUNCEMENT_TYPE,
companyType: '3',
time: Date.now(),
};
if (keyword) payload.keyWords = keyword;
const response = await fetch(API_LIST_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json;charset=utf-8',
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`[taxonomy=relay_unavailable] site=powerchina command=search api HTTP ${response.status}`);
}
const data = await response.json();
if ((data.code ?? 200) !== 200) {
throw new Error(`[taxonomy=relay_unavailable] site=powerchina command=search api code=${data.code ?? 'unknown'} msg=${cleanText(data.msg)}`);
}
const rows = Array.isArray(data.rows) ? data.rows : [];
const mapped = rows
.map((row) => toApiCandidate(row))
.filter(Boolean);
return dedupeCandidates(mapped).slice(0, limit);
}
cli({
site: 'powerchina',
name: 'search',
access: 'read',
description: '搜索中国电建阳光采购公告',
domain: 'bid.powerchina.cn',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'query', required: true, positional: true, help: 'Search keyword, e.g. "procurement"' },
{ name: 'limit', type: 'int', default: 20, help: 'Number of results (max 50)' },
],
columns: ['rank', 'content_type', 'title', 'publish_time', 'project_code', 'budget_or_limit', 'url'],
func: async (page, kwargs) => {
const query = cleanText(kwargs.query);
const limit = Math.max(1, Math.min(Number(kwargs.limit) || 20, 50));
let extractedRows = [];
let apiFailure = null;
let apiSucceeded = false;
try {
const apiRows = await searchRowsFromApi(query, limit);
extractedRows = apiRows;
apiSucceeded = true;
} catch (error) {
apiFailure = cleanText(error instanceof Error ? error.message : String(error || ''));
}
if (apiSucceeded && extractedRows.length === 0) {
return [];
}
if (!apiSucceeded) {
try {
extractedRows = await searchRowsFromEntries(page, {
query,
candidateUrls: buildSearchCandidates(query),
allowedHostFragments: ['bid.powerchina.cn', 'powerchina.cn'],
limit,
});
} catch (error) {
const message = cleanText(error instanceof Error ? error.message : String(error || ''));
if (RETRYABLE_SEARCH_ERROR_HINT.test(message)) {
throw new Error(`[taxonomy=relay_unavailable] site=powerchina command=search detached browser context: ${message}`);
}
throw error;
}
}
const rows = filterNavigationRows(
dedupeCandidates(extractedRows).map((item) => ({
title: cleanText(item.title),
url: cleanText(item.url),
date: normalizeDate(cleanText(item.date)),
contextText: cleanText(item.contextText),
})),
);
if (rows.length === 0 && extractedRows.length > 0) {
throw new EmptyResultError('powerchina search', 'extracted only navigation/portal rows, no bid entries matched');
}
if (rows.length === 0) {
const pageText = cleanText(await page.evaluate('document.body ? document.body.innerText : ""'));
if (/(请先登录|未登录|登录后|验证码|人机验证)/.test(pageText)) {
throw new AuthRequiredError(
'bid.powerchina.cn',
'[taxonomy=selector_drift] site=powerchina command=search login required or human verification',
);
}
if (apiFailure) {
throw new EmptyResultError('powerchina search', `api/dom yielded no result: ${apiFailure}`);
}
}
return toProcurementSearchRecords(rows, {
site: 'powerchina',
query,
limit,
});
},
});
export const __test__ = {
buildSearchCandidates,
normalizeDate,
dedupeCandidates,
filterNavigationRows,
isLikelyNavigationUrl,
isLikelyNavigationTitle,
buildApiDetailUrl,
toApiCandidate,
};