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

635 lines
21 KiB
JavaScript

/**
* Jianyu search — browser DOM extraction from Jianyu bid search page.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError } from '@jackwener/opencli/errors';
import { buildSearchCandidates, cleanText, dedupeCandidates, detectAuthPrompt, normalizeDate, searchRowsFromEntries, } from './shared/china-bid-search.js';
import { toProcurementSearchRecords } from './shared/procurement-contract.js';
const SITE = 'jianyu';
const DOMAIN = 'www.jianyu360.cn';
const SEARCH_ENTRY = 'https://www.jianyu360.cn/jylab/supsearch/index.html';
const SEARCH_ENTRIES = [
SEARCH_ENTRY,
'https://www.jianyu360.cn/list/stype/ZBGG.html',
'https://www.jianyu360.cn/',
];
const SEARCH_INDEX_PROXY = 'https://r.jina.ai/http://duckduckgo.com/html/?q=';
const PROCUREMENT_TITLE_HINT = /(公告|招标|采购|中标|成交|项目|投标|结果|notice|tender|procurement|bidding)/i;
const AUTH_REQUIRED_HINT = /(请在下图依次点击|登录即可获得更多浏览权限|验证登录|请完成验证|图形验证码)/;
const NAVIGATION_PATH_PREFIXES = [
'/product/',
'/front/',
'/helpcenter/',
'/brand/',
'/page_workdesktop/',
'/list/',
'/list/stype/',
'/list/rmxm',
'/big/page/',
'/jylab/',
'/tags/',
'/sitemap',
'/datasmt/',
'/bank/',
'/hj/',
'/exhibition/',
'/swordfish/page_big_pc/search/',
];
const BLOCKED_DETAIL_PATH_PREFIXES = [
'/nologin/content/',
'/article/bdprivate/',
];
const JIANYU_API_TYPES = ['fType', 'eType', 'vType', 'mType'];
export function buildSearchUrl(query) {
const url = new URL(SEARCH_ENTRY);
url.searchParams.set('keywords', query.trim());
url.searchParams.set('selectType', 'title');
url.searchParams.set('searchGroup', '1');
return url.toString();
}
function siteSearchCandidates(query) {
const preferred = buildSearchUrl(query);
const fallbacks = buildSearchCandidates(query, SEARCH_ENTRIES, ['keywords', 'keyword', 'q', 'search', 'title']);
const ordered = [];
const seen = new Set();
for (const candidate of [preferred, ...fallbacks]) {
const value = cleanText(candidate);
if (!value || seen.has(value))
continue;
seen.add(value);
ordered.push(value);
}
return ordered;
}
function isLikelyNavigationUrl(rawUrl) {
const urlText = cleanText(rawUrl);
if (!urlText)
return true;
try {
const parsed = new URL(urlText);
const path = cleanText(parsed.pathname).toLowerCase().replace(/\/+$/, '/') || '/';
if (path === '/')
return true;
if (NAVIGATION_PATH_PREFIXES.some((prefix) => path.startsWith(prefix)))
return true;
return false;
}
catch {
return true;
}
}
function classifyDetailStatus(rawUrl) {
const urlText = cleanText(rawUrl);
if (!urlText) {
return {
detail_status: 'blocked',
detail_reason: 'missing_url',
};
}
try {
const parsed = new URL(urlText);
const path = cleanText(parsed.pathname).toLowerCase().replace(/\/+$/, '/') || '/';
if (BLOCKED_DETAIL_PATH_PREFIXES.some((prefix) => path.includes(prefix))) {
return {
detail_status: 'blocked',
detail_reason: 'verification_or_paid_wall',
};
}
if (isLikelyNavigationUrl(urlText)) {
return {
detail_status: 'entry_only',
detail_reason: 'navigation_or_profile_entry',
};
}
return {
detail_status: 'ok',
detail_reason: path.includes('/jybx/') ? 'jybx_detail' : 'detail_candidate',
};
}
catch {
return {
detail_status: 'blocked',
detail_reason: 'invalid_url',
};
}
}
function extractNoticeId(rawUrl) {
const value = cleanText(rawUrl);
if (!value)
return '';
try {
const parsed = new URL(value);
const path = cleanText(parsed.pathname);
const jybxMatched = path.match(/\/jybx\/([^/?#]+)\.html$/i);
if (jybxMatched?.[1])
return cleanText(jybxMatched[1]);
const segments = path.split('/').filter(Boolean);
const tail = cleanText(segments[segments.length - 1] || '');
return cleanText(tail.replace(/\.html?$/i, ''));
}
catch {
return '';
}
}
function isWithinSinceDays(dateText, sinceDays, now = new Date()) {
const normalized = normalizeDate(dateText);
if (!normalized)
return false;
const timestamp = Date.parse(`${normalized}T00:00:00Z`);
if (!Number.isFinite(timestamp))
return false;
const today = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
const deltaDays = Math.floor((today - timestamp) / (24 * 3600 * 1000));
return deltaDays >= 0 && deltaDays <= sinceDays;
}
function dedupeByNoticeKey(items) {
const deduped = [];
const seen = new Set();
for (const item of items) {
const source = cleanText(item.source_id || '');
const notice = cleanText(item.notice_id || '');
const key = source && notice
? `${source}\t${notice}`
: `${cleanText(item.title)}\t${cleanText(item.url)}`;
if (!key || seen.has(key))
continue;
seen.add(key);
deduped.push(item);
}
return deduped;
}
function filterNavigationRows(query, items) {
const queryTokens = cleanText(query).split(/\s+/).filter(Boolean).map((token) => token.toLowerCase());
return items
.map((item) => ({
title: cleanText(item.title),
url: cleanText(item.url),
date: normalizeDate(cleanText(item.date)),
contextText: cleanText(item.contextText),
}))
.filter((item) => {
if (!item.title || !item.url)
return false;
const detailSignal = classifyDetailStatus(item.url);
if (detailSignal.detail_status !== 'ok')
return false;
const haystack = `${item.title} ${item.contextText}`.toLowerCase();
const hasQuery = queryTokens.length === 0 || queryTokens.some((token) => haystack.includes(token));
const hasProcurementHint = PROCUREMENT_TITLE_HINT.test(`${item.title} ${item.contextText}`);
const hasDate = !!item.date;
if (!hasQuery)
return false;
if (!isLikelyNavigationUrl(item.url))
return true;
return hasDate && hasProcurementHint;
});
}
async function isAuthRequired(page) {
const pageText = cleanText(await page.evaluate('document.body ? document.body.innerText : ""'));
if (AUTH_REQUIRED_HINT.test(pageText))
return true;
return detectAuthPrompt(page);
}
function toAbsoluteJianyuUrl(rawUrl) {
const value = cleanText(rawUrl);
if (!value)
return '';
if (value.startsWith('http://') || value.startsWith('https://'))
return value;
if (value.startsWith('//'))
return `https:${value}`;
if (value.startsWith('/')) {
try {
return new URL(value, SEARCH_ENTRY).toString();
}
catch {
return '';
}
}
return '';
}
function extractDateFromJianyuUrl(rawUrl) {
const value = cleanText(rawUrl);
if (!value)
return '';
const matched = value.match(/\/(20\d{2})(\d{2})(\d{2})(?:[_/]|$)/);
if (!matched)
return '';
return `${matched[1]}-${matched[2]}-${matched[3]}`;
}
function flattenStrings(input, depth = 0) {
if (depth > 2 || input == null)
return [];
if (typeof input === 'string' || typeof input === 'number') {
const text = cleanText(String(input));
return text ? [text] : [];
}
if (Array.isArray(input)) {
return input.flatMap((item) => flattenStrings(item, depth + 1));
}
if (typeof input === 'object') {
return Object.values(input).flatMap((item) => flattenStrings(item, depth + 1));
}
return [];
}
function pickString(record, keys) {
for (const key of keys) {
const value = record[key];
if (typeof value === 'string' || typeof value === 'number') {
const text = cleanText(String(value));
if (text)
return text;
}
}
return '';
}
function normalizeApiRow(item) {
if (!item || typeof item !== 'object')
return null;
const record = item;
const allStrings = flattenStrings(record);
let url = toAbsoluteJianyuUrl(pickString(record, [
'url',
'detailUrl',
'detailURL',
'link',
'href',
'articleUrl',
'newsUrl',
'contentUrl',
'jumpUrl',
'sourceUrl',
]));
if (!url) {
const maybeUrl = allStrings.find((value) => /jianyu360\.cn|\/jybx\/|\/nologin\/content\//i.test(value)) || '';
url = toAbsoluteJianyuUrl(maybeUrl);
}
let title = cleanText(pickString(record, [
'title',
'noticeTitle',
'bidTitle',
'projectName',
'name',
'articleTitle',
'newsTitle',
'tenderTitle',
'contentTitle',
]));
if (!title) {
title = allStrings.find((value) => value.length >= 8 && PROCUREMENT_TITLE_HINT.test(value)) || '';
}
const date = normalizeDate(pickString(record, [
'publishTime',
'publishDate',
'pubDate',
'createTime',
'time',
'releaseTime',
'date',
])) || extractDateFromJianyuUrl(url);
const contextText = cleanText([
pickString(record, ['content', 'summary', 'desc', 'description', 'buyer', 'winner', 'agency', 'industry']),
...allStrings.slice(0, 6),
].filter(Boolean).join(' '));
if (!title || !url)
return null;
return {
title,
url,
date,
contextText,
};
}
function parseSearchIndexMarkdown(markdown) {
const rows = [];
for (const line of markdown.split('\n')) {
const text = line.trim();
if (!text.startsWith('## ['))
continue;
const right = text.slice(3);
const sep = right.lastIndexOf('](');
if (sep <= 0 || !right.endsWith(')'))
continue;
const title = cleanText(right.slice(1, sep));
const url = cleanText(right.slice(sep + 2, -1));
if (!title || !url)
continue;
rows.push({ title, url });
}
return rows;
}
function unwrapDuckDuckGoUrl(rawUrl) {
const candidate = cleanText(rawUrl);
if (!candidate)
return '';
const normalized = candidate.startsWith('//') ? `https:${candidate}` : candidate;
try {
const parsed = new URL(normalized);
const host = parsed.hostname.toLowerCase();
if (!host.endsWith('duckduckgo.com'))
return normalized;
const uddg = parsed.searchParams.get('uddg');
if (!uddg)
return normalized;
try {
return decodeURIComponent(uddg);
}
catch {
return uddg;
}
}
catch {
return '';
}
}
function isJianyuHost(rawUrl) {
const value = cleanText(rawUrl);
if (!value)
return false;
try {
return new URL(value).hostname.toLowerCase().endsWith('jianyu360.cn');
}
catch {
return false;
}
}
function buildIndexQueryVariants(query) {
const tokens = cleanText(query).split(/\s+/).filter(Boolean);
const values = [cleanText(query), ...tokens];
const ordered = [];
const seen = new Set();
for (const value of values) {
const text = cleanText(value);
if (!text || seen.has(text))
continue;
seen.add(text);
ordered.push(text);
}
return ordered;
}
async function fetchDuckDuckGoIndexRows(query, limit) {
const results = [];
const seen = new Set();
for (const variant of buildIndexQueryVariants(query)) {
if (results.length >= limit)
break;
const fullQuery = `site:jianyu360.cn ${variant}`;
const url = `${SEARCH_INDEX_PROXY}${encodeURIComponent(fullQuery)}`;
let responseText = '';
try {
const response = await fetch(url, {
headers: {
Accept: 'text/plain, text/markdown, */*',
'User-Agent': 'opencli-jianyu-search/1.0',
},
});
if (!response.ok)
continue;
responseText = await response.text();
}
catch {
continue;
}
const indexedRows = parseSearchIndexMarkdown(responseText);
for (const row of indexedRows) {
const unwrapped = unwrapDuckDuckGoUrl(row.url);
const absoluteUrl = toAbsoluteJianyuUrl(unwrapped) || cleanText(unwrapped);
if (!isJianyuHost(absoluteUrl))
continue;
const key = `${row.title}\t${absoluteUrl}`;
if (seen.has(key))
continue;
seen.add(key);
results.push({
title: cleanText(row.title),
url: absoluteUrl,
date: extractDateFromJianyuUrl(absoluteUrl),
contextText: cleanText(`${row.title} ${variant}`),
});
if (results.length >= limit)
break;
}
}
return results;
}
async function fetchJianyuApiRows(page, query, limit) {
try {
await page.goto(buildSearchUrl(query));
await page.wait(2);
const payload = await page.evaluate(`
(async () => {
const now = Math.floor(Date.now() / 1000);
const body = {
searchGroup: 1,
reqType: 'lastNews',
pageNum: 1,
pageSize: Math.max(20, Math.min(${Math.max(20, limit)}, 50)),
keyWords: ${JSON.stringify(query)},
searchMode: 0,
bidField: '',
publishTime: \`\${now - 3600 * 24 * 365 * 3}-\${now}\`,
selectType: 'title,content',
subtype: '',
exclusionWords: '',
buyer: '',
winner: '',
agency: '',
industry: '',
province: '',
city: '',
district: '',
buyerClass: '',
fileExists: '',
price: '',
buyerTel: '',
winnerTel: '',
};
const responses = [];
const types = ${JSON.stringify([...JIANYU_API_TYPES])};
for (const type of types) {
try {
const response = await fetch('/jyapi/jybx/core/' + type + '/searchList', {
method: 'POST',
headers: {
Accept: 'application/json, text/plain, */*',
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify(body),
});
let raw = null;
try {
raw = await response.json();
} catch {
raw = null;
}
const dataList = raw && raw.data && Array.isArray(raw.data.list) ? raw.data.list : [];
responses.push({
type,
ok: response.ok,
status: response.status,
payload: {
antiVerify: raw && typeof raw.antiVerify === 'number' ? raw.antiVerify : undefined,
error_code: raw && typeof raw.error_code === 'number' ? raw.error_code : undefined,
hasLogin: raw && typeof raw.hasLogin === 'boolean' ? raw.hasLogin : undefined,
textVerify: raw && typeof raw.textVerify === 'string' ? raw.textVerify.slice(0, 16) : undefined,
list: dataList,
},
});
} catch {
responses.push({
type,
ok: false,
status: 0,
});
}
}
const challenge = responses.some((item) => item && item.payload && item.payload.antiVerify === -1);
return { challenge, responses };
})()
`);
const responses = Array.isArray(payload?.responses) ? payload.responses : [];
const rows = collectApiRowsFromResponses(responses);
const challenge = Boolean(payload?.challenge);
return { rows, challenge };
}
catch {
return { rows: [], challenge: false };
}
}
function collectApiRowsFromResponses(responses) {
const rows = [];
const seen = new Set();
for (const response of responses) {
if (!response || typeof response !== 'object')
continue;
const meta = response;
const body = meta.payload;
if (!body || typeof body !== 'object')
continue;
const list = body.list;
if (!Array.isArray(list))
continue;
for (const item of list) {
const row = normalizeApiRow(item);
if (!row)
continue;
const key = `${row.title}\t${row.url}`;
if (seen.has(key))
continue;
seen.add(key);
rows.push(row);
}
}
return rows;
}
cli({
site: SITE,
name: 'search',
access: 'read',
description: '搜索剑鱼标讯公告',
domain: DOMAIN,
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)' },
{ name: 'since_days', type: 'int', help: 'Only keep rows published within N days' },
],
columns: ['rank', 'content_type', 'title', 'published_at', 'detail_status', '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));
const rawSinceDays = Number(kwargs.since_days);
const sinceDays = Number.isFinite(rawSinceDays) && rawSinceDays > 0
? Math.max(1, Math.min(rawSinceDays, 3650))
: null;
const apiResult = await fetchJianyuApiRows(page, query, limit);
const mergedRows = dedupeCandidates(filterNavigationRows(query, apiResult.rows));
const extractedRows = await searchRowsFromEntries(page, {
query,
candidateUrls: siteSearchCandidates(query),
allowedHostFragments: ['jianyu360.cn'],
limit,
});
const domRows = dedupeCandidates(filterNavigationRows(query, extractedRows));
const rows = dedupeCandidates([...mergedRows, ...domRows]);
if (rows.length === 0) {
const indexedRows = await fetchDuckDuckGoIndexRows(query, limit);
const filteredIndexedRows = dedupeCandidates(filterNavigationRows(query, indexedRows));
if (filteredIndexedRows.length > 0) {
const records = toProcurementSearchRecords(filteredIndexedRows, {
site: SITE,
query,
limit,
});
const enriched = dedupeByNoticeKey(records.map((row) => {
const detailSignal = classifyDetailStatus(row.url);
const publishedAt = normalizeDate(row.publish_time || row.date);
return {
...row,
source_id: SITE,
notice_id: extractNoticeId(row.url),
published_at: publishedAt,
detail_status: detailSignal.detail_status,
detail_reason: detailSignal.detail_reason,
};
}))
.filter((row) => row.detail_status === 'ok')
.filter((row) => sinceDays == null || isWithinSinceDays(row.published_at, sinceDays))
.slice(0, limit)
.map((row, index) => ({
...row,
rank: index + 1,
}));
return enriched;
}
if (apiResult.challenge || await isAuthRequired(page)) {
throw new AuthRequiredError(DOMAIN, '[taxonomy=selector_drift] site=jianyu command=search blocked by human verification / access challenge');
}
}
const records = toProcurementSearchRecords(rows, {
site: SITE,
query,
limit,
});
const enriched = dedupeByNoticeKey(records.map((row) => {
const detailSignal = classifyDetailStatus(row.url);
const publishedAt = normalizeDate(row.publish_time || row.date);
return {
...row,
source_id: SITE,
notice_id: extractNoticeId(row.url),
published_at: publishedAt,
detail_status: detailSignal.detail_status,
detail_reason: detailSignal.detail_reason,
};
}))
.filter((row) => row.detail_status === 'ok')
.filter((row) => sinceDays == null || isWithinSinceDays(row.published_at, sinceDays))
.slice(0, limit)
.map((row, index) => ({
...row,
rank: index + 1,
}));
return enriched;
},
});
export const __test__ = {
buildSearchCandidates: siteSearchCandidates,
buildSearchUrl,
normalizeDate,
dedupeCandidates,
filterNavigationRows,
parseSearchIndexMarkdown,
unwrapDuckDuckGoUrl,
extractDateFromJianyuUrl,
normalizeApiRow,
fetchJianyuApiRows,
collectApiRowsFromResponses,
classifyDetailStatus,
extractNoticeId,
isWithinSinceDays,
dedupeByNoticeKey,
};