Skip to content

getElementSelector() generates invalid selectors for class names containing CSS syntax #361

Description

@LZWcc

Summary

getElementSelector() directly interpolates filtered HTML class names into CSS selectors. However, the current UNSAFE_CSS_CLASS_RE does not cover characters such as @, /, and ..

For example, the following valid HTML class reliably reproduces the problem:

<div hidden class="@container/main">

It produces this selector:

body > div.@container/main

When that selector is passed back to querySelector(), it throws a SyntaxError.

This prevents Defuddle from focusing the intended hidden content and can cause it to return a body-level result that includes unrelated page content.

Environment

  • Defuddle commit: 6de1634
  • Vitest: 3.2.6
  • DOM implementation: jsdom
  • OS: Linux / WSL

Minimal reproduction

Create the following file in the repository:

tests/css-special-class-selector.test.ts

Use this complete test file:

import { describe, expect, test, vi } from 'vitest';
import Defuddle from '../src/index';
import { parseDocument } from './helpers';

const longText = `
This is the main body content of the page. It contains enough meaningful
English prose to look like genuine article content to the extraction algorithm.
We repeat this content several times so that Defuddle has a strong candidate
for the primary content element on the page.
`.repeat(8);

const html = `
	<!DOCTYPE html>
	<html>
		<head><title>Selector reproduction</title></head>
		<body>
			<p>Short visible content.</p>
			<div hidden class="@container/main">
				<div class="group/scroll-root">
					<div class="mb-5.5">
						<h1>Hidden article</h1>
						<p>${longText}</p>
					</div>
				</div>
			</div>
		</body>
	</html>
`;

describe('CSS-special class selector regression', () => {
	test('generated hidden-content selector round-trips through querySelector', () => {
		const doc = parseDocument(html, 'https://example.com/');
		const defuddle = new Defuddle(doc);
		const selector = (defuddle as unknown as {
			findLargestHiddenContentSelector(): string | undefined;
		}).findLargestHiddenContentSelector();

		expect(selector).toBeTruthy();
		expect(() => doc.querySelector(selector!)).not.toThrow();
		expect(doc.querySelector(selector!)).not.toBeNull();
	});

	test('public parse does not silently fall back to the whole body', () => {
		const doc = parseDocument(html, 'https://example.com/');
		const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});

		const result = new Defuddle(doc).parse();
		const errors = consoleError.mock.calls;
		consoleError.mockRestore();

		expect(errors).toEqual([]);
		expect(result.content).toContain('Hidden article');
		expect(result.content).not.toContain('Short visible content.');
	});
});

Run:

DOM=jsdom npx vitest run \
  tests/css-special-class-selector.test.ts \
  --reporter=verbose

Actual behavior

Both tests fail:

Test Files  1 failed
Tests       2 failed

The first test shows that the generated selector cannot be parsed:

SyntaxError:
'body > div.@container/main' is not a valid selector

The second test shows that parse() catches the same error:

Defuddle Error processing document:

SyntaxError {
  message:
    "'body > div.@container/main' is not a valid selector"
}

The result then falls back to body-level content and still contains text that should be outside the extracted article:

Short visible content.

Expected behavior

  • Selectors generated by Defuddle should always be safe to pass to querySelector().
  • Class names such as @container/main, which cannot be interpolated into a CSS selector as-is, should not be appended directly to the selector.
  • The result should focus on Hidden article and should not include Short visible content.
  • Parsing should not log Error processing document.

Root cause

The current code uses:

const UNSAFE_CSS_CLASS_RE = /[:\[\]()#>~+,]/;

It then directly appends every class that does not match this regular expression:

const safe = getClassName(current)
  .trim()
  .split(/\s+/)
  .filter(cls => !UNSAFE_CSS_CLASS_RE.test(cls));

selector += '.' + safe.join('.');

Because @container/main does not match the current regular expression, it is incorrectly treated as safe and produces:

body > div.@container/main

HTML class attributes may contain these characters, but a class being valid in HTML does not mean that it is safe to interpolate unescaped as a CSS identifier.

Relationship to #352 / #358

This problem is related to, but distinct from, #352 and PR #358:

  • Issue #352 / PR #358 handle IDs containing CSS-special characters, such as id="S:a".
  • This issue concerns class names containing CSS-special characters, such as class="@container/main".

The code comment added in PR #358 explicitly states that classes carrying CSS syntax are intentionally dropped rather than escaped. Dropping such classes is therefore a deliberate design choice, but the current filter fails to recognize @container/main as unsafe.

I also ran the same test against the current head of PR #358, 6fdb351. The new contentSelector error handling prevents the exception from propagating, but the generated selector is still body > div.@container/main, and the final result still contains the body-level Short visible content. text.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions