@@ -195,6 +195,83 @@ function parseIndexStats(indexHtml) {
} ;
}
// ---- 邮箱提取 ----
// 资料页的「邮箱」值单元格往往不只有邮箱本身 (PTTIME: 格内还混着 "UID:98067" / "📝修改此项" / 🔐 标记,
// 且整页还有捐赠/页脚链接), 不同 NexusPHP 变体标签可能是 邮箱/电子邮件/電子郵件/Email,
// 值可能是 <a href="mailto:"> 也可能是纯文本, 甚至被反爬混淆成 "xxx [at] qq [dot] com"。
// 故按「标签行定位 → 值内 mailto → 值文本 → 整页 mailto → 标签邻近文本」逐级提取:
// 既不会抓到页脚的联系邮箱, 也不会因标签与值不在同一行/大小写 mailto 而漏抓。
var EMAIL _PATTERN = '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}' ;
var EMAIL _LABELS = '(?:邮箱|电子邮件|電子郵件|邮件|邮件地址|邮箱地址|E-?mail)' ;
// 反混淆: "xxx [at] qq [dot] com" / "xxx (at) qq (dot) com" / "xxx at qq dot com"
function deobfuscateEmail ( s ) {
return String ( s || '' )
. replace ( /\s*[\[({]\s*(?:at|@)\s*[\])}]\s*/gi , '@' )
. replace ( /\s*[\[({]\s*dot\s*[\])}]\s*/gi , '.' )
. replace ( /\s+(?:at|@)\s+/gi , '@' )
. replace ( /\s+dot\s+/gi , '.' ) ;
}
// 从一段文本里取第一个邮箱 (先直取, 失败再反混淆后取)
function firstEmailIn ( s ) {
if ( ! s ) return null ;
var m = String ( s ) . match ( new RegExp ( EMAIL _PATTERN ) ) ;
if ( m ) return m [ 0 ] ;
var m2 = deobfuscateEmail ( s ) . match ( new RegExp ( EMAIL _PATTERN ) ) ;
return m2 ? m2 [ 0 ] : null ;
}
// 从节点下的 <a href="mailto:"> 取邮箱 (大小写不敏感 + URL 解码 + 去掉 ?subject= 等参数)
function emailFromLinks ( node ) {
if ( ! node || typeof node . queryList !== 'function' ) return null ;
var links = node . queryList ( 'a' ) || [ ] ;
for ( var i = 0 ; i < links . length ; i ++ ) {
var href = ( typeof links [ i ] . attr === 'function' ? links [ i ] . attr ( 'href' ) : null ) || '' ;
var m = String ( href ) . match ( /^\s*mailto:\s*(\S+)/i ) ;
if ( ! m ) continue ;
var addr = m [ 1 ] . split ( '?' ) [ 0 ] ;
try { addr = decodeURIComponent ( addr ) ; } catch ( e ) { /* 非法转义: 保持原值 */ }
var hit = firstEmailIn ( addr ) ;
if ( hit ) return hit ;
}
return null ;
}
// 从单个单元格取邮箱: 先 mailto 链接, 再单元格文本
function emailFromCell ( cell ) {
if ( ! cell ) return null ;
return emailFromLinks ( cell ) || firstEmailIn ( typeof cell . text === 'function' ? cell . text ( ) : null ) ;
}
function extractEmail ( doc , text ) {
// 1) 资料表格: 定位标签为「邮箱/邮件/Email」的行, 取右侧值单元格 (最准确)
var rows = ( typeof doc . queryList === 'function' ? doc . queryList ( 'tr' ) : null ) || [ ] ;
for ( var i = 0 ; i < rows . length ; i ++ ) {
var cells = rows [ i ] . queryList ( 'td' ) || [ ] ;
if ( cells . length < 2 ) continue ;
var label = ( cells [ 0 ] . text ( ) || '' ) . replace ( /[\s:: ]/g , '' ) ;
if ( ! label ) continue ;
if ( ! new RegExp ( '^' + EMAIL _LABELS + '$' , 'i' ) . test ( label ) ) continue ;
for ( var c = 1 ; c < cells . length ; c ++ ) {
var hit = emailFromCell ( cells [ c ] ) ;
if ( hit ) return hit ;
}
}
// 2) 整页 mailto 链接 (标签行解析不到时的兜底, 例如标签与值不在同一 tr)
var byLink = emailFromLinks ( doc ) ;
if ( byLink ) return byLink ;
// 3) 整页文本: 标签后紧跟邮箱
var em = String ( text || '' ) . match ( new RegExp ( EMAIL _LABELS + '\\s*[:: ]?\\s*(' + EMAIL _PATTERN + ')' , 'i' ) ) ;
if ( em ) return em [ 1 ] ;
// 4) 标签与值之间夹了别的内容 (PTTIME: "邮箱27800734@qq.comUID:98067🔐") 时, 在标签后 120 字符内找
var near = String ( text || '' ) . match ( new RegExp ( EMAIL _LABELS + '[^\\n]{0,120}?(' + EMAIL _PATTERN + ')' , 'i' ) ) ;
if ( near ) return near [ 1 ] ;
// 5) 反混淆兜底 (邮箱被拆成 "xxx [at] qq [dot] com")
var obf = deobfuscateEmail ( text ) . match ( new RegExp ( EMAIL _LABELS + '[^\\n]{0,160}?(' + EMAIL _PATTERN + ')' , 'i' ) ) ;
return obf ? obf [ 1 ] : null ;
}
// 解析 userdetails.php 账号资料:邮箱/等级/注册日期/最近动向/做种积分/封存状态 + uid/用户名
// 注意: userdetails 的字段在 #info_block 之外的资料表格中,故用整页 body 文本解析
function parseUserDetails ( userHtml ) {
@@ -202,20 +279,7 @@ function parseUserDetails(userHtml) {
var body = doc . querySelector ( 'body' ) || doc ;
var text = body . text ( ) || userHtml ;
var email = null ;
// DOM 优先:从 <a href="mailto:"> 提取最准确
var mailLink = doc . querySelector ( 'a[href*="mailto:"]' ) ;
if ( mailLink ) {
var href = mailLink . attr ( 'href' ) || '' ;
var mm = href . match ( /mailto:([\w.+-]+@[\w.-]+\.\w+)/i ) ;
if ( mm ) email = mm [ 1 ] ;
else email = mailLink . text ( ) ;
}
// 退路:正则从 body text 搜
if ( ! email ) {
var em = text . match ( /(?:邮箱|Email|E-mail)\s*[:: ]?\s*([\w.+-]+@[\w.-]+\.\w+)/i ) ;
if ( em ) email = em [ 1 ] ;
}
var email = extractEmail ( doc , text ) ;
// 等级: DOM 解析,遍历每行 tr, 找 label td 文本为 等级/级别/Class 的行,取其右边 td 的内容
var classLevel = null ;
@@ -259,8 +323,29 @@ function parseUserDetails(userHtml) {
if ( ! isNaN ( sv ) ) seedingScore = sv ;
}
// 封存/封禁检测
var isSealed = /封存|帳號已?被封|账号已?被封/ . test ( text ) ;
// 封存/封禁检测:优先看「账号状态/用户状态」行的取值,其次认状态类表述 ("已封存"/"已封禁"/"封存中")。
// 排除 pttime 个人中心的干扰文本:操作行的「封存用户」按钮、"封存400天…自动封禁" 说明,
// 以及「已封禁次数」计数行 —— 否则正常账号会被误判为已封存。
var isSealed = false ;
var statusRows = doc . queryList ( 'tr' ) || [ ] ;
for ( var s = 0 ; s < statusRows . length && ! isSealed ; s ++ ) {
var stCells = statusRows [ s ] . queryList ( 'td' ) || [ ] ;
if ( stCells . length < 2 ) continue ;
var stLabel = ( stCells [ 0 ] . text ( ) || '' ) . replace ( /[\s:: ]/g , '' ) ;
if ( ! /^(?:账号状态|帐号状态|用戶狀態|用户状态|状态|狀態)$/ . test ( stLabel ) ) continue ;
if ( /封存|封禁|停用|禁用/ . test ( stCells [ 1 ] . text ( ) || '' ) ) isSealed = true ;
}
if ( ! isSealed ) {
var sealedRe = /已(?:被|经|經)?\s*(?:封存|封禁)([\s\S]{0,2})/g ;
var sm ;
while ( ( sm = sealedRe . exec ( text ) ) !== null ) {
// "已封禁次数" 是计数行, 不代表账号当前被封
if ( ! /^次/ . test ( sm [ 1 ] || '' ) ) { isSealed = true ; break ; }
}
}
// 末级兜底只认"封存中/封禁中"这类进行中状态; 不再写 "账号…封" 这类宽松模式 ——
// pttime 个人中心的「自ban账号 / 封禁用户」按钮文本会命中它, 造成正常账号误判。
if ( ! isSealed ) isSealed = /封(?:存|禁)中/ . test ( text ) ;
var identity = parseIdentity ( doc . querySelector ( '#info_block' ) || doc ) ;
return {
@@ -275,6 +360,23 @@ function parseUserDetails(userHtml) {
} ;
}
// 访客视图(会话失效 / Cookie 域名不匹配 / 未登录)识别:
// NexusPHP 对未登录访客只给公开字段(等级、注册日期),邮箱等「上锁」字段整个隐藏,
// 若照常当成功处理,表现就是「同步成功但邮箱一直为空」且没有任何失败样本可排查。
// 判据:页面出现登录表单,或出现"请先登录/登录后查看"等提示。
function looksLikeGuestView ( userHtml ) {
try {
var d = html . parse ( userHtml ) ;
if ( d . querySelector ( 'form[action*="login"]' ) ) return true ;
if ( d . querySelector ( 'input[type="password"]' ) ) return true ;
var b = d . querySelector ( 'body' ) ;
var t = ( b && typeof b . text === 'function' ) ? b . text ( ) : userHtml ;
return /(?:请先登录|請先登入|需要登录|登录后查看|您尚未登录|尚未登录|未登录|Not logged in|Please log in)/i . test ( t || '' ) ;
} catch ( e ) {
return false ;
}
}
// ================= 能力: index( 用户统计) =================
// ctx: { baseUrl, ... }。解析首页信息块,返回当前登录用户统计。
function index ( input ) {
@@ -368,6 +470,11 @@ function sync(input) {
if ( profile && profile . email === null && profile . classLevel === null && profile . registerDate === null ) {
profile = null ;
}
// 访客视图: 只拿到公开字段而上锁字段(邮箱)被隐藏 —— 不算解析成功, 否则会静默回传 email=null,
// 表现为"同步一直成功但邮箱始终为空"且无样本可排查。
if ( profile && profile . email === null && looksLikeGuestView ( userHtml ) ) {
profile = null ;
}
}
} catch ( e ) {
profile = null ;
@@ -423,10 +530,20 @@ function parse(content, url) {
// 签到( signInPath 默认 /attendance.php, 可在站点 definition 中覆盖,如 BTSCHOOL: /index.php?action=addbonus)
// 成功时若响应页含 #info_block (含魔力值) 则一并返回 bonus 供服务端更新 Site.Statistics.Bonus;
// 站点页面不含 / 解析不到时缺省不返回该字段, 服务端不处理 (report §四 签到奖励自动更新).
//
// 站点特化:
// URL 含 {uid} 占位符 → 用 ctx.uid (数据库已存) 替换 → GET 请求 (PTTime: /attendance.php?type=sign&uid={uid});
// 其他情况一律 POST (兼容 BTSCHOOL /index.php?action=addbonus 等带查询参数的 POST 签到).
function sign ( input ) {
var ctx = _ _asCtx ( input ) ;
var signPath = ctx . signInPath || '/attendance.php' ;
var res = http . post ( signPath ) ;
var method = 'POST' ;
if ( signPath . indexOf ( '{uid}' ) >= 0 ) {
signPath = signPath . replace ( '{uid}' , ( ctx && ctx . uid ) || '' ) ;
method = 'GET' ;
}
var res = method === 'GET' ? http . get ( signPath ) : http . post ( signPath ) ;
if ( res && res . indexOf ( '成功' ) >= 0 ) {
var out = { signed : true , message : '签到成功' } ;
try {