Transparency News

Source code

lib/text.js

// Turns an article page into plain paragraphs, and separates the outlet's own
// words from the words it quotes.

const { decode } = require('./rss');

// Pulls readable paragraphs from an article page. Prefers the <article> block
// when the page has one, drops scripts, navigation and very short lines.
function extractParagraphs(html) {
  if (!html) return [];
  let body = html.replace(/<(script|style|noscript|svg|nav|footer|header|aside|form|figure|figcaption)[\s\S]*?<\/\1>/gi, ' ');
  const art = body.match(/<article[\s>][\s\S]*?<\/article>/i);
  if (art && art[0].length > 2000) body = art[0];
  const paras = [];
  const re = /<p[\s>][\s\S]*?<\/p>/gi;
  let m;
  while ((m = re.exec(body))) {
    const t = decode(m[0]);
    if (t.split(/\s+/).length >= 8 && !/^(advertisement|subscribe|sign up|copyright|©|read more|related:|click here)/i.test(t)) paras.push(t);
  }
  const out = dedupe(paras);
  // Fallback (3.1.0): many sites embed the full story in the page's
  // schema.org data (articleBody), which is part of the page as served.
  if (countWords(out.join(' ')) < 150) {
    const fromData = articleBody(html);
    if (fromData.length && countWords(fromData.join(' ')) > countWords(out.join(' '))) return fromData;
  }
  return out;
}

function articleBody(html) {
  let best = '';
  for (const m of String(html).matchAll(/<script[^>]+application\/ld\+json[^>]*>([\s\S]*?)<\/script>/gi)) {
    try {
      const walk = (x) => {
        if (!x || typeof x !== 'object') return;
        if (Array.isArray(x)) { x.forEach(walk); return; }
        if (typeof x.articleBody === 'string' && x.articleBody.length > best.length) best = x.articleBody;
        for (const v of Object.values(x)) if (v && typeof v === 'object') walk(v);
      };
      walk(JSON.parse(m[1]));
    } catch { /* not valid JSON: skipped */ }
  }
  if (!best) return [];
  const text = decode(best);
  const parts = text.split(/\n{1,}|(?<=[.?!”"])\s{2,}/).map((t) => t.trim()).filter((t) => t.split(/\s+/).length >= 8);
  return dedupe(parts.length ? parts : [text]);
}

function dedupe(list) {
  const seen = new Set();
  return list.filter((p) => (seen.has(p) ? false : (seen.add(p), true)));
}

function countWords(text) {
  const m = String(text || '').match(/[A-Za-z0-9][A-Za-z0-9'’\-]*/g);
  return m ? m.length : 0;
}

const SAID = /\b(said|says|told|asked|wrote|added|stated|declared|according to|tweeted|posted|argued|called it|called the|described|testified|announced)\b/i;

// Removes quoted speech so only the outlet's own words are scored. A short
// quoted phrase (one to three words) with no speaker in the same sentence, within 60 characters, is a
// scare quote (3.1.0): the reporter chose to put it there, so its words stay
// in the outlet's own text and count in full. It is also listed separately.
function ownVoice(text) {
  const scare = [];
  const quotes = [];
  let quotedWords = 0;
  const handle = (match, inner, offset, whole) => {
    const n = countWords(inner);
    if (n >= 1 && n <= 3) {
      // A speaker counts only within the same sentence and 60 characters.
      let left = whole.slice(Math.max(0, offset - 60), offset);
      let right = whole.slice(offset + match.length, offset + match.length + 60);
      left = left.slice(Math.max(left.lastIndexOf('. '), left.lastIndexOf('? '), left.lastIndexOf('! ')) + 1);
      const end = right.search(/[.?!](\s|$)/);
      if (end >= 0) right = right.slice(0, end + 1);
      const near = `${left}${match}${right}`;
      if (!SAID.test(near)) { scare.push(inner.trim()); return ` ${inner.trim()} `; }
    }
    quotedWords += n;
    quotes.push(inner.trim());
    return ' ';
  };
  let out = String(text || '');
  out = out.replace(/"([^"]{1,600})"/g, handle);
  out = out.replace(/“([^”]{1,600})”/g, handle);
  out = out.replace(/‘([^’]{1,300})’(?![a-z])/gi, handle);
  out = out.replace(/(?<![A-Za-z])'([^']{1,300})'(?![A-Za-z])/g, handle);
  return { text: out.replace(/\s+/g, ' ').trim(), scare, quotes, quotedWords };
}

function sentences(text) {
  return String(text || '').split(/(?<=[.?!])\s+(?=[A-Z"“])/).filter((s) => countWords(s) >= 4);
}

module.exports = { articleBody, extractParagraphs, countWords, ownVoice, sentences };