/** * 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, };