在 JAV 作品详情页识别或选中番号,交给 Peach 下载入库。
Verzia zo dňa
// ==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();
})();