{
  "always_run_in_app": false,
  "icon": {
    "color": "deep-blue",
    "glyph": "chart-line"
  },
  "name": "行情",
  "script": "// market-widget.js — Scriptable 桌面小组件：美股 + Crypto 行情\n//\n// 安装：手机 Safari 打开面板(https://widget.sudoo.dev) → 一键安装，别手抄这段代码。\n//   桌面长按空白 → 添加小组件 → Scriptable 中号 → 长按「编辑小组件」→ Script 选「行情」\n// 叠放轮换：拖到另一个同尺寸小组件上即组成叠放，上下滑切换；编辑叠放可开「智能轮换」\n// 省电设计：每次刷新 1 个请求(Worker 模式)；MIN_FETCH_MIN 内重复触发直接读缓存；\n//   美股盘中/盘后自适应刷新间隔；请求失败回退本地缓存；深色模式纯黑背景(OLED 省电)\n\n// ===================== 配置 =====================\n// 显示哪些、以什么顺序显示，都在网页面板上调(https://widget.sudoo.dev)，存在 Worker 的 KV 里。\n// 改配置不用动这个脚本；下面的 ITEMS 只在直连模式(WORKER_URL 置空)下才生效。\nconst WORKER_URL = \"https://widget.sudoo.dev/api/market\"; // 置空则直连数据源\nconst RED_UP = false;            // false=绿涨红跌(美股/币圈惯例) true=红涨绿跌(A股惯例)\nconst REFRESH_OPEN_MIN = 15;     // 美股盘中建议刷新间隔(分钟)。iOS 有刷新配额，<15 意义不大\nconst REFRESH_CLOSED_MIN = 60;   // 盘后/周末刷新间隔(分钟)，越大越省电\nconst MIN_FETCH_MIN = 5;         // 数据新鲜期：n 分钟内重复运行不发网络请求\n\n// --- 以下仅直连模式(WORKER_URL=\"\")使用 ---\n// 股票和币可以任意交错，顺序即显示顺序。coin 的 id 用 CoinGecko 的(bitcoin 而非 BTC)。\nconst ITEMS = [\n  { type: \"stock\", id: \"QQQ\" },\n  { type: \"stock\", id: \"AAPL\" },\n  { type: \"stock\", id: \"TSLA\" },\n  { type: \"stock\", id: \"NVDA\" },\n  { type: \"coin\", id: \"bitcoin\" },\n  { type: \"coin\", id: \"ethereum\" },\n  { type: \"coin\", id: \"solana\" },\n];\nconst STOCK_SOURCE = \"yahoo\";    // \"yahoo\"=雅虎(需海外网络) | \"tencent\"=腾讯行情(国内直连)\n// ================================================\n\nconst ID_SYM = {\n  bitcoin: \"BTC\", ethereum: \"ETH\", solana: \"SOL\", binancecoin: \"BNB\", ripple: \"XRP\",\n  dogecoin: \"DOGE\", cardano: \"ADA\", \"avalanche-2\": \"AVAX\", chainlink: \"LINK\", sui: \"SUI\",\n  \"the-open-network\": \"TON\", tron: \"TRX\", litecoin: \"LTC\", hyperliquid: \"HYPE\",\n  polkadot: \"DOT\", \"matic-network\": \"MATIC\", uniswap: \"UNI\", aave: \"AAVE\",\n  \"shiba-inu\": \"SHIB\", aptos: \"APT\",\n};\nconst coinSym = (id) => ID_SYM[id] || id.slice(0, 4).toUpperCase();\n\nconst fm = FileManager.local();\nconst CACHE_PATH = fm.joinPath(fm.libraryDirectory(), \"market-widget-cache.json\");\n\nconst PRIMARY = Color.dynamic(new Color(\"#1c1c1e\"), new Color(\"#ffffff\"));\nconst SECONDARY = Color.dynamic(new Color(\"#8e8e93\"), new Color(\"#98989e\"));\nconst WARN = new Color(\"#ff9f0a\");\n\n// ---------- 网络 ----------\nfunction makeReq(url) {\n  const r = new Request(url);\n  r.timeoutInterval = 12;\n  r.headers = {\n    \"User-Agent\":\n      \"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15\",\n  };\n  return r;\n}\n\n// Worker 聚合模式：不带参数=用面板存在 KV 里的配置(含顺序)；单请求拿回有序的 items\nasync function fetchViaWorker() {\n  const j = await makeReq(WORKER_URL).loadJSON();\n  if (!j.items || !j.items.length) throw new Error(\"Worker 未返回数据\");\n  return j.items;\n}\n\n// --- 直连模式 ---\nasync function fetchStocksYahoo(symbols) {\n  const url =\n    \"https://query1.finance.yahoo.com/v8/finance/spark?symbols=\" +\n    encodeURIComponent(symbols.join(\",\")) +\n    \"&range=1d&interval=30m\";\n  const j = await makeReq(url).loadJSON();\n  return symbols.map((sym) => {\n    const d = j[sym];\n    const closes = ((d && d.close) || []).filter((x) => x != null);\n    if (!closes.length) throw new Error(\"Yahoo 无数据: \" + sym);\n    const price = closes[closes.length - 1];\n    const prev = d.chartPreviousClose ?? d.previousClose;\n    return { type: \"stock\", id: sym, sym, price, chg: prev ? (price / prev - 1) * 100 : 0 };\n  });\n}\n\n// 腾讯行情：国内直连。字段以 ~ 分隔，3=现价 4=昨收 32=涨跌幅%\nasync function fetchStocksTencent(symbols) {\n  const url = \"https://qt.gtimg.cn/q=\" + symbols.map((s) => \"us\" + s).join(\",\");\n  const text = await makeReq(url).loadString();\n  const map = {};\n  const re = /v_us([\\w.]+)=\"([^\"]*)\"/g;\n  let m;\n  while ((m = re.exec(text)) !== null) {\n    const f = m[2].split(\"~\");\n    const price = parseFloat(f[3]);\n    const prev = parseFloat(f[4]);\n    let chg = parseFloat(f[32]);\n    if (!isFinite(chg)) chg = isFinite(prev) && prev ? (price / prev - 1) * 100 : 0;\n    if (!isFinite(price)) continue;\n    const sym = (f[2] || m[1]).split(\".\")[0].toUpperCase();\n    map[sym] = { type: \"stock\", id: sym, sym, price, chg };\n  }\n  const out = symbols.map((s) => map[s.toUpperCase()]).filter(Boolean);\n  if (!out.length) throw new Error(\"腾讯行情无数据\");\n  return out;\n}\n\nasync function fetchCoinsGecko(ids) {\n  const url =\n    \"https://api.coingecko.com/api/v3/simple/price?ids=\" +\n    encodeURIComponent(ids.join(\",\")) +\n    \"&vs_currencies=usd&include_24hr_change=true\";\n  const j = await makeReq(url).loadJSON();\n  return ids.map((id) => {\n    const d = j[id];\n    if (!d || d.usd == null) throw new Error(\"CoinGecko 缺少 \" + id);\n    return { type: \"coin\", id, sym: coinSym(id), price: d.usd, chg: d.usd_24h_change ?? 0 };\n  });\n}\n\n// Coinbase 兜底：每币一个请求。不用币安——它 403 拒绝很多云出口 IP。\nasync function fetchCoinsCoinbase(ids) {\n  const list = await Promise.all(\n    ids.map(async (id) => {\n      const sym = coinSym(id);\n      try {\n        const j = await makeReq(\n          \"https://api.exchange.coinbase.com/products/\" + sym + \"-USD/stats\"\n        ).loadJSON();\n        const last = parseFloat(j.last);\n        const open = parseFloat(j.open);\n        if (!isFinite(last)) return null;\n        return {\n          type: \"coin\", id, sym, price: last,\n          chg: isFinite(open) && open ? (last / open - 1) * 100 : 0,\n        };\n      } catch (e) {\n        return null;\n      }\n    })\n  );\n  const out = list.filter(Boolean);\n  if (!out.length) throw new Error(\"Coinbase 无数据\");\n  return out;\n}\n\n// 直连模式：分两批取数，再按 ITEMS 的顺序拼回去\nasync function fetchDirect() {\n  const stockIds = ITEMS.filter((i) => i.type === \"stock\").map((i) => i.id);\n  const coinIds = ITEMS.filter((i) => i.type === \"coin\").map((i) => i.id);\n  const [s, c] = await Promise.all([\n    stockIds.length\n      ? (STOCK_SOURCE === \"tencent\" ? fetchStocksTencent(stockIds) : fetchStocksYahoo(stockIds))\n          .catch(() => null)\n      : null,\n    coinIds.length\n      ? fetchCoinsGecko(coinIds).catch(() => fetchCoinsCoinbase(coinIds)).catch(() => null)\n      : null,\n  ]);\n  const byKey = {};\n  for (const q of [...(s || []), ...(c || [])]) byKey[q.type + \":\" + q.id] = q;\n  const quotes = ITEMS.map((i) => byKey[i.type + \":\" + i.id]).filter(Boolean);\n  if (!quotes.length) throw new Error(\"数据源全部失败\");\n  return quotes;\n}\n\n// ---------- 缓存 ----------\nfunction loadCache() {\n  try {\n    if (!fm.fileExists(CACHE_PATH)) return null;\n    const j = JSON.parse(fm.readString(CACHE_PATH));\n    return j && j.ts && j.items ? j : null;\n  } catch (e) {\n    return null;\n  }\n}\n\nfunction saveCache(d) {\n  try {\n    fm.writeString(CACHE_PATH, JSON.stringify(d));\n  } catch (e) {}\n}\n\nasync function getData() {\n  const cache = loadCache();\n  const now = Date.now();\n  // 新鲜缓存直接用：叠放翻页/反复进 app 不会重复打接口\n  if (cache && now - cache.ts < MIN_FETCH_MIN * 60e3) return { ...cache, note: \"\" };\n\n  const live = await (WORKER_URL ? fetchViaWorker() : fetchDirect()).catch(() => null);\n  if (live) {\n    const d = { ts: now, items: live };\n    saveCache(d);\n    return { ...d, note: \"\" };\n  }\n  // 取数失败不该让桌面变白：有缓存就接着用，并标注出来\n  if (cache) return { ...cache, note: \"缓存\" };\n  throw new Error(\"网络请求失败，且无本地缓存\");\n}\n\n// ---------- 美股开盘判断(决定刷新频率) ----------\nfunction usMarketOpen() {\n  try {\n    const parts = {};\n    const fmt = new Intl.DateTimeFormat(\"en-US\", {\n      timeZone: \"America/New_York\",\n      weekday: \"short\",\n      hour: \"2-digit\",\n      minute: \"2-digit\",\n      hour12: false,\n    });\n    for (const p of fmt.formatToParts(new Date())) parts[p.type] = p.value;\n    if (parts.weekday === \"Sat\" || parts.weekday === \"Sun\") return false;\n    const mins = ((parseInt(parts.hour, 10) % 24) * 60) + parseInt(parts.minute, 10);\n    return mins >= 570 && mins < 960; // ET 9:30–16:00\n  } catch (e) {\n    // 无 Intl 时的粗略回退：UTC 13:30–21:00 当作盘中\n    const d = new Date();\n    if (d.getUTCDay() === 0 || d.getUTCDay() === 6) return false;\n    const m = d.getUTCHours() * 60 + d.getUTCMinutes();\n    return m >= 810 && m < 1260;\n  }\n}\n\n// ---------- 格式化 ----------\nconst pad2 = (n) => String(n).padStart(2, \"0\");\n\nfunction fmtPrice(p) {\n  if (p >= 100000) return Math.round(p).toLocaleString(\"en-US\");\n  if (p >= 1000) return p.toLocaleString(\"en-US\", { maximumFractionDigits: 1 });\n  if (p >= 1) return p.toFixed(2);\n  return p.toPrecision(3);\n}\n\nfunction fmtChg(c) {\n  return (c >= 0 ? \"+\" : \"\") + c.toFixed(2) + \"%\";\n}\n\nfunction chgColors(chg) {\n  const green = {\n    fg: Color.dynamic(new Color(\"#1e9e4e\"), new Color(\"#30d158\")),\n    bg: Color.dynamic(new Color(\"#1e9e4e\", 0.12), new Color(\"#30d158\", 0.18)),\n  };\n  const red = {\n    fg: Color.dynamic(new Color(\"#e0362f\"), new Color(\"#ff453a\")),\n    bg: Color.dynamic(new Color(\"#e0362f\", 0.12), new Color(\"#ff453a\", 0.18)),\n  };\n  const up = RED_UP ? red : green;\n  const down = RED_UP ? green : red;\n  return chg >= 0 ? up : down;\n}\n\n// ---------- 渲染 ----------\n// 每个尺寸能放下几行、几列。顺序是配置给的，这里只负责往格子里灌。\n// 小号最多 6 行：≤4 项用舒展排版，5–6 项自动压缩字号和行距。\nfunction layoutFor(family, count) {\n  if (family === \"small\") {\n    return count <= 4\n      ? { cols: 1, rows: 4, fSym: 11, fPrice: 11, fChg: 9, pill: false, gap: 4 }\n      : { cols: 1, rows: 6, fSym: 10, fPrice: 10, fChg: 8, pill: false, gap: 3 };\n  }\n  if (family === \"large\") return { cols: 2, rows: 8, fSym: 15, fPrice: 15, fChg: 12, pill: true, gap: 7 };\n  return { cols: 2, rows: 4, fSym: 12, fPrice: 12, fChg: 10, pill: true, gap: 5 };\n}\n\n// 先填满左列再填右列，两列尽量均分(6 项 = 左 3 右 3)。超出容量的项直接不显示。\nfunction splitColumns(items, { cols, rows }) {\n  const shown = items.slice(0, cols * rows);\n  if (cols === 1) return [shown];\n  const left = Math.ceil(shown.length / 2);\n  return [shown.slice(0, left), shown.slice(left)];\n}\n\nfunction addRow(col, it, L) {\n  const row = col.addStack();\n  row.centerAlignContent();\n  const sym = row.addText(it.sym);\n  sym.font = Font.semiboldSystemFont(L.fSym);\n  sym.textColor = PRIMARY;\n  sym.lineLimit = 1;\n  row.addSpacer();\n  const price = row.addText(fmtPrice(it.price));\n  price.font = Font.mediumMonospacedSystemFont(L.fPrice);\n  price.textColor = PRIMARY;\n  price.lineLimit = 1;\n  price.minimumScaleFactor = 0.7;\n  row.addSpacer(L.pill ? 6 : 5);\n  const c = chgColors(it.chg);\n  if (L.pill) {\n    const chip = row.addStack();\n    chip.setPadding(2, 5, 2, 5);\n    chip.cornerRadius = 5;\n    chip.backgroundColor = c.bg;\n    const ct = chip.addText(fmtChg(it.chg));\n    ct.font = Font.boldMonospacedSystemFont(L.fChg);\n    ct.textColor = c.fg;\n  } else {\n    const ct = row.addText(fmtChg(it.chg));\n    ct.font = Font.boldMonospacedSystemFont(L.fChg);\n    ct.textColor = c.fg;\n  }\n}\n\nfunction buildWidget(data) {\n  const w = new ListWidget();\n  // 深色下纯黑背景：OLED 屏常亮/待机展示时更省电\n  w.backgroundColor = Color.dynamic(new Color(\"#ffffff\"), new Color(\"#000000\"));\n  const family = config.widgetFamily ?? \"medium\";\n  const large = family === \"large\";\n  const pad = large ? 18 : 14;\n  w.setPadding(pad, pad + 2, pad - 2, pad + 2);\n  const mins = usMarketOpen() ? REFRESH_OPEN_MIN : REFRESH_CLOSED_MIN;\n  w.refreshAfterDate = new Date(Date.now() + mins * 60e3);\n\n  const head = w.addStack();\n  head.centerAlignContent();\n  const title = head.addText(family === \"small\" ? \"行情\" : \"美股 · Crypto\");\n  title.font = Font.semiboldSystemFont(large ? 12 : 10);\n  title.textColor = SECONDARY;\n  head.addSpacer();\n  const t = new Date(data.ts);\n  const stamp = head.addText(\n    pad2(t.getHours()) + \":\" + pad2(t.getMinutes()) + (data.note ? \" · \" + data.note : \"\")\n  );\n  stamp.font = Font.systemFont(large ? 12 : 10);\n  stamp.textColor = data.note ? WARN : SECONDARY;\n  w.addSpacer(large ? 10 : 7);\n\n  const L = layoutFor(family, data.items.length);\n  const columns = splitColumns(data.items, L);\n  const body = w.addStack();\n  body.topAlignContent();\n  // 小号单列：行间距 = 固定最小值 + 弹性均摊，把剩余高度分散到行间，\n  // 消掉底部大片留白。双列布局保持固定行距 + 顶部对齐，保证两列行行对齐。\n  const flexFill = family === \"small\";\n  columns.forEach((list, i) => {\n    if (i > 0) body.addSpacer(large ? 26 : 18);\n    const col = body.addStack();\n    col.layoutVertically();\n    list.forEach((it, j) => {\n      if (j > 0) {\n        col.addSpacer(L.gap);\n        if (flexFill) col.addSpacer();\n      }\n      addRow(col, it, L);\n    });\n    if (!flexFill) col.addSpacer();\n  });\n  return w;\n}\n\nfunction buildErrorWidget(msg) {\n  const w = new ListWidget();\n  w.backgroundColor = Color.dynamic(new Color(\"#ffffff\"), new Color(\"#000000\"));\n  w.setPadding(16, 16, 16, 16);\n  w.refreshAfterDate = new Date(Date.now() + 15 * 60e3);\n  const t1 = w.addText(\"行情加载失败\");\n  t1.font = Font.semiboldSystemFont(13);\n  t1.textColor = PRIMARY;\n  w.addSpacer(6);\n  const t2 = w.addText(String(msg).slice(0, 80));\n  t2.font = Font.systemFont(11);\n  t2.textColor = SECONDARY;\n  w.addSpacer(6);\n  const t3 = w.addText(\"检查网络/代理；直连模式可把 STOCK_SOURCE 改为 \\\"tencent\\\"\");\n  t3.font = Font.systemFont(10);\n  t3.textColor = SECONDARY;\n  return w;\n}\n\n// ---------- 主流程 ----------\nlet widget;\ntry {\n  widget = buildWidget(await getData());\n} catch (e) {\n  widget = buildErrorWidget((e && e.message) || e);\n}\nScript.setWidget(widget);\nif (!config.runsInWidget) {\n  if (config.widgetFamily === \"small\") await widget.presentSmall();\n  else if (config.widgetFamily === \"large\") await widget.presentLarge();\n  else await widget.presentMedium();\n}\nScript.complete();\n",
  "share_sheet_inputs": []
}