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
635 lines
21 KiB
JavaScript
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,
|
|
};
|