| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100 |
- /** Read the five requested research scopes from one fixed, current generation. */
- import fs from 'node:fs';
- import path from 'node:path';
- import {read, save, load, sha} from './lib/research-enrichment.mjs';
- const work = path.resolve('work/five-topic-collection-20261110');
- const plan = load(path.join(work, 'collection-plan.json'));
- const contract = load(path.join(work, 'contract-before.json'));
- const directory = path.join(work, 'library-pages');
- fs.mkdirSync(directory, {recursive: true});
- const stateFile = path.join(work, 'library-retrieval-state.json');
- const state = {startedAt: new Date().toISOString(), generation: contract.generation, pid: process.pid,
- status: 'running', topics: {}, productionWrites: 0};
- const alive = pid => {try {process.kill(pid, 0); return true;} catch {return false;}};
- if (fs.existsSync(stateFile)) {
- const previous = load(stateFile);
- if (previous.status === 'running' && alive(previous.pid)) throw Error('TOPIC_RETRIEVER_ALREADY_RUNNING');
- }
- const record = () => {state.updatedAt = new Date().toISOString(); save(stateFile, state);};
- const inScope = row => {
- if (['raw_noodle', 'sauce', 'other_food'].includes(row.category)) return false;
- if (row.entity === 'social' && row.content_scope && row.content_scope !== 'packaged_noodles') return false;
- if (row.entity === 'products') {
- const title = row.title || '';
- if (/挂面|生面条|鲜面条|鲜湿面|手擀面粉|面粉|拌面酱|拌面汁|堂食/.test(title)) return false;
- if (!/方便面|泡面|速食|即食|拉面|拌面|捞面|汤面|热干面|乌冬面|干脆面|杯面|桶面|碗面|面皮|凉皮|火鸡面|面饼/.test(title)) return false;
- }
- return true;
- };
- async function retrieve(topic) {
- const rows = [], seen = new Set();
- let cursor, page = 0, totalMatched;
- const progress = state.topics[topic.id] = {name: topic.name, pages: 0, retrieved: 0, status: 'running'};
- const groups = topic.keywords.slice(0, 20);
- do {
- page++;
- const file = path.join(directory, topic.id + '-' + String(page).padStart(3, '0') + '.json');
- const input = {entities: ['products', 'reviews', 'social'], quality: 'audit', limit: 100,
- text: {all: [{any: groups}]}, ...(cursor ? {cursor} : {})};
- const querySha256 = sha(Buffer.from(JSON.stringify({input, generation: contract.generation})));
- let result;
- if (fs.existsSync(file)) {
- const cached = load(file);
- if (cached.querySha256 === querySha256 && cached.result?.generation === contract.generation) result = cached.result;
- }
- if (!result) {
- result = await read('search', {input, generation: contract.generation});
- save(file, {querySha256, input, receivedAt: new Date().toISOString(), result});
- }
- if (result.generation !== contract.generation) throw Error('GENERATION_CHANGED');
- if (totalMatched === undefined) totalMatched = result.totalMatched;
- if (result.totalMatched !== totalMatched) throw Error('TOPIC_COUNT_CHANGED');
- for (const row of result.records) {
- const key = row.entity + ':' + row.record_id;
- if (seen.has(key)) throw Error('DUPLICATE_CURSOR_ROW');
- seen.add(key); rows.push(row);
- }
- progress.pages = page; progress.retrieved = rows.length; progress.totalMatched = totalMatched; record();
- cursor = result.nextCursor;
- } while (cursor);
- if (rows.length !== totalMatched) throw Error('TOPIC_CURSOR_INCOMPLETE');
- const accepted = [], excluded = [];
- for (const row of rows) {
- let reason;
- if (!inScope(row)) reason = 'OUTSIDE_PACKAGED_NOODLE_SCOPE';
- else if (row.entity === 'reviews' && !(row.match?.matchedTerms || []).length) reason = 'PRODUCT_TITLE_MATCH_ONLY_NOT_REVIEW_BODY';
- if (reason) excluded.push({entity: row.entity, id: row.record_id, reason});
- else accepted.push(row);
- }
- const counts = {};
- for (const row of accepted) counts[row.entity] = (counts[row.entity] || 0) + 1;
- const reviews = accepted.filter(r => r.entity === 'reviews');
- const reviewText = new Set(reviews.map(r => r.content_fingerprint));
- Object.assign(progress, {status: 'complete', accepted: accepted.length, counts,
- distinctReviewTexts: reviewText.size, excluded: excluded.length});
- save(path.join(work, 'library-' + topic.id + '.json'), {schema: 'ksf.five-topic.existing-library.v1',
- topic, generation: contract.generation, checkedAt: new Date().toISOString(), upstreamNewCollection: false,
- allPagesRetrieved: true, rawCandidates: totalMatched, counts,
- sourceIdsPresent: accepted.every(r => r.source_record_id && r.citation?.assetSha256),
- distinctReviewTexts: reviewText.size, records: accepted, excluded,
- limits: ['字面关键词候选;不证明功效、收入阶层或真实购买人群',
- '评价仅计正文或追评命中,商品标题带场景词不当成评价证言',
- '社媒与电商分开,不将相同原文记录当作独立消费者',
- '中式养生中的食材词不自动代表具有保健功效']});
- record();
- console.log(JSON.stringify({topic: topic.name, pages: page, counts, excluded: excluded.length}));
- }
- try {
- record();
- let next = 0;
- await Promise.all(Array.from({length: 2}, async () => {
- while (next < plan.topics.length) {const topic = plan.topics[next++]; await retrieve(topic);}
- }));
- const after = await read('contract');
- save(path.join(work, 'contract-after-library-retrieval.json'), after);
- if (after.generation !== contract.generation) throw Error('GENERATION_CHANGED');
- state.status = 'complete'; state.finishedAt = new Date().toISOString(); record();
- } catch (error) {
- state.status = 'failed'; state.errorCode = /^[A-Z][A-Z0-9_:.-]+$/.test(error.message) ? error.message : 'TOPIC_READ_FAILED';
- record(); console.error(JSON.stringify({ok: false, errorCode: state.errorCode})); process.exitCode = 1;
- }
|