TaoTimer

Schedule checkout and order submission on Taobao/Tmall from a local page panel.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name         TaoTimer
// @name:zh-CN   淘宝定时下单
// @namespace    https://github.com/Miracle3310/TaoTimer
// @version      3.3.1
// @description  Schedule checkout and order submission on Taobao/Tmall from a local page panel.
// @description:zh-CN 淘宝、天猫定时自动结算与提交订单。支持在页面面板直接设置时间并锁定商品,到点自动进行下单流程,不干扰平时正常购物。
// @author       Miracle3310
// @license      MIT
// @homepageURL  https://greasyfork.org/zh-CN/scripts/578859-taotimer
// @supportURL   https://github.com/Miracle3310/TaoTimer/issues
// @match        *://cart.taobao.com/*
// @match        *://buy.taobao.com/*
// @match        *://buy.tmall.com/*
// @grant        GM_deleteValue
// @grant        GM_getValue
// @grant        GM_setValue
// ==/UserScript==

(function () {
  "use strict";

  (() => {
    const BUYING_WINDOW_SECONDS = 5;
    const START_EARLY_MS = 10_000;
    const SUBMIT_TIMEOUT_MS = 15_000;
    const SUBMIT_RETRY_DELAY_MS = 2_000;
    const MAX_SUBMIT_ATTEMPTS = 3;
    const DRY_RUN = false;
    const AUTO_CHECKOUT = true;
    const AUTO_SUBMIT = true;

    const ROW_SELECTOR = "[class*='cartItemInfoContainer']";
    const ANT_WRAP = "label.ant-checkbox-wrapper";
    const ANT_INPUT = "input.ant-checkbox-input";
    const CHECKOUT_CANDIDATES = ["[class*='btn--']", "button", "div", "a"];
    const ORDER_SUBMIT_MATCH_TEXT = /提\s*交\s*订\s*单|确\s*认\s*订\s*单|提\s*交\s*并\s*支\s*付/;
    const ORDER_SUBMIT_CANDIDATES = [
      ".go-btn",
      "[title*='提交订单']",
      "[aria-label*='提交订单']",
      "[class*='submit-btn']",
      "[class*='SubmitBtn']",
      "[class*='btn--']",
      "[role='button']",
      "button",
      "a",
    ];
    const RISK_CONTROL_TEXT = /验证码|滑块|安全验证|操作频繁|访问受限|异常流量|请完成验证|环境异常|稍后再试|检测到.{0,12}风险|存在.{0,12}风险/;
    const AUTO_SUBMIT_KEY = "taotimer_auto_submit";

    const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

    const getStoredValue = (key, fallback = null) => {
      try {
        if (typeof GM_getValue === "function") return GM_getValue(key, fallback);
      } catch (e) {}
      return sessionStorage.getItem(key) ?? fallback;
    };

    const setStoredValue = (key, value) => {
      try {
        if (typeof GM_setValue === "function") {
          GM_setValue(key, value);
          return;
        }
      } catch (e) {}
      sessionStorage.setItem(key, String(value));
    };

    const deleteStoredValue = (key) => {
      try {
        if (typeof GM_deleteValue === "function") {
          GM_deleteValue(key);
          return;
        }
      } catch (e) {}
      sessionStorage.removeItem(key);
    };

    const isClickable = (el) => {
      if (!el) return false;
      const s = getComputedStyle(el);
      if (s.display === "none" || s.visibility === "hidden" || s.opacity === "0" || s.pointerEvents === "none") return false;
      if (el.disabled || el.getAttribute("aria-disabled") === "true" || el.getAttribute("disabled") !== null) return false;
      if (/\b(disabled|loading)\b/i.test(el.className || "")) return false;
      const rect = el.getBoundingClientRect();
      if (rect.width <= 0 || rect.height <= 0) return false;
      return true;
    };

    const hasText = (el, re) => {
      const text = [el.textContent, el.getAttribute("title"), el.getAttribute("aria-label")]
        .filter(Boolean)
        .join(" ")
        .trim();
      return re.test(text);
    };

    const findAllByText = (candidates, re) => {
      const matches = [];
      for (const sel of candidates) {
        try {
          for (const el of document.querySelectorAll(sel)) {
            if (hasText(el, re)) matches.push(el);
          }
        } catch (e) {
          continue;
        }
      }
      return [...new Set(matches)];
    };

    const findByText = (candidates, re) => {
      return findAllByText(candidates, re)[0] || null;
    };

    const isCovered = (el) => {
      const rect = el.getBoundingClientRect();
      const x = rect.left + rect.width / 2;
      const y = rect.top + rect.height / 2;
      const topEl = document.elementFromPoint(x, y);
      return topEl && topEl !== el && !el.contains(topEl);
    };

    const isReadyToClick = (el) => isClickable(el) && !isCovered(el);

    const findSubmitButton = () => {
      const candidates = [
        ...document.querySelectorAll(".go-btn, [title*='提交订单'], [aria-label*='提交订单'], [class*='submit-btn'], [class*='SubmitBtn']"),
        ...findAllByText(ORDER_SUBMIT_CANDIDATES, ORDER_SUBMIT_MATCH_TEXT),
      ];
      return [...new Set(candidates)]
        .filter((el) => hasText(el, ORDER_SUBMIT_MATCH_TEXT) || /go-btn|submit-btn|SubmitBtn/.test(el.className || ""))
        .filter(isReadyToClick)
        .sort((a, b) => {
          const ar = a.getBoundingClientRect();
          const br = b.getBoundingClientRect();
          return br.bottom - ar.bottom || br.right - ar.right;
        })[0] || null;
    };

    const updateConfirmStatus = (message, color = "#ff5000") => {
      let panel = document.getElementById("taotimer-confirm-status");
      if (!panel) {
        panel = document.createElement("div");
        panel.id = "taotimer-confirm-status";
        panel.style.cssText = `
          position: fixed; top: 120px; right: 20px; z-index: 999999;
          background: #fff; border: 2px solid #ff5000; border-radius: 8px;
          padding: 12px; box-shadow: 0 4px 15px rgba(0,0,0,0.2); width: 260px;
          font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; font-size: 13px; color: #333;
        `;
        document.body.appendChild(panel);
      }
      panel.style.borderColor = color;
      panel.innerText = message;
    };

    const hasRiskControlPrompt = () => {
      const pageText = (document.body && document.body.innerText) || "";
      const statusText = (document.getElementById("taotimer-confirm-status") || {}).innerText || "";
      return RISK_CONTROL_TEXT.test(pageText.replace(statusText, ""));
    };

    const cleanupAutoSubmit = () => {
      deleteStoredValue(AUTO_SUBMIT_KEY);
      sessionStorage.removeItem(AUTO_SUBMIT_KEY);
    };

    const forceClick = (element) => {
      if (!element) return;
      console.log("[taotimer] Executing force click on:", element);
      element.scrollIntoView({ block: "center", inline: "center" });
      try {
        element.focus();
        element.click();
      } catch (e) {}
      const dispatchMouseEvent = (type) => {
        const rect = element.getBoundingClientRect();
        element.dispatchEvent(new MouseEvent(type, {
          view: window,
          bubbles: true,
          cancelable: true,
          clientX: rect.left + rect.width / 2,
          clientY: rect.top + rect.height / 2,
        }));
      };
      dispatchMouseEvent("mousedown");
      dispatchMouseEvent("mouseup");
      dispatchMouseEvent("click");
    };

    const getRowSignature = (row) => {
      const link = row.querySelector("a");
      if (link && link.href) {
        const match = link.href.match(/[?&]id=(\d+)/);
        if (match) return match[1];
      }
      return row.textContent.replace(/\s+/g, "").substring(0, 50);
    };

    async function attemptFromCart() {
      const rows = Array.from(document.querySelectorAll(ROW_SELECTOR));
      if (!rows.length) return { progressed: false };

      const targets = JSON.parse(sessionStorage.getItem("taotimer_targets") || "[]");
      let itemReady = false;

      for (const row of rows) {
        const input = row.querySelector(ANT_INPUT);
        const wrapper = row.querySelector(ANT_WRAP);
        const sig = getRowSignature(row);

        if (targets.includes(sig)) {
          if (input && !input.checked) {
            if (!DRY_RUN && wrapper) wrapper.click();
            await sleep(100);
          }
          itemReady = true;
          row.style.outline = "3px solid limegreen";
        } else if (input && input.checked) {
          if (!DRY_RUN && wrapper) wrapper.click();
          await sleep(50);
        }
      }

      if (!itemReady) return { progressed: false };
      if (DRY_RUN || !AUTO_CHECKOUT) return { progressed: false };

      const checkoutBtn = findByText(CHECKOUT_CANDIDATES, /结算/);
      if (!isClickable(checkoutBtn)) return { progressed: false };

      console.log("[taotimer] Item targeted. Clicking checkout button.");

      setStoredValue(AUTO_SUBMIT_KEY, true);
      checkoutBtn.click();
      return { progressed: true };
    }

    async function aggressiveRefreshLoop(deadline) {
      const result = await attemptFromCart();
      if (result.progressed) {
        sessionStorage.removeItem("taotimer_windowEnd");
        sessionStorage.removeItem("taotimer_active");
        return;
      }

      if (Date.now() < deadline) {
        await sleep(300 + Math.random() * 200);
        location.reload();
      } else {
        sessionStorage.removeItem("taotimer_windowEnd");
        sessionStorage.removeItem("taotimer_active");
        if (document.getElementById("taotimer-status")) updateUI();
      }
    }

    async function scheduleCartChecker() {
      const targetTimeStr = sessionStorage.getItem("taotimer_time");
      if (!targetTimeStr) return;

      const [hh, mm, ss] = targetTimeStr.split(":").map(Number);
      let target = new Date();
      target.setHours(hh, mm, ss || 0, 0);
      if (target <= new Date()) target.setDate(target.getDate() + 1);

      const checkLoop = setInterval(async () => {
        if (sessionStorage.getItem("taotimer_active") !== "true") return clearInterval(checkLoop);

        const msUntilTarget = target - new Date();
        const statusEl = document.getElementById("taotimer-status");
        if (statusEl) statusEl.innerText = `状态: 倒计时 ${(msUntilTarget / 1000).toFixed(1)} 秒...`;

        if (msUntilTarget <= START_EARLY_MS && msUntilTarget > 0) {
          clearInterval(checkLoop);
          if (statusEl) statusEl.innerText = "状态: 准备进入刷新窗口...";

          while (new Date() < target) {
            if (sessionStorage.getItem("taotimer_active") !== "true") return;
            await sleep(50);
          }

          const deadline = Date.now() + BUYING_WINDOW_SECONDS * 1000;
          sessionStorage.setItem("taotimer_windowEnd", deadline);
          aggressiveRefreshLoop(deadline);
        }
      }, 500);
    }

    function updateUI() {
      const isActive = sessionStorage.getItem("taotimer_active") === "true";
      const startBtn = document.getElementById("taotimer-start-btn");
      const stopBtn = document.getElementById("taotimer-stop-btn");
      const statusEl = document.getElementById("taotimer-status");
      if (!statusEl) return;

      if (isActive) {
        startBtn.style.display = "none";
        stopBtn.style.display = "block";
        statusEl.innerText = `状态: 已设时间 ${sessionStorage.getItem("taotimer_time")},等待中...`;
        statusEl.style.color = "#ff5000";
      } else {
        startBtn.style.display = "block";
        stopBtn.style.display = "none";
        statusEl.innerText = "状态: 未启动";
        statusEl.style.color = "#666";
      }
    }

    function createUI() {
      if (document.getElementById("taotimer-panel")) return;

      const panel = document.createElement("div");
      panel.id = "taotimer-panel";
      panel.style.cssText = `
        position: fixed; top: 120px; right: 20px; z-index: 999999;
        background: #fff; border: 2px solid #ff5000; border-radius: 8px;
        padding: 15px; box-shadow: 0 4px 15px rgba(0,0,0,0.2); width: 260px;
        font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; font-size: 13px; color: #333;
      `;

      panel.innerHTML = `
        <div style="font-weight:bold; color:#ff5000; margin-bottom:12px; font-size: 15px; text-align: center;">淘宝定时下单控制台</div>
        <div style="margin-bottom:10px; line-height: 1.5;">
          <span style="display:inline-block; background:#ffe4d0; color:#ff5000; padding:2px 6px; border-radius:4px; margin-bottom:4px;">步骤 1</span><br>
          在左侧购物车中勾选你要定时下单的商品。
        </div>
        <div style="margin-bottom:15px; line-height: 1.5;">
          <span style="display:inline-block; background:#ffe4d0; color:#ff5000; padding:2px 6px; border-radius:4px; margin-bottom:4px;">步骤 2</span><br>
          设定定时下单时间 (时:分:秒):
          <input type="time" step="1" id="taotimer-time-input" style="width:100%; margin-top:6px; padding:6px; border:1px solid #ccc; border-radius:4px; box-sizing: border-box;">
        </div>
        <button id="taotimer-start-btn" style="width:100%; padding:10px; background:linear-gradient(90deg, #ff9000, #ff5000); color:white; border:none; border-radius:4px; font-weight:bold; cursor:pointer; font-size: 14px;">锁定商品并开始</button>
        <button id="taotimer-stop-btn" style="width:100%; padding:10px; background:#f0f0f0; color:#333; border:1px solid #ddd; border-radius:4px; font-weight:bold; cursor:pointer; font-size: 14px; display:none;">取消定时</button>
        <div id="taotimer-status" style="margin-top:12px; font-weight:bold; text-align: center;">状态: 未启动</div>
      `;
      document.body.appendChild(panel);

      const savedTime = sessionStorage.getItem("taotimer_time");
      if (savedTime) document.getElementById("taotimer-time-input").value = savedTime;

      document.getElementById("taotimer-start-btn").onclick = () => {
        const timeVal = document.getElementById("taotimer-time-input").value;
        if (!timeVal) return alert("请输入定时时间!注意要包含秒。");

        const rows = Array.from(document.querySelectorAll(ROW_SELECTOR));
        const targets = [];
        for (const row of rows) {
          const input = row.querySelector(ANT_INPUT);
          if (input && input.checked) targets.push(getRowSignature(row));
        }

        if (targets.length === 0) return alert("请先在购物车中勾选你要定时下单的商品!");

        sessionStorage.setItem("taotimer_active", "true");
        sessionStorage.setItem("taotimer_time", timeVal);
        sessionStorage.setItem("taotimer_targets", JSON.stringify(targets));

        updateUI();
        scheduleCartChecker();
        alert("已锁定目标商品并开启倒计时!请保持此页面打开。");
      };

      document.getElementById("taotimer-stop-btn").onclick = () => {
        sessionStorage.removeItem("taotimer_active");
        sessionStorage.removeItem("taotimer_windowEnd");
        cleanupAutoSubmit();
        updateUI();
      };

      updateUI();
    }

    function handleConfirmPage() {
      if (getStoredValue(AUTO_SUBMIT_KEY, false) !== true && getStoredValue(AUTO_SUBMIT_KEY, "false") !== "true") {
        console.log("[taotimer] Normal manual checkout detected. Script sleeps.");
        return;
      }

      console.log("[taotimer] Auto checkout detected. Looking for submit button.");
      if (DRY_RUN || !AUTO_SUBMIT) return;

      updateConfirmStatus("TaoTimer: 已进入订单确认页,正在等待提交按钮可用...");

      let clickAttempts = 0;
      let lastClickAt = 0;
      let stopped = false;

      const fireClick = () => {
        if (stopped) return;

        if (hasRiskControlPrompt()) {
          stopped = true;
          cleanupAutoSubmit();
          updateConfirmStatus("TaoTimer: 检测到验证、频繁操作或风险提示,已停止自动提交,请手动处理。", "#d93025");
          console.warn("[taotimer] Risk-control or verification prompt detected. Auto submit stopped.");
          observer.disconnect();
          clearInterval(pollingId);
          return;
        }

        if (clickAttempts >= MAX_SUBMIT_ATTEMPTS) {
          stopped = true;
          cleanupAutoSubmit();
          updateConfirmStatus("TaoTimer: 已多次尝试提交但页面未继续跳转,请手动确认订单状态。", "#d93025");
          observer.disconnect();
          clearInterval(pollingId);
          return;
        }

        if (Date.now() - lastClickAt < SUBMIT_RETRY_DELAY_MS) return;

        const submitBtn = findSubmitButton();
        if (submitBtn) {
          clickAttempts += 1;
          lastClickAt = Date.now();
          updateConfirmStatus(`TaoTimer: 提交按钮已可用,正在尝试提交 (${clickAttempts}/${MAX_SUBMIT_ATTEMPTS})...`);

          console.log("[taotimer] Submit button is ready. Clicking now:", submitBtn);
          forceClick(submitBtn);
        } else {
          updateConfirmStatus("TaoTimer: 正在等待提交按钮可用,若页面要求补充信息请手动处理...");
        }
      };

      window.addEventListener("beforeunload", cleanupAutoSubmit, { once: true });

      const pollingId = setInterval(fireClick, 250);
      const observer = new MutationObserver(fireClick);
      observer.observe(document.body, { childList: true, subtree: true });

      setTimeout(() => {
        observer.disconnect();
        clearInterval(pollingId);
        if (!stopped) {
          cleanupAutoSubmit();
          updateConfirmStatus("TaoTimer: 15 秒内未能完成自动提交,请手动确认订单状态。", "#d93025");
          console.error("[taotimer] Failed: submit button was not usable within timeout.");
        }
      }, SUBMIT_TIMEOUT_MS);

      fireClick();
    }

    function main() {
      console.log("[taotimer] Script loaded on:", location.host + location.pathname);

      if (location.host.includes("cart.taobao.com") || location.pathname.includes("cart.htm")) {
        createUI();
        const windowEnd = sessionStorage.getItem("taotimer_windowEnd");
        if (windowEnd && Date.now() < parseInt(windowEnd, 10) && sessionStorage.getItem("taotimer_active") === "true") {
          aggressiveRefreshLoop(parseInt(windowEnd, 10));
        } else if (sessionStorage.getItem("taotimer_active") === "true") {
          scheduleCartChecker();
        }
      } else if (location.host.includes("buy.taobao.com") || location.host.includes("buy.tmall.com")) {
        handleConfirmPage();
      }
    }

    main();
  })();
})();