☰

Peach · JAV 入库

在 JAV 作品详情页识别或选中番号,交给 Peach 下载入库。

От 18.10.2026. Виж последната версия.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да инсталирате разширение, като например Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

// ==UserScript==
// @name         Peach · JAV 入库
// @namespace    https://github.com/peach-mitao/peach-userscript
// @source       https://github.com/peach-mitao/peach-userscript
// @supportURL   https://github.com/peach-mitao/peach-userscript/issues
// @homepageURL  https://github.com/peach-mitao/peach-userscript
// @license      AGPL-3.0-or-later
// @version      0.3.0
// @description  在 JAV 作品详情页识别或选中番号,交给 Peach 下载入库。
// @match        https://missav.ws/*
// @match        https://*.missav.ws/*
// @match        https://missav.ai/*
// @match        https://*.missav.ai/*
// @match        https://missav.com/*
// @match        https://*.missav.com/*
// @match        https://missav123.com/*
// @match        https://*.missav123.com/*
// @match        https://missav.live/*
// @match        https://*.missav.live/*
// @match        https://missav.fans/*
// @match        https://*.missav.fans/*
// @match        https://missav.media/*
// @match        https://*.missav.media/*
// @match        https://jable.tv/*
// @match        https://*.jable.tv/*
// @match        https://supjav.com/*
// @match        https://*.supjav.com/*
// @match        https://jav.guru/*
// @match        https://*.jav.guru/*
// @match        https://njav.tv/*
// @match        https://*.njav.tv/*
// @match        https://njavtv.com/*
// @match        https://*.njavtv.com/*
// @match        https://123av.com/*
// @match        https://*.123av.com/*
// @match        https://javgo.to/*
// @match        https://*.javgo.to/*
// @match        https://javtiful.com/*
// @match        https://*.javtiful.com/*
// @match        https://javmost.com/*
// @match        https://*.javmost.com/*
// @match        https://javmost.ws/*
// @match        https://*.javmost.ws/*
// @match        https://javmost.cx/*
// @match        https://*.javmost.cx/*
// @match        https://javgg.net/*
// @match        https://*.javgg.net/*
// @match        https://7mmtv.sx/*
// @match        https://*.7mmtv.sx/*
// @match        https://hpjav.tv/*
// @match        https://*.hpjav.tv/*
// @match        https://thisav.com/*
// @match        https://*.thisav.com/*
// @match        https://javhd.com/*
// @match        https://*.javhd.com/*
// @match        https://javdb.com/*
// @match        https://*.javdb.com/*
// @match        https://javbus.com/*
// @match        https://*.javbus.com/*
// @match        https://dmmsee.casa/*
// @match        https://*.dmmsee.casa/*
// @match        https://busfan.casa/*
// @match        https://*.busfan.casa/*
// @match        https://busdmm.casa/*
// @match        https://*.busdmm.casa/*
// @match        https://javlibrary.com/*
// @match        https://*.javlibrary.com/*
// @match        https://javlib.com/*
// @match        https://*.javlib.com/*
// @match        https://r86m.com/*
// @match        https://*.r86m.com/*
// @match        https://f101w.com/*
// @match        https://*.f101w.com/*
// @match        https://c99i.com/*
// @match        https://*.c99i.com/*
// @match        https://e100k.com/*
// @match        https://*.e100k.com/*
// @match        https://jav321.com/*
// @match        https://*.jav321.com/*
// @match        https://avbase.net/*
// @match        https://*.avbase.net/*
// @match        https://javdatabase.com/*
// @match        https://*.javdatabase.com/*
// @match        https://jav.land/*
// @match        https://*.jav.land/*
// @match        https://avmoo.com/*
// @match        https://*.avmoo.com/*
// @match        https://avsox.com/*
// @match        https://*.avsox.com/*
// @match        https://avsox.click/*
// @match        https://*.avsox.click/*
// @match        https://av-wiki.net/*
// @match        https://*.av-wiki.net/*
// @match        https://fc2ppv-db.com/*
// @match        https://*.fc2ppv-db.com/*
// @match        https://fc2cmadb.com/*
// @match        https://*.fc2cmadb.com/*
// @match        https://javtrailers.com/*
// @match        https://*.javtrailers.com/*
// @match        https://javarchive.com/*
// @match        https://*.javarchive.com/*
// @match        https://javten.com/*
// @match        https://*.javten.com/*
// @match        https://r18.dev/*
// @match        https://*.r18.dev/*
// @match        https://dmm.co.jp/*
// @match        https://*.dmm.co.jp/*
// @match        https://mgstage.com/*
// @match        https://*.mgstage.com/*
// @match        https://duga.jp/*
// @match        https://*.duga.jp/*
// @match        https://prestige-av.com/*
// @match        https://*.prestige-av.com/*
// @match        https://sod.co.jp/*
// @match        https://*.sod.co.jp/*
// @match        https://faleno.jp/*
// @match        https://*.faleno.jp/*
// @match        https://adult.contents.fc2.com/*
// @match        https://*.adult.contents.fc2.com/*
// @match        https://s1s1s1.com/*
// @match        https://*.s1s1s1.com/*
// @match        https://moodyz.com/*
// @match        https://*.moodyz.com/*
// @match        https://ideapocket.com/*
// @match        https://*.ideapocket.com/*
// @match        https://madonna-av.com/*
// @match        https://*.madonna-av.com/*
// @match        https://attackers.net/*
// @match        https://*.attackers.net/*
// @match        https://premium-beauty.com/*
// @match        https://*.premium-beauty.com/*
// @match        https://kawaiikawaii.jp/*
// @match        https://*.kawaiikawaii.jp/*
// @match        https://max-a.co.jp/*
// @match        https://*.max-a.co.jp/*
// @match        https://maxing.jp/*
// @match        https://*.maxing.jp/*
// @match        https://1pondo.tv/*
// @match        https://*.1pondo.tv/*
// @match        https://caribbeancom.com/*
// @match        https://*.caribbeancom.com/*
// @match        https://10musume.com/*
// @match        https://*.10musume.com/*
// @match        https://pacopacomama.com/*
// @match        https://*.pacopacomama.com/*
// @match        https://heyzo.com/*
// @match        https://*.heyzo.com/*
// @match        https://tokyo-hot.com/*
// @match        https://*.tokyo-hot.com/*
// @match        https://manko.fun/*
// @match        https://*.manko.fun/*
// @match        https://sukebei.nyaa.si/*
// @match        https://*.sukebei.nyaa.si/*
// @match        https://onejav.com/*
// @match        https://*.onejav.com/*
// @match        https://freejavtorrent.com/*
// @match        https://*.freejavtorrent.com/*
// @match        https://16mag.com/*
// @match        https://*.16mag.com/*
// @match        https://netflav5.com/*
// @match        https://*.netflav5.com/*
// @match        https://netflav.com/*
// @match        https://*.netflav.com/*
// @match        https://bestjavporn.com/*
// @match        https://*.bestjavporn.com/*
// @match        https://javmenu.com/*
// @match        https://*.javmenu.com/*
// @match        https://avjoy.me/*
// @match        https://*.avjoy.me/*
// @match        https://ggjav.com/*
// @match        https://*.ggjav.com/*
// @match        https://av01.tv/*
// @match        https://*.av01.tv/*
// @match        https://av01.media/*
// @match        https://*.av01.media/*
// @match        https://highporn.net/*
// @match        https://*.highporn.net/*
// @match        https://evojav.pro/*
// @match        https://*.evojav.pro/*
// @match        https://hayav.com/*
// @match        https://*.hayav.com/*
// @match        https://javhub.net/*
// @match        https://*.javhub.net/*
// @match        https://141jav.com/*
// @match        https://*.141jav.com/*
// @match        https://141ppv.com/*
// @match        https://*.141ppv.com/*
// @match        https://freejavbt.com/*
// @match        https://*.freejavbt.com/*
// @match        https://freejavbt.xyz/*
// @match        https://*.freejavbt.xyz/*
// @match        https://javrate.com/*
// @match        https://*.javrate.com/*
// @match        https://sehuatang.net/*
// @match        https://*.sehuatang.net/*
// @match        https://sehuatang.org/*
// @match        https://*.sehuatang.org/*
// @match        https://169bbs.com/*
// @match        https://*.169bbs.com/*
// @match        https://hjd2048.com/*
// @match        https://*.hjd2048.com/*
// @match        https://airav.wiki/*
// @match        https://*.airav.wiki/*
// @match        https://airav.fun/*
// @match        https://*.airav.fun/*
// @match        https://airav.io/*
// @match        https://*.airav.io/*
// @match        https://javbooks.com/*
// @match        https://*.javbooks.com/*
// @match        https://javfree.me/*
// @match        https://*.javfree.me/*
// @match        https://javcv.com/*
// @match        https://*.javcv.com/*
// @match        https://projectjav.com/*
// @match        https://*.projectjav.com/*
// @match        https://paipancon.com/*
// @match        https://*.paipancon.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_xmlhttpRequest
// @grant        window.onurlchange
// @connect      *
// @run-at       document-idle
// @noframes
// ==/UserScript==

