// Variables used by Scriptable. // These must be at the very top of the file. Do not edit. // icon-color: yellow; icon-glyph: newspaper; // ============================================== // Scriptable Widget:财联社电报 // Author: YT // RSS Source: Zhihai Liao (https://github.com/hillerliao) // Version: 1.3.3 // Date: 2025.1.14 // Description: Display newest Cailian Press Telegraphs. // ============================================== /* ================= 可自定义部分 ================= */ // 脚本名称 const WIDGET_NAME = Script.name() || "财联社电报"; // RSS源地址 const RSS_URL = "https://pyrsshub.vercel.app/cls/telegraph/"; //const RSS_URL = "https://www.cls.cn/nodeapi/telegraphList"; // 预览尺寸设置 const PREVIEW_SIZE = "large"; // 可选值:"medium" | "large" // 显示设置 const ROW_SPACING = 3; // 行间距 const FONT_SIZE = 12; // 字体大小 const WIDGET_WIDTH = 330; // 信息展示宽度,根据手机屏幕大小调整到合适的数值 const TIME_WIDTH = 44; // 时间区域宽度,若时间显示不完整,调高此数值 const DISPLAY_MODE = "dark"; // 展示模式,可选值:深色模式"dark", 浅色模式"light", 自动切换模式"auto" // 根据小组件尺寸的不同,定义不同的配置 const CONFIG = { LARGE: { MAX_LINES: 17, // 最大显示行数 MAX_TITLE_LINES: 3 // 每条新闻标题的最大行数 }, MEDIUM: { MAX_LINES: 6, MAX_TITLE_LINES: 3 }, SMALL: { MESSAGE: "仅适配中大尺寸" // 小尺寸不支持显示内容时的提示信息 } }; // 缓存方式配置 const CACHE_METHOD = "FileManager"; // 可选值:"Keychain" | "FileManager" // 缓存配置 const CACHE_CONFIG = { method: CACHE_METHOD, status: "unloaded", keychain: { cacheKey: "CLS_TELEGRAPH_CACHE", // Keychain缓存键名 version: 1 // Keychain缓存版本 }, fileManager: { storageType: "local", // 可选值:"iCloud" | "local" cacheFileName: `${WIDGET_NAME}数据缓存.json`, // FileManager缓存文件名 version: 1 // FileManager缓存版本 } }; // 字符宽度 const CHARS_WIDTH = { chinese: 1, // 汉字宽度 englishcap: 0.685, // 英文字母宽度 english: 0.536, number: 0.613, // 数字宽度 others: 0.3, // 其他字符宽度 }; // Logo 配置 const ICON = { // 您可以自行替换为您的Logo图片链接 url: "https://is1-ssl.mzstatic.com/image/thumb/Purple211/v4/d8/03/5d/d8035d9a-bd09-dc3a-6aa4-fe29a805568e/AppIcon-0-0-1x_U007epad-0-0-0-sRGB-85-220.png/434x0w.webp", cacheName: `${WIDGET_NAME}图标缓存.webp` // 本地缓存Logo的文件名 }; /* ================= 工具函数部分 ================== */ /** * 解析 Atom Feed,提取标题、发布时间、正文,并过滤掉不需要的新闻 * @param {string} rssText - RSS源的XML文本内容 * @returns {Array<{title:string, pubDate:string, content:string}>} - 解析后的新闻条目数组 */ function parseRSS(rssText) { // 找到所有 ... 块 const entryRegex = /([\s\S]*?)<\/entry>/g; let items = []; let match; // 要过滤掉的关键词 const excludeKeywords = ["盘中宝","机构调研","财联社早知道","风口研报","机构龙虎榜解读","风口专家会议","金牌纪要库","电报解读","大佬持仓跟踪","九点特供","点金互动易","寻找年报预期差","公告全知道","研选","掘金行业龙头","季报高增长"]; while ((match = entryRegex.exec(rssText)) !== null) { const entryBlock = match[1]; // 1. 解析标题 ... const titleMatch = entryBlock.match( /(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/title>/i ); // 2. 解析发布时间 <published>...</published> const pubDateMatch = entryBlock.match( /<published>([\s\S]*?)<\/published>/i ); // 3. 解析正文 <content>...</content> const contentMatch = entryBlock.match( /<content[^>]*>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?<\/content>/i ); // 如果 title & pubDate & content 存在,则将它们加入结果 if (titleMatch && pubDateMatch && contentMatch) { const title = titleMatch[1].trim(); const pubDate = pubDateMatch[1].trim(); const content = contentMatch[1].trim(); // 新功能:将内容中的空格替换为逗号 // content = content.replace(/\s+/g, ","); // 检查标题中是否包含需要过滤的关键词 const isExcluded = excludeKeywords.some(keyword => title.includes(keyword)); if (!isExcluded) { items.push({ title, pubDate, content }); } } } return items; } /** * 拉取 RSS 数据 * @returns {Promise<Array<{title:string, pubDate:string}>>} - 返回新闻条目数组 */ async function fetchRSSData() { const req = new Request(RSS_URL); req.timeoutInterval = 10; const resp = await req.loadString(); const items = parseRSS(resp); if (!items || items.length === 0) { throw new Error("从RSS解析出0条新闻"); } return items; } /** * 加载缓存数据 * @returns {Array<{title:string, pubDate:string}>} - 缓存的新闻条目 */ function loadCache() { const CACHE_VALIDITY = 24 * 60 * 60 * 1000; // 缓存有效期:24小时 if (CACHE_CONFIG.method === "Keychain") { // 使用Keychain加载缓存 if (Keychain.contains(CACHE_CONFIG.keychain.cacheKey)) { try { const json = Keychain.get(CACHE_CONFIG.keychain.cacheKey); const { version, timestamp, data } = JSON.parse(json); if (version !== CACHE_CONFIG.keychain.version || (Date.now() - timestamp > CACHE_VALIDITY)) { console.warn("缓存已过期或版本不匹配,弃用缓存"); return []; } if (Array.isArray(data)) return data; } catch (e) { console.warn("解析Keychain缓存失败:", e); } } return []; } else if (CACHE_CONFIG.method === "FileManager") { // 使用FileManager加载缓存 const fm = getFileManager(CACHE_CONFIG.fileManager.storageType); const cachePath = getCachePath(); if (fm.fileExists(cachePath)) { try { const json = fm.readString(cachePath); const { version, timestamp, data } = JSON.parse(json); if (version !== CACHE_CONFIG.fileManager.version || (Date.now() - timestamp > CACHE_VALIDITY)) { console.warn("缓存已过期或版本不匹配,弃用缓存"); return []; } if (Array.isArray(data)) return data; } catch (e) { console.warn("解析FileManager缓存失败:", e); } } return []; } else { console.warn("未选择有效的缓存方式"); return []; } } /** * 保存缓存数据 * @param {Array<{title:string, pubDate:string}>} items - 要缓存的新闻条目 */ function saveCache(items) { const payload = { version: CACHE_CONFIG.method === "Keychain" ? CACHE_CONFIG.keychain.version : CACHE_CONFIG.fileManager.version, timestamp: Date.now(), // 缓存保存时间戳 data: items }; if (CACHE_CONFIG.method === "Keychain") { // 使用Keychain保存缓存 try { Keychain.set(CACHE_CONFIG.keychain.cacheKey, JSON.stringify(payload)); console.log("缓存已成功保存到Keychain"); } catch (e) { console.warn("保存Keychain缓存失败:", e); } } else if (CACHE_CONFIG.method === "FileManager") { // 使用FileManager保存缓存 const fm = getFileManager(CACHE_CONFIG.fileManager.storageType); const cachePath = getCachePath(); try { fm.writeString(cachePath, JSON.stringify(payload)); console.log("缓存已成功保存到FileManager: " + cachePath); } catch (e) { console.warn("保存FileManager缓存失败:", e); } } else { console.warn("未选择有效的缓存方式,无法保存缓存"); } } /** * 从图床拉取Logo并缓存 * @returns {Promise<Image|null>} - 返回Logo Image对象或null */ async function fetchLogo() { const fm = getFileManager(CACHE_CONFIG.fileManager.storageType); const logoCachePath = getLogoCachePath(); // 如果本地/云端已经存在Logo缓存文件,则尝试读取 if (fm.fileExists(logoCachePath)) { try { const cachedImage = fm.readImage(logoCachePath); if (cachedImage) { console.log("使用缓存的Logo"); return cachedImage; } } catch (e) { console.warn("读取缓存Logo失败:", e); } } // 如果没有缓存,则从图床拉取 try { const req = new Request(ICON.url); const logoData = await req.loadImage(); fm.writeImage(logoCachePath, logoData); // 写入缓存 console.log("已从图床拉取并缓存Logo"); return logoData; } catch (e) { console.warn("拉取Logo失败:", e); return null; } } /** * 格式化时间字符串,将GMT/UTC时间转换为[HH:MM]格式 * @param {string} dateStr - GMT/UTC格式的日期字符串 * @returns {string} - 格式化后的时间字符串,例如"[14:30]" */ function formatTime(dateStr) { const dateObj = new Date(dateStr); if (isNaN(dateObj.getTime())) return "??:??"; const formatter = new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false }); return `${formatter.format(dateObj)}`; } /** * 计算字符串的总宽度 * @param {string} str - 需要计算宽度的字符串 * @returns {number} - 字符串的总宽度 */ function getStringWidth(str) { let width = 0; for (let char of str) { width += getCharWidth(char); } return width; } /** * 根据字符类型返回对应的宽度 * 1. 中文字符 / 中文标点 => chinese * 2. 英文字母 => english * 3. 数字 => number * 4. 其他字符 => others * @param {string} char - 单个字符 * @returns {number} - 该字符的宽度 */ function getCharWidth(char) { // 中文字符或中文标点 => chinese ~1 if (/[\u4e00-\u9fa5]/.test(char) || /[,。!?;:《》「」『』‘’…%+]/.test(char)) { return CHARS_WIDTH.chinese; } // 英文字母 => english(cap) ~0.685 / 0.538 else if (/[A-Z]/.test(char) || /[【】]/.test(char)) { return CHARS_WIDTH.englishcap; } else if (/[a-z]/.test(char) || /[-@#$^&*()•·_""“”/]/.test(char)) { return CHARS_WIDTH.english; } // 数字 => number ~0.613 else if (/\d/.test(char) || /[()]/.test(char)) { return CHARS_WIDTH.number; } // 其他 => others else { return CHARS_WIDTH.others; } } /** * 计算动态可显示条目数和行数,并记录每条新闻的“额外行数”扩容情况 * * @param {Array<{ title:string }>} items - 新闻数组 * @param {number} maxLines - 小组件总行数限制 * @param {number} maxTitleLines - 默认给每条新闻的行数限制 * @param {number} charsPerLine - 单行可容纳多少“字符宽度”(用于估算标题需要几行) * @returns {{ * finalItemCount: number, * finalLineCount: number, * expansions: number[], *. expLable: string * }} * * 说明: * - finalItemCount:最终展示的新闻条数 * - finalLineCount:实际使用的总行数 * - expansions: 与 items 等长的数组,expansions[i] 表示第 i 条新闻最大显示行数调整参数 * - expLable: 扩展标识 */ function dynamicItemCount(items, maxLines, maxTitleLines, charsPerLine) { let totalLines = 0 let itemCount = 0 // 0. 先预计算每条新闻需要多少行(needed) // neededLines[i] = ceil(字符串宽度 / charsPerLine) const neededLines = items.map(item => { const needed = Math.ceil(getStringWidth(item.title) / charsPerLine) return needed }) // 用于记录“额外行数”,默认都为 0 // expansions[i] + maxTitleLines => 实际给第 i 条新闻的行数 const expansions = new Array(items.length).fill(0) // 1. 第一步:按计算条目数与总行数 for (let i = 0; i < items.length; i++) { // 该条新闻按默认 lineLimit 需要行数 const linesNeeded = Math.min(neededLines[i], maxTitleLines) if (totalLines + linesNeeded > maxLines) { break } totalLines += linesNeeded itemCount++ } // 2. 第二步:若 totalLines < maxLines,尝试给已有条目扩容 // (一次只给一个条目多+1行,再检查是否还有余量) let canExpand = true while (canExpand && totalLines < maxLines) { canExpand = false // 遍历当前已加入的前 itemCount 条 for (let i = 0; i < itemCount; i++) { // 该条新闻需要多少行 const needed = neededLines[i] // 当前给了多少行 const currentLimit = maxTitleLines + expansions[i] // 如果它的需要行数大于当前给的行数 => 说明被截断了,可多加1行 if (needed > currentLimit) { // 判断是否还剩余1行空间 if (totalLines + 1 <= maxLines) { expansions[i] += 1 totalLines += 1 canExpand = true break } } } } // 3. 第三步:若还有剩余行数 && 还有新闻没展示 => 再多展示 1 条新闻 if (totalLines < maxLines - 1 && itemCount < items.length) { const index = itemCount const leftover = maxLines - totalLines expansions[index] = leftover - maxTitleLines totalLines = maxLines itemCount += 1 } // 扩展标识 let expLable = "null" if (expansions.some(x => x !== 0)) { expLable = expansions.some(x => x > 0) ? "E" : "+" } else if (totalLines < maxLines) { expLable = "-" } console.log({itemCount, totalLines, expLable}) return { finalItemCount: itemCount, finalLineCount: totalLines, expansions, expLable } } /* ================= 创建小组件 =================== */ /** * 创建小组件 * @param {Array<{title:string, pubDate:string}>} items - 新闻条目数组 * @param {Image|null} logoImage - Logo图像对象 * @returns {ListWidget} - 构建好的小组件 */ function createWidget(items, logoImage) { const widget = new ListWidget(); const widgetFamily = config.widgetFamily || PREVIEW_SIZE; // 设置展示模式 let textColor; let secondTextColor; // 深色模式 if (DISPLAY_MODE === "dark"){ textColor = Color.white(); secondTextColor = new Color("#cccccc"); widget.backgroundColor = new Color("#1C1C1E", 0.3); } // 浅色模式 else if (DISPLAY_MODE === "light") { textColor = Color.black(); secondTextColor = new Color("#666666"); widget.backgroundColor = Color.white(); } // 自动切换模式 else { textColor = Color.dynamic( Color.black(), Color.white() ); secondTextColor = Color.dynamic( new Color("#666666"), new Color("#cccccc") ); } // 如果是小尺寸,显示适配提示信息 if (widgetFamily === "small") { widget.addSpacer(); const tipTxt = widget.addText(CONFIG.SMALL.MESSAGE); tipTxt.font = Font.mediumSystemFont(14); tipTxt.textColor = textColor; tipTxt.centerAlignText(); widget.addSpacer(); return widget; } // ===== 参数设置及数据处理 ===== // 根据小组件尺寸选择相应的配置 const isLarge = (widgetFamily === "large"); const widgetConfig = isLarge ? CONFIG.LARGE : CONFIG.MEDIUM; const widgetWidth = WIDGET_WIDTH; const timeWidth = TIME_WIDTH; const newsWidth = widgetWidth - timeWidth - 10; // 计算每行字数 const charsPerLine = (newsWidth - 8) / FONT_SIZE; // 计算可展示的新闻条目数及总行数 const { finalItemCount, finalLineCount, expansions, expLable } = dynamicItemCount( items, widgetConfig.MAX_LINES, widgetConfig.MAX_TITLE_LINES, charsPerLine); // 提取最终展示新闻数据 const showList = items.slice(0, finalItemCount); // ======== 展示设置 ======== // 创建顶部区域(标题栏) const headerStack = widget.addStack(); headerStack.layoutHorizontally(); headerStack.centerAlignContent(); headerStack.size = new Size(widgetWidth, 0); headerStack.addSpacer(6); // 左侧区域:Logo + 标题 const leftStack = headerStack.addStack(); leftStack.layoutHorizontally(); leftStack.centerAlignContent(); leftStack.url = "https://m.cls.cn"; // 点击跳转的链接 // Logo if (logoImage) { const logoImg = leftStack.addImage(logoImage); logoImg.imageSize = new Size(18, 18); // Logo尺寸 logoImg.cornerRadius = 4; logoImg.leftAlignImage(); } else { const defaultLogo = SFSymbol.named("photo"); // 默认图标 const logoImg = leftStack.addImage(defaultLogo.image); logoImg.imageSize = new Size(18, 18); logoImg.leftAlignImage(); } leftStack.addSpacer(6); // 标题 const titleTxt = leftStack.addText(WIDGET_NAME); titleTxt.font = Font.mediumSystemFont(16); titleTxt.textColor = textColor; headerStack.addSpacer(); // 右侧区域:更新时间 const refreshTime = headerStack.addText("更新于 " + formatTime(new Date())); refreshTime.font = Font.regularMonospacedSystemFont(10); refreshTime.textColor = secondTextColor; refreshTime.url = `scriptable:///run/${encodeURIComponent(WIDGET_NAME)}`; // 点击运行小组件 // 右侧区域:当使用缓存数据时显示标识“C” if (CACHE_CONFIG.status === "loaded") { headerStack.addSpacer(4); const lable = headerStack.addText("C"); lable.font = Font.boldSystemFont(10); lable.textColor = secondTextColor; } // 右侧区域:动态调整显示标识"E"/"+" if (expLable !== "null") { headerStack.addSpacer(4); const lable = headerStack.addText(expLable); lable.font =Font.boldSystemFont(10); lable.textColor = secondTextColor; } headerStack.addSpacer(8); widget.addSpacer(); // 新闻展示区域 const listStack = widget.addStack(); listStack.layoutVertically(); // 遍历并添加新闻条目 for (let i = 0; i < showList.length; i++) { const { title, pubDate } = showList[i]; // 创建单条新闻的水平堆栈 const rowStack = listStack.addStack(); rowStack.layoutHorizontally(); rowStack.size = new Size(widgetWidth, 0); // 让每条新闻可点击 -> 跳转到脚本并发送通知 // 传递参数 type = notify & index = i rowStack.url = `scriptable:///run/${encodeURIComponent(WIDGET_NAME)}?type=notify&index=${i}`; rowStack.addSpacer(4); // 时间区域 const timeStack = rowStack.addStack(); timeStack.size = new Size(timeWidth, 0); const timeTxt = timeStack.addText(`${ formatTime(pubDate)} |`); timeTxt.font = Font.regularMonospacedSystemFont(FONT_SIZE); timeTxt.textColor = secondTextColor; timeTxt.lineLimit = 1; rowStack.addSpacer(6); // 新闻标题 const newsTitle = rowStack.addText(title); //const customMonoFont = new Font("Sarasa Mono SC", 12); newsTitle.font = Font.regularMonospacedSystemFont(12); //newsTitle.font = customMonoFont; newsTitle.textColor = textColor; newsTitle.lineLimit = widgetConfig.MAX_TITLE_LINES + expansions[i]; // 第 i 条新闻的显示行数 rowStack.addSpacer(); // 条目间距 if (i < showList.length - 1) { listStack.addSpacer(ROW_SPACING); } } // 调整底部间距 if (finalLineCount < widgetConfig.MAX_LINES || isLarge) { widget.addSpacer(); } return widget; } /* =================== 主函数 ==================== */ /** * 主运行函数 */ async function run() { // 从缓存读取数据 let finalItems = loadCache(); // 检查是否为「通知模式」:点击某条新闻后跳转到脚本,带 query 参数 type = notify & index = i if (args.queryParameters.type === "notify") { // 获取要通知的索引 const idx = parseInt(args.queryParameters.index || "0", 10); // 如果缓存中对应索引的数据存在,则发送通知 if (finalItems.length > idx) { const item = finalItems[idx]; let n = new Notification(); n.title = `${WIDGET_NAME}` n.body = `[${formatTime(item.pubDate)}] ${item.content}`; await n.schedule(); App.close() } else { // 缓存中无数据或索引无效,可考虑给出提示 console.warn("缓存无可用数据,无法发送通知"); } // 结束脚本,不再渲染小组件 return; } // 如果不是「通知模式」,执行正常的小组件逻辑 try { // fetch最新数据 const fetched = await fetchRSSData(); finalItems = fetched; saveCache(finalItems); // 刷新缓存 } catch (err) { console.warn("拉取RSS失败:", err); if (finalItems.length === 0) { console.warn("无可用缓存,将显示空白小组件"); } else { // 标记从缓存加载 CACHE_CONFIG.status = "loaded"; console.warn("使用缓存数据"); } } // 拉取并缓存Logo const logoImage = await fetchLogo(); // 创建小组件 const widget = createWidget(finalItems, logoImage); if (!config.runsInWidget) { // 预览 switch (PREVIEW_SIZE) { case "small": await widget.presentSmall(); break; case "medium": await widget.presentMedium(); break; case "large": await widget.presentLarge(); break; default: await widget.presentLarge(); break; } } else { Script.setWidget(widget); Script.complete(); } } // 执行主函数 await run(); /* ================ 辅助函数部分 ================== */ /** * 获取 FileManager 实例 * @param {string} storageType - 存储类型:"iCloud" | "local" * @returns {FileManager} - 对应的FileManager实例 */ function getFileManager(storageType) { // 根据storageType返回对应的FileManager return storageType === "iCloud" ? FileManager.iCloud() : FileManager.local(); } /** * 获取数据缓存文件路径 * 当storageType为"local"时,将缓存数据存储到「Documents/脚本名」文件夹;若无则创建 * @returns {string} - 缓存文件的完整路径 */ function getCachePath() { const fm = getFileManager(CACHE_CONFIG.fileManager.storageType); if (CACHE_CONFIG.fileManager.storageType === "local") { // 本地 Documents 目录 const baseDir = fm.documentsDirectory(); // 构造子文件夹(脚本名) const subFolderPath = fm.joinPath(baseDir, Script.name()); // 如果该子文件夹不存在,则新建 if (!fm.fileExists(subFolderPath)) { fm.createDirectory(subFolderPath, true); console.log(`已创建文件夹: ${subFolderPath}`); } // 拼接最终缓存文件路径 return fm.joinPath(subFolderPath, CACHE_CONFIG.fileManager.cacheFileName); } else { // iCloud 或其他情况,放在Documents根目录 return fm.joinPath(fm.documentsDirectory(), CACHE_CONFIG.fileManager.cacheFileName); } } /** * 获取Logo缓存文件路径 * 当storageType为"local"时,将Logo缓存存储到「Documents/脚本名」文件夹;若无则创建 * @returns {string} - Logo缓存文件的完整路径 */ function getLogoCachePath() { const fm = getFileManager(CACHE_CONFIG.fileManager.storageType); if (CACHE_CONFIG.fileManager.storageType === "local") { // 本地 Documents 目录 const baseDir = fm.documentsDirectory(); // 构造子文件夹(脚本名) const subFolderPath = fm.joinPath(baseDir, Script.name()); // 如果该子文件夹不存在,则新建 if (!fm.fileExists(subFolderPath)) { fm.createDirectory(subFolderPath, true); console.log(`已创建文件夹: ${subFolderPath}`); } // 拼接最终Logo缓存文件路径 return fm.joinPath(subFolderPath, ICON.cacheName); } else { // iCloud 或其他情况,放在Documents根目录 return fm.joinPath(fm.documentsDirectory(), ICON.cacheName); } }