Last active
March 4, 2026 22:50
-
-
Save nobodywasishere/30808eaf4a9d86b20cde0dc9271999a3 to your computer and use it in GitHub Desktop.
kagi news scriptable widget
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Kagi News Widget for Scriptable | |
| // Data: kite.kagi.com (CC BY-NC 4.0 — Kagi Inc.) | |
| // ───────────────────────────────────────────────────── | |
| // CONFIGURATION — edit these values | |
| // ───────────────────────────────────────────────────── | |
| const PARAM_CATEGORY = args.widgetParameter || "world" | |
| const CFG = { | |
| accentColor: "#5B4CDB", | |
| maxSmall: 2, | |
| maxMedium: 3, | |
| maxLarge: 7, | |
| titleFontSize: 13, | |
| headlineFontSize: 12, | |
| metaFontSize: 9, | |
| cacheTTL: 60, | |
| baseURL: "https://kite.kagi.com", | |
| } | |
| // ───────────────────────────────────────────────────── | |
| // CACHE HELPERS | |
| // ───────────────────────────────────────────────────── | |
| const fm = FileManager.iCloud() | |
| const dir = fm.joinPath(fm.documentsDirectory(), "kagi_news_widget") | |
| if (!fm.fileExists(dir)) fm.createDirectory(dir) | |
| function cacheFile(key) { | |
| return fm.joinPath(dir, key.replace(/[^a-z0-9_]/gi, "_") + ".json") | |
| } | |
| async function cachedFetch(url, key) { | |
| const path = cacheFile(key) | |
| const now = Date.now() | |
| if (fm.fileExists(path)) { | |
| try { | |
| await fm.downloadFileFromiCloud(path) | |
| const obj = JSON.parse(fm.readString(path)) | |
| if ((now - obj.ts) / 60000 < CFG.cacheTTL) return obj.data | |
| } catch (_) {} | |
| } | |
| const req = new Request(url) | |
| req.timeoutInterval = 15 | |
| const data = await req.loadJSON() | |
| fm.writeString(path, JSON.stringify({ ts: now, data })) | |
| return data | |
| } | |
| // ───────────────────────────────────────────────────── | |
| // DATA FETCHING | |
| // ───────────────────────────────────────────────────── | |
| async function fetchIndex() { | |
| return cachedFetch(`${CFG.baseURL}/kite.json`, "kite_index") | |
| } | |
| async function fetchCategory(slug) { | |
| const index = await fetchIndex() | |
| const cats = index.categories || [] | |
| const cat = cats.find(c => { | |
| const stem = (c.file || "").replace(/\.json$/, "") | |
| return stem === slug || | |
| (c.name || "").toLowerCase() === slug.toLowerCase() | |
| }) | |
| if (!cat) return { error: `No category matched "${slug}"` } | |
| const url = `${CFG.baseURL}/${cat.file}` | |
| return { data: await cachedFetch(url, `cat_${slug}`), name: cat.name } | |
| } | |
| // ───────────────────────────────────────────────────── | |
| // UI HELPERS | |
| // ───────────────────────────────────────────────────── | |
| const IS_DARK = Device.isUsingDarkAppearance() | |
| const P = { | |
| bg: IS_DARK ? new Color("#111118") : new Color("#F5F5FA"), | |
| accent: new Color("#5B4CDB"), | |
| accentSoft: new Color("#5B4CDB", 0.15), | |
| text: IS_DARK ? new Color("#E8E8F0") : new Color("#1A1A2E"), | |
| subtext: IS_DARK ? new Color("#888899") : new Color("#666677"), | |
| sep: IS_DARK ? new Color("#2A2A3A") : new Color("#E0E0EC"), | |
| white: new Color("#FFFFFF"), | |
| } | |
| function trunc(s, n) { | |
| if (!s) return "" | |
| s = s.trim() | |
| return s.length <= n ? s : s.slice(0, n - 1) + "…" | |
| } | |
| function timeAgo(iso) { | |
| if (!iso) return "" | |
| const s = (Date.now() - new Date(iso).getTime()) / 1000 | |
| if (s < 3600) return `${Math.round(s / 60)}m ago` | |
| if (s < 86400) return `${Math.round(s / 3600)}h ago` | |
| return `${Math.round(s / 86400)}d ago` | |
| } | |
| // ───────────────────────────────────────────────────── | |
| // WIDGET BUILDER | |
| // ───────────────────────────────────────────────────── | |
| async function buildWidget(size) { | |
| const w = new ListWidget() | |
| w.backgroundColor = P.bg | |
| w.setPadding(12, 14, 10, 14) | |
| // ── Fetch ────────────────────────────────────────── | |
| let catName = PARAM_CATEGORY | |
| let clusters = [] | |
| let errorMsg = null | |
| try { | |
| const result = await fetchCategory(PARAM_CATEGORY) | |
| if (result.error) { | |
| errorMsg = result.error | |
| } else { | |
| catName = result.name | |
| clusters = result.data.clusters || [] | |
| } | |
| } catch (e) { | |
| errorMsg = `Fetch failed: ${e.message}` | |
| } | |
| // ── Header ───────────────────────────────────────── | |
| const hdr = w.addStack() | |
| hdr.layoutHorizontally() | |
| hdr.centerAlignContent() | |
| const badge = hdr.addStack() | |
| badge.backgroundColor = P.accent | |
| badge.cornerRadius = 5 | |
| badge.setPadding(2, 6, 2, 6) | |
| const bt = badge.addText("K") | |
| bt.font = Font.boldSystemFont(11) | |
| bt.textColor = P.white | |
| hdr.addSpacer(6) | |
| const titleTxt = hdr.addText("Kagi News") | |
| titleTxt.font = Font.boldSystemFont(CFG.titleFontSize) | |
| titleTxt.textColor = P.text | |
| hdr.addSpacer(5) | |
| const pill = hdr.addStack() | |
| pill.backgroundColor = P.accentSoft | |
| pill.cornerRadius = 8 | |
| pill.setPadding(2, 6, 2, 6) | |
| const pt = pill.addText(trunc(catName, 18)) | |
| pt.font = Font.mediumSystemFont(9) | |
| pt.textColor = P.accent | |
| hdr.addSpacer() | |
| const now = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) | |
| const nt = hdr.addText(now) | |
| nt.font = Font.systemFont(CFG.metaFontSize) | |
| nt.textColor = P.subtext | |
| w.addSpacer(6) | |
| // Separator | |
| const sepStack = w.addStack() | |
| sepStack.backgroundColor = P.sep | |
| sepStack.size = new Size(0, 1) | |
| w.addSpacer(6) | |
| // ── Body ─────────────────────────────────────────── | |
| const max = size === "small" ? CFG.maxSmall | |
| : size === "large" ? CFG.maxLarge | |
| : CFG.maxMedium | |
| if (errorMsg) { | |
| const et = w.addText(errorMsg) | |
| et.font = Font.systemFont(10) | |
| et.textColor = P.subtext | |
| et.lineLimit = 4 | |
| w.addSpacer() | |
| } else if (clusters.length === 0) { | |
| const et = w.addText("No stories available right now.") | |
| et.font = Font.systemFont(11) | |
| et.textColor = P.subtext | |
| w.addSpacer() | |
| } else { | |
| const items = clusters.slice(0, max) | |
| for (let i = 0; i < items.length; i++) { | |
| const s = items[i] | |
| const row = w.addStack() | |
| row.layoutHorizontally() | |
| row.centerAlignContent() | |
| row.url = `${CFG.baseURL}/${PARAM_CATEGORY}` | |
| // Accent dot | |
| const dot = row.addStack() | |
| dot.size = new Size(5, 5) | |
| dot.backgroundColor = P.accent | |
| dot.cornerRadius = 3 | |
| row.addSpacer(7) | |
| const col = row.addStack() | |
| col.layoutVertically() | |
| const headline = s.title || s.cluster_title || s.headline || "Untitled" | |
| const hlMax = size === "small" ? 55 : 82 | |
| const hl = col.addText(trunc(headline, hlMax)) | |
| hl.font = Font.mediumSystemFont(CFG.headlineFontSize) | |
| hl.textColor = P.text | |
| hl.lineLimit = size === "small" ? 1 : 2 | |
| const src = (s.articles && s.articles[0] && (s.articles[0].domain || s.articles[0].source)) || "" | |
| const more = s.articles && s.articles.length > 1 ? `+${s.articles.length - 1}` : "" | |
| const pub = s.updated_at || s.published_at || (s.articles && s.articles[0] && s.articles[0].published) | |
| const meta = [src, more, timeAgo(pub)].filter(Boolean).join(" · ") | |
| if (meta && size !== "small") { | |
| const mt = col.addText(meta) | |
| mt.font = Font.systemFont(CFG.metaFontSize) | |
| mt.textColor = P.subtext | |
| mt.lineLimit = 1 | |
| } | |
| if (i < items.length - 1) w.addSpacer(size === "small" ? 5 : 6) | |
| } | |
| w.addSpacer() | |
| } | |
| // ── Footer ───────────────────────────────────────── | |
| const ftr = w.addStack() | |
| ftr.url = `${CFG.baseURL}/${PARAM_CATEGORY}` | |
| const ft = ftr.addText("kite.kagi.com · CC BY-NC 4.0") | |
| ft.font = Font.systemFont(7) | |
| ft.textColor = P.subtext | |
| return w | |
| } | |
| // ───────────────────────────────────────────────────── | |
| // ENTRY POINT | |
| // ───────────────────────────────────────────────────── | |
| const size = config.widgetFamily || "medium" | |
| const widget = await buildWidget(size) | |
| if (config.runsInWidget) { | |
| Script.setWidget(widget) | |
| } else { | |
| if (size === "small") widget.presentSmall() | |
| else if (size === "large") widget.presentLarge() | |
| else widget.presentMedium() | |
| } | |
| Script.complete() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment