Skip to content

Instantly share code, notes, and snippets.

@nobodywasishere
Last active March 4, 2026 22:50
Show Gist options
  • Select an option

  • Save nobodywasishere/30808eaf4a9d86b20cde0dc9271999a3 to your computer and use it in GitHub Desktop.

Select an option

Save nobodywasishere/30808eaf4a9d86b20cde0dc9271999a3 to your computer and use it in GitHub Desktop.
kagi news scriptable widget
// 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