// ==UserScript== // @name 每日微语报 // @namespace https://scriptcat.org/scripts/daily-weiyu-report // @version 1.0.0 // @description 每天 07:00 自动抓取前一日社会热点 12 条,生成每日换色的「微语报」高清长图,推送到钉钉群机器人(失败自动降级文字版),同步支持企业微信 // @author WorkBuddy // @license MIT // @crontab 0 7 * * * // @connect 60s.viki.moe // @connect 60s-api.viki.moe // @connect api.vvhan.com // @connect oapi.dingtalk.com // @connect qyapi.weixin.qq.com // @connect api.imgbb.com // @connect www.imgurl.org // @grant GM_xmlhttpRequest // @grant GM_notification // @grant GM_getValue // @grant GM_setValue // @grant GM_log // @grant GM_info // @grant GM_openInTab // ==/UserScript== /* ==UserConfig== Config: count: title: 每日新闻条数 type: number default: 12 min: 1 max: 20 socialOnly: title: 社会热点优先(自动压低纯财经/体育/趣闻条目) type: checkbox default: true description: 娱乐类已在代码中永久排除,不受此开关影响 exclude: title: 排除关键词(英文逗号分隔) type: text default: "" description: 命中任一关键词的新闻会被剔除,例:涨停,股价,演唱会 reportTitle: title: 推送标题 type: text default: 每日微语报 image: title: 钉钉推送图片版(需要图床) type: checkbox default: true description: 用 Canvas 离屏绘制图片后上传图床,失败自动退回文字版;关闭则只发文字 Notice: bro: title: 浏览器通知(当前脚本) type: checkbox default: true dingding: title: 钉钉群机器人(不加签,关键词:#) type: text password: true description: 粘贴 access_token,或直接粘贴完整 webhook 地址 dingdingSecret: title: 钉钉加签密钥(可选,不加签请留空) type: text password: true description: 安全设置选「加签」时填 SEC 开头的密钥 keyword: title: 钉钉关键词兜底(默认 #) type: text default: "#" description: 仅当钉钉提示关键词不匹配时,自动追加到消息末尾重试 imgbbKey: title: imgbb API Key(免费注册,图片上传必需) type: text password: true description: 访问 api.imgbb.com 免费获取,不收费 wework: title: 企业微信消息推送(群机器人) type: text password: true description: 粘贴 key,或直接粘贴完整 webhook 地址 ==/UserConfig== */ "use strict"; /* ------------------------------------------------------------------ * * 0. 基础工具 * ------------------------------------------------------------------ */ const SCRIPT_NAME = (typeof GM_info !== "undefined" && GM_info.script && GM_info.script.name) || "每日微语报"; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** 强制超时,防止请求既不 resolve 也不 reject 时整个流程挂死 */ function withTimeout(promise, ms, label) { return Promise.race([ promise, new Promise((_, reject) => setTimeout(() => reject(new Error(`${label} 超时(${ms}ms 无响应)`)), ms) ), ]); } const logs = []; function log(...args) { const msg = args.join(" "); logs.push(msg); try { GM_log(`[${SCRIPT_NAME}] ${msg}`); } catch (e) { /* ignore */ } } function safeJSON(text) { try { return JSON.parse(text); } catch (e) { return null; } } /* ------------------------------------------------------------------ * * 1. 配置读取(对应上面 ==UserConfig== 的分组 key) * ------------------------------------------------------------------ */ const CFG = { get count() { const n = Number(GM_getValue("Config.count", 12)); if (!Number.isFinite(n) || n <= 0) return 12; return Math.min(Math.floor(n), 20); }, get socialOnly() { return GM_getValue("Config.socialOnly", true) !== false; }, get exclude() { return String(GM_getValue("Config.exclude", "") || "") .split(/[,,]/) .map((s) => s.trim()) .filter(Boolean); }, get reportTitle() { return String(GM_getValue("Config.reportTitle", "") || "").trim() || "每日微语报"; }, get image() { return GM_getValue("Config.image", true) !== false; }, get bro() { return GM_getValue("Notice.bro", true) !== false; }, get dingding() { return String(GM_getValue("Notice.dingding", "") || "").trim(); }, get dingdingSecret() { return String(GM_getValue("Notice.dingdingSecret", "") || "").trim(); }, get keyword() { const v = GM_getValue("Notice.keyword", "#"); return String(v === null || v === undefined ? "#" : v).trim(); }, get imgbbKey() { return String(GM_getValue("Notice.imgbbKey", "") || "").trim(); }, get wework() { return String(GM_getValue("Notice.wework", "") || "").trim(); }, }; /* ------------------------------------------------------------------ * * 2. 网络请求(GM_xmlhttpRequest 跨域,不受 CORS 限制) * ------------------------------------------------------------------ */ function http(opts) { return new Promise((resolve, reject) => { let done = false; const finish = (fn, val) => { if (done) return; done = true; fn(val); }; try { GM_xmlhttpRequest({ method: opts.method || "GET", url: opts.url, headers: opts.headers || {}, data: opts.data, timeout: opts.timeout || 15000, onload: (res) => { if (res.status >= 200 && res.status < 300) finish(resolve, res); else finish(reject, new Error(`HTTP ${res.status}`)); }, onerror: () => finish(reject, new Error("网络请求失败")), ontimeout: () => finish(reject, new Error("请求超时")), }); } catch (e) { finish(reject, e); } }); } async function getJSON(url) { const res = await http({ url, headers: { Accept: "application/json" } }); const text = res.responseText || ""; const json = safeJSON(text); if (!json) throw new Error("返回内容不是 JSON:" + text.slice(0, 80)); return json; } /* ------------------------------------------------------------------ * * 3. 新闻源 * ------------------------------------------------------------------ */ /** 每日 60 秒读懂世界:news 数组 + tip 微语,报头信息最全 */ function parse60s(json) { const d = (json && (json.data || json)) || {}; const news = Array.isArray(d.news) ? d.news : []; if (!news.length) throw new Error("news 为空"); return { date: d.date || "", weekday: d.day_of_week || "", lunar: d.lunar_date || "", tip: d.tip || "", link: d.link || "", source: "每日60秒", news: news.map((s) => String(s).replace(/\s+/g, " ").trim()).filter(Boolean), }; } /** 热搜榜兜底(只有标题,无微语/农历) */ function parseHotList(json) { const list = (json && (json.data || json.result || json.list)) || []; if (!Array.isArray(list) || !list.length) throw new Error("列表为空"); return { date: "", weekday: "", lunar: "", tip: "", link: "", source: "热搜榜", news: list .map((it) => String(it.title || it.name || it.word || it.query || "").replace(/\s+/g, " ").trim()) .filter(Boolean), }; } const SOURCES = [ { name: "60s 主站", url: "https://60s.viki.moe/v2/60s", parse: parse60s }, { name: "60s 备用", url: "https://60s-api.viki.moe/v2/60s", parse: parse60s }, { name: "百度热搜", url: "https://api.vvhan.com/api/hotlist/baiduRD", parse: parseHotList }, { name: "微博热搜", url: "https://api.vvhan.com/api/hotlist/wbHot", parse: parseHotList }, ]; async function fetchNews() { for (const s of SOURCES) { try { log(`尝试数据源:${s.name}`); const json = await getJSON(s.url); const data = s.parse(json); log(`数据源 ${s.name} 成功,共 ${data.news.length} 条`); return data; } catch (e) { log(`数据源 ${s.name} 失败:${e.message}`); } } throw new Error("所有数据源均不可用,请检查网络或稍后重试"); } /* ------------------------------------------------------------------ * * 4. 社会热点筛选 * —— 温和打分:社会民生类 +1,纯财经/纯娱乐类 -1,稳定排序后取前 N 条, * 最后恢复原始顺序输出,保证阅读顺序自然。 * ------------------------------------------------------------------ */ const SOCIAL_KEYS = [ "通报", "回应", "警方", "官方", "调查", "事故", "遇难", "受伤", "救援", "失联", "火灾", "地震", "台风", "暴雨", "洪水", "高温", "寒潮", "暴雪", "干旱", "汛情", "疫情", "感染", "医院", "医生", "医保", "社保", "养老金", "养老", "退休", "工资", "就业", "失业", "招聘", "教育", "学校", "校园", "学生", "老师", "高考", "中考", "幼儿园", "托育", "生育", "出生率", "人口", "房价", "楼市", "租房", "公积金", "物价", "消费", "食品", "药品", "快递", "外卖", "交通", "地铁", "高铁", "铁路", "航班", "机场", "高速", "车祸", "判刑", "起诉", "法院", "检察", "违法", "诈骗", "拐卖", "毒品", "彩礼", "结婚", "离婚", "景区", "旅游", "门票", "放假", "调休", "油价", "电价", "水价", "燃气", "供暖", "低保", "残疾人", "儿童", "老人", "妇女", "户口", "身份证", "驾照", "签证", "出入境", "农民工", "欠薪", "讨薪", "污染", "环保", "垃圾", "停车", "物业", "落户", "补贴", "疫苗", "诈骗", "溺水", "触电", // 国际社会事件 "游行", "示威", "抗议", "被捕", "袭击", "冲突", "局势", "警告", "制裁", "罢工", "判决", "遣返", "难民", "枪击", "爆炸", "空袭", "停火", "谈判", "会晤", "访问", // 民生政策与公共安全 "新规", "政策", "上线", "开通", "实施", "落地", "涨价", "降价", "维权", "投诉", "曝光", "整治", "查处", "处罚", "罚款", "拘留", "刑拘", "立案", "举报", "召回", "下架", "裁员", "降薪", "加班", "工伤", "理赔", "电梯", "停水", "停电", "封路", "春运", "抢票", "菜价", "猪肉", "坍塌", "中毒", "走失", "霸凌", "坠楼", "窒息", ]; /** * 娱乐类永久排除名单 —— 命中任一关键词即整条剔除,不受任何配置影响。 * 若日后想放回娱乐新闻,删除下方 isEntertainment 的调用即可。 */ const ENTERTAINMENT_KEYS = [ // 明星八卦 "明星", "艺人", "恋情", "绯闻", "出轨", "塌房", "粉丝", "应援", "番位", "撕番", "顶流", "小鲜肉", "偶像", "男团", "女团", "成团", "选秀", "追星", "代拍", "私生饭", "超话", "官宣恋情", "晒照", "合照", "结婚照", "求婚", "婚礼现场", // 影视综艺 "综艺", "真人秀", "恋综", "剧集", "热播", "开播", "定档", "杀青", "开机仪式", "路透照", "路透图", "片场", "剧组", "收视率", "收视", "票房", "影院排片", // 演出与奖项 "演唱会", "巡回演出", "音乐节", "颁奖礼", "颁奖典礼", "红毯", "电影节", "影展", "金鸡奖", "百花奖", "金鹰奖", "飞天奖", "白玉兰", "奥斯卡", "格莱美", "艾美奖", "戛纳", "柏林电影节", "威尼斯电影节", "维密", ]; function isEntertainment(text) { return ENTERTAINMENT_KEYS.some((k) => text.includes(k)); } const DOWN_KEYS = [ // 纯金融行情 "涨停", "跌停", "股价", "A股", "沪指", "私募", "基金净值", "期货", "汇率", "降息", "加息", "市值", "财报", "营收", "净利润", "北向资金", "板块", // 体育赛事(轻度降权,仍可能保留) "夺冠", "冠军", "亚军", "决赛", "联赛", "球员", "比分", "亚锦赛", "世界杯", // 趣闻猎奇 "吉尼斯", "刷新纪录", "爆火", "走红", "出圈", "萌宠", "颜值", ]; function scoreNews(text) { let s = 0; let hit = 0; for (const k of SOCIAL_KEYS) { if (text.includes(k)) { hit += 1; if (hit >= 3) break; } } s += Math.min(hit, 3); for (const k of DOWN_KEYS) { if (text.includes(k)) { s -= 1; break; } } return s; } function pickNews(list, count, socialOnly, exclude) { let arr = list.map((text, idx) => ({ text, idx })); // 1) 娱乐类:永久排除,优先于一切配置 const beforeEnt = arr.length; arr = arr.filter((o) => !isEntertainment(o.text)); if (arr.length !== beforeEnt) { log(`娱乐类永久排除,剔除 ${beforeEnt - arr.length} 条`); } // 2) 用户自定义排除词 if (exclude.length) { const before = arr.length; arr = arr.filter((o) => !exclude.some((k) => o.text.includes(k))); if (arr.length !== before) log(`排除关键词命中,剔除 ${before - arr.length} 条`); } // 3) 社会热点打分排序 if (socialOnly) { arr.forEach((o) => (o.score = scoreNews(o.text))); arr.sort((a, b) => b.score - a.score || a.idx - b.idx); } const picked = arr.slice(0, count); picked.sort((a, b) => a.idx - b.idx); // 恢复原始顺序 if (picked.length < count) { log(`剔除后仅剩 ${picked.length} 条可用新闻,少于目标 ${count} 条`); } return picked.map((o) => o.text); } /* ------------------------------------------------------------------ * * 5. 报头 / 正文组装 * ------------------------------------------------------------------ */ function pad2(n) { return n < 10 ? "0" + n : String(n); } function todayCN(d = new Date()) { return `${d.getMonth() + 1}月${d.getDate()}日`; } function weekdayCN(d = new Date()) { return "星期" + "日一二三四五六"[d.getDay()]; } /** 本地日期键,用于与接口返回的日期比对:2026-10-26 */ function dateKey(d) { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; } /** "丙午年九月十七" -> "九月十七" */ function normalizeLunar(str) { if (!str) return ""; const t = String(str).trim(); const m = /^[甲乙丙丁戊己庚辛壬癸][子丑寅卯辰巳午未申酉戌亥]年(.+)$/.exec(t); return (m ? m[1] : t).replace(/^农历/, ""); } /** 内置鸡汤兜底(接口没给 tip 时按日期轮换,保证每天不同),均控制在 30 字内 */ const FALLBACK_TIPS = [ "把日子过成自己喜欢的样子,就是最好的生活", "慢慢来,好戏都在烟火里", "心若向阳,无谓悲伤", "别急,花有花期,人有时运", "愿你眼中有光,心中有暖,脚下有路", "今天也要好好吃饭,好好睡觉,好好生活", "认真生活的人,运气都不会太差", "所有的坚持,终将美好", "把每一天都当作新的开始", "生活明朗,万物可爱,人间值得", ]; /** 取一句 30 字内的积极文案,优先用接口每日微语(每天不同),超长则取首个分句 */ function pickTip(raw) { const t = String(raw || "") .replace(/\s+/g, " ") .trim(); if (t) { if (t.length <= 30) return t; const first = t.split(/[,。;!?、]/)[0].trim(); if (first && first.length <= 30) return first; return t.slice(0, 29) + "…"; } const d = new Date(); return FALLBACK_TIPS[(d.getMonth() * 31 + d.getDate()) % FALLBACK_TIPS.length]; } function buildReport(data, items) { const now = new Date(); const today = dateKey(now); // 接口日期若与当天一致,说明是当天这期数据,星期/农历可直接采用; // 否则(数据未更新)只用本地星期,农历省略,避免日期与农历对不上。 const apiDate = String(data.date || "") .trim() .slice(0, 10) .replace(/\//g, "-"); const sameDay = apiDate === today; if (apiDate && !sameDay) { log(`接口数据日期 ${apiDate} 与当天 ${today} 不一致,农历信息已省略`); } const week = sameDay && data.weekday ? data.weekday : weekdayCN(now); const lunar = sameDay ? normalizeLunar(data.lunar) : ""; const dateStr = todayCN(now); const subParts = [week]; if (lunar) subParts.push("农历" + lunar); return { // 完整版(浏览器通知 / 纯文本兜底) head: `【${dateStr}微语报,${week}${lunar ? ",农历" + lunar : ""}】`, // 排版版:短标题 + 副信息分层,视觉更清爽 title: `【${dateStr}微语报】`, sub: subParts.join(" · "), lead: "一份微语报,众览天下事:", body: items.map((t, i) => { const text = /[。!?.!?]$/.test(t) ? t : t + "。"; return `${i + 1}. ${text}`; }), tip: (() => { const t = pickTip(data.tip); return t && !/[。!?.!?]$/.test(t) ? t + "!" : t; })(), source: data.source || "", }; } /** 纯文本版(浏览器通知 / 备用) */ function buildText(report) { const lines = [report.head, report.lead, ""]; report.body.forEach((b) => lines.push(b)); if (report.tip) lines.push("", report.tip); return lines.join("\n").trim(); } /** 钉钉版:短标题 + 引用式副标题,结尾文案加粗收束 */ function buildMarkdownDing(report) { const lines = []; lines.push(`### ${report.title}`); if (report.sub) lines.push(`> ${report.sub}`); lines.push(""); lines.push(report.lead); lines.push(""); report.body.forEach((b) => lines.push(b, "")); if (report.tip) lines.push(`**${report.tip}**`); return lines.join("\n").trim(); } /** 企业微信版:不支持分割线,改用 font 灰色小字做次级信息 */ function buildMarkdownWeCom(report) { const lines = []; lines.push(`### ${report.title}`); if (report.sub) lines.push(`${report.sub}`); lines.push(""); lines.push(report.lead); lines.push(""); report.body.forEach((b) => lines.push(b, "")); if (report.tip) lines.push(`**${report.tip}**`); return lines.join("\n").trim(); } /** * 图片版:只发图片,不再重复文字。 * alt 文本放钉钉关键词,保证「自定义关键词」校验能过,同时不会显示在消息里。 */ function buildMarkdownImage(report, url) { const kw = CFG.keyword || ""; return `![${kw}](${url})`; } /* ------------------------------------------------------------------ * * 5.5 图片渲染(OffscreenCanvas)+ 图床上传 * ------------------------------------------------------------------ */ const IMG_FONT = (size, bold) => `${bold ? "bold " : ""}${size}px "PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif`; /** * 按日期生成当天配色:色相用黄金角推进,一年 365 天不撞色。 * 背景 24~28% 饱和、95~98% 亮度(淡),强调色取同色相深一档,画面始终统一。 */ function dailyPalette(date = new Date()) { const yearStart = new Date(date.getFullYear(), 0, 0); const dayOfYear = Math.floor((date - yearStart) / 86400000); const seed = dayOfYear + date.getFullYear() * 367; // 色相走黄金角,相邻两天差 137.5°,一年 365 天不撞色 const hue = (seed * 137.508) % 360; // 第二个维度微调饱和/明度,让每天的观感差异更明显(不是只有色相在变) const wob = ((seed * 9301 + 49297) % 233280) / 233280; const sat = 34 + wob * 10; // 34~44 // 明度锁在 89.5%~92%:深色正文(#1A1F26)与之对比度始终 ≥ 9:1,保证辨识度 const light = 89.5 + wob * 2.5; const h = (d) => ((hue + d) % 360).toFixed(1); const f = (n) => n.toFixed(1); return { bg: [ `hsl(${h(0)}, ${f(sat - 8)}%, 96.5%)`, `hsl(${h(14)}, ${f(sat)}%, ${f(light)}%)`, `hsl(${h(32)}, ${f(sat - 3)}%, 95%)`, ], accent: `hsl(${h(0)}, ${f(sat + 6)}%, 30%)`, ink: "#0F1216", sub: "#454D57", lead: "#2F363E", body: "#1A1F26", tip: "#4F5761", faint: "#6B737D", }; } /** * 折行:连续的数字/英文字母作为一个整体,避免 "1000" 被拆成 "100" + "0"; * 中文按单字折行。 */ function wrapText(ctx, text, maxWidth) { const tokens = []; let buf = ""; for (const ch of text) { if (/[0-9A-Za-z.%]/.test(ch)) { buf += ch; } else { if (buf) { tokens.push(buf); buf = ""; } tokens.push(ch); } } if (buf) tokens.push(buf); const lines = []; let cur = ""; for (const tk of tokens) { const next = cur + tk; if (cur && ctx.measureText(next).width > maxWidth) { lines.push(cur); cur = tk; // 单个 token 本身超宽时按字符硬拆 while (cur.length > 1 && ctx.measureText(cur).width > maxWidth) { let cut = cur.length - 1; while (cut > 1 && ctx.measureText(cur.slice(0, cut)).width > maxWidth) cut--; lines.push(cur.slice(0, cut)); cur = cur.slice(cut); } } else { cur = next; } } if (cur) lines.push(cur); return lines; } /** 圆角矩形路径,兼容没有 roundRect 的环境 */ function roundRectPath(ctx, x, y, w, h, r) { ctx.beginPath(); if (typeof ctx.roundRect === "function") { ctx.roundRect(x, y, w, h, r); } else { ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); } } function fillRoundRect(ctx, x, y, w, h, r) { roundRectPath(ctx, x, y, w, h, r); ctx.fill(); } /** * 生成微语报图片(1080px 宽长图) * 版式:品牌竖线 + 标题区 / 渐变分割线 / 左序号栏 + 右正文 / 斜体收尾 / 右下属名 * 依赖 OffscreenCanvas,不可用时抛错由调用方降级为文字版 */ async function renderReportImage(report) { if (typeof OffscreenCanvas === "undefined") { throw new Error("当前环境不支持 OffscreenCanvas"); } // 780px 是移动端设计稿基准:图片被缩放到手机宽度时,正文仍有约 15~16px, // 1080px 的画布缩到手机只有 11px,这是"字太小"的根因。 // 1440px 超采样:超过手机 3x 屏物理像素(≈1170px),缩小显示时字体边缘更锐利 const W = 1440; const PAD = 59; const CW = W - PAD * 2; const NUM_W = 72; const BODY_X = PAD + NUM_W + 21; const BODY_W = W - PAD - BODY_X; const BRAND_H = 156; const EYEBROW_H = 48; const FS = { eyebrow: 25, title: 72, sub: 35, lead: 40, num: 37, body: 56, tip: 40 }; const LH = { body: 83, gap: 29, tip: 64 }; const pal = dailyPalette(new Date()); const items = report.body.map((b) => b.replace(/^\d+\.\s*/, "")); const probe = new OffscreenCanvas(W, 10).getContext("2d"); probe.font = IMG_FONT(FS.body); const blocks = items.map((t) => wrapText(probe, t, BODY_W)); probe.font = IMG_FONT(FS.tip); const tipLines = report.tip ? wrapText(probe, report.tip, CW) : []; let H = PAD + EYEBROW_H + BRAND_H + 53 + 1 + 51 + 56 + 35; for (const lines of blocks) H += lines.length * LH.body + LH.gap; H += 24 + 1 + 48; H += tipLines.length * LH.tip; H += PAD; H = Math.ceil(H); const canvas = new OffscreenCanvas(W, H); const ctx = canvas.getContext("2d"); ctx.textBaseline = "top"; const grad = ctx.createLinearGradient(0, 0, 0, H); grad.addColorStop(0, pal.bg[0]); grad.addColorStop(0.52, pal.bg[1]); grad.addColorStop(1, pal.bg[2]); ctx.fillStyle = grad; ctx.fillRect(0, 0, W, H); let y = PAD; // 英文眉标(品牌识别层,字距拉开) ctx.fillStyle = "#8B929B"; ctx.font = IMG_FONT(FS.eyebrow); ctx.letterSpacing = "3.5px"; ctx.fillText("DANRUSHUI", PAD + 40, y); ctx.letterSpacing = "0px"; y += EYEBROW_H; // 品牌竖线(覆盖标题 + 副标题) ctx.fillStyle = pal.accent; fillRoundRect(ctx, PAD, y + 5, 7, BRAND_H, 3.5); // 主标题 ctx.fillStyle = pal.ink; ctx.font = IMG_FONT(FS.title, true); ctx.fillText(report.title, PAD + 40, y); // 副标题 ctx.fillStyle = pal.sub; ctx.font = IMG_FONT(FS.sub); ctx.fillText(report.sub, PAD + 40, y + FS.title * 1.25 + 19); y += BRAND_H + 53; // 渐变分割线:画到 78% 宽即止,避免右侧"断头"感 const LINE_W = Math.round(CW * 0.78); const lineGrad = ctx.createLinearGradient(PAD, 0, PAD + LINE_W, 0); lineGrad.addColorStop(0, "rgba(20,23,28,0.22)"); lineGrad.addColorStop(1, "rgba(20,23,28,0)"); ctx.fillStyle = lineGrad; ctx.fillRect(PAD, y, LINE_W, 1); y += 1 + 51; // 导语 ctx.fillStyle = pal.lead; ctx.font = IMG_FONT(FS.lead); ctx.fillText(report.lead, PAD, y); y += 56 + 35; // 正文:左序号栏(右对齐等宽数字)+ 右正文 for (let i = 0; i < blocks.length; i++) { ctx.globalAlpha = 1; ctx.fillStyle = pal.accent; ctx.font = IMG_FONT(FS.num); ctx.textAlign = "right"; ctx.fillText(String(i + 1).padStart(2, "0"), PAD + NUM_W, y + 7); ctx.textAlign = "left"; ctx.globalAlpha = 1; ctx.fillStyle = pal.body; ctx.font = IMG_FONT(FS.body); for (const line of blocks[i]) { ctx.fillText(line, BODY_X, y); y += LH.body; } y += LH.gap; } // 收尾分割线 y += 24; ctx.fillStyle = lineGrad; ctx.fillRect(PAD, y, LINE_W, 1); y += 1 + 48; // 鸡汤收尾:中文无真斜体,用字重与颜色区分层级 ctx.fillStyle = pal.tip; ctx.font = IMG_FONT(FS.tip); for (const line of tipLines) { ctx.fillText(line, PAD, y); y += LH.tip; } // JPEG 体积约为同画面 PNG 的 1/5,渐变底尤其明显,直接决定上传能否在超时内完成 return canvas.convertToBlob({ type: "image/jpeg", quality: 0.85 }); } /** * 双通道提交 multipart:先走 GM_xmlhttpRequest(免 CORS), * 超时或无响应再走 fetch 兜底(Service Worker 原生能力)。 */ async function postFormData(url, fd, label, headers) { const h = headers || {}; try { const res = await withTimeout( http({ method: "POST", url, data: fd, headers: h, timeout: 20000 }), 20000, `${label}(GM)` ); return { status: res.status, text: res.responseText || "", via: "GM" }; } catch (e) { log(`${label} GM 通道失败:${e.message}`); } if (typeof fetch === "function") { try { const res = await withTimeout( fetch(url, { method: "POST", body: fd, headers: h }), 20000, `${label}(fetch)` ); return { status: res.status, text: await res.text(), via: "fetch" }; } catch (e) { log(`${label} fetch 通道失败:${e.message}`); } } throw new Error("两个通道均无响应"); } /** Blob → base64,分块处理避免大文件爆栈 */ async function blobToBase64(blob) { const bytes = new Uint8Array(await blob.arrayBuffer()); const CHUNK = 0x8000; let bin = ""; for (let i = 0; i < bytes.length; i += CHUNK) { bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)); } return btoa(bin); } /** * imgbb:base64 + urlencoded 提交。 * 走字符串 body 而不是 FormData,规避 GM_xmlhttpRequest 在 SW 里对 FormData 支持不稳的问题。 */ async function uploadImgbb(base64, key, filename) { const body = "image=" + encodeURIComponent(base64) + "&name=" + encodeURIComponent(filename.replace(/\.\w+$/, "")); log(`imgbb 请求已构造(base64 ${(base64.length / 1024).toFixed(0)} KB),等待响应`); const res = await withTimeout( http({ method: "POST", url: `https://api.imgbb.com/1/upload?key=${encodeURIComponent(key)}`, headers: { "Content-Type": "application/x-www-form-urlencoded" }, data: body, timeout: 40000, }), 40000, "imgbb 上传" ); const raw = (res.responseText || "").slice(0, 200); log(`imgbb 响应 HTTP ${res.status}:${raw || "(空)"}`); const j = safeJSON(res.responseText || ""); if (j && j.success && j.data && j.data.url) return j.data.url; throw new Error((j && j.error && j.error.message) || `HTTP ${res.status}`); } /** 上传图片:imgbb 优先,imgurl.org 兜底,全失败则抛错 */ async function uploadImage(blob, filename) { const key = CFG.imgbbKey; if (key) { try { log("尝试上传图片到 imgbb"); const url = await uploadImgbb(await blobToBase64(blob), key, filename); log("imgbb 上传成功"); return url; } catch (e) { log(`imgbb 上传失败:${e.message}`); } } else { log("未配置 imgbb Key,跳过 imgbb(在配置面板填入即可启用)"); } try { log("尝试上传图片到 imgurl.org"); const fd = new FormData(); fd.append("file", blob, filename); fd.append("apiType", "json"); const r = await postFormData("https://www.imgurl.org/api/v2/upload", fd, "imgurl.org"); log(`imgurl.org 响应 [${r.via}] HTTP ${r.status}:${r.text.slice(0, 160) || "(空)"}`); const j = safeJSON(r.text); if (j && j.code === 200 && j.data && j.data.url) { log("imgurl.org 上传成功"); return j.data.url; } throw new Error((j && (j.msg || j.message)) || `HTTP ${r.status}`); } catch (e) { log(`imgurl.org 上传失败:${e.message}`); } throw new Error("所有图床均上传失败"); } /* ------------------------------------------------------------------ * * 6. 推送渠道 * ------------------------------------------------------------------ */ function byteLen(str) { let n = 0; for (const ch of str) { const c = ch.codePointAt(0); n += c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0x10000 ? 3 : 4; } return n; } /** 企业微信 markdown 上限 4096 字节,按行安全切分 */ function splitByBytes(text, limit) { if (byteLen(text) <= limit) return [text]; const parts = []; let cur = ""; for (const line of text.split("\n")) { const next = cur ? cur + "\n" + line : line; if (byteLen(next) > limit && cur) { parts.push(cur); cur = line; } else { cur = next; } } if (cur) parts.push(cur); return parts; } async function hmacSha256Base64(key, data) { const enc = new TextEncoder(); const ck = await crypto.subtle.importKey( "raw", enc.encode(key), { name: "HMAC", hash: "SHA-256" }, false, ["sign"] ); const sig = await crypto.subtle.sign("HMAC", ck, enc.encode(data)); const bytes = new Uint8Array(sig); let bin = ""; for (const b of bytes) bin += String.fromCharCode(b); return btoa(bin); } /** 支持粘贴完整 webhook 地址,也支持只粘贴 token/key */ function extractToken(raw, param) { const m = new RegExp(`${param}=([^&\\s]+)`).exec(raw); return m ? m[1] : raw.replace(/^https?:\/\/\S*[?&]/, "").trim(); } async function pushDingTalk(markdown, title) { const raw = CFG.dingding; if (!raw) { log("未配置钉钉机器人,跳过"); return { ok: false, skipped: true }; } const token = extractToken(raw, "access_token"); let url = `https://oapi.dingtalk.com/robot/send?access_token=${encodeURIComponent(token)}`; const secret = CFG.dingdingSecret; if (secret) { try { const ts = Date.now(); const sign = await hmacSha256Base64(secret, `${ts}\n${secret}`); url += `×tamp=${ts}&sign=${encodeURIComponent(sign)}`; } catch (e) { log(`钉钉加签失败(已按不加签发送):${e.message}`); } } const send = async (text) => { const body = JSON.stringify({ msgtype: "markdown", markdown: { title, text }, }); const res = await http({ method: "POST", url, headers: { "Content-Type": "application/json;charset=utf-8" }, data: body, }); return { res, json: safeJSON(res.responseText || "") }; }; let { res, json } = await send(markdown); // 正文为了排版干净不含关键词时,钉钉可能报 310000,这里自动补关键词重发一次 const kw = CFG.keyword; const keywordError = json && json.errcode === 310000 && /keyword/i.test(String(json.errmsg || "")); if (keywordError && kw && !markdown.includes(kw)) { log(`钉钉关键词校验未通过,自动在末尾追加「${kw}」重试`); ({ res, json } = await send(`${markdown}\n\n${kw}`)); } if (json && json.errcode === 0) { log("钉钉推送成功"); return { ok: true }; } throw new Error( `钉钉推送失败:${json ? `${json.errcode} ${json.errmsg}` : (res.responseText || "").slice(0, 120)}` ); } async function pushWeWork(markdown) { const raw = CFG.wework; if (!raw) { log("未配置企业微信机器人,跳过"); return { ok: false, skipped: true }; } const key = extractToken(raw, "key"); const url = `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=${encodeURIComponent(key)}`; const parts = splitByBytes(markdown, 3800); for (let i = 0; i < parts.length; i++) { const body = JSON.stringify({ msgtype: "markdown", markdown: { content: parts[i] } }); const res = await http({ method: "POST", url, headers: { "Content-Type": "application/json" }, data: body, }); const json = safeJSON(res.responseText || ""); if (!json || json.errcode !== 0) { throw new Error( `企业微信推送失败:${json ? `${json.errcode} ${json.errmsg}` : (res.responseText || "").slice(0, 120)}` ); } if (i < parts.length - 1) await sleep(600); } log(`企业微信推送成功${parts.length > 1 ? `(分 ${parts.length} 条)` : ""}`); return { ok: true }; } function notify(title, text) { if (!CFG.bro) return; try { GM_notification({ title: `${SCRIPT_NAME} · ${title}`, text: String(text || ""), timeout: 10000, }); } catch (e) { /* ignore */ } } /* ------------------------------------------------------------------ * * 7. 主流程 * ------------------------------------------------------------------ */ async function main() { log(`开始执行,计划任务:每天 07:00(@crontab 0 7 * * *)`); try { const data = await fetchNews(); const items = pickNews(data.news, CFG.count, CFG.socialOnly, CFG.exclude); if (!items.length) throw new Error("筛选后没有可用新闻"); const report = buildReport(data, items); const wecomText = buildMarkdownWeCom(report); let dingText = buildMarkdownDing(report); log(`筛选完成:${items.length} 条(来源:${report.source})`); // 钉钉图片版:生成失败就退回文字版,不影响推送 if (CFG.image) { try { const blob = await renderReportImage(report); log(`图片已生成:${(blob.size / 1024).toFixed(0)} KB(${blob.type})`); const url = await uploadImage(blob, `weiyu-${dateKey(new Date())}.jpg`); dingText = buildMarkdownImage(report, url); log("图片版已生成并上传"); } catch (e) { log(`图片版不可用,改用文字版:${e.message}`); } } const errors = []; const msgTitle = CFG.keyword ? `${report.title} ${CFG.keyword}` : report.title; const ding = await pushDingTalk(dingText, msgTitle).catch((e) => { errors.push(e.message); log(e.message); return null; }); const ww = await pushWeWork(wecomText).catch((e) => { errors.push(e.message); log(e.message); return null; }); const anyOk = (ding && ding.ok) || (ww && ww.ok); const allSkipped = (ding && ding.skipped) && (ww && ww.skipped); if (allSkipped) { log("未配置任何推送渠道,本次仅生成内容,请在脚本配置面板填写钉钉或企业微信机器人"); notify("未配置推送渠道", buildText(report).slice(0, 200)); } else if (anyOk) { notify("推送完成", `已推送 ${items.length} 条热点新闻`); } else { notify("推送失败", errors.join("\n").slice(0, 200)); } log("执行结束"); return { ok: anyOk, count: items.length, errors }; } catch (e) { log(`执行失败:${e.message}`); notify("执行失败", e.message); return { ok: false, error: e.message }; } } return main();