(() => {
  'use strict';

  const SITE_RULES = {"version":1,"sites":[{"id":"missav","label":"MissAV","kind":"在线播放","domains":["missav.ws","missav.ai","missav.com","missav123.com","missav.live","missav.fans","missav.media"],"mode":"automatic","detail_pattern":"^/(?:dm[0-9]+/)?(?:(?:en|ja|zh|cn|tw)/)?([^/]+)/?$","selectors":["h1","[data-video-code]","dt",".video-info p",".video-detail p"]},{"id":"jable","label":"Jable","kind":"在线播放","domains":["jable.tv"],"mode":"automatic","detail_pattern":"^/videos/([^/]+)/?$","selectors":["h1",".video-info h4","[data-video-code]","dt",".video-info p"]},{"id":"supjav","label":"SupJAV","kind":"在线播放","domains":["supjav.com"],"mode":"selection"},{"id":"jav-guru","label":"JAV.guru","kind":"在线播放","domains":["jav.guru"],"mode":"automatic","detail_pattern":"^/[0-9]+/([^/]+)/?$","selectors":["h1.entry-title","h1"]},{"id":"njav","label":"NJAV / 123AV","kind":"在线播放","domains":["njav.tv","njavtv.com","123av.com","javgo.to"],"mode":"automatic","detail_pattern":"^/(?:en|ja|zh|cn|tw)/v/([^/]+)/?$","selectors":["h1","dt"]},{"id":"javtiful","label":"JAVtiful","kind":"在线播放","domains":["javtiful.com"],"mode":"automatic","detail_pattern":"^/(?:[a-z]{2}/)?video/[0-9]+/([^/]+)/?$","selectors":["h1","[data-video-code]"]},{"id":"javmost","label":"JAVMost","kind":"在线播放","domains":["javmost.com","javmost.ws","javmost.cx"],"mode":"selection"},{"id":"javgg","label":"JAVGG","kind":"在线播放","domains":["javgg.net"],"mode":"selection"},{"id":"7mmtv","label":"7mmtv","kind":"在线播放","domains":["7mmtv.sx"],"mode":"automatic","detail_pattern":"^/(?:ja|en|zh|cn|tw)/(?:censored|uncensored)_content/[0-9]+/([^/]+)\\.html$","selectors":["h1"]},{"id":"hpjav","label":"HPJAV","kind":"在线播放","domains":["hpjav.tv"],"mode":"selection"},{"id":"thisav","label":"ThisAV","kind":"在线播放","domains":["thisav.com"],"mode":"selection"},{"id":"javhd","label":"JAVHD","kind":"在线播放","domains":["javhd.com"],"mode":"selection"},{"id":"javdb","label":"JavDB","kind":"数据库","domains":["javdb.com"],"mode":"automatic","detail_pattern":"^/v/[A-Za-z0-9]+/?$","selectors":["h2.title",".panel-block strong"]},{"id":"javbus","label":"JavBus","kind":"数据库","domains":["javbus.com","dmmsee.casa","busfan.casa","busdmm.casa"],"mode":"automatic","detail_pattern":"^/(?:[a-z]{2}/)?([^/]+)/?$","selectors":["h3",".info p .header"]},{"id":"javlibrary","label":"JavLibrary","kind":"数据库","domains":["javlibrary.com","javlib.com","r86m.com","f101w.com","c99i.com","e100k.com"],"mode":"automatic","detail_pattern":"^/(?:cn|tw|en|ja)/?$","selectors":["#video_id .text","#video_title"],"query_keys":["v"]},{"id":"jav321","label":"Jav321","kind":"数据库","domains":["jav321.com"],"mode":"automatic","detail_pattern":"^/video/[A-Za-z0-9]+/?$","selectors":["h3",".panel-body b"]},{"id":"avbase","label":"AVBase","kind":"数据库","domains":["avbase.net"],"mode":"automatic","detail_pattern":"^/works/(?:[^/:]+:)?([^/:]+)/?$","selectors":["h1","#__NEXT_DATA__"]},{"id":"javdatabase","label":"JAVDatabase","kind":"数据库","domains":["javdatabase.com"],"mode":"automatic","detail_pattern":"^/movies/([^/]+)/?$","selectors":["h1",".movie-code"]},{"id":"jav-land","label":"JAV.land","kind":"数据库","domains":["jav.land"],"mode":"selection"},{"id":"avmoo","label":"AVMOO","kind":"数据库","domains":["avmoo.com"],"mode":"selection"},{"id":"avsox","label":"AVSOX","kind":"数据库","domains":["avsox.com","avsox.click"],"mode":"selection"},{"id":"av-wiki","label":"AV-Wiki","kind":"数据库","domains":["av-wiki.net"],"mode":"selection"},{"id":"fc2ppvdb","label":"FC2PPVDB","kind":"数据库","domains":["fc2ppv-db.com"],"mode":"selection"},{"id":"fc2cmadb","label":"FC2CmaDB","kind":"数据库","domains":["fc2cmadb.com"],"mode":"selection"},{"id":"javtrailers","label":"JAVTrailers","kind":"数据库","domains":["javtrailers.com"],"mode":"selection"},{"id":"javarchive","label":"JavArchive","kind":"数据库","domains":["javarchive.com"],"mode":"selection"},{"id":"javten","label":"JAVten","kind":"数据库","domains":["javten.com"],"mode":"selection"},{"id":"r18dev","label":"r18.dev","kind":"数据库","domains":["r18.dev"],"mode":"selection"},{"id":"dmm","label":"FANZA / DMM","kind":"发行平台","domains":["dmm.co.jp"],"mode":"selection","query_keys":["id"]},{"id":"mgs","label":"MGS","kind":"发行平台","domains":["mgstage.com"],"mode":"selection"},{"id":"duga","label":"DUGA","kind":"发行平台","domains":["duga.jp"],"mode":"selection"},{"id":"prestige","label":"Prestige","kind":"发行平台","domains":["prestige-av.com"],"mode":"selection"},{"id":"sod","label":"SOD / SODprime","kind":"发行平台","domains":["sod.co.jp"],"mode":"selection"},{"id":"faleno","label":"FALENO","kind":"发行平台","domains":["faleno.jp"],"mode":"selection"},{"id":"fc2-market","label":"FC2 内容市场","kind":"发行平台","domains":["adult.contents.fc2.com"],"mode":"selection"},{"id":"s1","label":"S1","kind":"片商","domains":["s1s1s1.com"],"mode":"selection"},{"id":"moodyz","label":"MOODYZ","kind":"片商","domains":["moodyz.com"],"mode":"selection"},{"id":"ideapocket","label":"IdeaPocket","kind":"片商","domains":["ideapocket.com"],"mode":"selection"},{"id":"madonna","label":"Madonna","kind":"片商","domains":["madonna-av.com"],"mode":"selection"},{"id":"attackers","label":"Attackers","kind":"片商","domains":["attackers.net"],"mode":"selection"},{"id":"premium","label":"Premium","kind":"片商","domains":["premium-beauty.com"],"mode":"selection"},{"id":"kawaii","label":"Kawaii","kind":"片商","domains":["kawaiikawaii.jp"],"mode":"selection"},{"id":"max-a","label":"MAX-A","kind":"片商","domains":["max-a.co.jp"],"mode":"selection"},{"id":"maxing","label":"MAXING","kind":"片商","domains":["maxing.jp"],"mode":"selection"},{"id":"1pondo","label":"一本道","kind":"无码","domains":["1pondo.tv"],"mode":"selection"},{"id":"caribbeancom","label":"加勒比","kind":"无码","domains":["caribbeancom.com"],"mode":"selection"},{"id":"10musume","label":"天然むすめ","kind":"无码","domains":["10musume.com"],"mode":"selection"},{"id":"pacopacomama","label":"Pacopacomama","kind":"无码","domains":["pacopacomama.com"],"mode":"selection"},{"id":"heyzo","label":"HEYZO","kind":"无码","domains":["heyzo.com"],"mode":"selection"},{"id":"tokyo-hot","label":"Tokyo-Hot","kind":"无码","domains":["tokyo-hot.com"],"mode":"selection"},{"id":"manko","label":"manko.fun","kind":"资源","domains":["manko.fun"],"mode":"selection"},{"id":"sukebei","label":"Sukebei","kind":"资源","domains":["sukebei.nyaa.si"],"mode":"selection"},{"id":"onejav","label":"OneJAV","kind":"资源","domains":["onejav.com"],"mode":"selection"},{"id":"freejavtorrent","label":"Free JAV Torrent","kind":"资源","domains":["freejavtorrent.com"],"mode":"selection"},{"id":"16mag","label":"16mag","kind":"资源","domains":["16mag.com"],"mode":"selection"},{"id":"netflav","label":"NETFLAV","kind":"在线播放","domains":["netflav5.com","netflav.com"],"mode":"selection"},{"id":"bestjavporn","label":"BestJavPorn","kind":"在线播放","domains":["bestjavporn.com"],"mode":"selection"},{"id":"javmenu","label":"JAVMenu","kind":"数据库","domains":["javmenu.com"],"mode":"selection"},{"id":"avjoy","label":"AVJOY","kind":"在线播放","domains":["avjoy.me"],"mode":"selection"},{"id":"ggjav","label":"GGJAV","kind":"在线播放","domains":["ggjav.com"],"mode":"selection"},{"id":"av01","label":"AV01","kind":"在线播放","domains":["av01.tv","av01.media"],"mode":"selection"},{"id":"highporn","label":"HighPorn","kind":"在线播放","domains":["highporn.net"],"mode":"selection"},{"id":"evojav","label":"EvoJAV","kind":"在线播放","domains":["evojav.pro"],"mode":"selection"},{"id":"hayav","label":"HayAV","kind":"在线播放","domains":["hayav.com"],"mode":"selection"},{"id":"javhub","label":"JAVHub","kind":"在线播放","domains":["javhub.net"],"mode":"selection"},{"id":"141jav","label":"141JAV","kind":"资源","domains":["141jav.com"],"mode":"selection"},{"id":"141ppv","label":"141PPV","kind":"资源","domains":["141ppv.com"],"mode":"selection"},{"id":"freejavbt","label":"FreeJavBT","kind":"资源","domains":["freejavbt.com","freejavbt.xyz"],"mode":"selection"},{"id":"javrate","label":"JAVRate","kind":"在线播放","domains":["javrate.com"],"mode":"selection"},{"id":"sehuatang","label":"色花堂","kind":"资源","domains":["sehuatang.net","sehuatang.org"],"mode":"selection","query_keys":["tid"]},{"id":"169bbs","label":"SEX169","kind":"资源","domains":["169bbs.com"],"mode":"selection"},{"id":"hjd2048","label":"2048论坛","kind":"资源","domains":["hjd2048.com"],"mode":"selection"},{"id":"airav","label":"AirAV","kind":"数据库","domains":["airav.wiki","airav.fun","airav.io"],"mode":"selection"},{"id":"javbooks","label":"JavBooks","kind":"资源","domains":["javbooks.com"],"mode":"selection"},{"id":"javfree","label":"JavFree","kind":"资源","domains":["javfree.me"],"mode":"selection"},{"id":"javcv","label":"JavCV","kind":"数据库","domains":["javcv.com"],"mode":"selection"},{"id":"projectjav","label":"ProjectJav","kind":"资源","domains":["projectjav.com"],"mode":"selection"},{"id":"paipancon","label":"Paipancon","kind":"在线播放","domains":["paipancon.com"],"mode":"selection"}]};
  const STORAGE_BASE = 'peach.jav.base';
  const STORAGE_TOKEN = 'peach.jav.token';
  const ROOT_ID = 'peach-jav-action';
  if (globalThis.__peachJavUserscriptActive) return;
  globalThis.__peachJavUserscriptActive = true;
  const ACTIVE = new Set(['queued', 'searching', 'submitted']);
  const STATES = new Set([...ACTIVE, 'ingested', 'owned', 'failed', 'no_match', 'idle']);
  const LABELS = {
    queued: '已排队', searching: '正在搜磁力', submitted: '云下载中',
    ingested: '已入库', owned: '馆藏已有', failed: '任务失败', no_match: '未找到资源', idle: '可以加入 Peach',
  };
  const attempts = new Set();
  let generation = 0;
  let current = null;
  let pageKey = '';
  let refreshTimer;
  let selected = null;

  // 只做粗提取;合法番号由 Peach 校验。
  function candidates(value) {
    const found = new Set();
    const bounded = String(value || '').slice(0, 800).toUpperCase();
    let rest = bounded.replace(/\bFC2(?:[-_ ]?PPV)?[-_ ]?(\d{4,9})\b/g, (_, digits) => {
      found.add(`FC2-PPV-${digits}`);
      return ' ';
    });
    rest = rest.replace(/\b([NK])(\d{4})\b/g, (_, prefix, digits) => {
      found.add(prefix.toLowerCase() + digits);
      return ' ';
    });
    for (const match of rest.matchAll(/\b((?:\d{1,5})?[A-Z]{2,12})[-_]?(\d{1,8})(?=$|[^A-Z0-9])/g)) {
      found.add(`${match[1]}-${match[2]}`);
    }
    for (const match of rest.matchAll(/\b\d{6}[-_]\d{3}\b/g)) found.add(match[0]);
    return found;
  }

  function comparisonKey(code) {
    if (code.startsWith('FC2-PPV-')) return code;
    return code.replace(/^((?:\d+)?[A-Z]+)-0*(\d+)$/, '$1-$2');
  }

  function siteFor(url) {
    if (url.protocol !== 'https:') return null;
    return SITE_RULES.sites.find(site => site.domains.some(domain =>
      url.hostname === domain || url.hostname.endsWith(`.${domain}`))) || null;
  }

  function sourcePage(url, site) {
    const source = new URL(url.origin + url.pathname);
    for (const key of site.query_keys || []) {
      const values = url.searchParams.getAll(key);
      if (values.length === 1 && /^[A-Za-z0-9_-]{1,100}$/.test(values[0])) {
        source.searchParams.set(key, values[0]);
      }
    }
    return source.href;
  }

  function fieldText(field) {
    if (field.hasAttribute('data-video-code')) return field.getAttribute('data-video-code');
    if (field.id === '__NEXT_DATA__') {
      const raw = field.textContent || '';
      if (raw.length > 1_000_000) return '';
      try {
        const props = JSON.parse(raw).props?.pageProps;
        const work = props?.work || (props?.works?.length === 1 ? props.works[0] : null);
        return String(work?.work_id || '');
      } catch { return ''; }
    }
    const text = (field.textContent || '').slice(0, 800);
    if (field.matches('dt, strong, b, .header, p')) {
      const label = text.trim().split(/[::]/, 1)[0].trim().toLowerCase();
      const labelled = ['番号', '番號', '品番', '識別碼', '识别码', 'id', 'code'].includes(label)
        || /^(番号|番號|品番|識別碼|识别码|ID|Code)\s*[::]/i.test(text.trim());
      if (!labelled) return '';
      if (field.tagName === 'DT') return field.nextElementSibling?.tagName === 'DD' ? field.nextElementSibling.textContent : '';
      if (field.tagName === 'P') return text;
      let value = text;
      let next = field.nextSibling;
      for (let index = 0; next && index < 4; index++, next = next.nextSibling) {
        if (next.nodeType === Node.ELEMENT_NODE && next.matches('br, b, strong, .header')) break;
        value += ` ${(next.textContent || '').slice(0, 800)}`;
      }
      return value.slice(0, 800);
    }
    return text;
  }

  function automaticIdentity(url, site) {
    if (site.mode !== 'automatic' || !site.detail_pattern) return null;
    const match = new RegExp(site.detail_pattern, 'i').exec(url.pathname);
    if (!match) return null;
    if (site.query_keys?.length && !site.query_keys.every(key => url.searchParams.getAll(key).length === 1
      && /^[A-Za-z0-9_-]{1,100}$/.test(url.searchParams.get(key)))) return null;
    let decoded = '';
    try { decoded = decodeURIComponent(match[1] || ''); } catch { return null; }
    const pathCodes = candidates(decoded);
    if (match[1] && !pathCodes.size) return null;
    const codes = new Map([...pathCodes].map(code => [comparisonKey(code), code]));
    const brand = site.label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    const title = document.title.replace(new RegExp(`(^|[^A-Z0-9])${brand}(?=$|[^A-Z0-9])`, 'gi'), '$1 ');
    const fields = [title];
    const seen = new Set();
    for (const selector of site.selectors || []) {
      const nodes = [...document.querySelectorAll(selector)].slice(0, 40);
      for (const field of nodes) {
        if (seen.has(field)) continue;
        seen.add(field);
        fields.push(fieldText(field));
        if (field.matches('h1,h2,h3,h4')) break;
      }
    }
    let foundField = false;
    for (const field of fields) for (const code of candidates(field)) {
      foundField = true;
      if (!codes.has(comparisonKey(code))) codes.set(comparisonKey(code), code);
    }
    if (!foundField) return null;
    return {
      code: codes.size === 1 ? [...codes.values()][0] : '',
      conflict: codes.size !== 1,
      source: sourcePage(url, site),
    };
  }

  function identity() {
    const url = new URL(location.href);
    const site = siteFor(url);
    if (!site) return null;
    if (selected?.url !== location.href) selected = null;
    return selected || automaticIdentity(url, site);
  }

  function selectCode() {
    const url = new URL(location.href);
    const site = siteFor(url);
    if (!site) return;
    const raw = String(getSelection() || '').trim();
    const shortCode = /^(?:FC2(?:[-_ ]?PPV)?[-_ ]?\d{4,9}|[NK]\d{4}|(?:\d{1,5})?[A-Z]{2,12}[-_]?\d{1,8}|\d{6}[-_]\d{3})$/i;
    const extracted = candidates(raw);
    if (raw.length > 80 || !shortCode.test(raw) || extracted.size !== 1) {
      alert('请只选中一个完整的短番号。');
      return;
    }
    selected = { url: location.href, code: [...extracted][0], conflict: false, source: sourcePage(url, site) };
    pageKey = '';
    refresh();
  }

  function settings(rawBase, rawToken) {
    const base = new URL(String(rawBase).trim());
    const token = String(rawToken).trim();
    const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(base.hostname);
    if (!(base.protocol === 'https:' || (base.protocol === 'http:' && loopback))
      || base.username || base.password || base.search || base.hash || base.pathname !== '/'
      || !token || token.length > 256 || /[\r\n]/.test(String(rawToken))) throw new Error();
    return { base: base.origin, token };
  }

  function configuration() {
    try {
      return settings(GM_getValue(STORAGE_BASE, ''), GM_getValue(STORAGE_TOKEN, ''));
    } catch { return null; }
  }

  function configure() {
    const base = prompt('Peach 地址(含 http:// 或 https://)', String(GM_getValue(STORAGE_BASE, '') || ''));
    if (base === null) return;
    const token = prompt('Peach 专用令牌', '');
    if (token === null) return;
    try {
      const config = settings(base, token);
      GM_setValue(STORAGE_BASE, config.base);
      GM_setValue(STORAGE_TOKEN, config.token);
      pageKey = '';
      refresh();
    } catch {
      if (current) current.note.textContent = '地址或令牌无效,请在用户脚本菜单中配置。';
    }
  }

  GM_registerMenuCommand('配置 Peach 地址与令牌', configure);
  GM_registerMenuCommand('选中番号加入 Peach', selectCode);

  function request(config, code, source, method) {
    return new Promise((resolve, reject) => {
      const headers = { Authorization: `Bearer ${config.token}`, Accept: 'application/json' };
      if (method === 'POST') headers['Content-Type'] = 'application/json';
      try {
        GM_xmlhttpRequest({
          method,
          url: `${config.base}/api/jav-browser${method === 'GET' ? `?code=${encodeURIComponent(code)}` : ''}`,
          headers,
          ...(method === 'POST' ? { data: JSON.stringify({ code, source_url: source }) } : {}),
          anonymous: true,
          timeout: 15_000,
          onload(response) {
            if (response.status < 200 || response.status >= 300) {
              reject(new Error(response.status === 401 || response.status === 403 ? '令牌无效或无权限,请重新配置。' : 'Peach 请求失败,请查询状态。'));
              return;
            }
            try {
              const body = JSON.parse(response.responseText);
              if (body.ok !== true || !STATES.has(body.state) || typeof body.code !== 'string'
                || candidates(body.code).size !== 1
                || ![...candidates(body.code)].some(candidate => comparisonKey(candidate) === comparisonKey(code))) throw new Error();
              resolve(body);
            } catch { reject(new Error('Peach 返回的状态无效,请查询状态。')); }
          },
          onerror: () => reject(new Error('无法连接 Peach,请查询状态。')),
          ontimeout: () => reject(new Error('Peach 请求超时,请查询状态。')),
          onabort: () => reject(new Error('Peach 请求已中止,请查询状态。')),
        });
      } catch { reject(new Error('无法连接 Peach,请查询状态。')); }
    });
  }

  function alive(view) { return current === view && generation === view.generation && view.url === location.href; }

  function render(view) {
    if (!alive(view)) return;
    const terminalSuccess = ['ingested', 'owned'].includes(view.state);
    view.button.disabled = view.identity.conflict || terminalSuccess;
    view.button.setAttribute('aria-busy', String(view.busy));
    view.button.setAttribute('aria-disabled', String(view.busy || view.button.disabled));
    view.button.textContent = view.identity.conflict ? '番号存在冲突'
      : !configuration() ? '配置 Peach'
      : terminalSuccess ? LABELS[view.state]
      : view.busy ? '正在查询 Peach'
      : view.queryOnly || ACTIVE.has(view.state) ? '查询 Peach 状态'
      : ['failed', 'no_match'].includes(view.state) ? '重试加入 Peach' : '加入 Peach';
  }

  function applyState(view, body) {
    view.state = body.state;
    view.queryOnly = ACTIVE.has(body.state);
    if (!view.queryOnly) attempts.delete(comparisonKey(view.identity.code));
    view.note.textContent = `${view.identity.code} · ${LABELS[body.state]}`;
    render(view);
  }

  async function query(view, method = 'GET') {
    if (!alive(view) || view.busy) return;
    const config = configuration();
    if (!config) { configure(); return; }
    view.busy = true;
    if (method === 'POST') {
      attempts.add(comparisonKey(view.identity.code));
      view.queryOnly = true;
      view.note.textContent = `${view.identity.code} · 正在交给 Peach`;
    }
    render(view);
    const deadline = Date.now() + 120_000;
    try {
      let body = await request(config, view.identity.code, view.identity.source, method);
      if (!alive(view)) return;
      applyState(view, body);
      for (let count = 0; ACTIVE.has(body.state) && count < 30 && Date.now() < deadline; count++) {
        await new Promise(resolve => setTimeout(resolve, 2000));
        if (!alive(view)) return;
        body = await request(config, view.identity.code, view.identity.source, 'GET');
        if (!alive(view)) return;
        applyState(view, body);
      }
      if (ACTIVE.has(body.state)) view.note.textContent = `${view.identity.code} · 任务仍在继续,可查询状态。`;
    } catch (error) {
      if (alive(view)) {
        view.queryOnly = true;
        view.note.textContent = error.message;
      }
    } finally {
      if (alive(view)) { view.busy = false; render(view); }
    }
  }

  function refresh() {
    const item = identity();
    const key = `${location.href}|${item?.code || ''}|${Boolean(item?.conflict)}`;
    if (key === pageKey && (!item || current?.root.isConnected)) return;
    pageKey = key;
    generation++;
    current?.root.remove();
    current = null;
    if (!item || !document.body) return;
    // Shadow DOM 隔离宿主样式;凭据只保留在 GM 存储与请求闭包中。
    document.getElementById(ROOT_ID)?.remove();
    const root = document.createElement('aside');
    root.id = ROOT_ID;
    root.setAttribute('aria-label', 'Peach 入库');
    const shadow = root.attachShadow({ mode: 'open' });
    const style = document.createElement('style');
    style.textContent = `:host{all:initial;position:fixed;right:16px;bottom:16px;z-index:2147483647;max-width:calc(100vw - 32px);font:400 14px/1.5 system-ui;color:#171717}section{background:#fff;border:1px solid #ccc;border-radius:8px;padding:12px;max-width:300px;box-shadow:0 2px 12px #0002}button{font:500 14px/1.5 system-ui;color:#fff;background:#171717;border:1px solid #171717;border-radius:6px;padding:8px 12px;cursor:pointer}button:hover{background:#333}button:focus-visible{outline:2px solid #2563eb;outline-offset:3px}button:disabled,button[aria-disabled=true]{color:#666;background:#eee;border-color:#ccc;cursor:default}p{margin:8px 0 0;overflow-wrap:anywhere}`;
    const panel = document.createElement('section');
    const button = document.createElement('button');
    button.type = 'button';
    const note = document.createElement('p');
    note.setAttribute('role', 'status');
    note.setAttribute('aria-live', 'polite');
    panel.append(button, note);
    shadow.append(style, panel);
    document.body.append(root);
    const view = { root, button, note, identity: item, url: location.href, generation, state: 'idle', busy: false, queryOnly: attempts.has(comparisonKey(item.code)) };
    current = view;
    note.textContent = item.conflict ? '页面身份不一致,请核对番号。' : `${item.code} · ${configuration() ? '正在查询 Peach' : '请先配置 Peach 地址与令牌。'}`;
    button.addEventListener('click', () => {
      if (!alive(view) || view.busy || view.button.disabled) return;
      if (!configuration()) { configure(); return; }
      void query(view, view.queryOnly || ACTIVE.has(view.state) ? 'GET' : 'POST');
    });
    render(view);
    if (!item.conflict && configuration()) void query(view);
  }

  const schedule = () => {
    clearTimeout(refreshTimer);
    refreshTimer = setTimeout(refresh, 100);
  };
  new MutationObserver(schedule).observe(document.documentElement, { subtree: true, childList: true, characterData: true });
  addEventListener('popstate', schedule);
  addEventListener('hashchange', schedule);
  const navigated = () => {
    generation++;
    pageKey = '';
    selected = null;
    schedule();
  };
  addEventListener('urlchange', navigated);
  globalThis.navigation?.addEventListener('navigate', navigated);
  // 用户脚本沙箱无需替换页面的 History API。
  setInterval(refresh, 750);
  refresh();
})();