Files
jackwener__opencli/clis/reuters/utils.js
jakevin da2453cfbd enrich(reuters): article-detail + bug fixes (silent clamp, silent error envelope) (#1362)
* enrich(reuters): article-detail + bug fixes (silent clamp, silent error envelope)

Per WAWQAQ "丰富现有 adapter" pivot — Phase 2 site #2.

- `reuters article-detail` — full article body + canonical metadata for a
  Reuters URL. Pairs with `reuters search` (use the `url` column to
  round-trip).

- **Silent clamp on `--limit` removed**: out-of-range values now raise
  `ArgumentError`. Validation happens before browser navigation.
- **Silent error envelope removed**: the in-page IIFE used to swallow
  `fetch` errors with `catch(e) {}` and return `{error: ...}`, then the node
  side did `if (!Array.isArray(data)) return [];`. Now:
  - in-page IIFE returns `{ ok, status, body, error? }` raw envelope
  - node side throws typed errors:
    - `CommandExecutionError` on in-page exception
    - `CliError(FETCH_ERROR)` on non-2xx upstream
    - `CommandExecutionError` on captcha HTML (200 + non-JSON body)
    - `EmptyResultError` on empty articles array
- **Empty query**: now `ARGUMENT_INVALID` instead of triggering an empty
  upstream call.
- **Column shape enriched**: previously dropped `section_path` and
  `authors` are now stable columns.

- `docs/adapters/browser/reuters.md`: full Commands / Columns / Error
  Behaviour section (was a 3-line stub).
- `docs/adapters/index.md`: add `article-detail` to the commands cell.

27 contract assertions across `parseLimit` / `mapSearchArticles` /
`mapArticleDetail` / `buildSearchScript` / `buildArticleDetailScript`
+ registry-level checks for both commands (Strategy, ARG validation
before nav, every typed-error path, success path).

- typed-error-lint: 196 → 195 (silent-clamp resolved on
  `clis/reuters/search.js:18`); baseline updated.
- silent-column-drop: 103 = 103 (unchanged).
- listing-id-pairing: advisory only (article-detail keys off `url`).

757 → 758 entries (+1 for `article-detail`).

* fix(reuters): type auth and fetch failures

* fix(reuters): preserve search detail round trip
2026-05-06 20:00:36 +08:00

160 lines
6.0 KiB
JavaScript

/**
* Shared helpers for the reuters adapter.
*/
import { ArgumentError } from '@jackwener/opencli/errors';
const MIN_LIMIT = 1;
const MAX_LIMIT = 40;
export function parseLimit(raw, fallback = 10) {
if (raw === undefined || raw === null || raw === '') return fallback;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
throw new ArgumentError(`--limit must be an integer between ${MIN_LIMIT} and ${MAX_LIMIT}, got ${JSON.stringify(raw)}`);
}
if (parsed < MIN_LIMIT || parsed > MAX_LIMIT) {
throw new ArgumentError(`--limit must be between ${MIN_LIMIT} and ${MAX_LIMIT}, got ${parsed}`);
}
return parsed;
}
/**
* Build the in-page IIFE that fetches the Reuters search API.
*
* Returns a raw envelope `{ ok, status, body, error? }` so that error-handling
* lives in node space (not silently swallowed by `catch(e) {}` inside the
* browser).
*/
export function buildSearchScript(query, count) {
return `
(async () => {
const apiQuery = JSON.stringify({
keyword: ${JSON.stringify(query)},
offset: 0,
orderby: 'display_date:desc',
size: ${count},
website: 'reuters'
});
const apiUrl = 'https://www.reuters.com/pf/api/v3/content/fetch/articles-by-search-v2?query=' + encodeURIComponent(apiQuery);
try {
const resp = await fetch(apiUrl, { credentials: 'include' });
const status = resp.status;
const statusText = resp.statusText || '';
const text = await resp.text();
let body = null;
let parseError = null;
if ((text || '').trim()) {
try { body = JSON.parse(text); } catch (e) { parseError = String((e && e.message) || e); }
}
return { ok: resp.ok, status, statusText, body, parseError, textPreview: (text || '').slice(0, 500) };
} catch (e) {
return { ok: false, status: 0, body: null, error: String((e && e.message) || e) };
}
})()
`;
}
/**
* In-page IIFE that walks the Reuters article page DOM and surfaces
* canonical metadata + body text. Returns a raw `{ ok, body, error? }`
* envelope so node-side handling can throw typed errors.
*/
export function buildArticleDetailScript() {
return `
(() => {
try {
const pageText = document.body ? (document.body.innerText || '') : '';
const authRequired = ${looksAuthWallText.toString()}([
window.location.href || '',
document.title || '',
pageText.slice(0, 4000),
].join('\\n')) || Boolean(document.querySelector('[data-testid*="paywall" i], [class*="paywall" i], [id*="paywall" i], [data-testid*="captcha" i], [class*="captcha" i]'));
const meta = document.getElementById('fusion-metadata');
let fusion = null;
if (meta) {
try { fusion = JSON.parse(meta.textContent || '{}'); } catch (e) { fusion = null; }
}
const article = fusion?.globalContent || null;
const paragraphs = Array.from(document.querySelectorAll('article [data-testid^="paragraph-"], article p'))
.map((p) => (p.textContent || '').trim())
.filter(Boolean);
const bodyText = paragraphs.length ? paragraphs.join('\\n\\n') : ((document.querySelector('article')?.innerText || '').trim() || null);
return { ok: true, authRequired, body: { article, bodyText } };
} catch (e) {
return { ok: false, error: String((e && e.message) || e) };
}
})()
`;
}
export function isAuthStatus(status) {
return Number(status) === 401 || Number(status) === 403;
}
export function looksAuthWallText(value) {
const text = String(value ?? '').toLowerCase();
return /datadome|captcha|verify you are human|human verification|access to this page has been denied|unusual traffic|subscribe to continue|subscription required|sign in to (continue|read|access)|log in to (continue|read|access)/.test(text);
}
function pickAuthors(a) {
if (!Array.isArray(a)) return null;
const names = a
.map((au) => (typeof au === 'string' ? au : au?.name || au?.byline || ''))
.map((s) => String(s).trim())
.filter(Boolean);
return names.length ? names.join(', ') : null;
}
function trimOrNull(v) {
const s = String(v ?? '').trim();
return s ? s : null;
}
function dateOnly(v) {
const s = String(v ?? '').trim();
if (!s) return null;
return s.split('T')[0];
}
function articleUrl(canonical) {
if (!canonical) return null;
const c = String(canonical);
if (/^https?:\/\//i.test(c)) return c;
return 'https://www.reuters.com' + c;
}
export function mapSearchArticles(body, limit) {
const articles = body?.result?.articles || body?.articles || [];
if (!Array.isArray(articles)) return [];
return articles
.filter((a) => a && typeof a === 'object')
.slice(0, limit)
.map((a, i) => ({
rank: i + 1,
title: trimOrNull(a.title || a.headlines?.basic),
date: dateOnly(a.display_date || a.published_time),
section: trimOrNull(a.taxonomy?.section?.name),
section_path: trimOrNull(a.taxonomy?.section?.path),
authors: pickAuthors(a.authors),
url: articleUrl(a.canonical_url),
}))
.filter((row) => row.title && row.url);
}
export function mapArticleDetail(article, bodyText, fallbackUrl = null) {
if (!article && !bodyText) return null;
return {
title: trimOrNull(article?.title || article?.headlines?.basic),
date: dateOnly(article?.display_date || article?.published_time),
section: trimOrNull(article?.taxonomy?.section?.name),
section_path: trimOrNull(article?.taxonomy?.section?.path),
authors: pickAuthors(article?.authors),
description: trimOrNull(article?.description?.basic || article?.subheadlines?.basic),
word_count: Number.isFinite(article?.word_count) ? article.word_count : null,
url: articleUrl(article?.canonical_url) || trimOrNull(fallbackUrl),
body: trimOrNull(bodyText),
};
}
export const __test__ = { MIN_LIMIT, MAX_LIMIT };