Files
2026-03-27 20:58:42 +08:00

342 lines
16 KiB
Python

import xml.etree.ElementTree as ET
from datetime import datetime, timedelta, timezone
import os
import json
from core.content_format import format_content
class RSS:
cache_dir = os.path.normpath("data/cache/rss")
content_cache_dir = os.path.normpath("data/cache/content")
rss_file="all"
def __init__(self, name:str="all",cache_dir: str = None,ext:str="rss"):
if cache_dir is not None:
self.cache_dir = cache_dir
self.ext=ext
os.makedirs(self.cache_dir, exist_ok=True)
os.makedirs(self.content_cache_dir, exist_ok=True)
normalized_path = os.path.normpath(f"{self.cache_dir}/{name}.{ext}")
if not normalized_path.startswith(self.cache_dir):
raise ValueError("Invalid file path: Path traversal detected.")
self.rss_file = normalized_path
pass
def get_type(self):
if self.ext in ["rss","atom","md","txt"]:
return "application/xml"
if self.ext=="json":
return "application/json"
return "text/plain"
def cache_content(self, content_id: str, content: dict):
"""缓存文章内容"""
content["content"]=self.add_logo_prefix_to_urls(content["content"])
content_path = os.path.normpath(f"{self.content_cache_dir}/{content_id}.json")
if not content_path.startswith(self.content_cache_dir):
raise ValueError("Invalid content path: Path traversal detected.")
with open(content_path, "w", encoding="utf-8") as f:
json.dump(content, f, ensure_ascii=False, indent=2)
def get_cached_content(self, content_id: str) -> dict:
"""获取缓存的文章内容"""
content_path = os.path.normpath(f"{self.content_cache_dir}/{content_id}.json")
if not content_path.startswith(self.content_cache_dir):
raise ValueError("Invalid content path: Path traversal detected.")
try:
with open(content_path, "r", encoding="utf-8") as f:
return json.load(f)
except FileNotFoundError:
return None
def serialize_datetime(self,obj):
if isinstance(obj, datetime):
return obj.isoformat
return obj
def datetime_to_rfc822(self, dt) -> str:
"""将datetime对象或时间字符串转换为RFC 822格式的时间字符串
Accepts either a datetime object or an ISO format string. If the
datetime is naive (no tzinfo), assume CST (UTC+8) so that '%z' is
populated (e.g. +0800).
"""
# Ensure we have a datetime object
if isinstance(dt, str):
# datetime.fromisoformat can raise; let it propagate for invalid input
try:
dt_obj = datetime.fromisoformat(dt)
except Exception:
# Fallback: try to parse common fallback formats
dt_obj = datetime.fromisoformat(dt.replace('Z', '+00:00'))
elif isinstance(dt, datetime):
dt_obj = dt
else:
# Last-resort: convert to str then parse
dt_obj = datetime.fromisoformat(str(dt))
# If datetime is naive, attach CST (UTC+8)
if dt_obj.tzinfo is None:
cst = timezone(timedelta(hours=8))
dt_obj = dt_obj.replace(tzinfo=cst)
return dt_obj.strftime('%a, %d %b %Y %H:%M:%S %z')
def add_logo_prefix_to_urls(self, text: str) -> str:
"""在字符串中所有http/https开头的图片URL前添加/static/res/logo/前缀
Args:
text: 包含URL的原始字符串
Returns:
处理后的字符串,所有图片URL前添加了前缀
"""
import re
# try:
# pattern = re.compile(r'(<img[^>]*src=["\'])(?!\/static\/res\/logo\/)([^"\']*)', re.IGNORECASE)
# return pattern.sub(r'\1/static/res/logo/\2', text)
# except:
# return text
return text
def generate_rss(self,rss_list: dict, title: str = "Mp-We-Rss",
link: str = "https://github.com/rachelos/we-mp-rss",
description: str = "RSS频道", language: str = "zh-CN",image_url:str=""):
from core.config import cfg
full_context=bool(cfg.get("rss.full_context",False))
# 创建根元素(RSS标准)
rss = ET.Element("rss", version="2.0")
if full_context==True:
rss.attrib["xmlns:content"] = "http://purl.org/rss/1.0/modules/content/"
channel=ET.SubElement(rss, "channel")
# 设置渠道信息
ET.SubElement(channel, "title").text = title
ET.SubElement(channel, "link").text = link
ET.SubElement(channel, "description").text = description
ET.SubElement(channel, "language").text = language
ET.SubElement(channel, "generator").text = "Mp-We-Rss"
# Use timezone-aware now (CST/UTC+8) so %z shows +0800
ET.SubElement(channel, "lastBuildDate").text = datetime.now(timezone(timedelta(hours=8))).strftime("%a, %d %b %Y %H:%M:%S %z")
# 设置image子项
if cfg.get("rss.add_cover",False)==True and image_url != "":
image = ET.SubElement(channel, "image")
ET.SubElement(image, "url").text = image_url
ET.SubElement(image, "title").text = title
ET.SubElement(image, "link").text = link
for rss_item in rss_list:
item = ET.SubElement(channel, "item")
ET.SubElement(item, "id").text = rss_item["id"]
ET.SubElement(item, "title").text = rss_item["title"]
ET.SubElement(item, "description").text = rss_item["description"]
ET.SubElement(item, "guid").text = rss_item["link"]
# 添加图片封面
if cfg.get("rss.add_cover",False)==True:
enclosure = ET.SubElement(item, "enclosure")
enclosure.set("url", rss_item["image"])
enclosure.set("length", "0")
enclosure.set("type", "image/jpeg")
if full_context==True:
try:
if cfg.get("rss.cdata",False)==True:
content = f"<![CDATA[{str(rss_item['content'])}]]>" # 使用CDATA包裹内容
else:
content = str(rss_item['content'])
ET.SubElement(item, "content:encoded").text = content
except Exception as e:
print(f"Error adding content:encoded element: {e}")
pass
# ET.SubElement(item, "category").text = rss_item["category"]
# ET.SubElement(item, "author").text = rss_item["author"]
ET.SubElement(item, "link").text = rss_item["link"]
ET.SubElement(item, "pubDate").text = self.datetime_to_rfc822(str(rss_item["updated"]))
# 生成XML字符串(添加声明和美化输出)
tree_str = '<?xml version="1.0" encoding="utf-8"?>\r\n' + \
ET.tostring(rss, encoding="utf-8", method="xml", short_empty_elements=False).decode("utf-8")
if self.rss_file is not None:
with open(self.rss_file, "w", encoding="utf-8") as f:
f.write(tree_str)
return tree_str
def generate_atom(self,rss_list: dict, title: str = "Mp-We-Rss",
link: str = "https://github.com/rachelos/we-mp-rss",
description: str = "RSS频道", language: str = "zh-CN",image_url:str="") -> str:
"""生成Atom格式的RSS内容
Args:
rss_list: RSS条目列表
title: 频道标题
link: 频道链接
description: 频道描述
language: 语言
Returns:
Atom格式的XML字符串
"""
from core.config import cfg
full_context = bool(cfg.get("rss.full_context", False))
# 创建根元素(Atom标准)
feed = ET.Element("feed", xmlns="http://www.w3.org/2005/Atom")
if full_context==True:
feed.attrib["xmlns:content"] = "http://purl.org/rss/1.0/modules/content/"
ET.SubElement(feed, "title").text = title
ET.SubElement(feed, "link",rel="alternate", href=link)
ET.SubElement(feed, "link",rel="icon", href=image_url)
ET.SubElement(feed, "logo").text=str(image_url)
ET.SubElement(feed, "icon").text=str(image_url)
# Use timezone-aware now (CST/UTC+8) so %z shows +0800
ET.SubElement(feed, "updated").text = datetime.now(timezone(timedelta(hours=8))).strftime("%a, %d %b %Y %H:%M:%S %z")
ET.SubElement(feed, "id").text = str(link)
ET.SubElement(feed, "author").text = "Mp-We-Rss"
# 设置image子项
if cfg.get("rss.add_cover",False)==True and image_url != "":
image = ET.SubElement(feed, "image")
ET.SubElement(image, "url").text = str(image_url)
ET.SubElement(image, "title").text = str(title)
ET.SubElement(image, "link").text = str(link)
for rss_item in rss_list:
entry = ET.SubElement(feed, "entry")
ET.SubElement(entry, "id").text = rss_item["id"]
ET.SubElement(entry, "title").text = str(rss_item["title"])
ET.SubElement(entry, "link", href=str(rss_item["link"]))
ET.SubElement(entry, "updated").text =self.datetime_to_rfc822(str(rss_item["updated"]))
ET.SubElement(entry, "summary").text = str(rss_item["description"])
ET.SubElement(entry, "author").text = str(rss_item["mp_name"])
# 添加图片封面
if cfg.get("rss.add_cover",False)==True:
enclosure = ET.SubElement(entry, "enclosure")
enclosure.set("url", str(rss_item["image"]))
enclosure.set("length", "0")
enclosure.set("type", "image/jpeg")
if full_context:
type=self.get_content_type()
# content = ET.SubElement(entry, "content", type=f"{str(type)}")
# content.text = format_content(rss_item["content"],type)
content=format_content(rss_item["content"],type)
try:
if cfg.get("rss.cdata",False)==True:
content = f"<![CDATA[{content}]]>" # 使用CDATA包裹内容
else:
ET.SubElement(entry, "content:encoded").text = content
except Exception as e:
print(f"Error adding content:encoded element: {e}")
pass
# 生成XML字符串
tree_str = '<?xml version="1.0" encoding="utf-8"?>\r\n' + \
ET.tostring(feed, encoding="utf-8", method="xml").decode("utf-8")
if self.rss_file is not None:
with open(self.rss_file, "w", encoding="utf-8") as f:
f.write(tree_str)
return tree_str
def set_content_type(self,type:str=None):
self.content_type=type
def get_content_type(self)->str:
ext=self.ext
if ext in("atom","xml","json","markdown"):
return "html",
elif ext in("md","jmd"):
return "markdown"
elif ext in("txt"):
return "text"
return "html"
def generate_json(self, rss_list: dict,title: str = "Mp-We-Rss",
link: str = "https://github.com/rachelos/we-mp-rss",
description: str = "RSS频道", language: str = "zh-CN",image_url:str="") -> str:
"""获取JSON格式的RSS内容
Args:
rss_list: RSS条目列表
Returns:
JSON格式的字符串
"""
type=self.get_content_type()
result = {
"name":title,
"link":link,
"description":description,
"language": language,
"cover":image_url,
"items": [
{
"id": item["id"],
"title": item["title"],
"description": item["description"],
"link": item["link"],
"updated": item["updated"].isoformat() if isinstance(item["updated"], datetime) else item["updated"],
"content": format_content(item["content"],type),
"channel_name": item.get("mp_name", ""),
"feed": item.get("feed")
} for item in rss_list
]
}
return json.dumps(result, ensure_ascii=False, indent=2, default=self.serialize_datetime)
def get_cache(self):
if not hasattr(self, 'rss_file') or not self.rss_file:
return None
try:
with open(self.rss_file, "r", encoding="utf-8") as f:
return f.read()
except FileNotFoundError:
return None
def generate(self,rss_list: dict,ext=str, title: str = "Mp-We-Rss",
link: str = "https://github.com/rachelos/we-mp-rss",
description: str = "RSS频道", language: str = "zh-CN",image_url:str="",template:str=None) -> str:
"""根据扩展名获取对应格式的RSS内容
Args:
rss_list: RSS条目列表
ext: 文件扩展名(.rss/.xml/.atom/.json)
**kwargs: 传递给各格式生成方法的参数
Returns:
对应格式的字符串
Raises:
ValueError: 当扩展名不支持时
"""
ext = ext.lower().strip('.')
self.ext=ext
if ext in ('rss', 'xml'):
return self.generate_rss(rss_list, title=title, link=link, description=description,language=language,image_url=image_url)
elif ext in ('atom','md','txt'):
return self.generate_atom(rss_list, title=title, link=link, description=description,language=language,image_url=image_url)
elif ext in ('json','jmd'):
return self.generate_json(rss_list, title=title, link=link, description=description,language=language,image_url=image_url)
elif template is not None:
return self.generate_by_template(rss_list,template, title=title, link=link, description=description,language=language,image_url=image_url)
else:
raise ValueError(f"Unsupported extension: {ext}")
def generate_by_template(self,rss_list: dict, template: str, title: str = "Mp-We-Rss",link: str = "https://github.com/rachelos/we-mp-rss",description: str = "RSS频道",language: str = "zh-CN",image_url:str=""):
from core.lax import TemplateParser
template = TemplateParser(template)
return template.render({"articles": rss_list, "title": title,"link":link,"description":description,"language":language,"image_url":image_url})
pass
def clear_cache(self,mp_id:str=""):
"""清除所有缓存文件
删除data/cache/rss和data/cache/content目录中包含'mp_id'的文件
保持与现有方法相同的路径安全检查机制
"""
import shutil
# 清除rss缓存目录
if os.path.exists(self.cache_dir):
for filename in os.listdir(self.cache_dir):
if f'{mp_id}_' not in filename:
continue
file_path = os.path.normpath(f"{self.cache_dir}/{filename}")
if not file_path.startswith(self.cache_dir):
raise ValueError("Invalid file path: Path traversal detected.")
try:
if os.path.isfile(file_path):
os.unlink(file_path)
except Exception as e:
print(f"Error deleting {file_path}: {e}")