源码分享实时获取小红书最热榜前十名

效果图
在这里插入图片描述
/**

  • @version 1.0.2
  • @author Honye
    */

/**

  • Thanks @mzeryck
  • @param {number} [height] The screen height measured in pixels
    /
    const phoneSize = (height) => {
    const phones = {
    /
    * 14 Pro Max /
    2796: {
    small: 510,
    medium: 1092,
    large: 1146,
    left: 99,
    right: 681,
    top: 282,
    middle: 918,
    bottom: 1554
    },
    /
    * 14 Pro /
    2556: {
    small: 474,
    medium: 1014,
    large: 1062,
    left: 82,
    right: 622,
    top: 270,
    middle: 858,
    bottom: 1446
    },
    /
    * 13 Pro Max, 12 Pro Max /
    2778: {
    small: 510,
    medium: 1092,
    large: 1146,
    left: 96,
    right: 678,
    top: 246,
    middle: 882,
    bottom: 1518
    },
    /
    * 13, 13 Pro, 12, 12 Pro /
    2532: {
    small: 474,
    medium: 1014,
    large: 1062,
    left: 78,
    right: 618,
    top: 231,
    middle: 819,
    bottom: 1407
    },
    /
    * 11 Pro Max, XS Max /
    2688: {
    small: 507,
    medium: 1080,
    large: 1137,
    left: 81,
    right: 654,
    top: 228,
    middle: 858,
    bottom: 1488
    },
    /
    * 11, XR /
    1792: {
    small: 338,
    medium: 720,
    large: 758,
    left: 55,
    right: 437,
    top: 159,
    middle: 579,
    bottom: 999
    },
    /
    * 13 mini, 12 mini / 11 Pro, XS, X /
    2436: {
    small: 465,
    medium: 987,
    large: 1035,
    x: {
    left: 69,
    right: 591,
    top: 213,
    middle: 783,
    bottom: 1353
    },
    mini: {
    left: 69,
    right: 591,
    top: 231,
    middle: 801,
    bottom: 1371
    }
    },
    /
    * Plus phones /
    2208: {
    small: 471,
    medium: 1044,
    large: 1071,
    left: 99,
    right: 672,
    top: 114,
    middle: 696,
    bottom: 1278
    },
    /
    * SE2 and 6/6S/7/8 /
    1334: {
    small: 296,
    medium: 642,
    large: 648,
    left: 54,
    right: 400,
    top: 60,
    middle: 412,
    bottom: 764
    },
    /
    * SE1 /
    1136: {
    small: 282,
    medium: 584,
    large: 622,
    left: 30,
    right: 332,
    top: 59,
    middle: 399,
    bottom: 399
    },
    /
    * 11 and XR in Display Zoom mode /
    1624: {
    small: 310,
    medium: 658,
    large: 690,
    left: 46,
    right: 394,
    top: 142,
    middle: 522,
    bottom: 902
    },
    /
    * Plus in Display Zoom mode */
    2001: {
    small: 444,
    medium: 963,
    large: 972,
    left: 81,
    right: 600,
    top: 90,
    middle: 618,
    bottom: 1146
    }
    };
    height = height || Device.screenResolution().height;
    const scale = Device.screenScale();

const phone = phones[height];
if (phone) {
return phone
}

if (config.runsInWidget) {
const pc = {
small: 164 * scale,
medium: 344 * scale,
large: 354 * scale
};
return pc
}

// in app screen fixed 375x812 pt
return {
small: 155 * scale,
medium: 329 * scale,
large: 345 * scale
}
};

/**

  • 多语言国际化
  • @param {{[language: string]: string} | [en:string, zh:string]} langs
    */
    const i18n = (langs) => {
    const language = Device.language();
    if (Array.isArray(langs)) {
    langs = {
    en: langs[0],
    zh: langs[1],
    others: langs[0]
    };
    } else {
    langs.others = langs.others || langs.en;
    }
    return langs[language] || langs.others
    };

/**

  • 获取网络图片
  • @param {string} url
    */
    const getImage = async (url) => {
    const request = new Request(url);
    const image = await request.loadImage();
    return image
    };

/**

  • @param {…string} paths
    */
    const joinPath = (…paths) => {
    const fm = FileManager.local();
    return paths.reduce((prev, curr) => {
    return fm.joinPath(prev, curr)
    }, ‘’)
    };

/**

  • 规范使用 FileManager。每个脚本使用独立文件夹
  • 注意:桌面组件无法写入 cacheDirectory 和 temporaryDirectory
  • @param {object} options
  • @param {boolean} [options.useICloud]
  • @param {string} [options.basePath]
    /
    const useFileManager = (options = {}) => {
    const { useICloud, basePath } = options;
    const fm = useICloud ? FileManager.iCloud() : FileManager.local();
    const paths = [fm.documentsDirectory(), Script.name()];
    if (basePath) {
    paths.push(basePath);
    }
    const cacheDirectory = joinPath(…paths);
    /
    *
    • 删除路径末尾所有的 /
    • @param {string} filePath
      /
      const safePath = (filePath) => {
      return fm.joinPath(cacheDirectory, filePath).replace(//+$/, ‘’)
      };
      /
      *
    • 如果上级文件夹不存在,则先创建文件夹
    • @param {string} filePath
      */
      const preWrite = (filePath) => {
      const i = filePath.lastIndexOf(’/’);
      const directory = filePath.substring(0, i);
      if (!fm.fileExists(directory)) {
      fm.createDirectory(directory, true);
      }
      };

const writeString = (filePath, content) => {
const nextPath = safePath(filePath);
preWrite(nextPath);
fm.writeString(nextPath, content);
};

/**

  • @param {string} filePath
  • @param {*} jsonData
    /
    const writeJSON = (filePath, jsonData) => writeString(filePath, JSON.stringify(jsonData));
    /
    *
  • @param {string} filePath
  • @param {Image} image
    */
    const writeImage = (filePath, image) => {
    const nextPath = safePath(filePath);
    preWrite(nextPath);
    return fm.writeImage(nextPath, image)
    };

/**

  • 文件不存在时返回 null
  • @param {string} filePath
  • @returns {string|null}
    */
    const readString = (filePath) => {
    const fullPath = fm.joinPath(cacheDirectory, filePath);
    if (fm.fileExists(fullPath)) {
    return fm.readString(
    fm.joinPath(cacheDirectory, filePath)
    )
    }
    return null
    };

/**

  • @param {string} filePath
    */
    const readJSON = (filePath) => JSON.parse(readString(filePath));

/**

  • @param {string} filePath
    */
    const readImage = (filePath) => {
    return fm.readImage(fm.joinPath(cacheDirectory, filePath))
    };

return {
cacheDirectory,
writeString,
writeJSON,
writeImage,
readString,
readJSON,
readImage
}
};

/** 规范使用文件缓存。每个脚本使用独立文件夹 */
const useCache = () => useFileManager({ basePath: ‘cache’ });

/**

  • @param {string} data
    */
    const hashCode = (data) => {
    return Array.from(data).reduce((accumulator, currentChar) => Math.imul(31, accumulator) + currentChar.charCodeAt(0), 0)
    };

/**

  • @file Scriptable WebView JSBridge native SDK
  • @version 1.0.3
  • @author Honye
    */

/**

  • @typedef Options
  • @property {Record<string, () => void>} methods
    */

const sendResult = (() => {
let sending = false;
/** @type {{ code: string; data: any }[]} */
const list = [];

/**

  • @param {WebView} webView
  • @param {string} code
  • @param {any} data
    */
    return async (webView, code, data) => {
    if (sending) return
sending = true;
list.push({ code, data });
const arr = list.splice(0, list.length);
for (const { code, data } of arr) {
  const eventName = `ScriptableBridge_${code}_Result`;
  const res = data instanceof Error ? { err: data.message } : data;
  await webView.evaluateJavaScript(
    `window.dispatchEvent(
      new CustomEvent(
        '${eventName}',
        { detail: ${JSON.stringify(res)} }
      )
    )`
  );
}
if (list.length) {
  const { code, data } = list.shift();
  sendResult(webView, code, data);
} else {
  sending = false;
}

}
})();

/**

  • @param {WebView} webView
  • @param {Options} options
    */
    const inject = async (webView, options) => {
    const js =
    `(() => {
    const queue = window.__scriptable_bridge_queue
    if (queue && queue.length) {
    completion(queue)
    }
    window.__scriptable_bridge_queue = null

if (!window.ScriptableBridge) {
window.ScriptableBridge = {
invoke(name, data, callback) {
const detail = { code: name, data }

    const eventName = \`ScriptableBridge_\${name}_Result\`
    const controller = new AbortController()
    window.addEventListener(
      eventName,
      (e) => {
        callback && callback(e.detail)
        controller.abort()
      },
      { signal: controller.signal }
    )

    if (window.__scriptable_bridge_queue) {
      window.__scriptable_bridge_queue.push(detail)
      completion()
    } else {
      completion(detail)
      window.__scriptable_bridge_queue = []
    }
  }
}
window.dispatchEvent(
  new CustomEvent('ScriptableBridgeReady')
)

}
})()`;

const res = await webView.evaluateJavaScript(js, true);
if (!res) return inject(webView, options)

const methods = options.methods || {};
const events = Array.isArray(res) ? res : [res];
// 同时执行多次 webView.evaluateJavaScript Scriptable 存在问题
// 可能是因为 JavaScript 是单线程导致的
const sendTasks = events.map(({ code, data }) => {
return (() => {
try {
return Promise.resolve(methodscode)
} catch (e) {
return Promise.reject(e)
}
})()
.then((res) => sendResult(webView, code, res))
.catch((e) => {
console.error(e);
sendResult(webView, code, e instanceof Error ? e : new Error(e));
})
});
await Promise.all(sendTasks);
inject(webView, options);
};

/**

  • @param {WebView} webView
  • @param {object} args
  • @param {string} args.html
  • @param {string} [args.baseURL]
  • @param {Options} options
    */
    const loadHTML = async (webView, args, options = {}) => {
    const { html, baseURL } = args;
    await webView.loadHTML(html, baseURL);
    inject(webView, options).catch((err) => console.error(err));
    };

/**

  • 轻松实现桌面组件可视化配置
    • 颜色选择器及更多表单控件
    • 快速预览
  • GitHub: https://github.com/honye
  • @version 1.6.1
  • @author Honye
    */

const fm = FileManager.local();
const fileName = ‘settings.json’;

const toast = (message) => {
const notification = new Notification();
notification.title = Script.name();
notification.body = message;
notification.schedule();
};

const isUseICloud = () => {
const ifm = useFileManager({ useICloud: true });
const filePath = fm.joinPath(ifm.cacheDirectory, fileName);
return fm.fileExists(filePath)
};

/**

  • @returns {Promise}
    */
    const readSettings = async () => {
    const useICloud = isUseICloud();
    console.log([info] use ${useICloud ? 'iCloud' : 'local'} settings);
    const fm = useFileManager({ useICloud });
    const settings = fm.readJSON(fileName);
    return settings
    };

/**

  • @param {Record<string, unknown>} data
  • @param {{ useICloud: boolean; }} options
    */
    const writeSettings = async (data, { useICloud }) => {
    const fm = useFileManager({ useICloud });
    fm.writeJSON(fileName, data);
    };

const removeSettings = async (settings) => {
const cache = useFileManager({ useICloud: settings.useICloud });
fm.remove(
fm.joinPath(cache.cacheDirectory, fileName)
);
};

const moveSettings = (useICloud, data) => {
const localFM = useFileManager();
const iCloudFM = useFileManager({ useICloud: true });
const [i, l] = [
fm.joinPath(iCloudFM.cacheDirectory, fileName),
fm.joinPath(localFM.cacheDirectory, fileName)
];
try {
// 移动文件需要创建父文件夹,写入操作会自动创建文件夹
writeSettings(data, { useICloud });
if (useICloud) {
if (fm.fileExists(l)) fm.remove(l);
} else {
if (fm.fileExists(i)) fm.remove(i);
}
} catch (e) {
console.error(e);
}
};

/**

  • @typedef {object} NormalFormItem
  • @property {string} name
  • @property {string} label
  • @property {‘text’|‘number’|‘color’|‘select’|‘date’|‘cell’} [type]
    • HTML type 属性
    • 'cell': 可点击的
  • @property {’(prefers-color-scheme: light)’|’(prefers-color-scheme: dark)’} [media]
  • @property {{ label: string; value: unknown }[]} [options]
  • @property {unknown} [default]
    /
    /
    *
  • @typedef {Pick<NormalFormItem, ‘label’|‘name’> & { type: ‘group’, items: FormItem[] }} GroupFormItem
    /
    /
    *
  • @typedef {Omit<NormalFormItem, ‘type’> & { type: ‘page’ } & Pick<Options, ‘formItems’|‘onItemClick’>} PageFormItem 单独的页面
    /
    /
    *
  • @typedef {NormalFormItem|GroupFormItem|PageFormItem} FormItem
    /
    /
    *
  • @typedef {object} CommonSettings
  • @property {boolean} useICloud
  • @property {string} [backgroundImage] 背景图路径
  • @property {string} [backgroundColorLight]
  • @property {string} [backgroundColorDark]
    /
    /
    *
  • @typedef {CommonSettings & Record<string, unknown>} Settings
    /
    /
    *
  • @typedef {object} Options
  • @property {(data: {
  • settings: Settings;
  • family?: typeof config.widgetFamily;
  • }) => ListWidget | Promise} render
  • @property {string} [head] 顶部插入 HTML
  • @property {FormItem[]} [formItems]
  • @property {(item: FormItem) => void} [onItemClick]
  • @property {string} [homePage] 右上角分享菜单地址
  • @property {(data: any) => void} [onWebEvent]
    /
    /
    *
  • @template T
  • @typedef {T extends infer O ? {[K in keyof O]: O[K]} : never} Expand
    */

const previewsHTML =
`


i 18 n ( [ ′ S m a l l ′ , ′ 预 览 小 号 ′ ] ) < / b u t t o n > < b u t t o n c l a s s = " p r e v i e w " d a t a − s i z e = " m e d i u m " > < i c l a s s = " i c o n f o n t i c o n − d a l i e b i a o " > < / i > {i18n(['Small', '预览小号'])}</button> <button class="preview" data-size="medium"><i class="iconfont icon-daliebiao"></i> i18n([Small,])</button><buttonclass="preview"datasize="medium"><iclass="iconfonticondaliebiao"></i>{i18n([‘Medium’, ‘预览中号’])}
${i18n([‘Large’, ‘预览大号’])}

`;

const copyrightHTML =
`

`;

/**

  • @param {Expand} options
  • @param {boolean} [isFirstPage]
  • @param {object} [others]
  • @param {Settings} [others.settings]
  • @returns {Promise<ListWidget|undefined>} 仅在 Widget 中运行时返回 ListWidget
    */
    const present = async (options, isFirstPage, others = {}) => {
    const {
    formItems = [],
    onItemClick,
    render,
    head,
    homePage = ‘https://www.imarkr.com’,
    onWebEvent
    } = options;
    const cache = useCache();

const settings = others.settings || await readSettings() || {};

/**

  • @param {Parameters<Options[‘render’]>[0]} param
    */
    const getWidget = async (param) => {
    const widget = await render(param);
    const { backgroundImage, backgroundColorLight, backgroundColorDark } = settings;
    if (backgroundImage && fm.fileExists(backgroundImage)) {
    widget.backgroundImage = fm.readImage(backgroundImage);
    }
    if (!widget.backgroundColor || backgroundColorLight || backgroundColorDark) {
    widget.backgroundColor = Color.dynamic(
    new Color(backgroundColorLight || ‘#ffffff’),
    new Color(backgroundColorDark || ‘#242426’)
    );
    }
    return widget
    };

if (config.runsInWidget) {
const widget = await getWidget({ settings });
Script.setWidget(widget);
return widget
}

// ====== web start =======
const style =
`:root {
–color-primary: #007aff;
–divider-color: rgba(60,60,67,0.36);
–card-background: #fff;
–card-radius: 10px;
–list-header-color: rgba(60,60,67,0.6);
}

  • {
    -webkit-user-select: none;
    user-select: none;
    }
    body {
    margin: 10px 0;
    -webkit-font-smoothing: antialiased;
    font-family: “SF Pro Display”,“SF Pro Icons”,“Helvetica Neue”,“Helvetica”,“Arial”,sans-serif;
    accent-color: var(–color-primary);
    }
    input {
    -webkit-user-select: auto;
    user-select: auto;
    }
    body {
    background: #f2f2f7;
    }
    button {
    font-size: 16px;
    background: var(–color-primary);
    color: #fff;
    border-radius: 8px;
    border: none;
    padding: 0.24em 0.5em;
    }
    button .iconfont {
    margin-right: 6px;
    }
    .list {
    margin: 15px;
    }
    .list__header {
    margin: 0 20px;
    color: var(–list-header-color);
    font-size: 13px;
    }
    .list__body {
    margin-top: 10px;
    background: var(–card-background);
    border-radius: var(–card-radius);
    border-radius: 12px;
    overflow: hidden;
    }
    .form-item {
    display: flex;
    align-items: center;
    justify-content: space-between;
    column-gap: 1em;
    font-size: 16px;
    min-height: 2em;
    padding: 0.5em 20px;
    position: relative;
    }
    .form-item[media*=“prefers-color-scheme”] {
    display: none;
    }
    .form-item–link .icon-arrow_right {
    color: #86868b;
    }
    .form-item + .form-item::before {
    content: “”;
    position: absolute;
    top: 0;
    left: 20px;
    right: 0;
    border-top: 0.5px solid var(–divider-color);
    }
    .form-item__input-wrapper {
    flex: 1;
    overflow: hidden;
    text-align: right;
    }
    .form-item__input {
    max-width: 100%;
    }
    .form-item .iconfont {
    margin-right: 4px;
    }
    .form-item input,
    .form-item select {
    font-size: 14px;
    text-align: right;
    }
    .form-item input[type=“checkbox”] {
    width: 1.25em;
    height: 1.25em;
    }
    input[type=“number”] {
    width: 4em;
    }
    input[type=“date”] {
    min-width: 6.4em;
    }
    input[type=‘checkbox’][role=‘switch’] {
    position: relative;
    display: inline-block;
    appearance: none;
    width: 40px;
    height: 24px;
    border-radius: 24px;
    background: #ccc;
    transition: 0.3s ease-in-out;
    }
    input[type=‘checkbox’][role=‘switch’]::before {
    content: ‘’;
    position: absolute;
    left: 2px;
    top: 2px;
    width: 20px;
    height: 20px;
    border-radius: 50%;
    background: #fff;
    transition: 0.3s ease-in-out;
    }
    input[type=‘checkbox’][role=‘switch’]:checked {
    background: var(–color-primary);
    }
    input[type=‘checkbox’][role=‘switch’]:checked::before {
    transform: translateX(16px);
    }
    .actions {
    margin: 15px;
    }
    .copyright {
    margin: 15px;
    margin-inline: 18px;
    font-size: 12px;
    color: #86868b;
    }
    .copyright a {
    color: #515154;
    text-decoration: none;
    }
    .preview.loading {
    pointer-events: none;
    }
    .icon-loading {
    display: inline-block;
    animation: 1s linear infinite spin;
    }
    @keyframes spin {
    0% {
    transform: rotate(0);
    }
    100% {
    transform: rotate(1turn);
    }
    }
    @media (prefers-color-scheme: light) {
    .form-item[media="(prefers-color-scheme: light)"] {
    display: flex;
    }
    }
    @media (prefers-color-scheme: dark) {
    :root {
    –divider-color: rgba(84,84,88,0.65);
    –card-background: #1c1c1e;
    –list-header-color: rgba(235,235,245,0.6);
    }
    body {
    background: #000;
    color: #fff;
    }
    input {
    background-color: rgb(58, 57, 57);
    color: var(–color-primary);
    }
    input[type=‘checkbox’][role=‘switch’] {
    background-color: rgb(56, 56, 60);
    }
    input[type=‘checkbox’][role=‘switch’]::before {
    background-color: rgb(206, 206, 206);
    }
    select {
    background-color: rgb(82, 82, 82);
    border: none;
    }
    .form-item[media="(prefers-color-scheme: dark)"] {
    display: flex;
    }
    }
    `;

    const js =
    `(() => {
    const settings = ${JSON.stringify({
    …settings,
    useICloud: isUseICloud()
    })}
    const formItems = ${JSON.stringify(formItems)}

    window.invoke = (code, data, cb) => {
    ScriptableBridge.invoke(code, data, cb)
    }

    const formData = {}

    const createFormItem = (item) => {
    const value = settings[item.name] ?? item.default ?? null
    formData[item.name] = value;
    const label = document.createElement(“label”);
    label.className = “form-item”;
    if (item.media) {
    label.setAttribute(‘media’, item.media)
    }
    const div = document.createElement(“div”);
    div.innerText = item.label;
    label.appendChild(div);
    if (/^(select|multi-select)$/.test(item.type)) {
    const wrapper = document.createElement(‘div’)
    wrapper.className = ‘form-item__input-wrapper’
    const select = document.createElement(‘select’)
    select.className = ‘form-item__input’
    select.name = item.name
    select.multiple = item.type === ‘multi-select’
    const map = (options, parent) => {
    for (const opt of (options || [])) {
    if (opt.children?.length) {
    const elGroup = document.createElement(‘optgroup’)
    elGroup.label = opt.label
    map(opt.children, elGroup)
    parent.appendChild(elGroup)
    } else {
    const option = document.createElement(‘option’)
    option.value = opt.value
    option.innerText = opt.label
    option.selected = Array.isArray(value) ? value.includes(opt.value) : (value === opt.value)
    parent.appendChild(option)
    }
    }
    }
    map(item.options || [], select)
    select.addEventListener(‘change’, ({ target }) => {
    let { value } = target
    if (item.type === ‘multi-select’) {
    value = Array.from(target.selectedOptions).map(({ value }) => value)
    }
    formData[item.name] = value
    invoke(‘changeSettings’, formData)
    })
    wrapper.appendChild(select)
    label.appendChild(wrapper)
    } else if (
    item.type === ‘cell’ ||
    item.type === ‘page’
    ) {
    label.classList.add(‘form-item–link’)
    const icon = document.createElement(‘i’)
    icon.className = ‘iconfont icon-arrow_right’
    label.appendChild(icon)
    label.addEventListener(‘click’, () => {
    const { name } = item
    switch (name) {
    case ‘backgroundImage’:
    invoke(‘chooseBgImg’)
    break
    case ‘clearBackgroundImage’:
    invoke(‘clearBgImg’)
    break
    case ‘reset’:
    reset()
    break
    default:
    invoke(‘itemClick’, item)
    }
    })
    } else {
    const input = document.createElement(“input”)
    input.className = ‘form-item__input’
    input.name = item.name
    input.type = item.type || “text”;
    input.enterKeyHint = ‘done’
    input.value = value
    // Switch
    if (item.type === ‘switch’) {
    input.type = ‘checkbox’
    input.role = ‘switch’
    input.checked = value
    if (item.name === ‘useICloud’) {
    input.addEventListener(‘change’, (e) => {
    invoke(‘moveSettings’, e.target.checked)
    })
    }
    }
    if (item.type === ‘number’) {
    input.inputMode = ‘decimal’
    }
    if (input.type === ‘text’) {
    input.size = 12
    }
    input.addEventListener(“change”, (e) => {
    formData[item.name] =
    item.type === ‘switch’
    ? e.target.checked
    : item.type === ‘number’
    ? Number(e.target.value)
    : e.target.value;
    invoke(‘changeSettings’, formData)
    });
    label.appendChild(input);
    }
    return label
    }

    const createList = (list, title) => {
    const fragment = document.createDocumentFragment()

    let elBody;
    for (const item of list) {
    if (item.type === ‘group’) {
    const grouped = createList(item.items, item.label)
    fragment.appendChild(grouped)
    } else {
    if (!elBody) {
    const groupDiv = fragment.appendChild(document.createElement(‘div’))
    groupDiv.className = ‘list’
    if (title) {
    const elTitle = groupDiv.appendChild(document.createElement(‘div’))
    elTitle.className = ‘list__header’
    elTitle.textContent = title
    }
    elBody = groupDiv.appendChild(document.createElement(‘div’))
    elBody.className = ‘list__body’
    }
    const label = createFormItem(item)
    elBody.appendChild(label)
    }
    }
    return fragment
    }

    const fragment = createList(formItems)
    document.getElementById(‘settings’).appendChild(fragment)

    for (const btn of document.querySelectorAll(’.preview’)) {
    btn.addEventListener(‘click’, (e) => {
    const target = e.currentTarget
    target.classList.add(‘loading’)
    const icon = e.currentTarget.querySelector(’.iconfont’)
    const className = icon.className
    icon.className = ‘iconfont icon-loading’
    invoke(
    ‘preview’,
    e.currentTarget.dataset.size,
    () => {
    target.classList.remove(‘loading’)
    icon.className = className
    }
    )
    })
    }

    const setFieldValue = (name, value) => {
    const input = document.querySelector(`.form-item__input[name="${name}"]`)
    if (!input) return
    if (input.type === ‘checkbox’) {
    input.checked = value
    } else {
    input.value = value
    }
    }

    const reset = (items = formItems) => {
    for (const item of items) {
    if (item.type === ‘group’) {
    reset(item.items)
    } else if (item.type === ‘page’) {
    continue;
    } else {
    setFieldValue(item.name, item.default)
    }
    }
    invoke(‘removeSettings’, formData)
    }
    })()`;

    const html =
    `

    ${head || ''}
    ${isFirstPage ? (previewsHTML + copyrightHTML) : ''}
`;

const webView = new WebView();
const methods = {
async preview (data) {
const widget = await getWidget({ settings, family: data });
widgetpresent${data.replace(data[0], data[0].toUpperCase())};
},
safari (data) {
Safari.openInApp(data, true);
},
changeSettings (data) {
Object.assign(settings, data);
writeSettings(settings, { useICloud: settings.useICloud });
},
moveSettings (data) {
settings.useICloud = data;
moveSettings(data, settings);
},
removeSettings (data) {
Object.assign(settings, data);
clearBgImg();
removeSettings(settings);
},
chooseBgImg (data) {
chooseBgImg();
},
clearBgImg () {
clearBgImg();
},
async itemClick (data) {
if (data.type === ‘page’) {
// data 经传到 HTML 后丢失了不可序列化的数据,因为需要从源数据查找
const item = (() => {
const find = (items) => {
for (const el of items) {
if (el.name === data.name) return el

          if (el.type === 'group') {
            const r = find(el.items);
            if (r) return r
          }
        }
        return null
      };
      return find(formItems)
    })();
    await present(item, false, { settings });
  } else {
    await onItemClick?.(data, { settings });
  }
},
native (data) {
  onWebEvent?.(data);
}

};
await loadHTML(
webView,
{ html, baseURL: homePage },
{ methods }
);

const clearBgImg = () => {
const { backgroundImage } = settings;
delete settings.backgroundImage;
if (backgroundImage && fm.fileExists(backgroundImage)) {
fm.remove(backgroundImage);
}
writeSettings(settings, { useICloud: settings.useICloud });
toast(i18n([‘Cleared success!’, ‘背景已清除’]));
};

const chooseBgImg = async () => {
try {
const image = await Photos.fromLibrary();
cache.writeImage(‘bg.png’, image);
const imgPath = fm.joinPath(cache.cacheDirectory, ‘bg.png’);
settings.backgroundImage = imgPath;
writeSettings(settings, { useICloud: settings.useICloud });
} catch (e) {
console.log(’[info] 用户取消选择图片’);
}
};

webView.present();
// ======= web end =========
};

/**

  • @param {Options} options
    */
    const withSettings = async (options) => {
    const { formItems, onItemClick, …restOptions } = options;
    return present({
    formItems: [
    {
    label: i18n([‘Common’, ‘通用’]),
    type: ‘group’,
    items: [
    {
    label: i18n([‘Sync with iCloud’, ‘iCloud 同步’]),
    type: ‘switch’,
    name: ‘useICloud’,
    default: false
    },
    {
    label: i18n([‘Background’, ‘背景’]),
    type: ‘page’,
    name: ‘background’,
    formItems: [
    {
    label: i18n([‘Background’, ‘背景’]),
    type: ‘group’,
    items: [
    {
    name: ‘backgroundColorLight’,
    type: ‘color’,
    label: i18n([‘Background color’, ‘背景色’]),
    media: ‘(prefers-color-scheme: light)’,
    default: ‘#ffffff’
    },
    {
    name: ‘backgroundColorDark’,
    type: ‘color’,
    label: i18n([‘Background color’, ‘背景色’]),
    media: ‘(prefers-color-scheme: dark)’,
    default: ‘#242426’
    },
    {
    label: i18n([‘Background image’, ‘背景图’]),
    type: ‘cell’,
    name: ‘backgroundImage’
    }
    ]
    },
    {
    type: ‘group’,
    items: [
    {
    label: i18n([‘Clear background image’, ‘清除背景图’]),
    type: ‘cell’,
    name: ‘clearBackgroundImage’
    }
    ]
    }
    ]
    },
    {
    label: i18n([‘Reset’, ‘重置’]),
    type: ‘cell’,
    name: ‘reset’
    }
    ]
    },
    {
    label: i18n([‘Settings’, ‘设置’]),
    type: ‘group’,
    items: formItems
    }
    ],
    onItemClick: (item, …args) => {
    onItemClick?.(item, …args);
    },
    …restOptions
    }, true)
    };

/**

  • @param {WidgetStack} stack
  • @param {object} options
  • @param {number} [options.column] column count
  • @param {number | [number, number]} [options.gap]
  • @param {‘row’ | ‘column’} [options.direction]
    */
    const useGrid = async (stack, options) => {
    const {
    column,
    gap = 0,
    direction = ‘row’
    } = options;
    const [columnGap, rowGap] = typeof gap === ‘number’ ? [gap, gap] : gap;

if (direction === ‘row’) {
stack.layoutVertically();
} else {
stack.layoutHorizontally();
}

let i = -1;
const rows = [];

const add = async (fn) => {
i++;
const r = Math.floor(i / column);
if (i % column === 0) {
if (r > 0) {
stack.addSpacer(rowGap);
}
const rowStack = stack.addStack();
if (direction === ‘row’) {
rowStack.layoutHorizontally();
} else {
rowStack.layoutVertically();
}
rows.push(rowStack);
}

if (i % column > 0) {
  rows[r].addSpacer(columnGap);
}
await fn(rows[r]);

};

return { add }
};

const paddingVertical = 10;
const preference = {
/** @type {‘light’|‘dark’|‘system’} */
colorScheme: ‘system’,
fontSize: 14,
useShadow: false,
lightColor: ‘#333333’,
darkColor: ‘#ffffff’,
indexLightColor: ‘’,
indexDarkColor: ‘’,
timeColor: ‘#666666’,
logoSize: 30,
padding: [NaN, 12, NaN, 14],
gap: 8,
columns: ‘1’
};

const conf = {};
const screen = Device.screenResolution();
const scale = Device.screenScale();
const phone = phoneSize(screen.height);
const cache = useCache();

if (config.runsInWidget) {
const [colorScheme] = (args.widgetParameter || ‘’).split(’;’).map(text => text.trim());
preference.colorScheme = colorScheme || preference.colorScheme;
}

const fetchData = async () => {
const url = ‘https://edith.xiaohongshu.com/api/sns/v1/search/hot_list’;
const request = new Request(url);
request.headers = {
‘User-Agent’: ‘discover/8.27 (iPhone; iOS 17.4; Scale/2.00) Resolution/828*1792 Version/8.27 Build/8270555 Device/(Apple Inc.;iPhone12,1) NetType/CellNetwork’,
‘xy-direction’: ‘11’,
‘Accept-Language’: ‘en-CN;q=1, zh-Hans-CN;q=0.9’,
shield: ‘XYAAAAAQAAAAEAAABTAAAAUzUWEe4xG1IYD9/c+qCLOlKGmTtFa+lG434LeOBXRqtCwIG0n8AVP53/i+Ytz8QursR+2dM1ZAw9FWKBZM6A31hp8uUzKCEBoUmKRKKeHM3/tlWY’,
‘xy-platform-info’: ‘platform=iOS&version=8.54.1&build=8541126&deviceId=05541FBC-7D6B-44B9-8A7D-C4790ED3C9D1&bundle=com.xingin.discover’,
‘xy-common-params’: ‘app_id=ECFAAF02&build=8541126&channel=AppStore&deviceId=05541FBC-7D6B-44B9-8A7D-C4790ED3C9D1&device_fingerprint=2020111813402415850d0efad8f9e4c36e80fb5930417d0175b0e50fd1adc8&device_fingerprint1=2020111813402415850d0efad8f9e4c36e80fb5930417d0175b0e50fd1adc8&device_model=phone&fid=1608005629-0-0-eed79a78f1907a1d38d9d05dcddae6bc&gid=7c4ed0bf25ed547a1267ed41d1df9eaf51de4a284735941f77a554c8&identifier_flag=1&is_mac=0&lang=en&launch_id=748588941&overseas_channel=0&platform=iOS&project_id=ECFAAF&sid=session.1726768676545524226817&t=1726896157&teenager=0&tz=Asia/Shanghai&uis=light&version=8.54.1’,
Referer: ‘https://app.xhs.cn/’
};
try {
const res = await request.loadJSON();
console.log(res);
const df = new DateFormatter();
df.dateFormat = ‘HH:mm’;
const timeString = df.string(new Date());
const data = {
data: res,
updatedAt: timeString
};
cache.writeJSON(‘trending.json’, data);
return data
} catch (e) {
const data = cache.readJSON(‘trending.json’);
return data
}
};

/**

  • 优先使用缓存的 Logo,如果不存在缓存则使用线上 Logo 并缓存
    */
    const getLogoImage = async () => {
    try {
    const image = cache.readImage(‘logo.png’);
    if (!image) {
    throw new Error(‘no cache’)
    }
    return image
    } catch (e) {
    const image = await getImage(‘https://cdn.jsdelivr.net/gh/Honye/scriptable-scripts@master/docs/assets/logo_xhs.png’);
    cache.writeImage(‘logo.png’, image);
    return image
    }
    };

const createWidget = async ({ data, updatedAt }) => {
const {
fontSize,
logoSize,
padding,
gap,
columns
} = preference;
const { widgetFamily } = config;
const heightPX = widgetFamily === ‘medium’ ? phone.small : phone[widgetFamily];
let height = heightPX / scale;
if (columns > 1) {
// 当列数大于 1 时 Logo 和时间占满一行
height -= logoSize;
}
conf.count = Math.floor((height - paddingVertical * 2 + gap) / (fontSize + gap));
if (widgetFamily === ‘small’) {
padding[1] = padding[3] = 6;
}

let stackBottom;
let widgetBottom;
const widget = new ListWidget();
widget.url = ‘xhsdiscover://home’;
const paddingY = paddingVertical - (gap / 2);
widget.setPadding(paddingY, padding[1], paddingY, padding[3]);

const max = Math.min(conf.count, data.data.items.length);
const logoLines = logoSize ? Math.ceil((logoSize + gap) / (fontSize + gap)) : 0;
if (columns > 1) {
await addLogoTime(widget, { time: updatedAt });
const stackItems = widget.addStack();
const { add } = await useGrid(stackItems, { column: columns });
for (let i = 0; i < max * columns; ++i) {
await add((stack) => addItem(stack, data.data.items[i]), i);
}
} else {
for (let i = 0; i < max; ++i) {
const item = data.data.items[i];
if (i === 0) {
const stack = widget.addStack();
await addItem(stack, item, i);
stack.addSpacer();
await addTime(stack, updatedAt);
} else if (i < max - logoLines) {
await addItem(widget, item, i);
} else {
if (!widgetBottom) {
stackBottom = widget.addStack();
stackBottom.bottomAlignContent();
widgetBottom = stackBottom.addStack();
widgetBottom.layoutVertically();
addItem(widgetBottom, item, i);
} else {
await addItem(widgetBottom, item, i);
}
widgetBottom.length = (widgetBottom.length || 0) + 1;
if (widgetBottom.length === logoLines) {
stackBottom.addSpacer();
await addLogo(stackBottom);
}
}
}
}
return widget
};

/**

  • 优先使用线上最新 Icon 并缓存,请求失败时使用缓存
    */
    const getIcon = async (src) => {
    const hash = ${hashCode(src)};
    try {
    const image = await getImage(src);
    cache.writeImage(hash, image);
    return image
    } catch (e) {
    return cache.readImage(hash)
    }
    };

/**

  • @param {WidgetStack} widget
  • @param {*} item
  • @param {number} i
    */
    const addItem = async (widget, item, i) => {
    const {
    fontSize,
    useShadow,
    lightColor,
    darkColor,
    indexLightColor,
    indexDarkColor,
    gap
    } = preference;
    i += 1;
    const { title, icon } = item;
    const stack = widget.addStack();
    stack.url = xhsdiscover://search/result?keyword=${encodeURIComponent(title)};
    stack.centerAlignContent();
    stack.size = new Size(-1, fontSize + gap);
    const stackIndex = stack.addStack();
    stackIndex.size = new Size(fontSize * 1.4, -1);
    const textIndex = stackIndex.addText(${i});
    textIndex.rightAlignText();
    let colors;
    if (indexLightColor) {
    colors = [new Color(indexLightColor), new Color(indexLightColor)];
    }
    if (indexDarkColor) {
    colors = colors || [new Color(indexDarkColor)];
    colors[1] = new Color(indexDarkColor);
    }
    textIndex.textColor = colors
    ? Color.dynamic(…colors)
    : i > 3 ? new Color(’#f5c94c’) : new Color(’#fe4f67’);
    textIndex.font = Font.boldSystemFont(fontSize);
    stack.addSpacer(4);
    const textTitle = stack.addText(title);
    textTitle.font = Font.systemFont(fontSize);
    textTitle.textColor = Color.dynamic(new Color(lightColor), new Color(darkColor));
    textTitle.lineLimit = 1;
    if (useShadow) {
    textTitle.shadowColor = Color.dynamic(
    new Color(lightColor, 0.2),
    new Color(darkColor, 0.2)
    );
    textTitle.shadowOffset = new Point(1, 1);
    textTitle.shadowRadius = 0.5;
    }
    if (icon) {
    stack.addSpacer(4);
    const imageIcon = stack.addImage(await getIcon(icon));
    imageIcon.imageSize = new Size(12, 12);
    }
    stack.addSpacer();
    };

/**

  • @param {WidgetStack} container
    */
    const addLogo = async (container) => {
    const { logoSize } = preference;
    const image = container.addImage(await getLogoImage());
    image.imageSize = new Size(logoSize, logoSize);
    return image
    };

/**

  • @param {WidgetStack} container
  • @param {string} time
    */
    const addTime = async (container, time) => {
    const { fontSize, timeColor } = preference;
    const textTime = container.addText(更新于 ${time});
    textTime.font = Font.systemFont(fontSize * 0.7);
    textTime.textColor = new Color(timeColor);
    return textTime
    };

/**

  • @param {WidgetStack} container
  • @param {object} data
  • @param {string} data.time
    */
    const addLogoTime = async (container, { time }) => {
    const stack = container.addStack();
    stack.centerAlignContent();
    await addLogo(stack);
    stack.addSpacer();
    await addTime(stack, time);
    return stack
    };

const main = async () => {
const data = await fetchData();

await withSettings({
homePage: ‘https://github.com/Honye/scriptable-scripts’,
formItems: [
{
name: ‘lightColor’,
label: i18n([‘Text color’, ‘文字颜色’]),
type: ‘color’,
media: ‘(prefers-color-scheme: light)’,
default: preference.lightColor
},
{
name: ‘darkColor’,
label: i18n([‘Text color’, ‘文字颜色’]),
type: ‘color’,
media: ‘(prefers-color-scheme: dark)’,
default: preference.darkColor
},
{
name: ‘indexLightColor’,
label: i18n([‘Index color’, ‘序号颜色’]),
type: ‘color’,
media: ‘(prefers-color-scheme: light)’,
default: preference.indexLightColor
},
{
name: ‘indexDarkColor’,
label: i18n([‘Index color’, ‘序号颜色’]),
type: ‘color’,
media: ‘(prefers-color-scheme: dark)’,
default: preference.indexDarkColor
},
{
name: ‘useShadow’,
label: i18n([‘Text shadow’, ‘文字阴影’]),
type: ‘switch’,
default: preference.useShadow
},
{
name: ‘fontSize’,
label: i18n([‘Font size’, ‘字体大小’]),
type: ‘number’,
default: preference.fontSize
},
{
name: ‘timeColor’,
label: i18n([‘Time color’, ‘时间颜色’]),
type: ‘color’,
default: preference.timeColor
},
{
name: ‘logoSize’,
label: i18n([‘Logo size (0: hidden)’, ‘Logo 大小(0:隐藏)’]),
type: ‘number’,
default: preference.logoSize
},
{
name: ‘columns’,
label: i18n([‘Column count’, ‘列数’]),
type: ‘select’,
options: [
{ label: ‘1’, value: ‘1’ },
{ label: ‘2’, value: ‘2’ },
{ label: ‘3’, value: ‘3’ }
],
default: preference.columns
}
],
render: async ({ family, settings }) => {
family && (config.widgetFamily = family);
Object.assign(preference, settings);
try {
return await createWidget(data)
} catch (e) {
console.error(e);
}
}
});

Script.complete();
};

await main();

/**

  • @version 1.0.2
  • @author Honye
    */

/**

  • Thanks @mzeryck
  • @param {number} [height] The screen height measured in pixels
    /
    const phoneSize = (height) => {
    const phones = {
    /
    * 14 Pro Max /
    2796: {
    small: 510,
    medium: 1092,
    large: 1146,
    left: 99,
    right: 681,
    top: 282,
    middle: 918,
    bottom: 1554
    },
    /
    * 14 Pro /
    2556: {
    small: 474,
    medium: 1014,
    large: 1062,
    left: 82,
    right: 622,
    top: 270,
    middle: 858,
    bottom: 1446
    },
    /
    * 13 Pro Max, 12 Pro Max /
    2778: {
    small: 510,
    medium: 1092,
    large: 1146,
    left: 96,
    right: 678,
    top: 246,
    middle: 882,
    bottom: 1518
    },
    /
    * 13, 13 Pro, 12, 12 Pro /
    2532: {
    small: 474,
    medium: 1014,
    large: 1062,
    left: 78,
    right: 618,
    top: 231,
    middle: 819,
    bottom: 1407
    },
    /
    * 11 Pro Max, XS Max /
    2688: {
    small: 507,
    medium: 1080,
    large: 1137,
    left: 81,
    right: 654,
    top: 228,
    middle: 858,
    bottom: 1488
    },
    /
    * 11, XR /
    1792: {
    small: 338,
    medium: 720,
    large: 758,
    left: 55,
    right: 437,
    top: 159,
    middle: 579,
    bottom: 999
    },
    /
    * 13 mini, 12 mini / 11 Pro, XS, X /
    2436: {
    small: 465,
    medium: 987,
    large: 1035,
    x: {
    left: 69,
    right: 591,
    top: 213,
    middle: 783,
    bottom: 1353
    },
    mini: {
    left: 69,
    right: 591,
    top: 231,
    middle: 801,
    bottom: 1371
    }
    },
    /
    * Plus phones /
    2208: {
    small: 471,
    medium: 1044,
    large: 1071,
    left: 99,
    right: 672,
    top: 114,
    middle: 696,
    bottom: 1278
    },
    /
    * SE2 and 6/6S/7/8 /
    1334: {
    small: 296,
    medium: 642,
    large: 648,
    left: 54,
    right: 400,
    top: 60,
    middle: 412,
    bottom: 764
    },
    /
    * SE1 /
    1136: {
    small: 282,
    medium: 584,
    large: 622,
    left: 30,
    right: 332,
    top: 59,
    middle: 399,
    bottom: 399
    },
    /
    * 11 and XR in Display Zoom mode /
    1624: {
    small: 310,
    medium: 658,
    large: 690,
    left: 46,
    right: 394,
    top: 142,
    middle: 522,
    bottom: 902
    },
    /
    * Plus in Display Zoom mode */
    2001: {
    small: 444,
    medium: 963,
    large: 972,
    left: 81,
    right: 600,
    top: 90,
    middle: 618,
    bottom: 1146
    }
    };
    height = height || Device.screenResolution().height;
    const scale = Device.screenScale();

const phone = phones[height];
if (phone) {
return phone
}

if (config.runsInWidget) {
const pc = {
small: 164 * scale,
medium: 344 * scale,
large: 354 * scale
};
return pc
}

// in app screen fixed 375x812 pt
return {
small: 155 * scale,
medium: 329 * scale,
large: 345 * scale
}
};

/**

  • 多语言国际化
  • @param {{[language: string]: string} | [en:string, zh:string]} langs
    */
    const i18n = (langs) => {
    const language = Device.language();
    if (Array.isArray(langs)) {
    langs = {
    en: langs[0],
    zh: langs[1],
    others: langs[0]
    };
    } else {
    langs.others = langs.others || langs.en;
    }
    return langs[language] || langs.others
    };

/**

  • 获取网络图片
  • @param {string} url
    */
    const getImage = async (url) => {
    const request = new Request(url);
    const image = await request.loadImage();
    return image
    };

/**

  • @param {…string} paths
    */
    const joinPath = (…paths) => {
    const fm = FileManager.local();
    return paths.reduce((prev, curr) => {
    return fm.joinPath(prev, curr)
    }, ‘’)
    };

/**

  • 规范使用 FileManager。每个脚本使用独立文件夹
  • 注意:桌面组件无法写入 cacheDirectory 和 temporaryDirectory
  • @param {object} options
  • @param {boolean} [options.useICloud]
  • @param {string} [options.basePath]
    /
    const useFileManager = (options = {}) => {
    const { useICloud, basePath } = options;
    const fm = useICloud ? FileManager.iCloud() : FileManager.local();
    const paths = [fm.documentsDirectory(), Script.name()];
    if (basePath) {
    paths.push(basePath);
    }
    const cacheDirectory = joinPath(…paths);
    /
    *
    • 删除路径末尾所有的 /
    • @param {string} filePath
      /
      const safePath = (filePath) => {
      return fm.joinPath(cacheDirectory, filePath).replace(//+$/, ‘’)
      };
      /
      *
    • 如果上级文件夹不存在,则先创建文件夹
    • @param {string} filePath
      */
      const preWrite = (filePath) => {
      const i = filePath.lastIndexOf(’/’);
      const directory = filePath.substring(0, i);
      if (!fm.fileExists(directory)) {
      fm.createDirectory(directory, true);
      }
      };

const writeString = (filePath, content) => {
const nextPath = safePath(filePath);
preWrite(nextPath);
fm.writeString(nextPath, content);
};

/**

  • @param {string} filePath
  • @param {*} jsonData
    /
    const writeJSON = (filePath, jsonData) => writeString(filePath, JSON.stringify(jsonData));
    /
    *
  • @param {string} filePath
  • @param {Image} image
    */
    const writeImage = (filePath, image) => {
    const nextPath = safePath(filePath);
    preWrite(nextPath);
    return fm.writeImage(nextPath, image)
    };

/**

  • 文件不存在时返回 null
  • @param {string} filePath
  • @returns {string|null}
    */
    const readString = (filePath) => {
    const fullPath = fm.joinPath(cacheDirectory, filePath);
    if (fm.fileExists(fullPath)) {
    return fm.readString(
    fm.joinPath(cacheDirectory, filePath)
    )
    }
    return null
    };

/**

  • @param {string} filePath
    */
    const readJSON = (filePath) => JSON.parse(readString(filePath));

/**

  • @param {string} filePath
    */
    const readImage = (filePath) => {
    return fm.readImage(fm.joinPath(cacheDirectory, filePath))
    };

return {
cacheDirectory,
writeString,
writeJSON,
writeImage,
readString,
readJSON,
readImage
}
};

/** 规范使用文件缓存。每个脚本使用独立文件夹 */
const useCache = () => useFileManager({ basePath: ‘cache’ });

/**

  • @param {string} data
    */
    const hashCode = (data) => {
    return Array.from(data).reduce((accumulator, currentChar) => Math.imul(31, accumulator) + currentChar.charCodeAt(0), 0)
    };

/**

  • @file Scriptable WebView JSBridge native SDK
  • @version 1.0.3
  • @author Honye
    */

/**

  • @typedef Options
  • @property {Record<string, () => void>} methods
    */

const sendResult = (() => {
let sending = false;
/** @type {{ code: string; data: any }[]} */
const list = [];

/**

  • @param {WebView} webView
  • @param {string} code
  • @param {any} data
    */
    return async (webView, code, data) => {
    if (sending) return
sending = true;
list.push({ code, data });
const arr = list.splice(0, list.length);
for (const { code, data } of arr) {
  const eventName = `ScriptableBridge_${code}_Result`;
  const res = data instanceof Error ? { err: data.message } : data;
  await webView.evaluateJavaScript(
    `window.dispatchEvent(
      new CustomEvent(
        '${eventName}',
        { detail: ${JSON.stringify(res)} }
      )
    )`
  );
}
if (list.length) {
  const { code, data } = list.shift();
  sendResult(webView, code, data);
} else {
  sending = false;
}

}
})();

/**

  • @param {WebView} webView
  • @param {Options} options
    */
    const inject = async (webView, options) => {
    const js =
    `(() => {
    const queue = window.__scriptable_bridge_queue
    if (queue && queue.length) {
    completion(queue)
    }
    window.__scriptable_bridge_queue = null

if (!window.ScriptableBridge) {
window.ScriptableBridge = {
invoke(name, data, callback) {
const detail = { code: name, data }

    const eventName = \`ScriptableBridge_\${name}_Result\`
    const controller = new AbortController()
    window.addEventListener(
      eventName,
      (e) => {
        callback && callback(e.detail)
        controller.abort()
      },
      { signal: controller.signal }
    )

    if (window.__scriptable_bridge_queue) {
      window.__scriptable_bridge_queue.push(detail)
      completion()
    } else {
      completion(detail)
      window.__scriptable_bridge_queue = []
    }
  }
}
window.dispatchEvent(
  new CustomEvent('ScriptableBridgeReady')
)

}
})()`;

const res = await webView.evaluateJavaScript(js, true);
if (!res) return inject(webView, options)

const methods = options.methods || {};
const events = Array.isArray(res) ? res : [res];
// 同时执行多次 webView.evaluateJavaScript Scriptable 存在问题
// 可能是因为 JavaScript 是单线程导致的
const sendTasks = events.map(({ code, data }) => {
return (() => {
try {
return Promise.resolve(methodscode)
} catch (e) {
return Promise.reject(e)
}
})()
.then((res) => sendResult(webView, code, res))
.catch((e) => {
console.error(e);
sendResult(webView, code, e instanceof Error ? e : new Error(e));
})
});
await Promise.all(sendTasks);
inject(webView, options);
};

/**

  • @param {WebView} webView
  • @param {object} args
  • @param {string} args.html
  • @param {string} [args.baseURL]
  • @param {Options} options
    */
    const loadHTML = async (webView, args, options = {}) => {
    const { html, baseURL } = args;
    await webView.loadHTML(html, baseURL);
    inject(webView, options).catch((err) => console.error(err));
    };

/**

  • 轻松实现桌面组件可视化配置
    • 颜色选择器及更多表单控件
    • 快速预览
  • GitHub: https://github.com/honye
  • @version 1.6.1
  • @author Honye
    */

const fm = FileManager.local();
const fileName = ‘settings.json’;

const toast = (message) => {
const notification = new Notification();
notification.title = Script.name();
notification.body = message;
notification.schedule();
};

const isUseICloud = () => {
const ifm = useFileManager({ useICloud: true });
const filePath = fm.joinPath(ifm.cacheDirectory, fileName);
return fm.fileExists(filePath)
};

/**

  • @returns {Promise}
    */
    const readSettings = async () => {
    const useICloud = isUseICloud();
    console.log([info] use ${useICloud ? 'iCloud' : 'local'} settings);
    const fm = useFileManager({ useICloud });
    const settings = fm.readJSON(fileName);
    return settings
    };

/**

  • @param {Record<string, unknown>} data
  • @param {{ useICloud: boolean; }} options
    */
    const writeSettings = async (data, { useICloud }) => {
    const fm = useFileManager({ useICloud });
    fm.writeJSON(fileName, data);
    };

const removeSettings = async (settings) => {
const cache = useFileManager({ useICloud: settings.useICloud });
fm.remove(
fm.joinPath(cache.cacheDirectory, fileName)
);
};

const moveSettings = (useICloud, data) => {
const localFM = useFileManager();
const iCloudFM = useFileManager({ useICloud: true });
const [i, l] = [
fm.joinPath(iCloudFM.cacheDirectory, fileName),
fm.joinPath(localFM.cacheDirectory, fileName)
];
try {
// 移动文件需要创建父文件夹,写入操作会自动创建文件夹
writeSettings(data, { useICloud });
if (useICloud) {
if (fm.fileExists(l)) fm.remove(l);
} else {
if (fm.fileExists(i)) fm.remove(i);
}
} catch (e) {
console.error(e);
}
};

/**

  • @typedef {object} NormalFormItem
  • @property {string} name
  • @property {string} label
  • @property {‘text’|‘number’|‘color’|‘select’|‘date’|‘cell’} [type]
    • HTML type 属性
    • 'cell': 可点击的
  • @property {’(prefers-color-scheme: light)’|’(prefers-color-scheme: dark)’} [media]
  • @property {{ label: string; value: unknown }[]} [options]
  • @property {unknown} [default]
    /
    /
    *
  • @typedef {Pick<NormalFormItem, ‘label’|‘name’> & { type: ‘group’, items: FormItem[] }} GroupFormItem
    /
    /
    *
  • @typedef {Omit<NormalFormItem, ‘type’> & { type: ‘page’ } & Pick<Options, ‘formItems’|‘onItemClick’>} PageFormItem 单独的页面
    /
    /
    *
  • @typedef {NormalFormItem|GroupFormItem|PageFormItem} FormItem
    /
    /
    *
  • @typedef {object} CommonSettings
  • @property {boolean} useICloud
  • @property {string} [backgroundImage] 背景图路径
  • @property {string} [backgroundColorLight]
  • @property {string} [backgroundColorDark]
    /
    /
    *
  • @typedef {CommonSettings & Record<string, unknown>} Settings
    /
    /
    *
  • @typedef {object} Options
  • @property {(data: {
  • settings: Settings;
  • family?: typeof config.widgetFamily;
  • }) => ListWidget | Promise} render
  • @property {string} [head] 顶部插入 HTML
  • @property {FormItem[]} [formItems]
  • @property {(item: FormItem) => void} [onItemClick]
  • @property {string} [homePage] 右上角分享菜单地址
  • @property {(data: any) => void} [onWebEvent]
    /
    /
    *
  • @template T
  • @typedef {T extends infer O ? {[K in keyof O]: O[K]} : never} Expand
    */

const previewsHTML =
`


i 18 n ( [ ′ S m a l l ′ , ′ 预 览 小 号 ′ ] ) < / b u t t o n > < b u t t o n c l a s s = " p r e v i e w " d a t a − s i z e = " m e d i u m " > < i c l a s s = " i c o n f o n t i c o n − d a l i e b i a o " > < / i > {i18n(['Small', '预览小号'])}</button> <button class="preview" data-size="medium"><i class="iconfont icon-daliebiao"></i> i18n([Small,])</button><buttonclass="preview"datasize="medium"><iclass="iconfonticondaliebiao"></i>{i18n([‘Medium’, ‘预览中号’])}
${i18n([‘Large’, ‘预览大号’])}

`;

const copyrightHTML =
`

`;

/**

  • @param {Expand} options
  • @param {boolean} [isFirstPage]
  • @param {object} [others]
  • @param {Settings} [others.settings]
  • @returns {Promise<ListWidget|undefined>} 仅在 Widget 中运行时返回 ListWidget
    */
    const present = async (options, isFirstPage, others = {}) => {
    const {
    formItems = [],
    onItemClick,
    render,
    head,
    homePage = ‘https://www.imarkr.com’,
    onWebEvent
    } = options;
    const cache = useCache();

const settings = others.settings || await readSettings() || {};

/**

  • @param {Parameters<Options[‘render’]>[0]} param
    */
    const getWidget = async (param) => {
    const widget = await render(param);
    const { backgroundImage, backgroundColorLight, backgroundColorDark } = settings;
    if (backgroundImage && fm.fileExists(backgroundImage)) {
    widget.backgroundImage = fm.readImage(backgroundImage);
    }
    if (!widget.backgroundColor || backgroundColorLight || backgroundColorDark) {
    widget.backgroundColor = Color.dynamic(
    new Color(backgroundColorLight || ‘#ffffff’),
    new Color(backgroundColorDark || ‘#242426’)
    );
    }
    return widget
    };

if (config.runsInWidget) {
const widget = await getWidget({ settings });
Script.setWidget(widget);
return widget
}

// ====== web start =======
const style =
`:root {
–color-primary: #007aff;
–divider-color: rgba(60,60,67,0.36);
–card-background: #fff;
–card-radius: 10px;
–list-header-color: rgba(60,60,67,0.6);
}

  • {
    -webkit-user-select: none;
    user-select: none;
    }
    body {
    margin: 10px 0;
    -webkit-font-smoothing: antialiased;
    font-family: “SF Pro Display”,“SF Pro Icons”,“Helvetica Neue”,“Helvetica”,“Arial”,sans-serif;
    accent-color: var(–color-primary);
    }
    input {
    -webkit-user-select: auto;
    user-select: auto;
    }
    body {
    background: #f2f2f7;
    }
    button {
    font-size: 16px;
    background: var(–color-primary);
    color: #fff;
    border-radius: 8px;
    border: none;
    padding: 0.24em 0.5em;
    }
    button .iconfont {
    margin-right: 6px;
    }
    .list {
    margin: 15px;
    }
    .list__header {
    margin: 0 20px;
    color: var(–list-header-color);
    font-size: 13px;
    }
    .list__body {
    margin-top: 10px;
    background: var(–card-background);
    border-radius: var(–card-radius);
    border-radius: 12px;
    overflow: hidden;
    }
    .form-item {
    display: flex;
    align-items: center;
    justify-content: space-between;
    column-gap: 1em;
    font-size: 16px;
    min-height: 2em;
    padding: 0.5em 20px;
    position: relative;
    }
    .form-item[media*=“prefers-color-scheme”] {
    display: none;
    }
    .form-item–link .icon-arrow_right {
    color: #86868b;
    }
    .form-item + .form-item::before {
    content: “”;
    position: absolute;
    top: 0;
    left: 20px;
    right: 0;
    border-top: 0.5px solid var(–divider-color);
    }
    .form-item__input-wrapper {
    flex: 1;
    overflow: hidden;
    text-align: right;
    }
    .form-item__input {
    max-width: 100%;
    }
    .form-item .iconfont {
    margin-right: 4px;
    }
    .form-item input,
    .form-item select {
    font-size: 14px;
    text-align: right;
    }
    .form-item input[type=“checkbox”] {
    width: 1.25em;
    height: 1.25em;
    }
    input[type=“number”] {
    width: 4em;
    }
    input[type=“date”] {
    min-width: 6.4em;
    }
    input[type=‘checkbox’][role=‘switch’] {
    position: relative;
    display: inline-block;
    appearance: none;
    width: 40px;
    height: 24px;
    border-radius: 24px;
    background: #ccc;
    transition: 0.3s ease-in-out;
    }
    input[type=‘checkbox’][role=‘switch’]::before {
    content: ‘’;
    position: absolute;
    left: 2px;
    top: 2px;
    width: 20px;
    height: 20px;
    border-radius: 50%;
    background: #fff;
    transition: 0.3s ease-in-out;
    }
    input[type=‘checkbox’][role=‘switch’]:checked {
    background: var(–color-primary);
    }
    input[type=‘checkbox’][role=‘switch’]:checked::before {
    transform: translateX(16px);
    }
    .actions {
    margin: 15px;
    }
    .copyright {
    margin: 15px;
    margin-inline: 18px;
    font-size: 12px;
    color: #86868b;
    }
    .copyright a {
    color: #515154;
    text-decoration: none;
    }
    .preview.loading {
    pointer-events: none;
    }
    .icon-loading {
    display: inline-block;
    animation: 1s linear infinite spin;
    }
    @keyframes spin {
    0% {
    transform: rotate(0);
    }
    100% {
    transform: rotate(1turn);
    }
    }
    @media (prefers-color-scheme: light) {
    .form-item[media="(prefers-color-scheme: light)"] {
    display: flex;
    }
    }
    @media (prefers-color-scheme: dark) {
    :root {
    –divider-color: rgba(84,84,88,0.65);
    –card-background: #1c1c1e;
    –list-header-color: rgba(235,235,245,0.6);
    }
    body {
    background: #000;
    color: #fff;
    }
    input {
    background-color: rgb(58, 57, 57);
    color: var(–color-primary);
    }
    input[type=‘checkbox’][role=‘switch’] {
    background-color: rgb(56, 56, 60);
    }
    input[type=‘checkbox’][role=‘switch’]::before {
    background-color: rgb(206, 206, 206);
    }
    select {
    background-color: rgb(82, 82, 82);
    border: none;
    }
    .form-item[media="(prefers-color-scheme: dark)"] {
    display: flex;
    }
    }
    `;

    const js =
    `(() => {
    const settings = ${JSON.stringify({
    …settings,
    useICloud: isUseICloud()
    })}
    const formItems = ${JSON.stringify(formItems)}

    window.invoke = (code, data, cb) => {
    ScriptableBridge.invoke(code, data, cb)
    }

    const formData = {}

    const createFormItem = (item) => {
    const value = settings[item.name] ?? item.default ?? null
    formData[item.name] = value;
    const label = document.createElement(“label”);
    label.className = “form-item”;
    if (item.media) {
    label.setAttribute(‘media’, item.media)
    }
    const div = document.createElement(“div”);
    div.innerText = item.label;
    label.appendChild(div);
    if (/^(select|multi-select)$/.test(item.type)) {
    const wrapper = document.createElement(‘div’)
    wrapper.className = ‘form-item__input-wrapper’
    const select = document.createElement(‘select’)
    select.className = ‘form-item__input’
    select.name = item.name
    select.multiple = item.type === ‘multi-select’
    const map = (options, parent) => {
    for (const opt of (options || [])) {
    if (opt.children?.length) {
    const elGroup = document.createElement(‘optgroup’)
    elGroup.label = opt.label
    map(opt.children, elGroup)
    parent.appendChild(elGroup)
    } else {
    const option = document.createElement(‘option’)
    option.value = opt.value
    option.innerText = opt.label
    option.selected = Array.isArray(value) ? value.includes(opt.value) : (value === opt.value)
    parent.appendChild(option)
    }
    }
    }
    map(item.options || [], select)
    select.addEventListener(‘change’, ({ target }) => {
    let { value } = target
    if (item.type === ‘multi-select’) {
    value = Array.from(target.selectedOptions).map(({ value }) => value)
    }
    formData[item.name] = value
    invoke(‘changeSettings’, formData)
    })
    wrapper.appendChild(select)
    label.appendChild(wrapper)
    } else if (
    item.type === ‘cell’ ||
    item.type === ‘page’
    ) {
    label.classList.add(‘form-item–link’)
    const icon = document.createElement(‘i’)
    icon.className = ‘iconfont icon-arrow_right’
    label.appendChild(icon)
    label.addEventListener(‘click’, () => {
    const { name } = item
    switch (name) {
    case ‘backgroundImage’:
    invoke(‘chooseBgImg’)
    break
    case ‘clearBackgroundImage’:
    invoke(‘clearBgImg’)
    break
    case ‘reset’:
    reset()
    break
    default:
    invoke(‘itemClick’, item)
    }
    })
    } else {
    const input = document.createElement(“input”)
    input.className = ‘form-item__input’
    input.name = item.name
    input.type = item.type || “text”;
    input.enterKeyHint = ‘done’
    input.value = value
    // Switch
    if (item.type === ‘switch’) {
    input.type = ‘checkbox’
    input.role = ‘switch’
    input.checked = value
    if (item.name === ‘useICloud’) {
    input.addEventListener(‘change’, (e) => {
    invoke(‘moveSettings’, e.target.checked)
    })
    }
    }
    if (item.type === ‘number’) {
    input.inputMode = ‘decimal’
    }
    if (input.type === ‘text’) {
    input.size = 12
    }
    input.addEventListener(“change”, (e) => {
    formData[item.name] =
    item.type === ‘switch’
    ? e.target.checked
    : item.type === ‘number’
    ? Number(e.target.value)
    : e.target.value;
    invoke(‘changeSettings’, formData)
    });
    label.appendChild(input);
    }
    return label
    }

    const createList = (list, title) => {
    const fragment = document.createDocumentFragment()

    let elBody;
    for (const item of list) {
    if (item.type === ‘group’) {
    const grouped = createList(item.items, item.label)
    fragment.appendChild(grouped)
    } else {
    if (!elBody) {
    const groupDiv = fragment.appendChild(document.createElement(‘div’))
    groupDiv.className = ‘list’
    if (title) {
    const elTitle = groupDiv.appendChild(document.createElement(‘div’))
    elTitle.className = ‘list__header’
    elTitle.textContent = title
    }
    elBody = groupDiv.appendChild(document.createElement(‘div’))
    elBody.className = ‘list__body’
    }
    const label = createFormItem(item)
    elBody.appendChild(label)
    }
    }
    return fragment
    }

    const fragment = createList(formItems)
    document.getElementById(‘settings’).appendChild(fragment)

    for (const btn of document.querySelectorAll(’.preview’)) {
    btn.addEventListener(‘click’, (e) => {
    const target = e.currentTarget
    target.classList.add(‘loading’)
    const icon = e.currentTarget.querySelector(’.iconfont’)
    const className = icon.className
    icon.className = ‘iconfont icon-loading’
    invoke(
    ‘preview’,
    e.currentTarget.dataset.size,
    () => {
    target.classList.remove(‘loading’)
    icon.className = className
    }
    )
    })
    }

    const setFieldValue = (name, value) => {
    const input = document.querySelector(`.form-item__input[name="${name}"]`)
    if (!input) return
    if (input.type === ‘checkbox’) {
    input.checked = value
    } else {
    input.value = value
    }
    }

    const reset = (items = formItems) => {
    for (const item of items) {
    if (item.type === ‘group’) {
    reset(item.items)
    } else if (item.type === ‘page’) {
    continue;
    } else {
    setFieldValue(item.name, item.default)
    }
    }
    invoke(‘removeSettings’, formData)
    }
    })()`;

    const html =
    `

    ${head || ''}
    ${isFirstPage ? (previewsHTML + copyrightHTML) : ''}
`;

const webView = new WebView();
const methods = {
async preview (data) {
const widget = await getWidget({ settings, family: data });
widgetpresent${data.replace(data[0], data[0].toUpperCase())};
},
safari (data) {
Safari.openInApp(data, true);
},
changeSettings (data) {
Object.assign(settings, data);
writeSettings(settings, { useICloud: settings.useICloud });
},
moveSettings (data) {
settings.useICloud = data;
moveSettings(data, settings);
},
removeSettings (data) {
Object.assign(settings, data);
clearBgImg();
removeSettings(settings);
},
chooseBgImg (data) {
chooseBgImg();
},
clearBgImg () {
clearBgImg();
},
async itemClick (data) {
if (data.type === ‘page’) {
// data 经传到 HTML 后丢失了不可序列化的数据,因为需要从源数据查找
const item = (() => {
const find = (items) => {
for (const el of items) {
if (el.name === data.name) return el

          if (el.type === 'group') {
            const r = find(el.items);
            if (r) return r
          }
        }
        return null
      };
      return find(formItems)
    })();
    await present(item, false, { settings });
  } else {
    await onItemClick?.(data, { settings });
  }
},
native (data) {
  onWebEvent?.(data);
}

};
await loadHTML(
webView,
{ html, baseURL: homePage },
{ methods }
);

const clearBgImg = () => {
const { backgroundImage } = settings;
delete settings.backgroundImage;
if (backgroundImage && fm.fileExists(backgroundImage)) {
fm.remove(backgroundImage);
}
writeSettings(settings, { useICloud: settings.useICloud });
toast(i18n([‘Cleared success!’, ‘背景已清除’]));
};

const chooseBgImg = async () => {
try {
const image = await Photos.fromLibrary();
cache.writeImage(‘bg.png’, image);
const imgPath = fm.joinPath(cache.cacheDirectory, ‘bg.png’);
settings.backgroundImage = imgPath;
writeSettings(settings, { useICloud: settings.useICloud });
} catch (e) {
console.log(’[info] 用户取消选择图片’);
}
};

webView.present();
// ======= web end =========
};

/**

  • @param {Options} options
    */
    const withSettings = async (options) => {
    const { formItems, onItemClick, …restOptions } = options;
    return present({
    formItems: [
    {
    label: i18n([‘Common’, ‘通用’]),
    type: ‘group’,
    items: [
    {
    label: i18n([‘Sync with iCloud’, ‘iCloud 同步’]),
    type: ‘switch’,
    name: ‘useICloud’,
    default: false
    },
    {
    label: i18n([‘Background’, ‘背景’]),
    type: ‘page’,
    name: ‘background’,
    formItems: [
    {
    label: i18n([‘Background’, ‘背景’]),
    type: ‘group’,
    items: [
    {
    name: ‘backgroundColorLight’,
    type: ‘color’,
    label: i18n([‘Background color’, ‘背景色’]),
    media: ‘(prefers-color-scheme: light)’,
    default: ‘#ffffff’
    },
    {
    name: ‘backgroundColorDark’,
    type: ‘color’,
    label: i18n([‘Background color’, ‘背景色’]),
    media: ‘(prefers-color-scheme: dark)’,
    default: ‘#242426’
    },
    {
    label: i18n([‘Background image’, ‘背景图’]),
    type: ‘cell’,
    name: ‘backgroundImage’
    }
    ]
    },
    {
    type: ‘group’,
    items: [
    {
    label: i18n([‘Clear background image’, ‘清除背景图’]),
    type: ‘cell’,
    name: ‘clearBackgroundImage’
    }
    ]
    }
    ]
    },
    {
    label: i18n([‘Reset’, ‘重置’]),
    type: ‘cell’,
    name: ‘reset’
    }
    ]
    },
    {
    label: i18n([‘Settings’, ‘设置’]),
    type: ‘group’,
    items: formItems
    }
    ],
    onItemClick: (item, …args) => {
    onItemClick?.(item, …args);
    },
    …restOptions
    }, true)
    };

/**

  • @param {WidgetStack} stack
  • @param {object} options
  • @param {number} [options.column] column count
  • @param {number | [number, number]} [options.gap]
  • @param {‘row’ | ‘column’} [options.direction]
    */
    const useGrid = async (stack, options) => {
    const {
    column,
    gap = 0,
    direction = ‘row’
    } = options;
    const [columnGap, rowGap] = typeof gap === ‘number’ ? [gap, gap] : gap;

if (direction === ‘row’) {
stack.layoutVertically();
} else {
stack.layoutHorizontally();
}

let i = -1;
const rows = [];

const add = async (fn) => {
i++;
const r = Math.floor(i / column);
if (i % column === 0) {
if (r > 0) {
stack.addSpacer(rowGap);
}
const rowStack = stack.addStack();
if (direction === ‘row’) {
rowStack.layoutHorizontally();
} else {
rowStack.layoutVertically();
}
rows.push(rowStack);
}

if (i % column > 0) {
  rows[r].addSpacer(columnGap);
}
await fn(rows[r]);

};

return { add }
};

const paddingVertical = 10;
const preference = {
/** @type {‘light’|‘dark’|‘system’} */
colorScheme: ‘system’,
fontSize: 14,
useShadow: false,
lightColor: ‘#333333’,
darkColor: ‘#ffffff’,
indexLightColor: ‘’,
indexDarkColor: ‘’,
timeColor: ‘#666666’,
logoSize: 30,
padding: [NaN, 12, NaN, 14],
gap: 8,
columns: ‘1’
};

const conf = {};
const screen = Device.screenResolution();
const scale = Device.screenScale();
const phone = phoneSize(screen.height);
const cache = useCache();

if (config.runsInWidget) {
const [colorScheme] = (args.widgetParameter || ‘’).split(’;’).map(text => text.trim());
preference.colorScheme = colorScheme || preference.colorScheme;
}

const fetchData = async () => {
const url = ‘https://edith.xiaohongshu.com/api/sns/v1/search/hot_list’;
const request = new Request(url);
request.headers = {
‘User-Agent’: ‘discover/8.27 (iPhone; iOS 17.4; Scale/2.00) Resolution/828*1792 Version/8.27 Build/8270555 Device/(Apple Inc.;iPhone12,1) NetType/CellNetwork’,
‘xy-direction’: ‘11’,
‘Accept-Language’: ‘en-CN;q=1, zh-Hans-CN;q=0.9’,
shield: ‘XYAAAAAQAAAAEAAABTAAAAUzUWEe4xG1IYD9/c+qCLOlKGmTtFa+lG434LeOBXRqtCwIG0n8AVP53/i+Ytz8QursR+2dM1ZAw9FWKBZM6A31hp8uUzKCEBoUmKRKKeHM3/tlWY’,
‘xy-platform-info’: ‘platform=iOS&version=8.54.1&build=8541126&deviceId=05541FBC-7D6B-44B9-8A7D-C4790ED3C9D1&bundle=com.xingin.discover’,
‘xy-common-params’: ‘app_id=ECFAAF02&build=8541126&channel=AppStore&deviceId=05541FBC-7D6B-44B9-8A7D-C4790ED3C9D1&device_fingerprint=2020111813402415850d0efad8f9e4c36e80fb5930417d0175b0e50fd1adc8&device_fingerprint1=2020111813402415850d0efad8f9e4c36e80fb5930417d0175b0e50fd1adc8&device_model=phone&fid=1608005629-0-0-eed79a78f1907a1d38d9d05dcddae6bc&gid=7c4ed0bf25ed547a1267ed41d1df9eaf51de4a284735941f77a554c8&identifier_flag=1&is_mac=0&lang=en&launch_id=748588941&overseas_channel=0&platform=iOS&project_id=ECFAAF&sid=session.1726768676545524226817&t=1726896157&teenager=0&tz=Asia/Shanghai&uis=light&version=8.54.1’,
Referer: ‘https://app.xhs.cn/’
};
try {
const res = await request.loadJSON();
console.log(res);
const df = new DateFormatter();
df.dateFormat = ‘HH:mm’;
const timeString = df.string(new Date());
const data = {
data: res,
updatedAt: timeString
};
cache.writeJSON(‘trending.json’, data);
return data
} catch (e) {
const data = cache.readJSON(‘trending.json’);
return data
}
};

/**

  • 优先使用缓存的 Logo,如果不存在缓存则使用线上 Logo 并缓存
    */
    const getLogoImage = async () => {
    try {
    const image = cache.readImage(‘logo.png’);
    if (!image) {
    throw new Error(‘no cache’)
    }
    return image
    } catch (e) {
    const image = await getImage(‘https://cdn.jsdelivr.net/gh/Honye/scriptable-scripts@master/docs/assets/logo_xhs.png’);
    cache.writeImage(‘logo.png’, image);
    return image
    }
    };

const createWidget = async ({ data, updatedAt }) => {
const {
fontSize,
logoSize,
padding,
gap,
columns
} = preference;
const { widgetFamily } = config;
const heightPX = widgetFamily === ‘medium’ ? phone.small : phone[widgetFamily];
let height = heightPX / scale;
if (columns > 1) {
// 当列数大于 1 时 Logo 和时间占满一行
height -= logoSize;
}
conf.count = Math.floor((height - paddingVertical * 2 + gap) / (fontSize + gap));
if (widgetFamily === ‘small’) {
padding[1] = padding[3] = 6;
}

let stackBottom;
let widgetBottom;
const widget = new ListWidget();
widget.url = ‘xhsdiscover://home’;
const paddingY = paddingVertical - (gap / 2);
widget.setPadding(paddingY, padding[1], paddingY, padding[3]);

const max = Math.min(conf.count, data.data.items.length);
const logoLines = logoSize ? Math.ceil((logoSize + gap) / (fontSize + gap)) : 0;
if (columns > 1) {
await addLogoTime(widget, { time: updatedAt });
const stackItems = widget.addStack();
const { add } = await useGrid(stackItems, { column: columns });
for (let i = 0; i < max * columns; ++i) {
await add((stack) => addItem(stack, data.data.items[i]), i);
}
} else {
for (let i = 0; i < max; ++i) {
const item = data.data.items[i];
if (i === 0) {
const stack = widget.addStack();
await addItem(stack, item, i);
stack.addSpacer();
await addTime(stack, updatedAt);
} else if (i < max - logoLines) {
await addItem(widget, item, i);
} else {
if (!widgetBottom) {
stackBottom = widget.addStack();
stackBottom.bottomAlignContent();
widgetBottom = stackBottom.addStack();
widgetBottom.layoutVertically();
addItem(widgetBottom, item, i);
} else {
await addItem(widgetBottom, item, i);
}
widgetBottom.length = (widgetBottom.length || 0) + 1;
if (widgetBottom.length === logoLines) {
stackBottom.addSpacer();
await addLogo(stackBottom);
}
}
}
}
return widget
};

/**

  • 优先使用线上最新 Icon 并缓存,请求失败时使用缓存
    */
    const getIcon = async (src) => {
    const hash = ${hashCode(src)};
    try {
    const image = await getImage(src);
    cache.writeImage(hash, image);
    return image
    } catch (e) {
    return cache.readImage(hash)
    }
    };

/**

  • @param {WidgetStack} widget
  • @param {*} item
  • @param {number} i
    */
    const addItem = async (widget, item, i) => {
    const {
    fontSize,
    useShadow,
    lightColor,
    darkColor,
    indexLightColor,
    indexDarkColor,
    gap
    } = preference;
    i += 1;
    const { title, icon } = item;
    const stack = widget.addStack();
    stack.url = xhsdiscover://search/result?keyword=${encodeURIComponent(title)};
    stack.centerAlignContent();
    stack.size = new Size(-1, fontSize + gap);
    const stackIndex = stack.addStack();
    stackIndex.size = new Size(fontSize * 1.4, -1);
    const textIndex = stackIndex.addText(${i});
    textIndex.rightAlignText();
    let colors;
    if (indexLightColor) {
    colors = [new Color(indexLightColor), new Color(indexLightColor)];
    }
    if (indexDarkColor) {
    colors = colors || [new Color(indexDarkColor)];
    colors[1] = new Color(indexDarkColor);
    }
    textIndex.textColor = colors
    ? Color.dynamic(…colors)
    : i > 3 ? new Color(’#f5c94c’) : new Color(’#fe4f67’);
    textIndex.font = Font.boldSystemFont(fontSize);
    stack.addSpacer(4);
    const textTitle = stack.addText(title);
    textTitle.font = Font.systemFont(fontSize);
    textTitle.textColor = Color.dynamic(new Color(lightColor), new Color(darkColor));
    textTitle.lineLimit = 1;
    if (useShadow) {
    textTitle.shadowColor = Color.dynamic(
    new Color(lightColor, 0.2),
    new Color(darkColor, 0.2)
    );
    textTitle.shadowOffset = new Point(1, 1);
    textTitle.shadowRadius = 0.5;
    }
    if (icon) {
    stack.addSpacer(4);
    const imageIcon = stack.addImage(await getIcon(icon));
    imageIcon.imageSize = new Size(12, 12);
    }
    stack.addSpacer();
    };

/**

  • @param {WidgetStack} container
    */
    const addLogo = async (container) => {
    const { logoSize } = preference;
    const image = container.addImage(await getLogoImage());
    image.imageSize = new Size(logoSize, logoSize);
    return image
    };

/**

  • @param {WidgetStack} container
  • @param {string} time
    */
    const addTime = async (container, time) => {
    const { fontSize, timeColor } = preference;
    const textTime = container.addText(更新于 ${time});
    textTime.font = Font.systemFont(fontSize * 0.7);
    textTime.textColor = new Color(timeColor);
    return textTime
    };

/**

  • @param {WidgetStack} container
  • @param {object} data
  • @param {string} data.time
    */
    const addLogoTime = async (container, { time }) => {
    const stack = container.addStack();
    stack.centerAlignContent();
    await addLogo(stack);
    stack.addSpacer();
    await addTime(stack, time);
    return stack
    };

const main = async () => {
const data = await fetchData();

await withSettings({
homePage: ‘https://github.com/Honye/scriptable-scripts’,
formItems: [
{
name: ‘lightColor’,
label: i18n([‘Text color’, ‘文字颜色’]),
type: ‘color’,
media: ‘(prefers-color-scheme: light)’,
default: preference.lightColor
},
{
name: ‘darkColor’,
label: i18n([‘Text color’, ‘文字颜色’]),
type: ‘color’,
media: ‘(prefers-color-scheme: dark)’,
default: preference.darkColor
},
{
name: ‘indexLightColor’,
label: i18n([‘Index color’, ‘序号颜色’]),
type: ‘color’,
media: ‘(prefers-color-scheme: light)’,
default: preference.indexLightColor
},
{
name: ‘indexDarkColor’,
label: i18n([‘Index color’, ‘序号颜色’]),
type: ‘color’,
media: ‘(prefers-color-scheme: dark)’,
default: preference.indexDarkColor
},
{
name: ‘useShadow’,
label: i18n([‘Text shadow’, ‘文字阴影’]),
type: ‘switch’,
default: preference.useShadow
},
{
name: ‘fontSize’,
label: i18n([‘Font size’, ‘字体大小’]),
type: ‘number’,
default: preference.fontSize
},
{
name: ‘timeColor’,
label: i18n([‘Time color’, ‘时间颜色’]),
type: ‘color’,
default: preference.timeColor
},
{
name: ‘logoSize’,
label: i18n([‘Logo size (0: hidden)’, ‘Logo 大小(0:隐藏)’]),
type: ‘number’,
default: preference.logoSize
},
{
name: ‘columns’,
label: i18n([‘Column count’, ‘列数’]),
type: ‘select’,
options: [
{ label: ‘1’, value: ‘1’ },
{ label: ‘2’, value: ‘2’ },
{ label: ‘3’, value: ‘3’ }
],
default: preference.columns
}
],
render: async ({ family, settings }) => {
family && (config.widgetFamily = family);
Object.assign(preference, settings);
try {
return await createWidget(data)
} catch (e) {
console.error(e);
}
}
});

Script.complete();
};

await main();

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

杨利杰YJlio

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值