import { CHROME_UA } from '../_seed-utils.mjs'; import { decodeHtmlEntities } from '../_html-entities.mjs'; import { extractDocumentNumber } from './normalize.mjs'; const DEFAULT_FETCH = (...args) => globalThis.fetch(...args); const LIST_BYTES = 750_000; const DOCUMENT_BYTES = 2_000_000; const MAX_DOCUMENTS = 3; const REQUEST_CADENCE_MS = 400; const REQUEST_TIMEOUT_MS = 15_000; const SAMR_LIST_URL = new URL('https://www.samr.gov.cn/api-gateway/jpaas-publish-server/front/page/build/unit'); SAMR_LIST_URL.search = new URLSearchParams({ webId: '29e9522dc89d4e088a953d8cede72f4c', pageId: 'dedefc44460a4338ac649d95f0ed8023', parseType: 'bulidstatic', pageType: 'column', tagId: '内容区域', tplSetId: '5c30fb89ae5e48b9aefe3cdf49853830', pageNo: '1', }).toString(); const MIIT_LIST_URL = new URL('https://www.miit.gov.cn/search-front-server/api/search/info'); MIIT_LIST_URL.search = new URLSearchParams({ websiteid: '110000000000000', scope: 'basic', q: '', pg: '10', cateid: '58', pos: 'title_text,infocontent,titlepy', _cus_eq_typename: '', _cus_eq_publishgroupname: '', _cus_eq_themename: '', begin: '', end: '', dateField: 'deploytime', selectFields: 'title,content,deploytime,_index,url,cdate,infoextends,infocontentattribute,columnname,filenumbername,publishgroupname,publishtime,metaid,bexxgk,columnid,xxgkextend1,xxgkextend2,themename,typename,indexcode,createdate', group: 'distinct', highlightConfigs: '[{"field":"infocontent","numberOfFragments":2,"fragmentOffset":0,"fragmentSize":30,"noMatchSize":145}]', highlightFields: 'title_text,infocontent,webid', level: '6', sortFields: '[{"name":"deploytime","type":"desc"}]', p: '1', }).toString(); function source(listUrl, allowedHost, listingFormat, documentPathPattern, requiredSearchParam = null) { return Object.freeze({ listUrl, allowedHost, listingFormat, documentPathPattern, requiredSearchParam, maxListBytes: LIST_BYTES, maxDocumentBytes: DOCUMENT_BYTES, maxDocumentsPerRun: MAX_DOCUMENTS, requestCadenceMs: REQUEST_CADENCE_MS, }); } export const CHINA_POLICY_SOURCES = Object.freeze({ CAC: source( 'https://www.cac.gov.cn/wxzw/zcfg/A093703index_1.htm', 'www.cac.gov.cn', 'html', /^\/\d{4}-\d{2}\/\d{2}\/c_\d+\.htm$/, ), SAMR: source( SAMR_LIST_URL.href, 'www.samr.gov.cn', 'samr-json', /^\/zw\/zfxxgk\/.+\/art\/\d{4}\/art_[0-9a-f]+\.html$/, ), MIIT: source( MIIT_LIST_URL.href, 'www.miit.gov.cn', 'miit-json', /^\/zwgk\/zcwj\/.+\/art\/\d{4}\/art_[0-9a-f]+\.html$/, ), MOFCOM: source( 'https://www.mofcom.gov.cn/zcfb/index.html', 'www.mofcom.gov.cn', 'html', /^\/zcfb\/.+\/art\/\d{4}\/art_[0-9a-f]+\.html$/, ), NDRC: source( 'https://zfxxgk.ndrc.gov.cn/web/zclist.jsp?dirid=41&pid=39', 'zfxxgk.ndrc.gov.cn', 'html', /^\/web\/iteminfo\.jsp$/, ['id', /^\d+$/], ), PBOC: source( 'https://www.pbc.gov.cn/tiaofasi/144941/144957/index.html', 'www.pbc.gov.cn', 'html', /^\/tiaofasi\/144941\/144957\/\d+\/index\.html$/, ), }); const NON_DOCUMENT_TITLE = /专家解读|答记者问|一图读懂|公开招聘|拟聘|预算|公示|工作动态/; const POLICY_TITLE_HINT = /条例|规定|办法|规章|决定|公告|通知|意见|方案|指南|规划|批复|裁定|处罚|调查|管控名单|管理措施|禁令|令〔|令\d/; const BLOCK_PAGE_PATTERN = /Access Denied|访问频繁|安全验证|验证码|请求被拒绝|系统繁忙|页面不存在|404 Not Found|服务异常/i; const RAW_TEXT_TAGS = new Set(['script', 'style']); const VOID_TAGS = new Set([ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr', ]); function attributeValue(attributes, name) { const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const match = String(attributes ?? '').match(new RegExp( `(?:^|\\s)${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>]+))`, 'i', )); return match?.[1] ?? match?.[2] ?? match?.[3] ?? null; } function parseTagAt(html, start) { if (html.startsWith('', start + 4); return { end: commentEnd === -1 ? html.length : commentEnd + 3, name: null, }; } let cursor = start + 1; if (html[cursor] === '!' || html[cursor] === '?') { const specialEnd = html.indexOf('>', cursor + 1); return { end: specialEnd === -1 ? html.length : specialEnd + 1, name: null, }; } let closing = false; if (html[cursor] === '/') { closing = true; cursor += 1; } if (!/[A-Za-z]/.test(html[cursor] ?? '')) return null; let nameEnd = cursor + 1; while (/[A-Za-z0-9:-]/.test(html[nameEnd] ?? '')) nameEnd += 1; const name = html.slice(cursor, nameEnd).toLowerCase(); const attributesStart = nameEnd; let quote = null; let end = attributesStart; for (; end < html.length; end += 1) { const char = html[end]; if (quote) { if (char === quote) quote = null; continue; } if (char === '"' || char === "'") { quote = char; continue; } if (char === '>') break; } const complete = end < html.length; const attributes = html.slice(attributesStart, complete ? end : html.length); return { attributes, closing, end: complete ? end + 1 : html.length, name, selfClosing: /\/\s*$/.test(attributes), }; } function walkHtml(input, { onTag, onText } = {}) { const html = String(input ?? ''); let cursor = 0; let suppressedTag = null; while (cursor < html.length) { const tagStart = html.indexOf('<', cursor); if (tagStart === -1) { if (!suppressedTag && cursor < html.length) onText?.(html.slice(cursor)); break; } if (!suppressedTag && tagStart > cursor) onText?.(html.slice(cursor, tagStart)); const tag = parseTagAt(html, tagStart); if (!tag) { if (!suppressedTag) onText?.('<'); cursor = tagStart + 1; continue; } cursor = tag.end; if (!tag.name) continue; if (suppressedTag) { if (tag.closing && tag.name === suppressedTag) suppressedTag = null; continue; } if (!tag.closing && !tag.selfClosing && RAW_TEXT_TAGS.has(tag.name)) { suppressedTag = tag.name; continue; } onTag?.(tag); } } function normalizeHtmlText(parts) { return decodeHtmlEntities(parts.join(' ')).replace(/\s+/g, ' ').trim(); } function stripHtml(input) { const parts = []; walkHtml(input, { onText: (text) => parts.push(text), }); return normalizeHtmlText(parts); } function validDay(value) { const token = String(value ?? '').trim(); if (!/^\d{4}-\d{2}-\d{2}$/.test(token)) return null; const parsed = Date.parse(`${token}T00:00:00.000Z`); return Number.isFinite(parsed) ? token : null; } function canonicalizeSourceUrl(value, sourceConfig) { const raw = String(value ?? '').trim(); if (!raw) return null; let parsed; try { if (raw.startsWith('//')) parsed = new URL(`https:${raw}`); else parsed = new URL(raw, sourceConfig.listUrl); } catch { return null; } if (parsed.protocol === 'http:') parsed.protocol = 'https:'; parsed.hash = ''; if ( parsed.protocol !== 'https:' || parsed.hostname !== sourceConfig.allowedHost || parsed.username || parsed.password || !sourceConfig.documentPathPattern.test(parsed.pathname) || ( sourceConfig.requiredSearchParam && !sourceConfig.requiredSearchParam[1].test( parsed.searchParams.get(sourceConfig.requiredSearchParam[0]) ?? '', ) ) ) { return null; } return parsed.href; } function dateNearAnchor(html, anchorEnd) { const nearby = html.slice(anchorEnd, anchorEnd + 500); return validDay(nearby.match(/\b(20\d{2}-\d{2}-\d{2})\b/)?.[1]); } function parseHtmlListing(agency, html, sourceConfig) { const rows = []; let anchor = null; walkHtml(html, { onTag: (tag) => { if (tag.name !== 'a') return; if (!tag.closing) { anchor = { attributes: tag.attributes, parts: [], }; return; } if (!anchor) return; const rawHref = attributeValue(anchor.attributes, 'href') ?? ''; const titleOriginal = normalizeHtmlText([ attributeValue(anchor.attributes, 'title') ?? anchor.parts.join(' '), ]); const canonicalUrl = canonicalizeSourceUrl(rawHref, sourceConfig); const publicationDate = dateNearAnchor(html, tag.end); if ( canonicalUrl && publicationDate && titleOriginal.length >= 5 && !NON_DOCUMENT_TITLE.test(titleOriginal) && POLICY_TITLE_HINT.test(titleOriginal) ) { rows.push({ agency, canonicalUrl, titleOriginal, publicationDate, originalText: '', documentNumber: extractDocumentNumber(titleOriginal), }); } anchor = null; }, onText: (text) => { if (anchor) anchor.parts.push(text); }, }); return rows; } function parseMiitListing(raw, sourceConfig) { let payload; try { payload = JSON.parse(raw); } catch { return []; } const groups = payload?.data?.searchResult?.dataResults ?? []; const rows = []; for (const result of groups) { for (const group of result?.groupData ?? []) { const data = group?.data; if (!data || typeof data !== 'object') continue; const canonicalUrl = canonicalizeSourceUrl(data.url, sourceConfig); const publicationDate = validDay(data.jsearch_date); const titleOriginal = stripHtml(data.title_text || data.title); const listingText = stripHtml(data.infocontent); if (!canonicalUrl || !publicationDate || titleOriginal.length < 5 || listingText.length < 5) { continue; } rows.push({ agency: 'MIIT', canonicalUrl, titleOriginal, publicationDate, originalText: '', documentNumber: stripHtml(data.filenumbername) || extractDocumentNumber(titleOriginal), }); } } return rows; } export function parseAgencyListing(agency, raw) { const sourceConfig = CHINA_POLICY_SOURCES[agency]; if (!sourceConfig) throw new TypeError(`Unknown agency ${String(agency)}`); if (sourceConfig.listingFormat === 'miit-json') return parseMiitListing(raw, sourceConfig); if (sourceConfig.listingFormat === 'samr-json') { try { const payload = JSON.parse(raw); return parseHtmlListing(agency, String(payload?.data?.html ?? ''), sourceConfig); } catch { return []; } } return parseHtmlListing(agency, raw, sourceConfig); } function parsePolicyHtmlFields(html) { const classPriority = ['article-content', 'main-content', 'TRS_Editor', 'content']; const stack = []; const openTagCounts = new Map(); const captures = new Map(); const candidates = new Map(); const allParts = []; const titleParts = []; const metadata = {}; let bodyCapture = null; let titleDepth = 0; let titleComplete = false; walkHtml(html, { onTag: (tag) => { if (tag.closing) { if (!openTagCounts.has(tag.name)) return; let stackIndex = stack.length - 1; while (stackIndex >= 0 && stack[stackIndex] !== tag.name) stackIndex -= 1; const closingDepth = stackIndex + 1; for (const [priority, capture] of captures) { if (capture.depth !== closingDepth || capture.tagName !== tag.name) continue; const text = normalizeHtmlText(capture.parts); if (text.length >= 10) candidates.set(priority, text); captures.delete(priority); } if ( bodyCapture && bodyCapture.depth === closingDepth && bodyCapture.tagName === tag.name ) { bodyCapture.text = normalizeHtmlText(bodyCapture.parts); } if (tag.name === 'title' && titleDepth > 0) { titleDepth -= 1; if (titleDepth === 0) titleComplete = true; } for (let index = stack.length - 1; index >= stackIndex; index -= 1) { const openName = stack[index]; const remaining = openTagCounts.get(openName) - 1; if (remaining === 0) openTagCounts.delete(openName); else openTagCounts.set(openName, remaining); } stack.length = stackIndex; return; } if (tag.name === 'meta') { const metaName = attributeValue(tag.attributes, 'name')?.toLowerCase(); if ( (metaName === 'articletitle' || metaName === 'pubdate') && !metadata[metaName] ) { metadata[metaName] = normalizeHtmlText([ attributeValue(tag.attributes, 'content') ?? '', ]); } } if (tag.selfClosing || VOID_TAGS.has(tag.name)) return; stack.push(tag.name); openTagCounts.set(tag.name, (openTagCounts.get(tag.name) ?? 0) + 1); if (tag.name === 'title' && !titleComplete) titleDepth += 1; if (tag.name === 'body' && !bodyCapture) { bodyCapture = { depth: stack.length, parts: [], tagName: tag.name, text: '', }; } if (!['div', 'td', 'article'].includes(tag.name)) return; const classes = new Set((attributeValue(tag.attributes, 'class') ?? '').split(/\s+/)); const priority = classPriority.findIndex((className) => classes.has(className)); if (priority === -1 || candidates.has(priority) || captures.has(priority)) return; captures.set(priority, { depth: stack.length, parts: [], tagName: tag.name, }); }, onText: (text) => { allParts.push(text); for (const capture of captures.values()) capture.parts.push(text); if (bodyCapture && !bodyCapture.text) bodyCapture.parts.push(text); if (titleDepth > 0 && !titleComplete) titleParts.push(text); }, }); let originalText = ''; for (let priority = 0; priority < classPriority.length; priority += 1) { if (candidates.has(priority)) { originalText = candidates.get(priority); break; } } return { articleTitle: metadata.articletitle || normalizeHtmlText(titleParts), publicationDate: metadata.pubdate || '', originalText: originalText || bodyCapture?.text || normalizeHtmlText(allParts), }; } export function extractEffectiveDate(text) { const match = String(text).match( /(?:自|于)\s*(20\d{2})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*日(?:起)?(?:施行|实施|生效)/, ); if (!match) return null; const year = match[1]; const month = match[2].padStart(2, '0'); const day = match[3].padStart(2, '0'); return validDay(`${year}-${month}-${day}`); } export function parsePolicyDocumentHtml(html, fallback = {}) { const parsed = parsePolicyHtmlFields(html); const titleOriginal = parsed.articleTitle || fallback.titleOriginal || ''; const publicationDate = validDay(parsed.publicationDate) ?? fallback.publicationDate ?? null; const { originalText } = parsed; return { titleOriginal, publicationDate, originalText, effectiveDate: extractEffectiveDate(originalText), documentNumber: extractDocumentNumber(`${titleOriginal} ${originalText}`), }; } async function readBoundedBody(response, maxBytes) { const declaredLength = Number(response.headers.get('content-length')); if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { throw new Error(`response exceeded ${maxBytes} bytes`); } if (!response.body?.getReader) { const text = await response.text(); if (Buffer.byteLength(text) > maxBytes) throw new Error(`response exceeded ${maxBytes} bytes`); return text; } const reader = response.body.getReader(); const decoder = new TextDecoder(); const chunks = []; let bytes = 0; try { while (true) { const { done, value } = await reader.read(); if (done) break; bytes += value.byteLength; if (bytes > maxBytes) { await reader.cancel(); throw new Error(`response exceeded ${maxBytes} bytes`); } chunks.push(decoder.decode(value, { stream: true })); } chunks.push(decoder.decode()); return chunks.join(''); } finally { reader.releaseLock(); } } async function fetchBounded(url, sourceConfig, maxBytes, fetchImpl, allowedContentTypes) { const parsed = new URL(url); if (parsed.protocol !== 'https:' || parsed.hostname !== sourceConfig.allowedHost) { throw new Error(`disallowed source URL ${parsed.href}`); } const response = await fetchImpl(parsed.href, { headers: { Accept: 'application/json, text/html;q=0.9, */*;q=0.1', 'User-Agent': CHROME_UA, }, redirect: 'error', signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const contentType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase(); if (!contentType || !allowedContentTypes.includes(contentType)) { throw new Error(`unexpected content type ${contentType || 'missing'}`); } return readBoundedBody(response, maxBytes); } function wait(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } export async function fetchAgencyDocuments(agency, { fetchImpl = DEFAULT_FETCH, sleep = wait, } = {}) { const sourceConfig = CHINA_POLICY_SOURCES[agency]; if (!sourceConfig) throw new TypeError(`Unknown agency ${String(agency)}`); const listedAt = new Date().toISOString(); const listing = await fetchBounded( sourceConfig.listUrl, sourceConfig, sourceConfig.maxListBytes, fetchImpl, sourceConfig.listingFormat.endsWith('json') ? ['application/json'] : ['text/html', 'application/xhtml+xml'], ); const candidates = parseAgencyListing(agency, listing) .slice(0, sourceConfig.maxDocumentsPerRun); const documents = []; const failures = []; let requestCount = 1; if (candidates.length === 0) { failures.push({ canonicalUrl: sourceConfig.listUrl, reason: 'listing produced no eligible policy documents', }); } for (const candidate of candidates) { if (candidate.originalText) { documents.push({ ...candidate, effectiveDate: extractEffectiveDate(candidate.originalText), }); continue; } await sleep(sourceConfig.requestCadenceMs); requestCount += 1; try { const detail = await fetchBounded( candidate.canonicalUrl, sourceConfig, sourceConfig.maxDocumentBytes, fetchImpl, ['text/html', 'application/xhtml+xml'], ); const parsed = parsePolicyDocumentHtml(detail, candidate); if ( !parsed.originalText || !parsed.publicationDate || BLOCK_PAGE_PATTERN.test(`${parsed.titleOriginal} ${parsed.originalText.slice(0, 500)}`) ) { throw new Error('document body or date missing'); } documents.push({ ...candidate, ...parsed, documentNumber: parsed.documentNumber ?? candidate.documentNumber, }); } catch (error) { failures.push({ canonicalUrl: candidate.canonicalUrl, reason: error instanceof Error ? error.message : String(error), }); } } return { agency, status: documents.length === 0 ? 'error' : failures.length > 0 ? 'partial' : 'ok', retrievedAt: listedAt, documents, failures, requestCount, }; } export async function fetchAllChinaPolicyDocuments(options = {}) { const results = await Promise.all(Object.keys(CHINA_POLICY_SOURCES).map(async (agency) => { try { return { agency, result: await fetchAgencyDocuments(agency, options), }; } catch (error) { return { agency, error, }; } })); const agencies = {}; const documents = []; let successCount = 0; for (const entry of results) { const { agency } = entry; if (entry.result) { const { result } = entry; agencies[agency] = { status: result.status, retrievedAt: result.retrievedAt, documentCount: result.documents.length, failures: result.failures, requestCount: result.requestCount, }; if (result.documents.length > 0) { successCount += 1; documents.push(...result.documents.map((document) => ({ ...document, retrievedAt: result.retrievedAt, }))); } } else { const { error } = entry; agencies[agency] = { status: 'error', retrievedAt: new Date().toISOString(), documentCount: 0, failures: [{ canonicalUrl: CHINA_POLICY_SOURCES[agency].listUrl, reason: error instanceof Error ? error.message : String(error), }], requestCount: 1, }; } } if (successCount === 0) throw new Error('All official China policy sources failed'); return { agencies, documents }; } export const __testing__ = Object.freeze({ canonicalizeSourceUrl, readBoundedBody, stripHtml, });