热点抓取 → 选题 → 框架 → 写作 → SEO → 视觉AI → 排版 → 微信草稿箱, 一句话触发完整流程。适用于 Claude Code skill 格式。 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
197 lines
5.5 KiB
Python
197 lines
5.5 KiB
Python
"""
|
|
Theme system for media-agent.
|
|
|
|
Loads YAML theme definitions and provides CSS parsing utilities
|
|
for the inline style converter.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import cssutils
|
|
import yaml
|
|
|
|
# Suppress cssutils warnings (it's very noisy about non-standard properties)
|
|
cssutils.log.setLevel(logging.CRITICAL)
|
|
|
|
|
|
@dataclass
|
|
class Theme:
|
|
"""A theme definition with colors and base CSS."""
|
|
|
|
name: str
|
|
description: str
|
|
base_css: str
|
|
colors: dict = field(default_factory=dict)
|
|
|
|
|
|
def _default_themes_dir() -> str:
|
|
"""Return the themes/ directory relative to this file."""
|
|
return str(Path(__file__).parent / "themes")
|
|
|
|
|
|
def load_theme(name: str, themes_dir: str = None) -> Theme:
|
|
"""
|
|
Load a theme by name from a YAML file.
|
|
|
|
Args:
|
|
name: Theme name (without .yaml extension).
|
|
themes_dir: Directory containing theme YAML files.
|
|
Defaults to themes/ relative to this file.
|
|
|
|
Returns:
|
|
A Theme object.
|
|
|
|
Raises:
|
|
FileNotFoundError: If the theme YAML file does not exist.
|
|
ValueError: If the YAML is malformed or missing required fields.
|
|
"""
|
|
if themes_dir is None:
|
|
themes_dir = _default_themes_dir()
|
|
|
|
theme_path = os.path.join(themes_dir, f"{name}.yaml")
|
|
if not os.path.exists(theme_path):
|
|
raise FileNotFoundError(f"Theme file not found: {theme_path}")
|
|
|
|
with open(theme_path, "r", encoding="utf-8") as f:
|
|
data = yaml.safe_load(f)
|
|
|
|
if not isinstance(data, dict):
|
|
raise ValueError(f"Invalid theme file: {theme_path}")
|
|
|
|
required = ("name", "description", "base_css", "colors")
|
|
for key in required:
|
|
if key not in data:
|
|
raise ValueError(f"Theme file missing required field '{key}': {theme_path}")
|
|
|
|
return Theme(
|
|
name=data["name"],
|
|
description=data["description"],
|
|
base_css=data["base_css"],
|
|
colors=data.get("colors", {}),
|
|
)
|
|
|
|
|
|
def list_themes(themes_dir: str = None) -> list[str]:
|
|
"""
|
|
List available theme names.
|
|
|
|
Args:
|
|
themes_dir: Directory containing theme YAML files.
|
|
Defaults to themes/ relative to this file.
|
|
|
|
Returns:
|
|
Sorted list of theme names (without .yaml extension).
|
|
"""
|
|
if themes_dir is None:
|
|
themes_dir = _default_themes_dir()
|
|
|
|
if not os.path.isdir(themes_dir):
|
|
return []
|
|
|
|
names = []
|
|
for filename in os.listdir(themes_dir):
|
|
if filename.endswith(".yaml") or filename.endswith(".yml"):
|
|
names.append(filename.rsplit(".", 1)[0])
|
|
|
|
return sorted(names)
|
|
|
|
|
|
def _resolve_css_variables(css_text: str, colors: dict) -> str:
|
|
"""
|
|
Replace var(--xxx) references in CSS with actual color values.
|
|
|
|
Supports var(--primary), var(--secondary), etc. based on the
|
|
colors dict keys. The CSS variable name is mapped by stripping
|
|
the leading --.
|
|
"""
|
|
def replacer(match: re.Match) -> str:
|
|
var_name = match.group(1).strip()
|
|
# Strip leading -- prefix
|
|
key = var_name.lstrip("-")
|
|
# Also try with hyphens converted to underscores
|
|
key_underscore = key.replace("-", "_")
|
|
if key in colors:
|
|
return str(colors[key])
|
|
if key_underscore in colors:
|
|
return str(colors[key_underscore])
|
|
# Return original if not found
|
|
return match.group(0)
|
|
|
|
return re.sub(r"var\(\s*--([a-zA-Z0-9_-]+)\s*\)", replacer, css_text)
|
|
|
|
|
|
def _is_simple_selector(selector: str) -> bool:
|
|
"""
|
|
Check if a selector is simple enough for inline styling.
|
|
|
|
Rejects pseudo-classes, pseudo-elements, media queries,
|
|
and complex combinators.
|
|
"""
|
|
selector = selector.strip()
|
|
|
|
# Reject if contains any of these characters
|
|
reject_chars = (":", "@", ">", "+", "~", "[", "*")
|
|
for ch in reject_chars:
|
|
if ch in selector:
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def get_inline_css_rules(theme: Theme) -> dict[str, dict[str, str]]:
|
|
"""
|
|
Parse a theme's base_css into a selector -> {property: value} dict.
|
|
|
|
This resolves CSS variable references using theme.colors, then
|
|
parses the CSS with cssutils. Only simple selectors are included
|
|
(no pseudo-classes, pseudo-elements, media queries, or complex
|
|
combinators).
|
|
|
|
Args:
|
|
theme: A Theme object with base_css and colors.
|
|
|
|
Returns:
|
|
Dict mapping CSS selectors to dicts of {property: value}.
|
|
Example: {"h1": {"color": "#333", "font-size": "28px"}, ...}
|
|
"""
|
|
# Resolve CSS variables first
|
|
resolved_css = _resolve_css_variables(theme.base_css, theme.colors)
|
|
|
|
# Parse with cssutils
|
|
sheet = cssutils.parseString(resolved_css, validate=False)
|
|
|
|
rules: dict[str, dict[str, str]] = {}
|
|
|
|
for rule in sheet:
|
|
if rule.type != rule.STYLE_RULE:
|
|
continue
|
|
|
|
selector_text = rule.selectorText
|
|
|
|
# A rule can have multiple comma-separated selectors
|
|
selectors = [s.strip() for s in selector_text.split(",")]
|
|
|
|
# Build property dict for this rule
|
|
props: dict[str, str] = {}
|
|
for prop in rule.style:
|
|
props[prop.name] = prop.value
|
|
|
|
if not props:
|
|
continue
|
|
|
|
for selector in selectors:
|
|
if not _is_simple_selector(selector):
|
|
continue
|
|
|
|
if selector in rules:
|
|
# Merge (later rules override)
|
|
rules[selector].update(props)
|
|
else:
|
|
rules[selector] = dict(props)
|
|
|
|
return rules
|