Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions css_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import sys
from unittest.mock import MagicMock
try:
from redbot.resource.link_parse import CSSLinkParser
except ImportError:
# Set path if needed, but running from root should work if installed or in pythonpath
import os
sys.path.append(os.getcwd())
from redbot.resource.link_parse import CSSLinkParser

# Mock dependencies
class MockMessage:
def __init__(self):
self.headers = MagicMock()
self.headers.parsed = {"content-type": ["text/css"]}
self.character_encoding = "utf-8"
self.base_uri = "http://example.com/style.css"

def link_proc(base, link, tag, title):
print(f"Found link: {link} (tag: {tag})")

msg = MockMessage()
parser = CSSLinkParser(msg, [link_proc])

css_content = """
@import "imported.css";
@import url('imported2.css');
body {
background-image: url('bg.png');
}
"""


print("Feeding CSS content...")
parser.feed(css_content)

# Inject debugging
import tinycss2
rules = tinycss2.parse_stylesheet(css_content, skip_comments=True, skip_whitespace=True)

def print_structure(items, indent=0):
for item in items:
print(" " * indent + str(type(item)) + ": " + str(item))
if hasattr(item, 'prelude'):
print(" " * indent + " Prelude:")
print_structure(item.prelude, indent + 2)
if hasattr(item, 'content') and item.content:
print(" " * indent + " Content:")
print_structure(item.content, indent + 2)
if hasattr(item, 'arguments'):
print(" " * indent + " Arguments:")
print_structure(item.arguments, indent + 2)

print("--- Parsed Structure ---")
print_structure(rules)
print("------------------------")

print("Closing parser...")
parser.close()
print("Done.")
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ dependencies = [
"thor >= 0.12.5",
"typing-extensions >= 4.8.0",
"Babel >= 2.14.0",
"tinycss2 >= 1.3.0",
]

[project.urls]
Expand Down
23 changes: 15 additions & 8 deletions redbot/resource/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from redbot.resource.active_check import active_checks



class HttpResource(RedFetcher):
"""
Given a URI (optionally with method, request headers and content), examine the URI for issues
Expand Down Expand Up @@ -55,17 +56,22 @@ def __init__(self, config: SectionProxy, descend: bool = False) -> None:
self.links: Dict[str, Set[str]] = {}
self.link_count: int = 0
self.linked: List[Tuple[HttpResource, str]] = [] # linked HttpResources
self._link_parser = link_parse.HTMLLinkParser(
self.response, [self.process_link]
)
self._link_parsers: List[link_parse.LinkParser] = [
link_parse.HTMLLinkParser(self.response, [self.process_link]),
link_parse.CSSLinkParser(self.response, [self.process_link]),
]
if self.descend or config.getboolean("content_links", False):
self.response_content_processors.append(self._link_parser.feed_bytes)
for parser in self._link_parsers:
self.response_content_processors.append(parser.feed_bytes)

def run_active_checks(self) -> None:
"""
Response is available; perform subordinate requests (e.g., conneg check).
"""
if self.response.complete:
if self.descend or self.config.getboolean("content_links", False):
for parser in self._link_parsers:
parser.close()
for active_check in list(self.subreqs.values()):
self.add_check(active_check)
active_check.check()
Expand All @@ -76,10 +82,11 @@ def descendable(self) -> bool:
"""
Return whether this resource can be descended.
"""
return (
self.response.headers.parsed.get("content-type", [None])[0]
in self._link_parser.link_parseable_types
)
content_type = self.response.headers.parsed.get("content-type", [None])[0]
for parser in self._link_parsers:
if content_type in parser.link_parseable_types:
return True
return False

def add_check(self, *resources: RedFetcher) -> None:
"Remember a subordinate check on one or more HttpResource instance."
Expand Down
110 changes: 109 additions & 1 deletion redbot/resource/link_parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,26 @@
import codecs

from html.parser import HTMLParser
from typing import Optional, Any, Callable, Dict, List, Tuple
from typing import Optional, Any, Callable, Dict, List, Tuple, Protocol

from httplint.field.utils import split_string, unquote_string
from httplint.message import HttpMessageLinter
from httplint.syntax import rfc9110
import tinycss2 # type: ignore


DEFAULT_ENCODING = "utf-8"


class LinkParser(Protocol):
"""
Protocol for link parsers.
"""
link_parseable_types: List[str]
def feed_bytes(self, bchunk: bytes) -> None: ...
def close(self) -> None: ...


class HTMLLinkParser(HTMLParser):
"""
Parse the links out of an HTML document in a very forgiving way.
Expand Down Expand Up @@ -132,6 +142,7 @@ def handle_starttag(self, tag: str, attrs: List[Tuple[str, Optional[str]]]) -> N
except LookupError:
pass


def error(self, message: str) -> None:
self.errors += 1
if self.getpos() == self.last_err_pos:
Expand All @@ -144,6 +155,103 @@ def error(self, message: str) -> None:
if self.err:
self.err(message)

def close(self) -> None:
if self.ok:
HTMLParser.close(self)


class CSSLinkParser:
"""
Parse links out of a CSS document.
"""

link_parseable_types = [
"text/css",
]

def __init__(
self,
message: HttpMessageLinter,
link_procs: List[Callable[[str, str, str, str], None]],
err: Optional[Callable[[str], int]] = None,
) -> None:
self.message = message
self.link_procs = link_procs
self.err = err
self.ok = True
self._buffer = ""

def feed_bytes(self, bchunk: bytes) -> None:
if self.ok:
encoding = self.message.character_encoding or DEFAULT_ENCODING
try:
decoded = bchunk.decode(encoding, "ignore")
self.feed(decoded)
except LookupError:
pass

def feed(self, data: str) -> None:
if not self.ok:
return
if (
self.message.headers.parsed.get("content-type", [None])[0]
in self.link_parseable_types
):
self._buffer += data
else:
self.ok = False

def close(self) -> None:
if not self.ok or not self._buffer:
return

try:
rules = tinycss2.parse_stylesheet(
self._buffer, skip_comments=True, skip_whitespace=True
)
self._process_rules(rules)
except Exception as why: # pylint: disable=broad-except
if self.err:
self.err(f"CSS parse problem: {why}")

def _process_rules(self, rules: List[Any]) -> None:
for rule in rules:
if rule.type == "at-rule":
if rule.lower_at_keyword == "import":
# @import "url" ...;
# @import url("url") ...;
self._process_import_prelude(rule.prelude)
if rule.content:
self._process_rules(rule.content)
elif rule.type == "qualified-rule":
if rule.content:
self._scan_for_urls(rule.content)

def _process_import_prelude(self, tokens: List[Any]) -> None:
for token in tokens:
if token.type == "string":
self._emit(token.value, "css_import")
elif token.type == "url":
self._emit(token.value, "css_import")
elif token.type == "function" and token.lower_name == "url":
self._process_url_function(token, "css_import")

def _scan_for_urls(self, tokens: List[Any]) -> None:
for token in tokens:
if token.type == "url":
self._emit(token.value, "css_resource")
elif token.type == "function" and token.lower_name == "url":
self._process_url_function(token, "css_resource")

def _process_url_function(self, token: Any, tag: str) -> None:
for arg in token.arguments:
if arg.type == "string":
self._emit(arg.value, tag)

def _emit(self, link: str, tag: str) -> None:
for proc in self.link_procs:
proc(self.message.base_uri, link, tag, "")


class BadErrorIReallyMeanIt(Exception):
"""See http://bugs.python.org/issue8885 for why this is necessary."""
Loading