Skip to content

fix(brief): parse URL pathname in opinion-classifier (backport from PR #3748)#3750

Merged
koala73 merged 1 commit into
mainfrom
feat/brief-opinion-classifier-pathname-backport
May 17, 2026
Merged

fix(brief): parse URL pathname in opinion-classifier (backport from PR #3748)#3750
koala73 merged 1 commit into
mainfrom
feat/brief-opinion-classifier-pathname-backport

Conversation

@koala73

@koala73 koala73 commented May 17, 2026

Copy link
Copy Markdown
Owner

Summary

Sibling backport of PR #3748's pathname-parsing fix to server/_shared/opinion-classifier.js. The original PR closed the tracking-param injection vector in feelgood-classifier.js and explicitly flagged the same latent gap in opinion-classifier.js:93 as a follow-up. Greptile's review of #3748 also called it out.

Mechanism (identical to adv-002 closed in #3748)

Before:
```js
const lowerLink = link.toLowerCase();
if (STRONG_URL_SEGMENTS.some((seg) => lowerLink.includes(seg))) return true;
```
Raw lowerLink.includes('/opinion/') returns true for any URL whose query string OR fragment contains /opinion/ — e.g. ?utm_campaign=/opinion/promo, news.google.com style redirects whose ?url=… carries an /opinion/ section, etc. A hard-news article with that tracking shape silently classifies as opinion and drops from the brief.

After:
```js
const pathname = safePathname(link);
if (pathname && STRONG_URL_SEGMENTS.some((seg) => pathname.includes(seg))) return true;
```
safePathname() runs new URL(link).pathname.toLowerCase() inside try/catch and returns '' on malformed URLs. Match runs against the parsed pathname only — query strings and fragments cannot spoof a section match. Helper mirrors feelgood-classifier.js byte-for-byte.

Two sites fixed

classifyOpinion had the bug at:

  1. STRONG Batch market and RSS fetching with progressive updates #1 URL match (server/_shared/opinion-classifier.js:93) — STRONG_URL_SEGMENTS.some(...)
  2. CORROBORATING URL match (:103) — lowerLink.includes('/analysis/') || lowerLink.includes('/analyses/')

Both now go through the same parsed-pathname check.

Test plan

  • 6 new regression tests in tests/opinion-classifier.test.mjs:
    • Tracking param containing /opinion/ → not STRONG
    • URL fragment containing /commentary/ → not STRONG
    • Tracking param containing /analysis/ → no CORROBORATING contribution
    • Malformed URL → no throw, returns false
    • Regression: genuine /opinion/ pathname WITH ?utm_source=newsletter → still STRONG (fix does not over-correct)
    • Regression: genuine /analysis/ pathname WITH ?ref=twitter → still contributes CORROBORATING
  • All 12 original tests still pass (18 total).
  • biome clean.
  • Broader sweep: 315 tests across opinion + feelgood + ingest + persistence + buildDigest + brief-from-digest-stories + brief-llm — no regressions.

Out of scope

Same scope as PR #3748 — purely the pathname-parse hardening. No signal-list changes, no threshold changes, no telemetry changes. The opinion classifier's behavior on legitimate URLs is unchanged.

🤖 Generated with Claude Code

…#3748)

PR #3748 added the same fix to feelgood-classifier.js and explicitly
documented the latent gap in opinion-classifier.js:93 as a follow-up
backport. Greptile's review of #3748 also flagged this. Closing the
gap now.

Mechanism (same as adv-002 in #3748):
- Pre-fix: `lowerLink.includes('/opinion/')` on the raw link string
  returns true for any URL whose query string OR fragment contains
  "/opinion/" — including legitimate aggregator tracking params like
  `?utm_campaign=/opinion/promo` or news.google.com style redirects
  whose original `?url=...` carries an /opinion/ section. Hard news
  with that tracking shape would silently classify as opinion and
  drop from the brief.
- Fix: `safePathname(link)` runs `new URL(link).pathname.toLowerCase()`
  inside try/catch and returns '' on malformed URLs. Matching is then
  done against the parsed pathname only — query strings and fragments
  cannot spoof a section match. Mirrors the helper in
  feelgood-classifier.js byte-for-byte.

Two sites in classifyOpinion shared the bug:
- STRONG #1 URL match (line 93)
- CORROBORATING URL match (line 103) for /analysis/ + /analyses/

Both now run against the parsed pathname.

6 new regression tests prove the injection vector is closed (tracking
param, URL fragment, malformed URL defensive) AND that the fix does
not over-correct (genuine /opinion/ + genuine /analysis/ with
tracking params still classify correctly). All 12 original tests
still pass.
@vercel

vercel Bot commented May 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
worldmonitor Ready Ready Preview, Comment May 17, 2026 10:54am

Request Review

@greptile-apps

greptile-apps Bot commented May 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR backports the pathname-parsing hardening from feelgood-classifier.js (PR #3748) to opinion-classifier.js, closing the same tracking-param injection vector at both the STRONG URL-segment check and the CORROBORATING /analysis/ check.

  • Core fix: replaces raw lowerLink.includes(seg) with safePathname(link) + pathname.includes(seg), so query strings (?utm_campaign=/opinion/promo) and fragments (#/commentary/footer) can no longer spoof a section match and silently drop a hard-news article from the brief.
  • Tests: 6 new regression tests cover the false-positive paths (tracking param, fragment, combined CORROBORATING) and two regression guards confirming that genuine /opinion/ and /analysis/ pathnames still classify correctly alongside tracking params.

Confidence Score: 4/5

Safe to merge; the fix correctly narrows URL matching to the parsed pathname and the tests cover both the new protection and regressions on legitimate opinion URLs.

The logic change is small and well-contained. The only non-trivial note is that safePathname is now duplicated between opinion-classifier.js and feelgood-classifier.js; a future change to the helper would need to be applied in two places to stay in sync.

server/_shared/opinion-classifier.js — the duplicated safePathname helper is worth extracting to a shared utility before the pattern spreads to additional classifiers.

Important Files Changed

Filename Overview
server/_shared/opinion-classifier.js Adds safePathname() helper and replaces raw lowerLink.includes() with parsed-pathname checks at STRONG #1 and the CORROBORATING /analysis/ branch — closes the tracking-param injection vector. Logic is correct; implementation is byte-for-byte identical to the copy in feelgood-classifier.js.
tests/opinion-classifier.test.mjs Adds 6 targeted regression tests: tracking-param/fragment spoofing (STRONG and CORROBORATING), malformed URL defensive handling, and two regression guards ensuring genuine opinion pathnames still classify correctly.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[classifyOpinion called] --> B[Extract title / link / description]
    B --> C[safePathname link]
    C -->|valid URL| D[pathname = URL.pathname.toLowerCase]
    C -->|malformed / empty| E[pathname = '']
    D --> F{pathname &&\nSTRONG_URL_SEGMENTS\n.some seg in pathname?}
    E --> F
    F -->|yes| G[return true — STRONG #1]
    F -->|no| H{STRONG_HEADLINE_PREFIX_RE.test title?}
    H -->|yes| I[return true — STRONG #2]
    H -->|no| J[corroborating = 0]
    J --> K{isWholeHeadlineQuoted?}
    K -->|yes| L[corroborating += 1]
    K -->|no| M[unchanged]
    L --> N{CORROBORATING_DESCRIPTION_RE.test description?}
    M --> N
    N -->|yes| O[corroborating += 1]
    N -->|no| P[unchanged]
    O --> Q{pathname && pathname includes /analysis/ or /analyses/?}
    P --> Q
    Q -->|yes| R[corroborating += 1]
    Q -->|no| S[unchanged]
    R --> T{corroborating >= 2?}
    S --> T
    T -->|yes| U[return true — CORROBORATING]
    T -->|no| V[return false]
Loading

Reviews (1): Last reviewed commit: "fix(brief): parse URL pathname in opinio..." | Re-trigger Greptile

Comment on lines +80 to +96
/**
* Parse the URL pathname defensively. Malformed URL → empty string
* (skip URL signal entirely; do not throw). Closes the tracking-param
* injection vector — aggregator tracking params (?utm=/opinion/promo)
* and URL fragments (#/opinion/footer) live OUTSIDE the pathname and
* must not trigger STRONG (or CORROBORATING) via raw-string includes()
* matching on the full link. Backport of the same helper added in
* feelgood-classifier.js (PR #3748 / adv-002).
*/
function safePathname(link) {
if (typeof link !== 'string' || link.length === 0) return '';
try {
return new URL(link).pathname.toLowerCase();
} catch {
return '';
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 safePathname is duplicated verbatim between opinion-classifier.js and feelgood-classifier.js. If the helper ever needs a behavioural tweak (e.g. normalising percent-encoding, stripping a trailing slash, or handling protocol-relative URLs), the two copies can silently diverge. Extracting it to a shared utility file server/_shared/url-utils.js and importing it in both classifiers would eliminate the drift risk.

Suggested change
/**
* Parse the URL pathname defensively. Malformed URL empty string
* (skip URL signal entirely; do not throw). Closes the tracking-param
* injection vector aggregator tracking params (?utm=/opinion/promo)
* and URL fragments (#/opinion/footer) live OUTSIDE the pathname and
* must not trigger STRONG (or CORROBORATING) via raw-string includes()
* matching on the full link. Backport of the same helper added in
* feelgood-classifier.js (PR #3748 / adv-002).
*/
function safePathname(link) {
if (typeof link !== 'string' || link.length === 0) return '';
try {
return new URL(link).pathname.toLowerCase();
} catch {
return '';
}
}
// safePathname is defined in server/_shared/url-utils.js and imported
// by both opinion-classifier.js and feelgood-classifier.js so a single
// change keeps both in sync.
import { safePathname } from './url-utils.js';

@koala73
koala73 merged commit 8dc087b into main May 17, 2026
12 checks passed
@koala73
koala73 deleted the feat/brief-opinion-classifier-pathname-backport branch May 17, 2026 10:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant