一键转载 linux.do 到 WordPress:我的 iOS/浏览器油猴脚本分享

青年 发布于 2025-12-10 25 次阅读


AI智能摘要
本文作者为方便将论坛内容收藏到个人博客,开发了一款针对 linux.do 论坛的浏览器脚本,实现一键转载主楼内容至 WordPress。该脚本能自动抓取主楼,优化正文排版,修复懒加载图片,并可选择将外链图片下载至 WordPress 媒体库,最后在文末添加格式化的转载信息。它采用“两步发布”机制以提升在插件较多的 WordPress 站点上的稳定性。脚本适用于电脑浏览器(通过 Tampermonkey)和 iOS(通过 Safari + Userscripts 扩展),无需在 WordPress 端安装插件,操作轻便可控。
— 此摘要由AI分析文章内容生成,仅供参考。

平时逛论坛看到好帖,总想顺手收藏到自己的 WordPress 博客里。手动复制粘贴倒也能做,但遇到长文 + 多图就很折腾:
要找主楼、去掉无关内容、修复懒加载、把外链图片搬到媒体库、最后还要写转载出处……一套流程下来,热情都被磨光了。

所以我干脆写了一个 “浏览器侧一键转载”脚本,在 linux.do 浏览帖子时点一下按钮,就能把主楼内容发到我的 WordPress。
不需要在 WordPress 里装任何插件,整个过程都在我自己的浏览器里完成,适合轻量、可控、随手转载的场景。


脚本能做什么?

一句话:只转载主楼 → 自动优化正文 → 可选图片入库 → 末尾标明转载信息 → 发布到 WP

具体功能:

1) 只转载主楼

  • 只抓取楼主(OP)的正文
  • 不包含评论、楼层回复
  • 避免转载成“评论合集”,更干净

2) 标题自动加前缀

转载后的文章标题会变成:

【转载】原帖标题

便于自己归档,也对读者更直观。

3) 修复论坛常见图片问题

linux.do 的图片经常出现:

  • 懒加载 data-src
  • 复制后变成空白图
  • 图旁边还带文件名、尺寸、KB 等“元文本”

脚本会:

  • 自动把 data-src 转为正常 src
  • 清理图片旁边的哈希/文件名/尺寸/KB/截图软件名等“脏文本”
  • 只清文本,不破坏图片标签,阅读更舒服

4) 正文结构简单优化(无目录)

不会瞎改原帖意思,只做“轻排版”:

  • 去掉头尾空段落
  • 连续换行归并成段落
  • 识别简单列表变成 ul/ol
  • 图片自动居中、最大宽度自适应
  • 不生成目录(你要求的)

5) 可选:外链图片下载到 WP 媒体库

如果你勾选“图片入库”,脚本会:

  • 下载外链图片
  • 逐个上传到 WordPress 媒体库
  • 把正文内图片链接替换为本地媒体库地址

而且考虑到“图多会卡”的情况:

  • 并发上传限制为 3(手机更稳)
  • 超过 8MB 的大图自动跳过
  • 单张失败不中断整篇发布

6) 自动追加美化后的转载信息

文章末尾会自动加一块“转载信息”:

  • 来源链接
  • 原作者(楼主用户名)
  • 转载时间

样式专门调过,兼容主题/编辑器,不乱、不额外空行

7) 两步发布,避免 REST 超时

有些 WordPress 站点 POST 创建文章会慢/卡/超时(尤其图多、插件多时)。
脚本采用“两步发布”:

  1. 先创建一个极简草稿占位
  2. 再把完整正文写进去
  3. 如果你勾了“直接发布”,再切换为 publish

这样稳定性大幅提升。


使用方法

电脑端(Chrome/Edge/Firefox)

  1. 安装 Tampermonkey 扩展
  2. 新建脚本,把代码完整粘进去保存
  3. 访问任意 linux.do 帖子页
  4. 右下角会出现 “转载到 WP” 按钮
  5. 点按钮 → 选择分类/草稿或发布/是否图片入库 → 发送

注意:第一次使用前,请先在同一浏览器里登录 WordPress 后台一次,脚本需要读取 REST nonce。


iOS(手机)使用

iOS 不支持 Tampermonkey,但可以用两种方式跑 Userscript:

推荐:Safari + Userscripts 扩展

  1. App Store 安装 Userscripts
  2. 设置 → Safari → 扩展 → Userscripts → 允许所有网站
  3. 在 Userscripts App 里新建 .user.js 文件,粘贴脚本
  4. Safari 打开 linux.do 帖子
  5. aA → 扩展 → Userscripts → 启用脚本
  6. 刷新后右下角按钮出现

备选:Orion 浏览器
Orion 原生支持 Userscript,配置更省一步。


一些常见问题

Q1. 为什么发布会超时?

如果你连“占位草稿”都发不出去,那一般是 WordPress 服务器端的问题:
插件保存钩子太重、内存不足、WAF 静默拦截 REST POST 等。
脚本做了客户端防卡死,但根治还是要调服务器(我后面可能单独写一篇)。

Q2. 手机入库图片会不会很慢?

会慢一点,毕竟手机带宽/内存弱。
你可以临时取消勾选“图片入库”,先发正文再说。

Q3. 会不会转载到错误的楼层?

不会。本脚本仅抓主楼 .post-stream article[data-post-id] 的 first post。

Q4. 转载会不会破坏原帖排版?

只做轻度结构清理和图片居中,不改语义。
你仍然可以在 WP 编辑器里二次微调。


为什么我选择“浏览器侧脚本”而不是 WordPress 插件?

  • 不污染 WordPress:不装插件、不改主题、不增加后台负担
  • 随用随开:只在自己浏览器运行,想转载就点,不想就关
  • 安全可控:完全在个人环境里执行,不交给第三方服务
  • 迁移简单:换站点只需改 WP 域名和 connect

最后

这个脚本是我自己日常使用的工具,优先保证“稳定和干净”。
后续我还计划加一些可选功能,比如:

  • 自动检测是否重复转载
  • 自动设置特色图
  • 手机下“最多入库前 N 张图片”的开关

如果你也常看 linux.do / 其他 Discourse 论坛,又想把好内容沉淀到自己的 WordPress,欢迎试试。

需要反馈问题,可以留言交流~

// ==UserScript==
// @name         linux.do → WordPress 纯转载 正式版(两步发布防超时+无AI+无快捷键+只主楼+图片入库限并发+去图片名+正文优化无目录+Gutenberg无空行+日志框)
// @namespace    http://tampermonkey.net/
// @version      7.0.0
// @description  只转载 linux.do 主楼到 WordPress:无 AI、无快捷键、标题前加【转载】;清理图片文件名/哈希/尺寸/KB/截图软件名等元信息(仅清文本不伤图片);修复懒加载图片;正文结构轻优化(段落/列表/图片/间距,无目录);可选分类/草稿或发布/图片入库;末尾美化转载信息且不被主题打乱;UI 内置运行日志框;图片入库支持并发限速+跳过大图+失败不中断;发布采用“两步发稿”防 WP 超时;intro 为 Gutenberg 段落块,后台编辑器无首行空段落。
// @match        https://linux.do/t/*
// @grant        GM_xmlhttpRequest
// @connect      blog.qinnian.xyz
// ==/UserScript==

