Files
2026-09-08 00:45:03 +08:00

1152 lines
43 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==UserScript==
// @name NAMCO Parks 改个人信息(手机版)2
// @namespace https://parks2.bandainamco-am.co.jp/
// @version 1.6.5
// @description 改会员资料姓名/生日;全站版;调试标记;零依赖(无 @grant,与测试脚本同格式)
// @author park-tools
// @match *://*/*
// @icon https://parks2.bandainamco-am.co.jp/client_info/BNAM_LBC_EC/view/userweb/favicon.ico
// @run-at document-end
// ==/UserScript==
(function () {
'use strict';
// ========== 调试标记(v1.6.3,定位“页面无任何展示元素”用)==========
function npDebug(msg, isError) {
try {
if (typeof console !== 'undefined' && console.log) console.log('[namco-debug]', msg);
var badge = document.getElementById('npDebugBadge');
if (!badge) {
badge = document.createElement('div');
badge.id = 'npDebugBadge';
badge.style.cssText =
'position:fixed;top:6px;left:6px;z-index:2147483647;background:' +
(isError ? '#b00020' : '#059669') +
';color:#fff;font-size:11px;line-height:1.4;padding:4px 8px;border-radius:6px;' +
'max-width:85vw;white-space:pre-wrap;word-break:break-all;font-family:-apple-system,sans-serif;box-shadow:0 2px 8px rgba(0,0,0,.3);';
try {
document.documentElement.appendChild(badge);
} catch (e) { /* ignore */ }
}
if (badge) badge.textContent = (isError ? '❌ ' : '🔧 ') + msg;
} catch (e) { /* 调试代码自身异常不影响主逻辑 */ }
}
window.addEventListener('error', function (ev) {
npDebug('脚本错误: ' + (ev && ev.message ? ev.message : '未知') + (ev && ev.lineno ? ' (行 ' + ev.lineno + ')' : ''), true);
});
npDebug('脚本已注入 @ ' + location.hostname);
const ORIGIN = 'https://parks2.bandainamco-am.co.jp';
const LS_KEY = 'namco_rename_draft_v1';
const LS_OVERLAY = 'namco_ticket_overlay_v1';
const TICKET_PATH_RE = /\/admission_(use_)?ticket\.html/i;
const PAGE = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
function isLoggedInFromDom() {
if (document.querySelector('a[href*="logoff"], a[href*="request=logoff"]')) return true;
const html = document.documentElement.innerHTML;
if (html.includes('ログアウト')) return true;
return !!parseMemberData(html).member_id;
}
function htmlLooksLoggedIn(html) {
if (!html) return false;
if (html.includes('ログアウト')) return true;
if (parseMemberData(html).member_id) return true;
if (parseInput(html, 'PC_MAIL') && (parseInput(html, 'TEL') || parseInput(html, 'L_NAME'))) return true;
return false;
}
/** iOS Tampermonkey 沙箱 fetch 不带 Cookie;结果放页面 window,避免把整页 HTML 塞进 DOM 属性被截断 */
function pageFetch(url, options) {
return new Promise((resolve, reject) => {
const id = '__npFetch_' + Date.now() + '_' + Math.random().toString(36).slice(2);
const opt = {
method: (options && options.method) || 'GET',
credentials: 'same-origin',
redirect: 'follow',
headers: (options && options.headers) || {},
};
if (options && options.body) opt.body = options.body;
const win = PAGE;
const script = document.createElement('script');
script.textContent =
'(function(){var id=' +
JSON.stringify(id) +
';window[id]={p:1};fetch(' +
JSON.stringify(url) +
',' +
JSON.stringify(opt) +
').then(function(r){return r.text().then(function(t){window[id]={s:r.status,u:r.url,t:t};});}).catch(function(e){window[id]={e:String(e&&e.message||e)};});})();';
document.documentElement.appendChild(script);
script.remove();
const start = Date.now();
const timer = setInterval(() => {
const box = (win && win[id]) || window[id];
if (box && box.e) {
clearInterval(timer);
try { delete win[id]; } catch (e) { /* ignore */ }
reject(new Error(box.e));
return;
}
if (box && typeof box.t === 'string') {
clearInterval(timer);
const out = { status: Number(box.s || 0), url: box.u || url, text: box.t };
try { delete win[id]; } catch (e) { /* ignore */ }
resolve(out);
return;
}
if (Date.now() - start > 90000) {
clearInterval(timer);
try { delete win[id]; } catch (e) { /* ignore */ }
reject(new Error('请求超时'));
}
}, 40);
});
}
async function httpGet(path, referer) {
const url = path.startsWith('http') ? path : ORIGIN + path;
const headers = { Referer: referer || ORIGIN + '/member_mypage.html' };
try {
return await pageFetch(url, { method: 'GET', headers });
} catch (e1) {
try {
const r = await PAGE.fetch(url, { method: 'GET', credentials: 'include', headers });
return { status: r.status, text: await r.text(), url: r.url };
} catch (e2) {
throw e1;
}
}
}
async function httpPost(path, body, referer) {
const url = path.startsWith('http') ? path : ORIGIN + path;
const headers = {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
Origin: ORIGIN,
Referer: referer || ORIGIN + '/member_regist.html?request=edit',
};
const bodyStr = new URLSearchParams(body).toString();
try {
return await pageFetch(url, { method: 'POST', headers, body: bodyStr });
} catch (e1) {
try {
const r = await PAGE.fetch(url, { method: 'POST', credentials: 'include', headers, body: bodyStr });
return { status: r.status, text: await r.text(), url: r.url };
} catch (e2) {
throw e1;
}
}
}
const store = {
get(k, def) {
try {
if (typeof GM_getValue === 'function') return GM_getValue(k, def);
} catch (e) { /* ignore */ }
try {
const raw = localStorage.getItem(k);
return raw == null ? def : JSON.parse(raw);
} catch (e2) {
return def;
}
},
set(k, v) {
try {
if (typeof GM_setValue === 'function') GM_setValue(k, v);
} catch (e) { /* ignore */ }
try {
localStorage.setItem(k, JSON.stringify(v));
} catch (e2) { /* ignore */ }
},
};
function $(sel, root) {
return (root || document).querySelector(sel);
}
function escapeRe(name) {
return String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function parseInput(html, name) {
const re = new RegExp(
'<input\\b[^>]*\\bname=["\']' + escapeRe(name) + '["\'][^>]*>',
'i'
);
const m = html.match(re);
if (!m) return '';
const v = m[0].match(/\bvalue=["']([^"']*)["']/i);
return v ? v[1].trim() : '';
}
function parseSelected(html, name) {
const sm = html.match(
new RegExp('<select[^>]*name=["\']' + escapeRe(name) + '["\'][^>]*>([\\s\\S]*?)</select>', 'i')
);
if (!sm) return '';
const opt = sm[1].match(/<option[^>]*\\bselected\\b[^>]*>/i) || sm[1].match(/<option[^>]*selected=["']selected["'][^>]*>/i);
if (!opt) return '';
const v = opt[0].match(/\\bvalue=["']([^"']*)["']/i);
return v ? v[1].trim() : '';
}
function parseCheckedRadio(html, name) {
const re = new RegExp(
'<input\\b[^>]*\\bname=["\']' + escapeRe(name) + '["\'][^>]*>',
'gi'
);
let m;
while ((m = re.exec(html))) {
const tag = m[0];
if (!/\bchecked\b/i.test(tag)) continue;
const v = tag.match(/\bvalue=["']([^"']*)["']/i);
return v ? v[1] : '';
}
return '';
}
function parseFormChunk(html, formName) {
const head = html.match(
new RegExp('<form[^>]*name=["\']' + escapeRe(formName) + '["\'][^>]*>', 'i')
);
const body = html.match(
new RegExp('<form[^>]*name=["\']' + escapeRe(formName) + '["\'][^>]*>([\\s\\S]*?)</form>', 'i')
);
let action = '';
if (head) {
const am = head[0].match(/\baction=["']([^"']+)/i);
if (am) action = am[1];
}
return { action, chunk: body ? body[1] : '' };
}
function parseHiddenFields(chunk) {
const fields = {};
const re = /<input\b[^>]*>/gi;
let m;
while ((m = re.exec(chunk))) {
const tag = m[0];
if (!/type=["']hidden["']/i.test(tag) && !/type=["']checkbox["']/i.test(tag) && !/type=["']radio["']/i.test(tag)) {
const nm = tag.match(/\bname=["']([^"']+)["']/i);
const vm = tag.match(/\bvalue=["']([^"']*)["']/i);
if (nm && !/^jp\.co\.interfactory\.framework\./i.test(nm[1])) {
fields[nm[1]] = vm ? vm[1] : '';
}
continue;
}
const nm = tag.match(/\bname=["']([^"']+)["']/i);
if (!nm) continue;
const name = nm[1];
if (/^jp\.co\.interfactory\.framework\./i.test(name)) continue;
if (/type=["']checkbox["']/i.test(tag)) {
if (/\bchecked\b/i.test(tag)) {
const vm = tag.match(/\bvalue=["']([^"']*)["']/i);
fields[name] = vm ? vm[1] : '1';
}
continue;
}
if (/type=["']radio["']/i.test(tag)) {
if (/\bchecked\b/i.test(tag)) {
const vm = tag.match(/\bvalue=["']([^"']*)["']/i);
fields[name] = vm ? vm[1] : '';
}
continue;
}
const vm = tag.match(/\bvalue=["']([^"']*)["']/i);
fields[name] = vm ? vm[1] : '';
}
const selRe = /<select\b[^>]*name=["']([^"']+)["'][^>]*>([\s\S]*?)<\/select>/gi;
let sm;
while ((sm = selRe.exec(chunk))) {
const name = sm[1];
const opt = sm[2].match(/<option[^>]*\bselected\b[^>]*>/i);
if (!opt) continue;
const v = opt[0].match(/\bvalue=["']([^"']*)["']/i);
fields[name] = v ? v[1] : '';
}
return fields;
}
function extractParksError(html) {
const text = html || '';
const pats = [
/form-error-message[\s\S]*?<li>([^<]+)/i,
/<li[^>]*>([^<]{4,200})<\/li>/i,
/class="error[^"]*"[^>]*>([^<]+)/i,
/errorMessage[^>]*>([^<]+)/i,
];
for (let i = 0; i < pats.length; i++) {
const m = text.match(pats[i]);
if (m && m[1] && m[1].trim()) return m[1].replace(/\s+/g, ' ').trim();
}
if (text.includes('セッションがタイムアウト') || text.includes('セキュリティのため')) {
return '会话超时,请刷新页面重新登录后再提交';
}
return '';
}
function looksExecuteSuccess(resp) {
const t = (resp && resp.text) || '';
const u = (resp && resp.url) || '';
if (t.includes('会員情報を更新しました')) return true;
if (t.includes('form-message') && t.includes('更新しました')) return true;
if (u.includes('member_regist_confirm') && t.includes('更新')) return true;
return false;
}
function parseToken(html) {
const m = (html || '').match(/name="token"\s+value="([0-9a-f]+)"/i);
return m ? m[1] : '';
}
function parseMemberData(html) {
const m = (html || '').match(/var\s+member_data\s*=\s*(\{[\s\S]*?\})\s*;/);
if (!m) return {};
try {
return JSON.parse(m[1]);
} catch (e) {
return {};
}
}
function parseProfile(html) {
const md = parseMemberData(html);
let tel = parseInput(html, 'TEL');
if (!tel) {
const tm = html.match(/(?<!\d)(070\d{8}|080\d{8}|090\d{8})(?!\d)/);
if (tm) tel = tm[1];
}
const y = parseInput(html, 'BIRTH_YEAR');
const mo = parseInput(html, 'BIRTH_MONTH');
const d = parseInput(html, 'BIRTH_DAY');
let birthday = (md.birth || '').replace(/\//g, '-');
if (y && mo && d) {
birthday = `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
}
const sex = parseCheckedRadio(html, 'SEX') || (md.sex || 'M').toString().charAt(0).toUpperCase();
return {
email: parseInput(html, 'PC_MAIL'),
last_name: parseInput(html, 'L_NAME'),
first_name: parseInput(html, 'F_NAME'),
last_name_kana: parseInput(html, 'L_KANA') || parseInput(html, 'L_NAME_KANA'),
first_name_kana: parseInput(html, 'F_KANA') || parseInput(html, 'F_NAME_KANA'),
nickname: parseInput(html, 'NICKNAME'),
addr1: parseSelected(html, 'ADDR1') || parseInput(html, 'ADDR1') || '東京都',
zip: (parseInput(html, 'ZIP') || '').replace(/-/g, ''),
addr2: parseInput(html, 'ADDR2'),
addr_street: parseInput(html, 'MEMBER.FREE_ITEM16'),
addr3: parseInput(html, 'ADDR3'),
tel,
gender: sex === 'F' || sex === '2' ? 'F' : (sex === 'M' || sex === '1' ? 'M' : 'M'),
birthday: birthday || '1990-01-01',
formFields: parseHiddenFields(parseFormChunk(html, 'memberFrm').chunk || html),
};
}
function splitFullName(full) {
const s = String(full || '').trim().replace(/[\t\r\n]+/g, ' ').replace(/\s+/g, ' ');
if (!s) return { l: '', f: '' };
if (/^[A-Za-z]/.test(s)) {
const parts = s.split(' ');
if (parts.length >= 2) return { l: parts[0], f: parts.slice(1).join(' ') };
return { l: s.charAt(0), f: s.slice(1) || s };
}
return { l: s.charAt(0), f: s.slice(1) };
}
function getOverlayConfig() {
return store.get(LS_OVERLAY, { enabled: false, displayName: '' });
}
function setOverlayConfig(cfg) {
store.set(LS_OVERLAY, cfg);
}
function shouldHidePluginUi() {
return false;
}
function buildDisplayName(l, f, full) {
if (full && full.trim()) return full.trim().replace(/\s+/g, ' ');
return `${l || ''} ${f || ''}`.trim();
}
function isTicketPage() {
return TICKET_PATH_RE.test(location.pathname + location.search);
}
/** 底部姓名+EC 的 dl(排除整理券番号那个 dl) */
function getTicketNameDl() {
const dls = document.querySelectorAll('dl.block-mypage-ticket-detail-code');
for (let i = 0; i < dls.length; i++) {
const dl = dls[i];
if (dl.classList.contains('block-mypage-ticket-detail-code-margin-small')) continue;
if (dl.querySelector('dd.block-mypage-ticket-detail-code-value')) return dl;
}
return null;
}
function injectOverlayStyles() {
const css =
'dd[data-np-overlay="1"],dd.np-injected-name{' +
'display:block!important;visibility:visible!important;opacity:1!important;' +
'-webkit-text-fill-color:currentColor!important}';
let st = document.getElementById('np-overlay-style');
if (!st) {
st = document.createElement('style');
st.id = 'np-overlay-style';
document.head.appendChild(st);
}
st.textContent = css;
}
/** 找到或创建券面姓名节点(手机端使用済み时官方可能不输出姓名) */
function ensureNameSlot() {
const dl = getTicketNameDl();
if (!dl) return null;
let nameDd = null;
dl.querySelectorAll('dd.block-mypage-coupon-list-item-code-value').forEach((dd) => {
if (nameDd) return;
const t = (dd.textContent || '').trim();
if (!/^EC-\d/i.test(t) && !/^\d+$/.test(t)) nameDd = dd;
});
if (!nameDd) {
nameDd = document.createElement('dd');
nameDd.className = 'block-mypage-coupon-list-item-code-value np-injected-name';
const ec = dl.querySelector('dd.block-mypage-ticket-detail-code-value');
if (ec) dl.insertBefore(nameDd, ec);
else dl.appendChild(nameDd);
}
return nameDd;
}
/** 券面/入場页姓名节点(排除 EC- 编号、整理券纯数字) */
function findTicketNameNodes(scope, createIfMissing) {
const root = scope || document;
const nodes = [];
const seen = new Set();
if (createIfMissing) {
const slot = ensureNameSlot();
if (slot && !seen.has(slot)) {
seen.add(slot);
nodes.push(slot);
}
}
root.querySelectorAll('dl.block-mypage-ticket-detail-code dd.block-mypage-coupon-list-item-code-value').forEach((dd) => {
if (seen.has(dd)) return;
const t = (dd.textContent || '').trim();
if (/^EC-\d/i.test(t)) return;
if (/^\d+$/.test(t)) return;
seen.add(dd);
nodes.push(dd);
});
return nodes;
}
function restoreTicketNames() {
document.querySelectorAll('dd.np-injected-name').forEach((el) => el.remove());
findTicketNameNodes(document, false).forEach((el) => {
if (el.dataset.npOrig != null) {
el.textContent = el.dataset.npOrig;
delete el.dataset.npPatched;
delete el.dataset.npOverlay;
}
});
}
function applyTicketOverlay(force) {
const cfg = getOverlayConfig();
if (!cfg.enabled || !cfg.displayName) {
restoreTicketNames();
return 0;
}
if (!isTicketPage() && !force) return 0;
injectOverlayStyles();
let n = 0;
const nodes = findTicketNameNodes(document, true);
nodes.forEach((el) => {
const cur = (el.textContent || '').trim();
if (el.dataset.npOrig == null && cur && cur !== cfg.displayName) {
el.dataset.npOrig = cur;
}
if (cur !== cfg.displayName || el.dataset.npPatched !== '1') {
el.textContent = cfg.displayName;
el.dataset.npOverlay = '1';
el.dataset.npPatched = '1';
n += 1;
}
});
return n;
}
function startOverlayWatcher() {
if (window.__npOverlayWatcher) return;
window.__npOverlayWatcher = true;
const run = () => {
if (!getOverlayConfig().enabled) return;
applyTicketOverlay();
};
run();
document.addEventListener('DOMContentLoaded', run);
window.addEventListener('load', run);
window.addEventListener('pageshow', run);
const mo = new MutationObserver(() => {
if (!getOverlayConfig().enabled) return;
clearTimeout(window.__npOverlayTimer);
window.__npOverlayTimer = setTimeout(run, 80);
});
mo.observe(document.documentElement, { childList: true, subtree: true, characterData: true });
let lastUrl = location.href;
setInterval(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
setTimeout(run, 100);
}
}, 500);
}
startOverlayWatcher();
async function checkLoggedIn() {
if (isLoggedInFromDom()) return true;
try {
const r = await httpGet('/member_mypage.html');
return htmlLooksLoggedIn(r.text);
} catch (e) {
return isLoggedInFromDom();
}
}
async function loadProfile() {
await httpGet('/member_mypage.html');
const r = await httpGet('/member_regist.html?request=edit');
if (!htmlLooksLoggedIn(r.text)) {
if (isLoggedInFromDom()) {
throw new Error('已登录但读取资料失败,请刷新页面后重试');
}
throw new Error('未登录:请用 Safari 打开 parks2 并完成登录(不要用无痕模式)');
}
const p = parseProfile(r.text);
if (!p.tel) throw new Error('未读取到手机号,无法安全提交');
return p;
}
function normalizeBirthday(raw) {
const s = String(raw || '').trim();
if (!s) return '';
const m1 = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
if (m1) {
return `${m1[1]}-${String(parseInt(m1[2], 10)).padStart(2, '0')}-${String(parseInt(m1[3], 10)).padStart(2, '0')}`;
}
const digits = s.replace(/\D/g, '');
if (digits.length === 8) {
return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6, 8)}`;
}
return '';
}
function bdayParts(bday) {
const norm = normalizeBirthday(bday) || '1990-01-01';
const m = norm.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
if (!m) return { y: '1990', mo: '1', d: '1' };
return {
y: m[1],
mo: String(parseInt(m[2], 10)),
d: String(parseInt(m[3], 10)),
};
}
async function updateMemberName(profile, changes, password) {
const ln = changes.last_name || profile.last_name;
const fn = changes.first_name || profile.first_name;
const lk = changes.last_name_kana != null ? changes.last_name_kana : profile.last_name_kana;
const fk = changes.first_name_kana != null ? changes.first_name_kana : profile.first_name_kana;
const nick = changes.nickname != null ? changes.nickname : (profile.nickname || ln);
const bday = normalizeBirthday(changes.birthday || profile.birthday) || profile.birthday || '1990-01-01';
const { y, mo, d } = bdayParts(bday);
const editRef = ORIGIN + '/member_regist.html?request=edit';
const zip7 = String(profile.zip || '').replace(/-/g, '');
const addr1 = profile.addr1 || '東京都';
const addr2 = profile.addr2 || '';
const addrStreet = profile.addr_street || '';
const addr3 = profile.addr3 || '';
const sex = profile.gender || 'M';
if (!zip7 || !addr2 || !addrStreet) {
throw new Error('当前资料缺邮编/市区町村/番地(官网已改为必填)。请先在「会員情報変更」填完整地址,再回来改名字。');
}
const confirm = Object.assign({}, profile.formFields || {}, {
request: 'confirm',
PC_MAIL_OLD: profile.email,
FOREIGN_LOGIN_PROVIDER_KIND: '',
MOBILE_MAIL_OLD: '',
mode: '1',
CART_MEMBER_REGIST: '',
MAIL_FLG_OLD: '1',
'SOCIAL_PLUS:SOCIAL_PLUS_ID': '',
'SOCIAL_PLUS:PROVIDER': '',
NICKNAME: nick,
'jp.co.interfactory.framework.trim.NICKNAME': '',
PC_MAIL: profile.email,
'jp.co.interfactory.framework.trim.PC_MAIL': '',
PASSWORD: password,
PASSWORD2: password,
SEX: sex,
BIRTH_YEAR: y,
'jp.co.interfactory.framework.trim.BIRTH_YEAR': '',
BIRTH_MONTH: mo,
'jp.co.interfactory.framework.trim.BIRTH_MONTH': '',
BIRTH_DAY: d,
'jp.co.interfactory.framework.trim.BIRTH_DAY': '',
ZIP: zip7,
'jp.co.interfactory.framework.trim.ZIP': '',
ADDR1: addr1,
ADDR2: addr2,
'jp.co.interfactory.framework.trim.ADDR2': '',
'MEMBER.FREE_ITEM16': addrStreet,
'jp.co.interfactory.framework.trim.MEMBER.FREE_ITEM16': '',
ADDR3: addr3,
'jp.co.interfactory.framework.trim.ADDR3': '',
TEL: profile.tel,
'jp.co.interfactory.framework.trim.TEL': '',
L_NAME: ln,
F_NAME: fn,
L_KANA: lk,
'jp.co.interfactory.framework.trim.L_KANA': '',
F_KANA: fk,
'jp.co.interfactory.framework.trim.F_KANA': '',
PC_MAIL_TYPE: '1',
MOBILE_MAIL_TYPE: '1',
});
if (!addr3) confirm['MEMBER.FREE_ITEM19'] = '1';
else delete confirm['MEMBER.FREE_ITEM19'];
const r1 = await httpPost('/member_regist.html', confirm, editRef);
if (r1.text.includes('sms_authentication') || r1.url.includes('sms_authentication')) {
throw new Error('触发了 SMS 验证(请勿改手机号)');
}
const confirmParsed = parseFormChunk(r1.text, 'confirmForm');
const hidden = parseHiddenFields(confirmParsed.chunk);
const token = hidden.token || parseToken(r1.text);
if (!token) {
throw new Error(extractParksError(r1.text) || 'confirm 失败,请检查密码是否正确');
}
const execute = Object.assign({}, hidden, {
request: 'execute',
token,
MAIL_FLG: hidden.MAIL_FLG || '1',
SEX: sex,
BIRTH_YEAR: y,
BIRTH_MONTH: mo,
BIRTH_DAY: d,
BIRTH: y + '/' + mo + '/' + d,
ZIP: zip7 || hidden.ZIP || '',
ADDR1: addr1 || hidden.ADDR1 || '',
ADDR2: addr2 || hidden.ADDR2 || '',
'MEMBER.FREE_ITEM16': addrStreet || hidden['MEMBER.FREE_ITEM16'] || '',
ADDR3: addr3 || hidden.ADDR3 || '',
TEL: profile.tel,
L_NAME: ln,
F_NAME: fn,
L_KANA: lk,
F_KANA: fk,
NICKNAME: nick,
PC_MAIL: profile.email,
PASSWORD: password,
PASSWORD2: password,
});
if (addr3) delete execute['MEMBER.FREE_ITEM19'];
else execute['MEMBER.FREE_ITEM19'] = '1';
const action = confirmParsed.action || '/member_regist_confirm.html';
const r2 = await httpPost(action, execute, ORIGIN + '/member_regist.html');
if (r2.text.includes('sms_authentication') || r2.url.includes('sms_authentication')) {
throw new Error('execute 触发 SMS');
}
if (!looksExecuteSuccess(r2)) {
const afterEdit = await httpGet('/member_regist.html?request=edit', ORIGIN + '/member_mypage.html');
const after = parseProfile(afterEdit.text);
if (after.last_name === ln && after.first_name === fn) {
return {
last_name: ln,
first_name: fn,
last_name_kana: lk,
first_name_kana: fk,
birthday: `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`,
};
}
throw new Error(extractParksError(r2.text) || 'execute 未返回成功页');
}
return {
last_name: ln,
first_name: fn,
last_name_kana: lk,
first_name_kana: fk,
birthday: `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`,
};
}
async function verifyTicketNames() {
const r = await httpGet('/admission_ticket.html');
const orders = [...r.text.matchAll(/admission_use_ticket\.html\?order_no=(\d+)/g)].map((m) => m[1]);
const tickets = [];
for (const ono of orders) {
const t = await httpGet('/admission_use_ticket.html?order_no=' + ono, ORIGIN + '/admission_ticket.html');
const m = t.text.match(
/block-mypage-coupon-list-item-code-value">([^<]+)<\/dd>\s*<dd class="block-mypage-ticket-detail-code-value">(EC-\d+)<\/dd>/s
);
if (m) tickets.push({ order: ono, ec: m[2], name: m[1].trim() });
}
const hist = await httpGet('/member_history.html');
const clients = [...hist.text.matchAll(/ご依頼主<\/dt>\s*<dd[^>]*>\s*([^<]+)/g)].map((m) => m[1].trim());
const prof = await loadProfile();
const member = `${prof.last_name} ${prof.first_name}`.trim();
return { member, tickets, clients, kana: `${prof.last_name_kana} ${prof.first_name_kana}`.trim() };
}
/* ---------- UI ---------- */
const css = `
#npRenameRoot{all:initial;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;}
#npRenameFab{position:fixed;right:14px;bottom:calc(18px + env(safe-area-inset-bottom));z-index:2147483646;width:54px;height:54px;border-radius:27px;border:none;background:linear-gradient(135deg,#e60012,#b8000f);color:#fff;font-size:14px;font-weight:700;box-shadow:0 4px 16px rgba(0,0,0,.35);cursor:pointer;}
#npRenameMask{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:2147483647;display:none;}
#npRenamePanel{position:fixed;left:0;right:0;bottom:0;max-height:88vh;overflow:auto;background:#fff;border-radius:16px 16px 0 0;padding:16px 16px calc(20px + env(safe-area-inset-bottom));z-index:2147483647;transform:translateY(110%);transition:transform .25s ease;box-sizing:border-box;}
#npRenamePanel.open{transform:translateY(0);}
#npRenamePanel *{box-sizing:border-box;font-family:inherit;}
.np-title{font-size:17px;font-weight:700;margin:0 0 4px;color:#111;}
.np-sub{font-size:12px;color:#666;margin:0 0 12px;line-height:1.5;}
.np-warn{font-size:11px;color:#b45309;background:#fffbeb;border:1px solid #fcd34d;border-radius:8px;padding:8px 10px;margin-bottom:12px;line-height:1.45;}
.np-row{margin-bottom:10px;}
.np-row label{display:block;font-size:12px;color:#444;margin-bottom:4px;}
.np-row input{width:100%;height:42px;border:1px solid #ddd;border-radius:8px;padding:0 12px;font-size:16px;}
.np-row input:focus{outline:none;border-color:#e60012;}
.np-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;}
.np-btns{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:12px;}
.np-btn{height:44px;border:none;border-radius:10px;font-size:14px;font-weight:600;cursor:pointer;}
.np-btn-primary{background:#e60012;color:#fff;}
.np-btn-secondary{background:#f3f4f6;color:#111;}
.np-btn-full{grid-column:1/-1;}
.np-log{margin-top:12px;font-size:12px;line-height:1.55;color:#333;background:#f9fafb;border-radius:8px;padding:10px;white-space:pre-wrap;max-height:160px;overflow:auto;}
.np-close{position:absolute;right:12px;top:12px;border:none;background:#eee;width:32px;height:32px;border-radius:16px;font-size:18px;cursor:pointer;}
.np-switch-box{background:linear-gradient(135deg,#ecfdf5,#f0fdf4);border:1px solid #6ee7b7;border-radius:12px;padding:12px;margin-bottom:12px;}
.np-switch-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:8px;}
.np-switch-title{font-size:14px;font-weight:700;color:#065f46;}
.np-switch-hint{font-size:11px;color:#047857;line-height:1.45;margin:0 0 8px;}
.np-switch{position:relative;width:52px;height:30px;flex-shrink:0;}
.np-switch input{opacity:0;width:0;height:0;}
.np-switch-slider{position:absolute;inset:0;background:#cbd5e1;border-radius:15px;transition:.2s;cursor:pointer;}
.np-switch-slider:before{content:"";position:absolute;width:24px;height:24px;left:3px;top:3px;background:#fff;border-radius:50%;transition:.2s;box-shadow:0 1px 3px rgba(0,0,0,.2);}
.np-switch input:checked+.np-switch-slider{background:#059669;}
.np-switch input:checked+.np-switch-slider:before{transform:translateX(22px);}
#npOverlayBadge{position:fixed;left:10px;bottom:calc(18px + env(safe-area-inset-bottom));z-index:2147483645;background:#059669;color:#fff;font-size:11px;padding:6px 10px;border-radius:8px;display:none;max-width:42vw;line-height:1.3;box-shadow:0 2px 8px rgba(0,0,0,.25);}
`;
const root = document.createElement('div');
root.id = 'npRenameRoot';
root.innerHTML = `
<style>${css}</style>
<button id="npRenameFab" type="button" title="改个人信息">改资料</button>
<div id="npRenameMask"></div>
<div id="npRenamePanel">
<button class="np-close" type="button" id="npRenameClose">×</button>
<p class="np-title">NAMCO Parks 改个人信息</p>
<p class="np-sub">需已登录 parks2。改的是<strong>会员资料/会員情報変更</strong>中的姓名与生日,无 SMS(手机号不变)。<br>面板随页面自动打开,无需任何操作;关闭后可用右下角按钮 / 长按顶部 2 秒 / 三击顶部重新打开</p>
<div class="np-warn">⚠ 「提交修改」改服务器会员资料(姓名/生日)。官网编辑页生日虽显示只读,接口可改。「券面强制显示」仅本机浏览器覆盖画面。</div>
<div class="np-switch-box">
<div class="np-switch-head">
<span class="np-switch-title">券面强制显示</span>
<label class="np-switch" title="刷新后仍显示">
<input type="checkbox" id="npOverlayOn" />
<span class="np-switch-slider"></span>
</label>
</div>
<p class="np-switch-hint">开启后替换/插入券面姓名。iPhone 使用済み券有时官方不显示姓名,开此开关并填写姓名即可补上;刷新后仍有效。</p>
<div class="np-row" style="margin-bottom:0">
<label>券面显示姓名</label>
<input id="npOverlayName" placeholder="李 楷彤 或 WU JIAN" />
</div>
<button class="np-btn np-btn-secondary np-btn-full" id="npSyncOverlay" type="button" style="margin-top:8px;height:38px">用上方姓名填入</button>
</div>
<div class="np-row">
<label>完整姓名(自动拆分)</label>
<input id="npFull" placeholder="李楷彤 或 WU JIAN" autocomplete="name" />
</div>
<div class="np-grid">
<div class="np-row"><label>姓 L_NAME</label><input id="npL" /></div>
<div class="np-row"><label>名 F_NAME</label><input id="npF" /></div>
<div class="np-row"><label>片假名姓(可选)</label><input id="npLk" placeholder="リ" /></div>
<div class="np-row"><label>片假名名(可选)</label><input id="npFk" placeholder="カイトウ" /></div>
</div>
<div class="np-row">
<label>生日 BIRTHYYYY-MM-DD</label>
<input id="npBirthday" type="date" placeholder="1999-07-12" />
</div>
<div class="np-row">
<label>账号密码(提交 confirm 必填,不会保存)</label>
<input id="npPwd" type="password" placeholder="Lp149717" autocomplete="current-password" />
</div>
<div class="np-btns">
<button class="np-btn np-btn-secondary" id="npLoad" type="button">读取当前</button>
<button class="np-btn np-btn-secondary" id="npSplit" type="button">拆分姓名</button>
<button class="np-btn np-btn-primary np-btn-full" id="npSubmit" type="button">提交修改</button>
<button class="np-btn np-btn-secondary np-btn-full" id="npVerify" type="button">验证券面 / 订单</button>
</div>
<div class="np-log" id="npLog">请先登录 NAMCO,再点「读取当前」。</div>
</div>
<div id="npOverlayBadge"></div>`;
document.documentElement.appendChild(root);
npDebug('UI 元素已注入 @ ' + location.hostname + location.pathname);
const fab = $('#npRenameFab', root);
const mask = $('#npRenameMask', root);
const panel = $('#npRenamePanel', root);
const logEl = $('#npLog', root);
const overlayBadge = $('#npOverlayBadge', root);
function log(msg) {
logEl.textContent = msg;
}
function refreshOverlayBadge() {
const cfg = getOverlayConfig();
if (cfg.enabled && cfg.displayName) {
overlayBadge.style.display = 'block';
overlayBadge.textContent = '券面强制显示:' + cfg.displayName;
} else {
overlayBadge.style.display = 'none';
}
}
function refreshPluginUiVisibility() {
fab.style.display = ''; // v1.6.1: 修改按钮常驻显示,不受任何设置影响
refreshOverlayBadge();
}
function syncOverlayFromForm() {
const name = buildDisplayName(
$('#npL', root).value.trim(),
$('#npF', root).value.trim(),
$('#npFull', root).value.trim() || $('#npOverlayName', root).value.trim()
);
if (name) $('#npOverlayName', root).value = name;
return name;
}
function saveOverlayFromUI() {
const enabled = $('#npOverlayOn', root).checked;
const displayName = ($('#npOverlayName', root).value || syncOverlayFromForm()).trim();
setOverlayConfig({ enabled, displayName });
refreshPluginUiVisibility();
if (enabled && displayName) {
findTicketNameNodes(document, true).forEach((el) => {
el.dataset.npOverlay = '1';
});
const n = applyTicketOverlay(true);
return { enabled, displayName, patched: n };
}
return { enabled, displayName, patched: 0 };
}
function loadOverlayToUI() {
const cfg = getOverlayConfig();
$('#npOverlayOn', root).checked = !!cfg.enabled;
if (cfg.displayName) $('#npOverlayName', root).value = cfg.displayName;
refreshPluginUiVisibility();
}
function openPanel(noMask) {
if (!noMask) mask.style.display = 'block';
panel.classList.add('open');
const draft = store.get(LS_KEY, {});
if (draft.full) $('#npFull', root).value = draft.full;
if (draft.l) $('#npL', root).value = draft.l;
if (draft.f) $('#npF', root).value = draft.f;
if (draft.lk) $('#npLk', root).value = draft.lk;
if (draft.fk) $('#npFk', root).value = draft.fk;
if (draft.birthday) $('#npBirthday', root).value = draft.birthday;
loadOverlayToUI();
if (isLoggedInFromDom()) {
log('✅ 当前页已登录\n• 手机没名字:开「券面强制显示」+ 填姓名\n• 必须在「詳細」页(有 EC 号那页),不是列表页');
} else {
log('⚠ 未检测到登录(改服务器资料才需要)\n• 手机券面没名字:直接开「券面强制显示」填姓名即可');
}
}
function closePanel() {
panel.classList.remove('open');
mask.style.display = 'none';
store.set(LS_KEY, {
full: $('#npFull', root).value,
l: $('#npL', root).value,
f: $('#npF', root).value,
lk: $('#npLk', root).value,
fk: $('#npFk', root).value,
birthday: $('#npBirthday', root).value,
});
saveOverlayFromUI();
}
fab.addEventListener('click', openPanel);
mask.addEventListener('click', closePanel);
$('#npRenameClose', root).addEventListener('click', closePanel);
/** 触发:长按页面顶部 2 秒 / 快速三击顶部 */
(function setupTopTriggers() {
const HOLD_MS = 2000;
const TOP_ZONE = 100;
const TRIPLE_MS = 500;
const TRIPLE_SPREAD = 40;
let holdTimer = null;
let startY = 0;
let touchStartTime = 0;
let tripleTimes = [];
function clearHold() {
if (holdTimer) {
clearTimeout(holdTimer);
holdTimer = null;
}
}
function openFromTop() {
if (!panel.classList.contains('open')) openPanel();
}
function beginHold(clientY) {
if (panel.classList.contains('open')) return;
if (clientY > TOP_ZONE) return;
clearHold();
startY = clientY;
touchStartTime = Date.now();
holdTimer = setTimeout(() => {
holdTimer = null;
openFromTop();
}, HOLD_MS);
}
function moveHold(clientY) {
if (!holdTimer) return;
if (Math.abs(clientY - startY) > 20 || clientY > TOP_ZONE + 20) {
clearHold();
}
}
/** 长按结束时的兜底:若计时器被系统打断(如长按文字弹放大镜/链接菜单)但按住时长已够,仍然打开 */
function endHold(clientY) {
if (holdTimer) {
clearHold();
return;
}
if (
clientY <= TOP_ZONE &&
Date.now() - touchStartTime >= HOLD_MS &&
!panel.classList.contains('open')
) {
openFromTop();
}
}
/** 快速三击顶部(鼠标事件在 iOS 上由点击合成,一次点击只记一次) */
function recordTopTap(clientY) {
if (panel.classList.contains('open')) return;
if (clientY > TOP_ZONE) return;
const now = Date.now();
tripleTimes = tripleTimes.filter((t) => now - t.time <= TRIPLE_MS);
tripleTimes.push({ time: now, y: clientY });
if (tripleTimes.length < 3) return;
const ys = tripleTimes.map((t) => t.y);
const spread = Math.max(...ys) - Math.min(...ys);
tripleTimes = [];
if (spread <= TRIPLE_SPREAD) openFromTop();
}
// 捕获阶段监听,避免页面自身 touch 处理拦截事件
document.addEventListener(
'touchstart',
(e) => {
const t = e.touches && e.touches[0];
if (!t) return;
beginHold(t.clientY);
},
{ capture: true, passive: true }
);
document.addEventListener(
'touchmove',
(e) => {
const t = e.touches && e.touches[0];
if (!t) return;
moveHold(t.clientY);
},
{ capture: true, passive: true }
);
document.addEventListener(
'touchend',
(e) => {
const t = e.changedTouches && e.changedTouches[0];
clearHold();
if (t) endHold(t.clientY);
},
{ capture: true, passive: true }
);
document.addEventListener('touchcancel', clearHold, { capture: true, passive: true });
document.addEventListener('mousedown', (e) => beginHold(e.clientY));
document.addEventListener('mousemove', (e) => moveHold(e.clientY));
document.addEventListener('mouseup', (e) => {
clearHold();
recordTopTap(e.clientY);
});
document.addEventListener('mouseleave', clearHold);
})();
$('#npOverlayOn', root).addEventListener('change', () => {
const r = saveOverlayFromUI();
if (r.enabled && !r.displayName) {
log('请先填写「券面显示姓名」');
$('#npOverlayOn', root).checked = false;
setOverlayConfig({ enabled: false, displayName: '' });
refreshPluginUiVisibility();
return;
}
log(r.enabled ? `✅ 券面强制显示已开启:${r.displayName}\n刷新/店员 F5 后会自动再覆盖。` : '券面强制显示已关闭');
});
$('#npOverlayName', root).addEventListener('input', () => {
if ($('#npOverlayOn', root).checked) saveOverlayFromUI();
});
$('#npSyncOverlay', root).addEventListener('click', () => {
const name = syncOverlayFromForm();
if (!name) {
log('请先在上方填写完整姓名或姓/名');
return;
}
const r = saveOverlayFromUI();
log(`券面显示名:${name}${r.enabled ? '(已生效)' : '(请打开开关)'}`);
});
loadOverlayToUI();
refreshPluginUiVisibility();
if (getOverlayConfig().enabled) applyTicketOverlay(true);
// v1.6.2:页面加载即自动打开设置面板,无需任何唤起动作;券面页不弹(避免挡住给店员看的券面)
if (!isTicketPage()) {
openPanel(true);
npDebug('UI 初始化完成,面板已自动打开');
} else {
npDebug('券面页:面板不自动弹(右下角按钮可用)');
}
$('#npSplit', root).addEventListener('click', () => {
const { l, f } = splitFullName($('#npFull', root).value);
$('#npL', root).value = l;
$('#npF', root).value = f;
log(`已拆分:姓「${l}」名「${f}」`);
});
$('#npLoad', root).addEventListener('click', async () => {
log('读取中…');
try {
const ok = await checkLoggedIn();
if (!ok) throw new Error('未登录,请打开网站先登录');
const p = await loadProfile();
log(
`当前会员\n氏名:${p.last_name} ${p.first_name}\nカナ:${p.last_name_kana} ${p.first_name_kana}\n生日:${p.birthday}\n手机:${p.tel}\n邮箱:${p.email}`
);
$('#npL', root).value = p.last_name || '';
$('#npF', root).value = p.first_name || '';
if (!$('#npLk', root).value) $('#npLk', root).value = p.last_name_kana || '';
if (!$('#npFk', root).value) $('#npFk', root).value = p.first_name_kana || '';
$('#npBirthday', root).value = normalizeBirthday(p.birthday);
} catch (e) {
log('❌ ' + e.message);
}
});
$('#npSubmit', root).addEventListener('click', async () => {
const l = $('#npL', root).value.trim();
const f = $('#npF', root).value.trim();
const bdayRaw = $('#npBirthday', root).value.trim();
const pwd = $('#npPwd', root).value;
if (!l || !f) {
log('请填写姓和名');
return;
}
if (bdayRaw && !normalizeBirthday(bdayRaw)) {
log('生日格式无效,请用 YYYY-MM-DD');
return;
}
if (!pwd) {
log('请填写账号密码');
return;
}
log('提交中…请勿关页面');
try {
const profile = await loadProfile();
const changes = {
last_name: l,
first_name: f,
nickname: l,
};
const lk = $('#npLk', root).value.trim();
const fk = $('#npFk', root).value.trim();
if (lk) changes.last_name_kana = lk;
if (fk) changes.first_name_kana = fk;
const bday = normalizeBirthday(bdayRaw);
if (bday) changes.birthday = bday;
await updateMemberName(profile, changes, pwd);
const after = await loadProfile();
log(
`✅ 会员资料已更新\n` +
`新氏名:${after.last_name} ${after.first_name}\n` +
`カナ:${after.last_name_kana} ${after.first_name_kana}\n` +
`生日:${after.birthday}\n` +
`建议开启「券面强制显示」并验证券面。`
);
const dn = `${after.last_name} ${after.first_name}`.trim();
if (dn) {
$('#npOverlayName', root).value = dn;
if (!$('#npOverlayOn', root).checked) {
$('#npOverlayOn', root).checked = true;
}
saveOverlayFromUI();
}
} catch (e) {
log('❌ ' + e.message);
}
});
$('#npVerify', root).addEventListener('click', async () => {
log('验证中…');
try {
const v = await verifyTicketNames();
const prof = await loadProfile();
let msg = `会员资料:${v.member}\n片假名:${v.kana || '(空)'}\n生日:${prof.birthday || '(空)'}\n`;
if (v.clients.length) msg += `订单ご依頼主:${v.clients[0]}\n`;
if (!v.tickets.length) {
msg += '当前无入場チケット。';
} else {
v.tickets.forEach((t) => {
const ok = t.name === v.member;
msg += `\n券面 [${t.ec}]${t.name} ${ok ? '✅与会员一致' : '❌仍为订单快照'}`;
});
}
log(msg);
} catch (e) {
log('❌ ' + e.message);
}
});
})();