amogaddy's picture
Integra World Monitor (AGPL-3.0, self-hosted) nello Space: pagina, menu, e arricchimento notizie per la AI (part 4)
ee888e1 verified
Raw
History Blame Contribute Delete
21.2 kB
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)) {
const commentEnd = html.indexOf('-->', 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,
});