(function () {
  "use strict";

  /************** 配置区(必改) **************/
  const WP_BASE = "https://blog.qinnian.xyz"; // 你的 WordPress(不要以 / 结尾)
  const DEFAULT_STATUS = "draft";             // draft 草稿 / publish 发布
  const DEFAULT_DOWNLOAD_IMAGES = true;       // 默认勾选“图片入库”
  /*******************************************/

  const WP_POSTS = `${WP_BASE}/wp-json/wp/v2/posts`;
  const WP_CATEGORIES = `${WP_BASE}/wp-json/wp/v2/categories?per_page=100`;
  const WP_MEDIA = `${WP_BASE}/wp-json/wp/v2/media`;
  const WP_ADMIN = `${WP_BASE}/wp-admin/`;

  let cachedNonce = null;
  let cachedNonceAt = 0;

  const $ = (sel, root = document) => root.querySelector(sel);

  function escapeHtml(str = "") {
    return str.replace(/[&<>"']/g, (ch) => ({
      "&": "&amp;", "<": "&lt;", ">": "&gt;",
      '"': "&quot;", "'": "&#39;",
    }[ch]));
  }

  function toErrorMessage(e) {
    if (!e) return "未知错误";
    if (typeof e === "string") return e;
    if (e.message) return e.message;
    try { return JSON.stringify(e); } catch { return String(e); }
  }

  // ✅ 防卡死版 GM 请求(强制超时)
  function gmRequest({ method, url, headers = {}, data = null, timeout = 60000, binary = false }) {
    return new Promise((resolve, reject) => {
      let finished = false;

      const timer = setTimeout(() => {
        if (finished) return;
        finished = true;
        reject(new Error(`请求超时(>${timeout/1000}s):${url}`));
      }, timeout + 2000);

      GM_xmlhttpRequest({
        method, url, headers, data, timeout, binary,
        anonymous: false,
        onload: (resp) => {
          if (finished) return;
          finished = true;
          clearTimeout(timer);
          resolve(resp);
        },
        onerror: (err) => {
          if (finished) return;
          finished = true;
          clearTimeout(timer);
          reject(new Error("网络错误:" + toErrorMessage(err)));
        },
        ontimeout: () => {
          if (finished) return;
          finished = true;
          clearTimeout(timer);
          reject(new Error("GM 请求超时"));
        },
      });
    });
  }

  /* ---------------- 0) 获取 WP REST Nonce(基于登录态) ---------------- */

  async function getRestNonce() {
    const now = Date.now();
    if (cachedNonce && now - cachedNonceAt < 10 * 60 * 1000) return cachedNonce;

    const resp = await gmRequest({ method: "GET", url: WP_ADMIN, timeout: 60000 });
    if (resp.status < 200 || resp.status >= 300) {
      throw new Error(`获取后台页面失败(${resp.status}),请确认已在本浏览器登录 WP 后台。`);
    }

    const html = resp.responseText || "";
    const m1 = html.match(/wpApiSettings\s*=\s*{[^}]*"nonce"\s*:\s*"([^"]+)"/);
    const m2 = html.match(/"rest_nonce"\s*:\s*"([^"]+)"/);
    const nonce = (m1 && m1[1]) || (m2 && m2[1]);

    if (!nonce) {
      throw new Error("未能解析 REST Nonce,请检查是否有安全插件移除了后台 nonce。");
    }

    cachedNonce = nonce;
    cachedNonceAt = now;
    return nonce;
  }

  /* ---------------- 1) 提取 linux.do 主楼 + 只清文本不伤图 ---------------- */

  function extractLinuxDoOP() {
    const rawTitle =
      $("#topic-title h1 a, #topic-title a")?.innerText?.trim() ||
      document.title.trim();

    const title = `【转载】${rawTitle}`;

    const firstPost = document.querySelector(".post-stream article[data-post-id]");
    if (!firstPost) throw new Error("未找到主楼(第一个帖子元素不存在)");

    const author =
      firstPost.dataset.username ||
      firstPost.querySelector(".username")?.innerText?.trim() ||
      "";

    const cooked = firstPost.querySelector(".cooked");
    if (!cooked) throw new Error("未找到主楼正文(.cooked)");

    const clone = cooked.cloneNode(true);

    clone.querySelectorAll(
      "script, style, nav, footer, .quote-controls, .post-menu-area, .onebox-metadata"
    ).forEach(n => n.remove());

    clone.querySelectorAll("figcaption").forEach(n => n.remove());

    // 清理 a(img) 周围的文本
    clone.querySelectorAll("a").forEach(a => {
      if (a.querySelector("img")) {
        [...a.childNodes].forEach(node => {
          if (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) node.remove();
        });
      }
    });

    // 修复懒加载
    clone.querySelectorAll("img").forEach(img => {
      const src = img.getAttribute("src") || "";
      const dataSrc =
        img.getAttribute("data-src") ||
        img.getAttribute("data-original") ||
        img.getAttribute("data-lazy-src") ||
        img.getAttribute("data-actualsrc") ||
        "";
      if ((!src || src === "about:blank") && dataSrc) {
        img.setAttribute("src", dataSrc);
      }
    });

    // 判断是否是“图片名/尺寸/哈希/KB/截图软件名”等元文本
    const isMetaText = (t = "") => {
      const s = t.trim();
      if (!s) return false;

      const hasSize = /\d+\s*[x×]\s*\d+/.test(s);
      const hasWeight = /\d+(\.\d+)?\s*(kb|mb)\b/i.test(s);

      const cleaned = s.replace(/\s+/g, "");
      const hexish = cleaned.match(/[0-9A-Fa-f-]/g) || [];
      const ratio = hexish.length / cleaned.length;
      const looksHash = cleaned.length >= 8 && ratio > 0.9;

      const shotName =
        /^(pixpin|snipaste|screenshot|screen_shot|img|wechat|wx_camera|photo|image|截图|截屏)[-_ ]*/i.test(s);

      return hasSize || hasWeight || looksHash || shotName;
    };

    // 清文字节点
    const walker = document.createTreeWalker(clone, NodeFilter.SHOW_TEXT, null);
    const textNodes = [];
    while (walker.nextNode()) textNodes.push(walker.currentNode);
    textNodes.forEach(tn => { if (isMetaText(tn.textContent)) tn.remove(); });

    // 清容器式元文本
    clone.querySelectorAll("p, div, span, li, code, em, strong").forEach(el => {
      const txt = el.textContent.trim();
      if (isMetaText(txt) && el.querySelectorAll("img").length === 0) el.remove();
    });

    let html = clone.innerHTML.trim();
    html = html.replace(/<p>\s*<\/p>/gi, "");
    html = html.replace(/<div>\s*<\/div>/gi, "");

    return {
      title,
      content: html,
      author_name: author,
      source_url: location.href.split("?")[0],
    };
  }

  /* ---------------- 1.5) 正文结构轻优化(无目录) ---------------- */

  function optimizeBodyHTML(html) {
    if (!html) return html;

    html = html
      .replace(/\r\n/g, "\n")
      .replace(/<br\s*\/?>\s*<br\s*\/?>/gi, "\n\n")
      .replace(/<br\s*\/?>/gi, "\n");

    const doc = new DOMParser().parseFromString(`<div id="root">${html}</div>`, "text/html");
    const root = doc.querySelector("#root");

    const textWalker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
    const textNodes = [];
    while (textWalker.nextNode()) textNodes.push(textWalker.currentNode);

    textNodes.forEach(tn => {
      const text = tn.textContent;
      if (!text || !text.trim()) return;

      const ptag = tn.parentElement?.tagName?.toLowerCase();
      if (["code","pre","blockquote","li"].includes(ptag)) return;

      const blocks = text.split(/\n{2,}/).map(b => b.trim()).filter(Boolean);
      if (blocks.length <= 1) return;

      const frag = doc.createDocumentFragment();

      blocks.forEach(block => {
        const lines = block.split("\n").map(l => l.trim()).filter(Boolean);

        const isOL = lines.length > 1 && lines.every(l => /^\d+[\.\)]\s+/.test(l));
        if (isOL) {
          const ol = doc.createElement("ol");
          lines.forEach(l => {
            const li = doc.createElement("li");
            li.textContent = l.replace(/^\d+[\.\)]\s+/, "");
            ol.appendChild(li);
          });
          frag.appendChild(ol);
          return;
        }

        const isUL = lines.length > 1 && lines.every(l => /^[-•*]\s+/.test(l));
        if (isUL) {
          const ul = doc.createElement("ul");
          lines.forEach(l => {
            const li = doc.createElement("li");
            li.textContent = l.replace(/^[-•*]\s+/, "");
            ul.appendChild(li);
          });
          frag.appendChild(ul);
          return;
        }

        const p = doc.createElement("p");
        p.textContent = block;
        frag.appendChild(p);
      });

      tn.parentNode.replaceChild(frag, tn);
    });

    // 图片居中 + 自适应
    root.querySelectorAll("img").forEach(img => {
      const parent = img.parentElement;
      if (parent && parent.tagName.toLowerCase() === "figure") return;

      const fig = doc.createElement("figure");
      fig.style.cssText = "margin:16px auto;text-align:center;";
      img.style.cssText = (img.getAttribute("style") || "") + ";max-width:100%;height:auto;border-radius:6px;";

      parent.insertBefore(fig, img);
      fig.appendChild(img);
    });

    root.querySelectorAll("p").forEach(p => {
      p.style.cssText = (p.getAttribute("style") || "") + ";margin:0 0 14px;line-height:1.8;font-size:16px;";
    });
    root.querySelectorAll("ul,ol").forEach(list => {
      list.style.cssText = (list.getAttribute("style") || "") + ";margin:0 0 14px 20px;line-height:1.8;font-size:16px;";
    });
    root.querySelectorAll("li").forEach(li => {
      li.style.cssText = (li.getAttribute("style") || "") + ";margin:4px 0;";
    });

    // 去掉空容器
    root.querySelectorAll("p,div,span").forEach(el => {
      if (!el.textContent.trim() && el.querySelectorAll("img,video,pre,code,blockquote,ul,ol").length === 0) {
        el.remove();
      }
    });

    return root.innerHTML.trim();
  }

  /* ---------------- 2) 拉分类 ---------------- */

  async function fetchCategories() {
    try {
      const resp = await gmRequest({
        method: "GET",
        url: WP_CATEGORIES,
        headers: { "Content-Type": "application/json" }
      });
      if (resp.status >= 200 && resp.status < 300) {
        const arr = JSON.parse(resp.responseText || "[]");
        return arr.map(c => ({ id: c.id, name: c.name }));
      }
    } catch (e) {
      console.warn("拉取分类失败:", e);
    }
    return [];
  }

  /* ---------------- 3) 图片入库相关(限并发 + 大图跳过) ---------------- */

  async function downloadImageAsArrayBuffer(url) {
    const resp = await new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: "GET",
        url,
        responseType: "arraybuffer",
        anonymous: true,
        onload: r => resolve(r),
        onerror: e => reject(new Error("图片下载失败:" + toErrorMessage(e))),
        ontimeout: () => reject(new Error("图片下载超时")),
      });
    });

    if (resp.status < 200 || resp.status >= 300) {
      throw new Error(`图片下载失败(${resp.status})`);
    }
    return resp;
  }

  async function uploadImageToMedia(url, nonce) {
    const resp = await downloadImageAsArrayBuffer(url);
    const arrayBuffer = resp.response;

    const headers = resp.responseHeaders || "";
    const m = headers.match(/content-type:\s*([^\r\n]+)/i);
    const contentType = m ? m[1].trim() : "image/jpeg";

    const urlObj = new URL(url);
    let filename = urlObj.pathname.split("/").pop() || "image";
    if (!/\.(png|jpe?g|gif|webp|svg)$/i.test(filename)) {
      if (/png/i.test(contentType)) filename += ".png";
      else if (/webp/i.test(contentType)) filename += ".webp";
      else if (/gif/i.test(contentType)) filename += ".gif";
      else if (/svg/i.test(contentType)) filename += ".svg";
      else filename += ".jpg";
    }

    const uploadResp = await new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: "POST",
        url: WP_MEDIA,
        headers: {
          "Content-Type": contentType,
          "Content-Disposition": `attachment; filename="${filename}"`,
          "X-WP-Nonce": nonce,
        },
        data: arrayBuffer,
        binary: true,
        anonymous: false,
        onload: r => resolve(r),
        onerror: e => reject(new Error("图片上传失败:" + toErrorMessage(e))),
        ontimeout: () => reject(new Error("图片上传超时")),
      });
    });

    if (uploadResp.status < 200 || uploadResp.status >= 300) {
      throw new Error(`图片上传失败(${uploadResp.status}): ${uploadResp.responseText}`);
    }

    const json = JSON.parse(uploadResp.responseText || "{}");
    return json.source_url || "";
  }

  async function mapWithConcurrency(items, limit, mapper) {
    const results = new Array(items.length);
    let idx = 0;

    async function worker() {
      while (idx < items.length) {
        const current = idx++;
        try {
          results[current] = await mapper(items[current], current);
        } catch (e) {
          results[current] = { __error: e };
        }
      }
    }

    const workers = Array.from({ length: limit }, () => worker());
    await Promise.all(workers);
    return results;
  }

  async function downloadAndReplaceImages(post, nonce, log = null) {
    const doc = new DOMParser().parseFromString(post.content, "text/html");
    const imgs = Array.from(doc.querySelectorAll("img"));
    if (!imgs.length) return post;

    const wpHost = new URL(WP_BASE).host;
    const base = post.source_url || location.href;
    const cacheMap = {};

    const CONCURRENCY = 3;
    const MAX_SIZE_MB = 8;

    const tasks = imgs.map(img => {
      const src = img.getAttribute("src");
      if (!src) return null;

      let abs;
      try { abs = new URL(src, base).href; }
      catch { return null; }

      const host = new URL(abs).host;
      if (host === wpHost) return null;

      return { img, abs };
    }).filter(Boolean);

    if (log) log(`发现外链图片 ${tasks.length} 张,开始处理(并发=${CONCURRENCY})`);

    await mapWithConcurrency(tasks, CONCURRENCY, async (t, i) => {
      const { img, abs } = t;

      if (cacheMap[abs]) {
        img.setAttribute("src", cacheMap[abs]);
        if (log) log(`[#${i+1}] 使用缓存:${abs}`);
        return;
      }

      try {
        if (log) log(`[#${i+1}] 下载图片:${abs}`);
        const resp = await downloadImageAsArrayBuffer(abs);

        let sizeBytes = 0;
        const h = resp.responseHeaders || "";
        const m = h.match(/content-length:\s*(\d+)/i);
        if (m) sizeBytes = parseInt(m[1], 10) || 0;
        else sizeBytes = resp.response?.byteLength || 0;

        const sizeMB = sizeBytes / (1024 * 1024);

        if (sizeMB > MAX_SIZE_MB) {
          if (log) log(`[#${i+1}] 图片过大(${sizeMB.toFixed(1)}MB) 跳过入库`);
          return;
        }

        if (log) log(`[#${i+1}] 上传媒体库(${sizeMB.toFixed(1)}MB)`);
        const newUrl = await uploadImageToMedia(abs, nonce);

        if (newUrl) {
          cacheMap[abs] = newUrl;
          img.setAttribute("src", newUrl);
          if (log) log(`[#${i+1}] 入库成功 → ${newUrl}`);
        } else {
          if (log) log(`[#${i+1}] 入库失败(空返回)`);
        }
      } catch (e) {
        if (log) log(`[#${i+1}] 入库异常:${toErrorMessage(e)},保留外链`);
      }
    });

    if (log) log("图片处理完成");
    return { ...post, content: doc.body.innerHTML };
  }

  /* ---------------- 4) 两步发布(防超时) ---------------- */

  async function updatePostContent(postId, content, nonce, log) {
    const resp = await gmRequest({
      method: "POST",
      url: `${WP_POSTS}/${postId}`,
      headers: {
        "Content-Type": "application/json",
        "X-WP-Nonce": nonce,
      },
      data: JSON.stringify({ content }),
      timeout: 90000,
    });

    if (resp.status >= 200 && resp.status < 300) {
      if (log) log("二次更新正文成功");
      return JSON.parse(resp.responseText || "{}");
    }
    throw new Error(`二次更新失败(${resp.status}): ${resp.responseText}`);
  }

  async function updatePostStatus(postId, status, nonce, log) {
    const resp = await gmRequest({
      method: "POST",
      url: `${WP_POSTS}/${postId}`,
      headers: {
        "Content-Type": "application/json",
        "X-WP-Nonce": nonce,
      },
      data: JSON.stringify({ status }),
      timeout: 60000,
    });

    if (resp.status >= 200 && resp.status < 300) {
      if (log) log(`状态更新成功 → ${status}`);
      return JSON.parse(resp.responseText || "{}");
    }
    throw new Error(`状态更新失败(${resp.status}): ${resp.responseText}`);
  }

  async function sendPostToWordPress(post, { categoryId, status, nonce, log }) {
    const now = new Date().toLocaleString("zh-CN", { hour12: false });

    // ✅ intro:Gutenberg 段落块开头,后台无首行空段落
    const intro =
`<!-- wp:paragraph -->
<p style="
  margin:0 0 10px 0;
  padding:7px 10px;
  background:#f6f7f9;
  border-left:3px solid #94a3b8;
  border-radius:6px;
  font-size:13.5px;
  color:#475569;
  line-height:1.7;
">
  本文为转载内容,保留原帖观点与结构;如有侵权请联系我处理。
</p>
<!-- /wp:paragraph -->`;

    // ✅ footer:div + span inline,主题不会乱
    const footer =
`<div style="margin-top:18px;">
  <hr style="border:0;height:1px;background:#e2e8f0;margin:16px 0;">

  <div style="
    padding:12px 14px;
    background:#f8fafc;
    border:1px dashed #d0d7de;
    border-radius:12px;
    font-size:14px;
    color:#334155;
    line-height:1.8;
  ">
    <div style="margin:0 0 8px 0;font-weight:700;color:#0f172a;font-size:15px;">
      📌 转载信息
    </div>

    <div style="margin:0 0 6px 0;">
      <span style="color:#64748b;display:inline;font-weight:600;">来源:</span>
      <a href="${post.source_url}" target="_blank" rel="nofollow noopener"
         style="display:inline;color:#2563eb;text-decoration:underline;word-break:break-all;">
        ${post.source_url}
      </a>
    </div>

    ${post.author_name ? `
    <div style="margin:0 0 6px 0;">
      <span style="color:#64748b;display:inline;font-weight:600;">原作者:</span>
      <span style="display:inline;font-weight:600;">${escapeHtml(post.author_name)}</span>
    </div>` : ""}

    <div style="margin:0;">
      <span style="color:#64748b;display:inline;font-weight:600;">转载时间:</span>
      <span style="display:inline;">${now}</span>
    </div>
  </div>
</div>`;

    let optimizedBody = optimizeBodyHTML(post.content).trim();

    // ✅ 彻底清掉正文开头空白/空段落/&nbsp;/br
    optimizedBody = optimizedBody.replace(
      /^(\s|&nbsp;|<br\s*\/?>|<p>\s*(?:&nbsp;|\u00a0|<br\s*\/?>|\s)*<\/p>)+/i,
      ""
    );

    const finalContent = intro.trim() + optimizedBody + footer.trim();

    // ✅ 第一步:占位草稿(秒返回)
    const firstPayload = {
      title: post.title,
      content: "<p>草稿占位,正在写入正文...</p>",
      status: "draft",
      categories: categoryId > 0 ? [categoryId] : [],
    };

    if (log) log("第1步:创建占位草稿...");
    const r1 = await gmRequest({
      method: "POST",
      url: WP_POSTS,
      headers: {
        "Content-Type": "application/json",
        "X-WP-Nonce": nonce,
      },
      data: JSON.stringify(firstPayload),
      timeout: 60000,
    });

    if (r1.status < 200 || r1.status >= 300) {
      throw new Error(`创建草稿失败(${r1.status}): ${r1.responseText}`);
    }

    const created = JSON.parse(r1.responseText || "{}");
    const postId = created.id;
    if (log) log(`草稿创建成功,ID=${postId}`);

    // ✅ 第二步:写入完整正文
    if (log) log("第2步:写入完整正文...");
    let updated = await updatePostContent(postId, finalContent, nonce, log);

    // ✅ 第三步:切换 publish(可选)
    if (status === "publish") {
      if (log) log("第3步:切换为 publish...");
      updated = await updatePostStatus(postId, "publish", nonce, log);
    }

    return updated;
  }

  /* ---------------- 5) UI 弹窗 + 日志框 ---------------- */

  function openRepostUI() {
    let postData = null;
    let categories = [];

    const mask = document.createElement("div");
    mask.style.cssText = `
      position:fixed; inset:0; background:rgba(0,0,0,.45);
      z-index:999999; display:flex; align-items:center; justify-content:center;
      font-family: system-ui, -apple-system, Segoe UI, Roboto, PingFang SC, sans-serif;
    `;

    const modal = document.createElement("div");
    modal.style.cssText = `
      width:520px; max-width:90vw; background:#fff; border-radius:14px;
      box-shadow:0 12px 40px rgba(0,0,0,.26); padding:18px 18px 14px;
    `;

    modal.innerHTML = `
      <div style="font-size:18px;font-weight:700;margin-bottom:6px;">
        转载到 WordPress(只主楼)
      </div>
      <div id="dw-title" style="font-size:13px;color:#666;margin-bottom:8px;max-height:48px;overflow:hidden;"></div>

      <label style="display:block;font-size:14px;margin:6px 0 4px;">选择分类</label>
      <select id="dw-cat" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:8px;font-size:14px;">
        <option value="0">不设置分类</option>
      </select>

      <div style="display:flex;gap:12px;margin-top:10px;flex-wrap:wrap;">
        <label style="display:flex;align-items:center;gap:6px;font-size:14px;">
          <input id="dw-img" type="checkbox" ${DEFAULT_DOWNLOAD_IMAGES ? "checked" : ""} />
          图片入库(下载到媒体库)
        </label>

        <label style="display:flex;align-items:center;gap:6px;font-size:14px;">
          <input id="dw-pub" type="checkbox" ${DEFAULT_STATUS === "publish" ? "checked" : ""} />
          直接发布(否则草稿)
        </label>
      </div>

      <div id="dw-status" style="margin-top:8px;font-size:13px;color:#888;"></div>

      <div style="margin-top:10px;">
        <div style="font-size:13px;color:#666;margin-bottom:6px;">运行日志</div>
        <div id="dw-log" style="
          height:140px;
          overflow:auto;
          background:#0b1020;
          color:#d1d5db;
          font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
          font-size:12px;
          line-height:1.6;
          padding:8px 10px;
          border-radius:8px;
          border:1px solid #111827;
          white-space:pre-wrap;
        "></div>
      </div>

      <div style="display:flex;justify-content:flex-end;gap:8px;margin-top:14px;">
        <button id="dw-cancel" style="padding:8px 12px;border-radius:8px;border:1px solid #ddd;background:#fff;cursor:pointer;">
          取消
        </button>
        <button id="dw-send" style="padding:8px 14px;border-radius:8px;border:none;background:#111;color:#fff;cursor:pointer;">
          发送
        </button>
      </div>
    `;

    mask.appendChild(modal);
    document.body.appendChild(mask);

    const q = (sel) => modal.querySelector(sel);
    const statusEl = q("#dw-status");
    const sendBtn = q("#dw-send");
    const logEl = q("#dw-log");

    function log(msg) {
      const t = new Date().toLocaleTimeString("zh-CN", { hour12:false });
      logEl.textContent += `[${t}] ${msg}\n`;
      logEl.scrollTop = logEl.scrollHeight;
    }

    q("#dw-cancel").onclick = () => mask.remove();

    (async () => {
      try {
        log("开始提取主楼内容...");
        postData = extractLinuxDoOP();
        q("#dw-title").textContent = "标题:" + postData.title;
        log("提取成功");

        log("拉取 WordPress 分类...");
        categories = await fetchCategories();
        const catSel = q("#dw-cat");
        categories.forEach((c) => {
          const opt = document.createElement("option");
          opt.value = c.id;
          opt.textContent = c.name;
          catSel.appendChild(opt);
        });
        log(`分类数量:${categories.length}`);

        statusEl.textContent = "准备就绪,选择分类/选项后点击发送。";
        log("准备就绪");
      } catch (e) {
        const em = toErrorMessage(e);
        statusEl.textContent = "❌ 提取帖子失败:" + em;
        log("提取帖子失败:" + em);
        sendBtn.disabled = true;
      }
    })();

    sendBtn.onclick = async () => {
      try {
        sendBtn.disabled = true;

        const categoryId = parseInt(q("#dw-cat").value, 10) || 0;
        const downloadImages = q("#dw-img").checked;
        const status = q("#dw-pub").checked ? "publish" : "draft";

        statusEl.textContent = "检查 WordPress 登录态...";
        log("检查 WordPress 登录态...");
        const nonce = await getRestNonce();
        log("Nonce 获取成功");

        if (downloadImages) {
          statusEl.textContent = "正在下载并将图片上传到媒体库...";
          log("开始图片入库与替换外链...");
          postData = await downloadAndReplaceImages(postData, nonce, log);
          log("图片入库流程结束");
        } else {
          log("图片入库未开启,跳过");
        }

        statusEl.textContent = "正在发布到 WordPress...";
        log(`开始发布(status=${status}, categoryId=${categoryId || 0})`);

        const res = await sendPostToWordPress(postData, { categoryId, status, nonce, log });

        const postId = res.id || res.ID;
        const link = res.link || (res.guid && res.guid.rendered) || "#";

        statusEl.innerHTML = `✅ 发布成功!文章ID: ${postId}<br><a href="${link}" target="_blank">打开文章</a>`;
        log(`发布成功:ID=${postId}`);
        log(`文章链接:${link}`);
      } catch (e) {
        const em = toErrorMessage(e);
        statusEl.textContent = "❌ 发布失败:" + em;
        log("发布失败:" + em);
      } finally {
        sendBtn.disabled = false;
      }
    };
  }

  /* ---------------- 6) 右下角按钮 ---------------- */

  function initButton() {
    const btn = document.createElement("button");
    btn.textContent = "转载到 WP";
    btn.style.cssText = `
      position:fixed; right:20px; bottom:20px; z-index:99999;
      padding:8px 14px; border:none; border-radius:9px;
      background:#111; color:#fff; cursor:pointer; font-size:14px;
      box-shadow:0 6px 20px rgba(0,0,0,.25);
    `;
    btn.addEventListener("click", openRepostUI);
    document.body.appendChild(btn);
  }

  initButton();
})();