|
// ==UserScript== |
|
// @name RootData 团队成员项目状态自动尽调面板 |
|
// @namespace https://cn.rootdata.com/ |
|
// @version 3.5.0 |
|
// @description RootData 团队成员项目状态面板,并保守识别中国团队与中国投资机构共同参与的“国人盘子”项目 |
|
// @author You |
|
// @match https://cn.rootdata.com/* |
|
// @grant GM_addStyle |
|
// @grant GM_xmlhttpRequest |
|
// @grant GM_getValue |
|
// @grant GM_setValue |
|
// @grant GM_deleteValue |
|
// @grant GM_listValues |
|
// @connect cn.rootdata.com |
|
// @run-at document-idle |
|
// @noframes |
|
// ==/UserScript== |
|
|
|
(function () { |
|
'use strict'; |
|
|
|
if (window.top !== window.self) return; |
|
|
|
const CONFIG = { |
|
cacheVersion: 'v3.5.0', |
|
cacheTTL: 1000 * 60 * 60 * 6, |
|
requestTimeout: 20000, |
|
requestInterval: 450, |
|
maxMembers: 20, |
|
maxProjectsPerMember: 25, |
|
maxUniqueProjects: 80, |
|
maxVisibleInvestors: 3, |
|
panelId: 'rd-auto-dd-panel', |
|
processedAttr: 'data-rd-auto-dd-processed', |
|
purgeFlag: 'rd-auto-dd-purged-large-html-cache-v3.5.0', |
|
currentProjectBadgeId: 'rd-auto-dd-current-cn-badge', |
|
chinaTeamMinScore: 4, |
|
chinaInvestorMinScore: 4, |
|
maxChinaEvidenceNames: 6 |
|
}; |
|
|
|
const PROGRESS_STEPS = [ |
|
'团队', |
|
'成员主页', |
|
'项目详情', |
|
'评分', |
|
'完成' |
|
]; |
|
|
|
const EXPLICIT_STATUS_KEYWORDS = [ |
|
'停止运营', |
|
'已关闭', |
|
'关闭', |
|
'停止', |
|
'暂停', |
|
'下线', |
|
'疑似停止', |
|
'运营中', |
|
'已上线', |
|
'测试网', |
|
'未上线', |
|
'即将推出' |
|
]; |
|
|
|
const BAD_STATUS_KEYWORDS = [ |
|
'停止运营', |
|
'已关闭', |
|
'关闭', |
|
'停止', |
|
'暂停', |
|
'下线', |
|
'疑似停止' |
|
]; |
|
|
|
const IMPORTANT_SECTIONS = [ |
|
'工作经历', |
|
'投资组合', |
|
'创办项目', |
|
'参与项目', |
|
'关联项目', |
|
'项目', |
|
'Portfolio' |
|
]; |
|
|
|
const CHINA_LOCATION_KEYWORDS = [ |
|
'中国', |
|
'中国大陆', |
|
'大陆', |
|
'香港', |
|
'澳门', |
|
'台湾', |
|
'北京', |
|
'上海', |
|
'深圳', |
|
'广州', |
|
'杭州', |
|
'成都', |
|
'重庆', |
|
'南京', |
|
'武汉', |
|
'西安', |
|
'苏州', |
|
'天津', |
|
'长沙', |
|
'厦门', |
|
'青岛', |
|
'郑州', |
|
'合肥', |
|
'宁波', |
|
'福州', |
|
'无锡', |
|
'东莞', |
|
'珠海', |
|
'台北', |
|
'新北', |
|
'高雄' |
|
]; |
|
|
|
const CHINA_IDENTITY_KEYWORDS = [ |
|
'中国人', |
|
'华人', |
|
'华裔', |
|
'来自中国', |
|
'中国籍', |
|
'中国国籍', |
|
'Chinese entrepreneur', |
|
'Chinese founder', |
|
'China-based', |
|
'based in China', |
|
'based in Hong Kong' |
|
]; |
|
|
|
const CHINESE_SURNAMES = new Set(Array.from( |
|
'赵钱孙李周吴郑王冯陈褚卫蒋沈韩杨朱秦尤许何吕施张孔曹严华金魏陶姜戚谢邹喻柏水窦章云苏潘葛奚范彭郎鲁韦昌马苗凤花方俞任袁柳酆鲍史唐费廉岑薛雷贺倪汤滕殷罗毕郝邬安常乐于时傅皮卞齐康伍余元卜顾孟平黄和穆萧尹姚邵湛汪祁毛禹狄米贝明臧计伏成戴谈宋茅庞熊纪舒屈项祝董梁杜阮蓝闵席季麻强贾路娄危江童颜郭梅盛林刁钟徐邱骆高夏蔡田樊胡凌霍虞万支柯昝管卢莫经房裘缪干解应宗丁宣贲邓郁单杭洪包诸左石崔吉龚程嵇邢裴陆荣翁荀羊於惠甄曲家封芮羿储靳汲邴糜松井段富巫乌焦巴弓牧隗山谷车侯宓蓬全郗班仰秋仲伊宫宁仇栾暴甘钭厉戎祖武符刘景詹束龙叶幸司韶郜黎蓟薄印宿白怀蒲邰从鄂索咸籍赖卓蔺屠蒙池乔阴胥能苍双闻莘党翟谭贡劳逄姬申扶堵冉宰郦雍却璩桑桂濮牛寿通边扈燕冀郏浦尚农温别庄晏柴瞿阎充慕连茹习宦艾鱼容向古易慎戈廖庾终暨居衡步都耿满弘匡国文寇广禄阙东欧殳沃利蔚越夔隆师巩厍聂晁勾敖融冷訾辛阚那简饶空曾毋沙乜养鞠须丰巢关蒯相查后荆红游竺权逯盖益桓公' |
|
)); |
|
|
|
const COMPOUND_CHINESE_SURNAMES = [ |
|
'欧阳', '太史', '端木', '上官', '司马', '东方', '独孤', '南宫', '万俟', '闻人', |
|
'夏侯', '诸葛', '尉迟', '公羊', '赫连', '澹台', '皇甫', '宗政', '濮阳', '公冶', |
|
'太叔', '申屠', '公孙', '慕容', '仲孙', '钟离', '长孙', '宇文', '司徒', '鲜于', |
|
'司空', '闾丘', '子车', '亓官', '司寇', '巫马', '公西', '颛孙', '壤驷', '公良', |
|
'漆雕', '乐正', '宰父', '谷梁', '拓跋', '夹谷', '轩辕', '令狐', '段干', '百里', |
|
'呼延', '东郭', '南门', '羊舌', '微生' |
|
]; |
|
|
|
const COMMON_JAPANESE_SURNAME_PREFIXES = [ |
|
'佐藤', '鈴木', '铃木', '高橋', '高桥', '田中', '渡辺', '渡边', '伊藤', '山本', |
|
'中村', '小林', '加藤', '吉田', '山田', '山口', '松本', '井上', '木村', '斎藤', |
|
'齋藤', '斋藤', '清水', '山崎', '阿部', '池田', '橋本', '桥本', '山下', '石川', |
|
'中島', '中岛', '前田', '藤田', '後藤', '后藤', '岡田', '冈田', '長谷川', '长谷川', |
|
'村上', '近藤', '石井', '坂本', '遠藤', '远藤', '青木', '藤井', '西村', '福田', |
|
'太田', '三浦', '藤原', '岡本', '冈本', '松田', '中川', '中野', '原田', '小野' |
|
]; |
|
|
|
const PINYIN_SURNAMES = new Set([ |
|
'ai', 'an', 'ao', 'bai', 'bao', 'bi', 'bian', 'bu', 'cai', 'cao', 'cen', 'chai', |
|
'chang', 'che', 'chen', 'cheng', 'chi', 'chu', 'cui', 'dai', 'dang', 'deng', 'di', |
|
'ding', 'dong', 'du', 'duan', 'fan', 'fang', 'fei', 'feng', 'fu', 'gao', 'ge', |
|
'gong', 'gu', 'guan', 'guo', 'han', 'hao', 'he', 'hong', 'hou', 'hu', 'hua', |
|
'huang', 'huo', 'ji', 'jia', 'jiang', 'jin', 'jing', 'kang', 'ke', 'kong', |
|
'lai', 'lan', 'lei', 'li', 'lian', 'liang', 'liao', 'lin', 'ling', 'liu', |
|
'long', 'lu', 'luo', 'ma', 'mao', 'meng', 'mi', 'mo', 'mu', 'ni', 'nie', |
|
'ning', 'ou', 'pan', 'pei', 'peng', 'qian', 'qiang', 'qiao', 'qin', 'qiu', |
|
'ren', 'rong', 'ruan', 'shao', 'shen', 'sheng', 'shi', 'song', 'su', 'sun', |
|
'tan', 'tang', 'tao', 'tian', 'tong', 'wan', 'wang', 'wei', 'wen', 'wu', |
|
'xi', 'xia', 'xian', 'xiang', 'xiao', 'xie', 'xin', 'xing', 'xu', 'xue', |
|
'yan', 'yang', 'yao', 'ye', 'yi', 'yin', 'ying', 'you', 'yu', 'yuan', |
|
'zeng', 'zhan', 'zhang', 'zhao', 'zheng', 'zhong', 'zhou', 'zhu', 'zou' |
|
]); |
|
|
|
const CHINESE_INVESTOR_ALIASES = [ |
|
'真格基金', 'ZhenFund', '奇绩创坛', 'MiraclePlus', '清谷资本', 'TSVC', '红杉中国', 'HongShan', 'Sequoia China', '高瓴资本', 'Hillhouse Capital', |
|
'顺为资本', 'Shunwei Capital', '启明创投', 'Qiming Venture Partners', '高榕资本', 'Gaorong Capital', |
|
'君联资本', 'Legend Capital', '源码资本', 'Source Code Capital', '经纬中国', 'Matrix Partners China', |
|
'IDG Capital', '云九资本', 'Sky9 Capital', '招银国际', 'CMB International', 'CMBI', |
|
'BAI Capital', '贝塔斯曼亚洲投资基金', '光速中国', 'Lightspeed China Partners', |
|
'分布式资本', 'Fenbushi Capital', 'HashKey Capital', 'HashKey Group', 'SNZ Holding', 'SNZ', |
|
'IOSG Ventures', 'IOSG', 'LD Capital', 'Foresight Ventures', 'SevenX Ventures', 'SevenX', |
|
'Bing Ventures', 'Hash Global', 'Waterdrip Capital', '水滴资本', 'NGC Ventures', 'NGC', |
|
'Cobo Ventures', 'Cobo', 'Matrixport Ventures', 'Matrixport', 'Amber Group', 'Amber', |
|
'YZi Labs', 'Binance Labs', '币安实验室', 'OKX Ventures', '欧易 Ventures', 'Gate Ventures', |
|
'KuCoin Ventures', 'A&T Capital', 'Fundamental Labs', 'Continue Capital', 'Cabin VC', |
|
'ArkStream Capital', 'Zonff Partners', 'LK Venture', 'Mask Network', 'Foresight X', |
|
'ABCDE Capital', 'ABCDE', 'ABCDE Labs', 'ABCDE基金', 'Web3.com Ventures', 'MetaWeb Ventures', |
|
'Draper Dragon', 'DragonRoark Venture', 'DFG', 'Digital Finance Group', 'Spark Digital Capital', |
|
'BlockArk', 'BlockArk Capital', 'TokenInsight Research', '链兴资本', '共识实验室', '科银资本', |
|
'了得资本', '节点资本', '创世资本', '想象力基金', '维京资本', '极客资本', '火币资本', |
|
'Huobi Ventures', 'HTX Ventures', 'Bitget Ventures', 'Bixin Ventures', '币信资本', |
|
'OK Blockchain Capital', '链杉资本', 'PreAngel', '德鼎创新基金', 'DraperDragon Innovation Fund' |
|
]; |
|
|
|
const NORMALIZED_CHINESE_INVESTOR_ALIASES = new Set( |
|
CHINESE_INVESTOR_ALIASES.map(normalizeInstitutionName) |
|
); |
|
|
|
let isRunning = false; |
|
let lastUrl = location.href; |
|
let abortToken = 0; |
|
|
|
GM_addStyle(` |
|
#${CONFIG.panelId} { |
|
margin: 18px 0 22px; |
|
font-family: inherit; |
|
color: #111827; |
|
} |
|
|
|
.rd-dd-card { |
|
border: 1px solid rgba(252, 106, 1, 0.22); |
|
background: linear-gradient(135deg, rgba(252, 106, 1, 0.06), rgba(255, 255, 255, 0.98)), #fff; |
|
border-radius: 18px; |
|
padding: 16px 18px; |
|
box-shadow: 0 10px 30px rgba(252, 106, 1, 0.08); |
|
} |
|
|
|
.rd-dd-head { |
|
display: flex; |
|
align-items: flex-start; |
|
justify-content: space-between; |
|
gap: 14px; |
|
margin-bottom: 12px; |
|
} |
|
|
|
.rd-dd-title { |
|
display: flex; |
|
align-items: center; |
|
gap: 8px; |
|
font-size: 17px; |
|
font-weight: 850; |
|
line-height: 1.3; |
|
} |
|
|
|
.rd-dd-icon { |
|
width: 24px; |
|
height: 24px; |
|
border-radius: 999px; |
|
display: inline-flex; |
|
align-items: center; |
|
justify-content: center; |
|
color: #fff; |
|
background: #fc6a01; |
|
font-size: 14px; |
|
font-weight: 900; |
|
flex: 0 0 auto; |
|
} |
|
|
|
.rd-dd-icon svg { |
|
width: 13px; |
|
height: 13px; |
|
display: block; |
|
} |
|
|
|
.rd-dd-icon-danger { |
|
background: #dc2626; |
|
} |
|
|
|
.rd-dd-subtitle { |
|
margin-top: 6px; |
|
font-size: 13px; |
|
color: #6b7280; |
|
line-height: 1.55; |
|
} |
|
|
|
.rd-dd-actions { |
|
display: flex; |
|
align-items: center; |
|
gap: 8px; |
|
flex: 0 0 auto; |
|
} |
|
|
|
.rd-dd-btn { |
|
width: 30px; |
|
height: 30px; |
|
border: 1px solid rgba(252, 106, 1, 0.25); |
|
background: rgba(252, 106, 1, 0.08); |
|
color: #fc6a01; |
|
border-radius: 999px; |
|
padding: 0; |
|
font-size: 15px; |
|
font-weight: 850; |
|
cursor: pointer; |
|
display: inline-flex; |
|
align-items: center; |
|
justify-content: center; |
|
line-height: 1; |
|
} |
|
|
|
.rd-dd-btn svg { |
|
width: 15px; |
|
height: 15px; |
|
display: block; |
|
} |
|
|
|
.rd-dd-btn:hover { |
|
background: rgba(252, 106, 1, 0.14); |
|
} |
|
|
|
.rd-dd-progress-wrap { |
|
margin: 10px 0 12px; |
|
padding: 12px; |
|
border-radius: 14px; |
|
border: 1px solid rgba(17, 24, 39, 0.08); |
|
background: rgba(255, 255, 255, 0.76); |
|
} |
|
|
|
.rd-dd-stepper { |
|
display: flex; |
|
align-items: center; |
|
gap: 6px; |
|
margin-bottom: 12px; |
|
overflow-x: auto; |
|
padding-bottom: 2px; |
|
} |
|
|
|
.rd-dd-step { |
|
display: inline-flex; |
|
align-items: center; |
|
gap: 6px; |
|
flex: 0 0 auto; |
|
color: #9ca3af; |
|
font-size: 12px; |
|
font-weight: 800; |
|
} |
|
|
|
.rd-dd-step-dot { |
|
width: 20px; |
|
height: 20px; |
|
border-radius: 999px; |
|
display: inline-flex; |
|
align-items: center; |
|
justify-content: center; |
|
border: 1px solid rgba(17, 24, 39, 0.12); |
|
background: rgba(17, 24, 39, 0.04); |
|
font-size: 11px; |
|
color: #9ca3af; |
|
font-weight: 900; |
|
} |
|
|
|
.rd-dd-step-dot svg { |
|
width: 11px; |
|
height: 11px; |
|
display: block; |
|
} |
|
|
|
.rd-dd-step-active { |
|
color: #fc6a01; |
|
} |
|
|
|
.rd-dd-step-active .rd-dd-step-dot { |
|
background: rgba(252, 106, 1, 0.12); |
|
border-color: rgba(252, 106, 1, 0.3); |
|
color: #fc6a01; |
|
} |
|
|
|
.rd-dd-step-done { |
|
color: #16a34a; |
|
} |
|
|
|
.rd-dd-step-done .rd-dd-step-dot { |
|
background: rgba(22, 163, 74, 0.12); |
|
border-color: rgba(22, 163, 74, 0.28); |
|
color: #16a34a; |
|
} |
|
|
|
.rd-dd-step-line { |
|
width: 24px; |
|
height: 1px; |
|
flex: 0 0 auto; |
|
background: rgba(17, 24, 39, 0.14); |
|
} |
|
|
|
.rd-dd-progress-top { |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
gap: 12px; |
|
font-size: 12px; |
|
color: #6b7280; |
|
margin-bottom: 8px; |
|
} |
|
|
|
.rd-dd-progress-stage { |
|
font-weight: 800; |
|
color: #374151; |
|
} |
|
|
|
.rd-dd-progress-bar { |
|
height: 7px; |
|
border-radius: 999px; |
|
overflow: hidden; |
|
background: rgba(17, 24, 39, 0.08); |
|
} |
|
|
|
.rd-dd-progress-fill { |
|
height: 100%; |
|
width: 0%; |
|
border-radius: inherit; |
|
background: linear-gradient(90deg, rgba(252, 106, 1, 0.55), rgba(252, 106, 1, 0.95)); |
|
transition: width 0.25s ease; |
|
} |
|
|
|
.rd-dd-status-line { |
|
display: flex; |
|
flex-wrap: wrap; |
|
gap: 8px; |
|
margin: 12px 0; |
|
} |
|
|
|
.rd-dd-pill { |
|
display: inline-flex; |
|
align-items: center; |
|
gap: 5px; |
|
border-radius: 999px; |
|
padding: 4px 9px; |
|
font-size: 12px; |
|
font-weight: 750; |
|
background: rgba(17, 24, 39, 0.06); |
|
color: #374151; |
|
border: 1px solid rgba(17, 24, 39, 0.08); |
|
} |
|
|
|
.rd-dd-pill-danger { |
|
background: rgba(220, 38, 38, 0.09); |
|
color: #dc2626; |
|
border-color: rgba(220, 38, 38, 0.24); |
|
} |
|
|
|
.rd-dd-pill-cn { |
|
background: rgba(190, 24, 93, 0.09); |
|
color: #be185d; |
|
border-color: rgba(190, 24, 93, 0.22); |
|
} |
|
|
|
.rd-dd-china-banner { |
|
margin: 10px 0 12px; |
|
padding: 11px 12px; |
|
border-radius: 14px; |
|
border: 1px solid rgba(190, 24, 93, 0.24); |
|
background: linear-gradient(135deg, rgba(190, 24, 93, 0.09), rgba(252, 106, 1, 0.07)); |
|
color: #9d174d; |
|
font-size: 13px; |
|
line-height: 1.6; |
|
} |
|
|
|
.rd-dd-china-banner strong { |
|
font-size: 14px; |
|
color: #831843; |
|
} |
|
|
|
.rd-dd-warning { |
|
margin-top: 12px; |
|
padding: 12px; |
|
border-radius: 14px; |
|
border: 1px solid rgba(245, 158, 11, 0.28); |
|
background: rgba(245, 158, 11, 0.09); |
|
color: #92400e; |
|
font-size: 13px; |
|
line-height: 1.65; |
|
font-weight: 750; |
|
} |
|
|
|
.rd-dd-team-line { |
|
display: flex; |
|
flex-wrap: wrap; |
|
gap: 7px; |
|
margin: 10px 0 12px; |
|
} |
|
|
|
.rd-dd-member-chip { |
|
display: inline-flex; |
|
align-items: center; |
|
gap: 5px; |
|
border-radius: 999px; |
|
padding: 4px 9px; |
|
font-size: 12px; |
|
color: #374151; |
|
background: rgba(255, 255, 255, 0.82); |
|
border: 1px solid rgba(17, 24, 39, 0.08); |
|
max-width: 220px; |
|
} |
|
|
|
.rd-dd-member-chip a { |
|
color: inherit; |
|
text-decoration: none; |
|
font-weight: 750; |
|
overflow: hidden; |
|
text-overflow: ellipsis; |
|
white-space: nowrap; |
|
} |
|
|
|
.rd-dd-member-chip-cn { |
|
color: #9d174d; |
|
background: rgba(190, 24, 93, 0.07); |
|
border-color: rgba(190, 24, 93, 0.18); |
|
} |
|
|
|
.rd-dd-mini-tag { |
|
display: inline-flex; |
|
align-items: center; |
|
border-radius: 999px; |
|
padding: 1px 5px; |
|
font-size: 10px; |
|
line-height: 1.35; |
|
font-weight: 850; |
|
color: #be185d; |
|
background: rgba(190, 24, 93, 0.1); |
|
border: 1px solid rgba(190, 24, 93, 0.18); |
|
white-space: nowrap; |
|
} |
|
|
|
.rd-dd-project-table { |
|
margin-top: 12px; |
|
border: 1px solid rgba(17, 24, 39, 0.08); |
|
border-radius: 16px; |
|
overflow: hidden; |
|
background: rgba(255, 255, 255, 0.84); |
|
} |
|
|
|
.rd-dd-row { |
|
display: grid; |
|
grid-template-columns: |
|
minmax(220px, 2fr) |
|
minmax(80px, 0.65fr) |
|
minmax(140px, 0.95fr) |
|
minmax(110px, 0.8fr) |
|
minmax(170px, 1.15fr) |
|
minmax(145px, 1fr); |
|
gap: 12px; |
|
align-items: center; |
|
padding: 11px 13px; |
|
border-bottom: 1px solid rgba(17, 24, 39, 0.07); |
|
} |
|
|
|
.rd-dd-row:last-child { |
|
border-bottom: none; |
|
} |
|
|
|
.rd-dd-row-head { |
|
background: rgba(17, 24, 39, 0.035); |
|
color: #6b7280; |
|
font-size: 12px; |
|
font-weight: 800; |
|
padding-top: 9px; |
|
padding-bottom: 9px; |
|
} |
|
|
|
.rd-dd-project-main { |
|
display: flex; |
|
align-items: center; |
|
gap: 10px; |
|
min-width: 0; |
|
} |
|
|
|
.rd-dd-logo { |
|
width: 34px; |
|
height: 34px; |
|
border-radius: 999px; |
|
object-fit: cover; |
|
background: rgba(17, 24, 39, 0.06); |
|
border: 1px solid rgba(17, 24, 39, 0.08); |
|
flex: 0 0 auto; |
|
} |
|
|
|
.rd-dd-logo-fallback { |
|
width: 34px; |
|
height: 34px; |
|
border-radius: 999px; |
|
display: inline-flex; |
|
align-items: center; |
|
justify-content: center; |
|
background: rgba(252, 106, 1, 0.1); |
|
color: #fc6a01; |
|
font-size: 14px; |
|
font-weight: 850; |
|
flex: 0 0 auto; |
|
border: 1px solid rgba(252, 106, 1, 0.18); |
|
} |
|
|
|
.rd-dd-project-title { |
|
min-width: 0; |
|
} |
|
|
|
.rd-dd-project-title a { |
|
display: block; |
|
font-size: 14px; |
|
font-weight: 850; |
|
color: #111827; |
|
text-decoration: none; |
|
overflow: hidden; |
|
text-overflow: ellipsis; |
|
white-space: nowrap; |
|
} |
|
|
|
.rd-dd-project-title a:hover { |
|
color: #fc6a01; |
|
} |
|
|
|
.rd-dd-project-title-line { |
|
display: flex; |
|
align-items: center; |
|
gap: 6px; |
|
min-width: 0; |
|
} |
|
|
|
.rd-dd-project-title-line a { |
|
min-width: 0; |
|
flex: 0 1 auto; |
|
} |
|
|
|
.rd-dd-project-cn-badge, |
|
.rd-dd-current-project-cn { |
|
display: inline-flex; |
|
align-items: center; |
|
justify-content: center; |
|
flex: 0 0 auto; |
|
border-radius: 999px; |
|
padding: 2px 7px; |
|
font-size: 10px; |
|
line-height: 1.35; |
|
font-weight: 900; |
|
color: #fff; |
|
background: linear-gradient(135deg, #be185d, #ea580c); |
|
border: 1px solid rgba(190, 24, 93, 0.22); |
|
white-space: nowrap; |
|
box-shadow: 0 4px 12px rgba(190, 24, 93, 0.12); |
|
} |
|
|
|
.rd-dd-current-project-cn { |
|
margin-left: 9px; |
|
vertical-align: middle; |
|
font-size: 11px; |
|
padding: 3px 8px; |
|
} |
|
|
|
.rd-dd-project-sub { |
|
margin-top: 2px; |
|
font-size: 12px; |
|
color: #9ca3af; |
|
overflow: hidden; |
|
text-overflow: ellipsis; |
|
white-space: nowrap; |
|
} |
|
|
|
.rd-dd-cell { |
|
min-width: 0; |
|
font-size: 13px; |
|
color: #374151; |
|
overflow: hidden; |
|
text-overflow: ellipsis; |
|
white-space: nowrap; |
|
} |
|
|
|
.rd-dd-score { |
|
font-weight: 800; |
|
color: #111827; |
|
} |
|
|
|
.rd-dd-muted { |
|
color: #9ca3af; |
|
} |
|
|
|
.rd-dd-status { |
|
display: inline-flex; |
|
align-items: center; |
|
width: fit-content; |
|
border-radius: 999px; |
|
padding: 3px 8px; |
|
font-size: 11px; |
|
font-weight: 850; |
|
border: 1px solid rgba(17, 24, 39, 0.1); |
|
background: rgba(17, 24, 39, 0.05); |
|
color: #4b5563; |
|
white-space: nowrap; |
|
} |
|
|
|
.rd-dd-status-danger { |
|
color: #dc2626; |
|
background: rgba(220, 38, 38, 0.09); |
|
border-color: rgba(220, 38, 38, 0.24); |
|
} |
|
|
|
.rd-dd-status-empty { |
|
color: #9ca3af; |
|
background: rgba(17, 24, 39, 0.035); |
|
border-color: rgba(17, 24, 39, 0.07); |
|
} |
|
|
|
.rd-dd-related-members, |
|
.rd-dd-investors { |
|
display: flex; |
|
flex-wrap: wrap; |
|
gap: 4px; |
|
overflow: hidden; |
|
} |
|
|
|
.rd-dd-related-member, |
|
.rd-dd-investor-chip, |
|
.rd-dd-more-chip { |
|
display: inline-flex; |
|
max-width: 120px; |
|
border-radius: 999px; |
|
padding: 2px 6px; |
|
font-size: 11px; |
|
overflow: hidden; |
|
text-overflow: ellipsis; |
|
white-space: nowrap; |
|
} |
|
|
|
.rd-dd-related-member { |
|
color: #6b7280; |
|
background: rgba(17, 24, 39, 0.05); |
|
border: 1px solid rgba(17, 24, 39, 0.06); |
|
} |
|
|
|
.rd-dd-investor-chip { |
|
color: #ea580c; |
|
background: rgba(252, 106, 1, 0.08); |
|
border: 1px solid rgba(252, 106, 1, 0.18); |
|
} |
|
|
|
.rd-dd-investor-chip-cn { |
|
color: #be185d; |
|
background: rgba(190, 24, 93, 0.09); |
|
border-color: rgba(190, 24, 93, 0.2); |
|
font-weight: 850; |
|
} |
|
|
|
.rd-dd-more-chip { |
|
color: #6b7280; |
|
background: rgba(17, 24, 39, 0.05); |
|
border: 1px solid rgba(17, 24, 39, 0.06); |
|
} |
|
|
|
.rd-dd-details { |
|
margin-top: 12px; |
|
border: 1px solid rgba(17, 24, 39, 0.08); |
|
border-radius: 14px; |
|
background: rgba(255, 255, 255, 0.72); |
|
overflow: hidden; |
|
} |
|
|
|
.rd-dd-details summary { |
|
cursor: pointer; |
|
padding: 10px 12px; |
|
font-size: 13px; |
|
font-weight: 800; |
|
color: #374151; |
|
user-select: none; |
|
} |
|
|
|
.rd-dd-detail-body { |
|
padding: 0 12px 12px; |
|
display: grid; |
|
grid-template-columns: 1fr 1fr; |
|
gap: 8px; |
|
} |
|
|
|
.rd-dd-detail-member { |
|
border-radius: 12px; |
|
padding: 9px 10px; |
|
background: rgba(17, 24, 39, 0.035); |
|
font-size: 12px; |
|
color: #6b7280; |
|
line-height: 1.55; |
|
} |
|
|
|
.rd-dd-detail-member strong { |
|
color: #111827; |
|
} |
|
|
|
.rd-dd-empty { |
|
padding: 12px; |
|
font-size: 13px; |
|
color: #6b7280; |
|
line-height: 1.55; |
|
} |
|
|
|
.rd-dd-error { |
|
color: #dc2626; |
|
} |
|
|
|
.rd-dd-team-card-badges { |
|
position: absolute; |
|
top: 8px; |
|
right: 8px; |
|
z-index: 3; |
|
display: flex; |
|
flex-direction: column; |
|
align-items: flex-end; |
|
gap: 4px; |
|
pointer-events: none; |
|
} |
|
|
|
.rd-dd-team-card-risk, |
|
.rd-dd-team-card-cn { |
|
display: inline-flex; |
|
align-items: center; |
|
padding: 2px 7px; |
|
border-radius: 999px; |
|
background: rgba(255, 255, 255, 0.98); |
|
font-size: 11px; |
|
line-height: 1.25; |
|
font-weight: 850; |
|
white-space: nowrap; |
|
box-shadow: 0 4px 14px rgba(252, 106, 1, 0.08); |
|
pointer-events: auto; |
|
} |
|
|
|
.rd-dd-team-card-risk { |
|
border: 1px solid rgba(220, 38, 38, 0.26); |
|
color: #dc2626; |
|
} |
|
|
|
.rd-dd-team-card-cn { |
|
border: 1px solid rgba(190, 24, 93, 0.24); |
|
color: #be185d; |
|
} |
|
|
|
@media (max-width: 1280px) { |
|
.rd-dd-row { |
|
grid-template-columns: |
|
minmax(220px, 2fr) |
|
minmax(80px, 0.65fr) |
|
minmax(140px, 0.95fr) |
|
minmax(110px, 0.8fr) |
|
minmax(165px, 1.1fr); |
|
} |
|
|
|
.rd-dd-row > :nth-child(6) { |
|
display: none; |
|
} |
|
} |
|
|
|
@media (max-width: 980px) { |
|
.rd-dd-row { |
|
grid-template-columns: |
|
minmax(220px, 2fr) |
|
minmax(80px, 0.65fr) |
|
minmax(135px, 0.95fr) |
|
minmax(100px, 0.8fr); |
|
} |
|
|
|
.rd-dd-row > :nth-child(5) { |
|
display: none; |
|
} |
|
|
|
.rd-dd-detail-body { |
|
grid-template-columns: 1fr; |
|
} |
|
} |
|
|
|
@media (max-width: 760px) { |
|
.rd-dd-head { |
|
flex-direction: column; |
|
} |
|
|
|
.rd-dd-actions { |
|
width: 100%; |
|
justify-content: flex-start; |
|
} |
|
|
|
.rd-dd-row { |
|
grid-template-columns: 1fr; |
|
gap: 6px; |
|
} |
|
|
|
.rd-dd-row-head { |
|
display: none; |
|
} |
|
|
|
.rd-dd-cell { |
|
white-space: normal; |
|
} |
|
} |
|
|
|
html.dark .rd-dd-card, |
|
html.dark .rd-dd-project-table, |
|
html.dark .rd-dd-details, |
|
html.dark .rd-dd-progress-wrap { |
|
background: rgba(15, 23, 42, 0.88); |
|
color: #e5e7eb; |
|
border-color: rgba(252, 106, 1, 0.25); |
|
} |
|
|
|
html.dark .rd-dd-project-title a, |
|
html.dark .rd-dd-score, |
|
html.dark .rd-dd-detail-member strong { |
|
color: #f9fafb; |
|
} |
|
|
|
html.dark .rd-dd-subtitle, |
|
html.dark .rd-dd-project-sub, |
|
html.dark .rd-dd-cell, |
|
html.dark .rd-dd-empty { |
|
color: #cbd5e1; |
|
} |
|
|
|
html.dark .rd-dd-warning { |
|
color: #fbbf24; |
|
background: rgba(245, 158, 11, 0.12); |
|
border-color: rgba(245, 158, 11, 0.32); |
|
} |
|
|
|
html.dark .rd-dd-china-banner { |
|
color: #f9a8d4; |
|
background: rgba(190, 24, 93, 0.13); |
|
border-color: rgba(244, 114, 182, 0.3); |
|
} |
|
|
|
html.dark .rd-dd-china-banner strong { |
|
color: #fbcfe8; |
|
} |
|
|
|
html.dark .rd-dd-team-card-risk, |
|
html.dark .rd-dd-team-card-cn { |
|
background: rgba(15, 23, 42, 0.96); |
|
} |
|
`); |
|
|
|
const ICONS = { |
|
scan: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"><path d="M4 8V6a2 2 0 0 1 2-2h2"/><path d="M16 4h2a2 2 0 0 1 2 2v2"/><path d="M20 16v2a2 2 0 0 1-2 2h-2"/><path d="M8 20H6a2 2 0 0 1-2-2v-2"/><circle cx="12" cy="12" r="3"/></svg>', |
|
alert: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"><path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"/></svg>', |
|
refresh: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M3 21v-5h5"/><path d="M16 3h5v5"/></svg>', |
|
trash: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg>', |
|
check: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>' |
|
}; |
|
|
|
function main() { |
|
if (!isProjectDetailPage()) return; |
|
if (isRunning) return; |
|
|
|
cleanupOldBadges(); |
|
|
|
const currentProjectName = getCurrentProjectName(); |
|
if (!currentProjectName) return; |
|
|
|
const container = ensurePanel(); |
|
if (!container) return; |
|
|
|
isRunning = true; |
|
abortToken += 1; |
|
const token = abortToken; |
|
|
|
renderProgress(container, { |
|
title: currentProjectName, |
|
stage: '初始化', |
|
stepIndex: 0, |
|
current: 0, |
|
total: 1, |
|
detail: currentProjectName |
|
}); |
|
|
|
runDueDiligence(currentProjectName, container, token) |
|
.then(result => { |
|
if (token !== abortToken) return; |
|
renderResult(container, result); |
|
markTeamCards(result.members); |
|
markCurrentProjectBadge(result.currentProject); |
|
}) |
|
.catch(error => { |
|
if (token !== abortToken) return; |
|
renderError(container, error); |
|
}) |
|
.finally(() => { |
|
if (token === abortToken) { |
|
isRunning = false; |
|
} |
|
}); |
|
} |
|
|
|
async function runDueDiligence(currentProjectName, container, token) { |
|
assertNotAborted(token); |
|
|
|
renderProgress(container, { |
|
title: currentProjectName, |
|
stage: '团队', |
|
stepIndex: 0, |
|
current: 0, |
|
total: 1, |
|
detail: '' |
|
}); |
|
|
|
const teamInfo = getTeamMembersFromCurrentPage(); |
|
const members = teamInfo.members.slice(0, CONFIG.maxMembers); |
|
const currentProject = extractProjectDetail(document, { |
|
name: currentProjectName, |
|
url: location.href |
|
}); |
|
|
|
currentProject.teamMembers = members.map(member => ({ ...member })); |
|
currentProject.chinaAssessment = assessChinaProject(currentProject); |
|
|
|
if (!members.length) { |
|
renderProgress(container, { |
|
title: currentProjectName, |
|
stage: '完成', |
|
stepIndex: 4, |
|
current: 1, |
|
total: 1, |
|
detail: '' |
|
}); |
|
|
|
return { |
|
currentProjectName, |
|
currentProject, |
|
members: [], |
|
teamMissing: true |
|
}; |
|
} |
|
|
|
renderProgress(container, { |
|
title: currentProjectName, |
|
stage: '团队', |
|
stepIndex: 0, |
|
current: 1, |
|
total: 1 + members.length + 1, |
|
detail: `${members.length} 人` |
|
}); |
|
|
|
const enrichedMembers = []; |
|
|
|
for (let i = 0; i < members.length; i += 1) { |
|
assertNotAborted(token); |
|
|
|
const member = members[i]; |
|
|
|
renderProgress(container, { |
|
title: currentProjectName, |
|
stage: '成员主页', |
|
stepIndex: 1, |
|
current: 1 + i, |
|
total: 1 + members.length + 1, |
|
detail: member.name |
|
}); |
|
|
|
await sleep(CONFIG.requestInterval); |
|
|
|
try { |
|
const memberPageHtml = await httpGet(member.url); |
|
assertNotAborted(token); |
|
|
|
const memberDoc = parseHtml(memberPageHtml); |
|
const memberProjects = extractProjectsFromMemberPage(memberDoc) |
|
.slice(0, CONFIG.maxProjectsPerMember); |
|
const chinaEvidence = extractMemberChinaEvidence(memberDoc, member); |
|
|
|
enrichedMembers.push({ |
|
...member, |
|
chinaEvidence, |
|
projects: memberProjects |
|
}); |
|
} catch (error) { |
|
enrichedMembers.push({ |
|
...member, |
|
chinaEvidence: assessMemberChinaFromNames(member), |
|
error: error.message || String(error), |
|
projects: [] |
|
}); |
|
} |
|
|
|
renderProgress(container, { |
|
title: currentProjectName, |
|
stage: '成员主页', |
|
stepIndex: 1, |
|
current: 2 + i, |
|
total: 1 + members.length + 1, |
|
detail: member.name |
|
}); |
|
} |
|
|
|
currentProject.teamMembers = enrichedMembers.map(member => ({ |
|
name: member.name, |
|
aliases: member.aliases || [], |
|
role: member.role || '', |
|
url: member.url, |
|
chinaEvidence: member.chinaEvidence |
|
})); |
|
currentProject.chinaAssessment = assessChinaProject(currentProject); |
|
|
|
const uniqueBasicProjects = getUniqueProjectsFromMembers(enrichedMembers) |
|
.slice(0, CONFIG.maxUniqueProjects); |
|
|
|
const total = 1 + members.length + uniqueBasicProjects.length + 1; |
|
|
|
renderProgress(container, { |
|
title: currentProjectName, |
|
stage: '项目详情', |
|
stepIndex: 2, |
|
current: 1 + members.length, |
|
total, |
|
detail: `${uniqueBasicProjects.length} 个` |
|
}); |
|
|
|
const detailMap = new Map(); |
|
|
|
for (let i = 0; i < uniqueBasicProjects.length; i += 1) { |
|
assertNotAborted(token); |
|
|
|
const project = uniqueBasicProjects[i]; |
|
|
|
renderProgress(container, { |
|
title: currentProjectName, |
|
stage: '项目详情', |
|
stepIndex: 2, |
|
current: 1 + members.length + i, |
|
total, |
|
detail: project.name |
|
}); |
|
|
|
await sleep(CONFIG.requestInterval); |
|
|
|
try { |
|
const projectPageHtml = await httpGet(project.url); |
|
assertNotAborted(token); |
|
|
|
const projectDoc = parseHtml(projectPageHtml); |
|
const detail = extractProjectDetail(projectDoc, project); |
|
detailMap.set(getProjectKey(project), detail); |
|
} catch (error) { |
|
detailMap.set(getProjectKey(project), makeFailedProject(project, error)); |
|
} |
|
|
|
renderProgress(container, { |
|
title: currentProjectName, |
|
stage: '项目详情', |
|
stepIndex: 2, |
|
current: 2 + members.length + i, |
|
total, |
|
detail: project.name |
|
}); |
|
} |
|
|
|
for (const member of enrichedMembers) { |
|
member.projects = dedupeProjects( |
|
(member.projects || []).map(project => { |
|
const detail = detailMap.get(getProjectKey(project)); |
|
const merged = detail ? mergeRelationIntoDetail(detail, project) : { ...project }; |
|
return attachRelatedMemberEvidence(merged, member); |
|
}) |
|
); |
|
} |
|
|
|
renderProgress(container, { |
|
title: currentProjectName, |
|
stage: '完成', |
|
stepIndex: 4, |
|
current: total, |
|
total, |
|
detail: '' |
|
}); |
|
|
|
return { |
|
currentProjectName, |
|
currentProject, |
|
members: enrichedMembers, |
|
teamMissing: false |
|
}; |
|
} |
|
|
|
function assertNotAborted(token) { |
|
if (token !== abortToken) { |
|
throw new Error('页面已切换'); |
|
} |
|
} |
|
|
|
function isProjectDetailPage() { |
|
return location.hostname === 'cn.rootdata.com' && location.pathname.startsWith('/projects/detail/'); |
|
} |
|
|
|
function getCurrentProjectName() { |
|
const h1 = document.querySelector('h1'); |
|
return cleanText(h1 ? h1.textContent : ''); |
|
} |
|
|
|
function ensurePanel() { |
|
const oldPanel = document.getElementById(CONFIG.panelId); |
|
if (oldPanel) return oldPanel; |
|
|
|
const header = document.querySelector('#base-info-header'); |
|
const parent = header && header.parentElement ? header.parentElement : document.querySelector('main'); |
|
if (!parent) return null; |
|
|
|
const panel = document.createElement('div'); |
|
panel.id = CONFIG.panelId; |
|
|
|
if (header && header.nextSibling) { |
|
parent.insertBefore(panel, header.nextSibling); |
|
} else { |
|
parent.prepend(panel); |
|
} |
|
|
|
return panel; |
|
} |
|
|
|
function renderProgress(container, state) { |
|
const percent = Math.max( |
|
0, |
|
Math.min(100, Math.round((state.current / Math.max(state.total, 1)) * 100)) |
|
); |
|
|
|
container.innerHTML = ` |
|
<div class="rd-dd-card"> |
|
<div class="rd-dd-head"> |
|
<div> |
|
<div class="rd-dd-title"> |
|
<span class="rd-dd-icon">${ICONS.scan}</span> |
|
<span>团队成员项目状态自动尽调</span> |
|
</div> |
|
<div class="rd-dd-subtitle"> |
|
${escapeHtml(state.title)} |
|
</div> |
|
</div> |
|
</div> |
|
|
|
<div class="rd-dd-progress-wrap"> |
|
${renderStepper(Number.isFinite(state.stepIndex) ? state.stepIndex : 0)} |
|
<div class="rd-dd-progress-top"> |
|
<span class="rd-dd-progress-stage">${escapeHtml(state.stage)}</span> |
|
<span>${escapeHtml(String(state.current))} / ${escapeHtml(String(state.total))} · ${percent}%</span> |
|
</div> |
|
<div class="rd-dd-progress-bar"> |
|
<div class="rd-dd-progress-fill" style="width:${percent}%"></div> |
|
</div> |
|
${state.detail ? ` |
|
<div class="rd-dd-subtitle" style="margin-top:8px;">${escapeHtml(state.detail)}</div> |
|
` : ''} |
|
</div> |
|
</div> |
|
`; |
|
} |
|
|
|
function renderStepper(activeIndex) { |
|
return ` |
|
<div class="rd-dd-stepper"> |
|
${PROGRESS_STEPS.map((step, index) => { |
|
const stateClass = index < activeIndex |
|
? 'rd-dd-step rd-dd-step-done' |
|
: index === activeIndex |
|
? 'rd-dd-step rd-dd-step-active' |
|
: 'rd-dd-step'; |
|
|
|
return ` |
|
<span class="${stateClass}"> |
|
<span class="rd-dd-step-dot">${index < activeIndex ? ICONS.check : index + 1}</span> |
|
<span>${escapeHtml(step)}</span> |
|
</span> |
|
${index < PROGRESS_STEPS.length - 1 ? '<span class="rd-dd-step-line"></span>' : ''} |
|
`; |
|
}).join('')} |
|
</div> |
|
`; |
|
} |
|
|
|
function renderError(container, error) { |
|
const message = error && error.message ? error.message : String(error || '未知错误'); |
|
|
|
container.innerHTML = ` |
|
<div class="rd-dd-card"> |
|
<div class="rd-dd-title"> |
|
<span class="rd-dd-icon rd-dd-icon-danger">${ICONS.alert}</span> |
|
<span>团队成员项目状态自动尽调</span> |
|
</div> |
|
<div class="rd-dd-empty rd-dd-error"> |
|
${escapeHtml(message)} |
|
</div> |
|
</div> |
|
`; |
|
} |
|
|
|
function renderResult(container, result) { |
|
const stats = computeGlobalStats(result.members || []); |
|
const uniqueProjects = stats.uniqueProjects; |
|
|
|
container.innerHTML = ` |
|
<div class="rd-dd-card"> |
|
<div class="rd-dd-head"> |
|
<div> |
|
<div class="rd-dd-title"> |
|
<span class="rd-dd-icon">${ICONS.scan}</span> |
|
<span>团队成员项目状态自动尽调</span> |
|
</div> |
|
</div> |
|
<div class="rd-dd-actions"> |
|
<button class="rd-dd-btn" data-rd-dd-refresh aria-label="刷新">${ICONS.refresh}</button> |
|
<button class="rd-dd-btn" data-rd-dd-clear-cache aria-label="清理">${ICONS.trash}</button> |
|
</div> |
|
</div> |
|
|
|
${renderCurrentProjectChinaBanner(result.currentProject)} |
|
|
|
${result.teamMissing ? ` |
|
<div class="rd-dd-warning">⚠️ 团队匿名或信息缺失</div> |
|
` : ` |
|
<div class="rd-dd-status-line"> |
|
<span class="rd-dd-pill">团队成员 ${stats.memberCount}</span> |
|
<span class="rd-dd-pill">项目 ${stats.uniqueProjectCount}</span> |
|
<span class="rd-dd-pill">重复关联 ${stats.duplicateRelationCount}</span> |
|
<span class="rd-dd-pill ${stats.dangerUniqueProjectCount ? 'rd-dd-pill-danger' : ''}"> |
|
停止/异常 ${stats.dangerUniqueProjectCount} |
|
</span> |
|
<span class="rd-dd-pill ${stats.chinaProjectCount ? 'rd-dd-pill-cn' : ''}"> |
|
关联国人盘子 ${stats.chinaProjectCount} |
|
</span> |
|
</div> |
|
|
|
${renderTeamLine(result.members || [])} |
|
|
|
${uniqueProjects.length ? renderProjectTable(uniqueProjects) : ` |
|
<div class="rd-dd-empty">没有解析到关联项目。</div> |
|
`} |
|
|
|
${renderRelationDetails(result.members || [])} |
|
`} |
|
</div> |
|
`; |
|
|
|
const refreshBtn = container.querySelector('[data-rd-dd-refresh]'); |
|
const clearCacheBtn = container.querySelector('[data-rd-dd-clear-cache]'); |
|
|
|
if (refreshBtn) { |
|
refreshBtn.addEventListener('click', () => { |
|
abortToken += 1; |
|
isRunning = false; |
|
cleanupOldBadges(); |
|
container.remove(); |
|
setTimeout(main, 100); |
|
}); |
|
} |
|
|
|
if (clearCacheBtn) { |
|
clearCacheBtn.addEventListener('click', () => { |
|
clearCache(); |
|
abortToken += 1; |
|
isRunning = false; |
|
cleanupOldBadges(); |
|
container.remove(); |
|
setTimeout(main, 100); |
|
}); |
|
} |
|
} |
|
|
|
function renderTeamLine(members) { |
|
if (!members.length) { |
|
return `<div class="rd-dd-empty">没有解析到团队成员。</div>`; |
|
} |
|
|
|
return ` |
|
<div class="rd-dd-team-line"> |
|
${members.map(member => { |
|
const isChinese = Boolean(member.chinaEvidence && member.chinaEvidence.isChinese); |
|
const title = buildMemberChinaTooltip(member); |
|
|
|
return ` |
|
<span class="rd-dd-member-chip ${isChinese ? 'rd-dd-member-chip-cn' : ''}" title="${escapeAttr(title || member.role || '')}"> |
|
<a href="${escapeAttr(member.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(member.name)}</a> |
|
${isChinese ? '<span class="rd-dd-mini-tag">中国成员</span>' : ''} |
|
</span> |
|
`; |
|
}).join('')} |
|
</div> |
|
`; |
|
} |
|
|
|
function renderProjectTable(projects) { |
|
const sortedProjects = [...projects].sort((a, b) => { |
|
const ad = a.statusType === 'danger' ? 1 : 0; |
|
const bd = b.statusType === 'danger' ? 1 : 0; |
|
|
|
if (bd !== ad) return bd - ad; |
|
return normalizeProjectName(a.name).localeCompare(normalizeProjectName(b.name)); |
|
}); |
|
|
|
return ` |
|
<div class="rd-dd-project-table"> |
|
<div class="rd-dd-row rd-dd-row-head"> |
|
<div>项目</div> |
|
<div>成立时间</div> |
|
<div>评分</div> |
|
<div>状态</div> |
|
<div>投资方</div> |
|
<div>关联成员</div> |
|
</div> |
|
${sortedProjects.map(renderProjectRow).join('')} |
|
</div> |
|
`; |
|
} |
|
|
|
function renderProjectRow(project) { |
|
return ` |
|
<div class="rd-dd-row"> |
|
<div class="rd-dd-project-main"> |
|
${renderProjectLogo(project)} |
|
<div class="rd-dd-project-title"> |
|
<div class="rd-dd-project-title-line"> |
|
<a href="${escapeAttr(project.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(project.name)}</a> |
|
${renderProjectChinaBadge(project)} |
|
</div> |
|
${project.category ? `<div class="rd-dd-project-sub">${escapeHtml(project.category)}</div>` : ''} |
|
</div> |
|
</div> |
|
|
|
<div class="rd-dd-cell">${project.founded ? escapeHtml(project.founded) : '<span class="rd-dd-muted">—</span>'}</div> |
|
|
|
<div class="rd-dd-cell"> |
|
${formatScore(project) ? `<span class="rd-dd-score">${escapeHtml(formatScore(project))}</span>` : '<span class="rd-dd-muted">—</span>'} |
|
</div> |
|
|
|
<div class="rd-dd-cell">${renderStatus(project)}</div> |
|
|
|
<div class="rd-dd-cell">${renderInvestors(project.investors || [], project.investorDetails || [])}</div> |
|
|
|
<div class="rd-dd-cell"> |
|
${renderRelatedMembers(project.relatedMembers || [])} |
|
</div> |
|
</div> |
|
`; |
|
} |
|
|
|
function renderProjectLogo(project) { |
|
if (project.logo) { |
|
return `<img class="rd-dd-logo" src="${escapeAttr(project.logo)}" alt="${escapeAttr(project.name)}">`; |
|
} |
|
|
|
return `<span class="rd-dd-logo-fallback">${escapeHtml((project.name || '?').slice(0, 1).toUpperCase())}</span>`; |
|
} |
|
|
|
function renderStatus(project) { |
|
if (!project.status) { |
|
return `<span class="rd-dd-status rd-dd-status-empty">未标注</span>`; |
|
} |
|
|
|
const className = project.statusType === 'danger' ? 'rd-dd-status rd-dd-status-danger' : 'rd-dd-status'; |
|
|
|
return `<span class="${className}">${escapeHtml(project.status)}</span>`; |
|
} |
|
|
|
function renderInvestors(investors, investorDetails = []) { |
|
if (!investors.length) { |
|
return '<span class="rd-dd-muted">—</span>'; |
|
} |
|
|
|
const detailMap = new Map( |
|
(investorDetails || []).map(investor => [normalizeInstitutionName(investor.name), investor]) |
|
); |
|
const visible = investors.slice(0, CONFIG.maxVisibleInvestors); |
|
const hiddenCount = Math.max(0, investors.length - visible.length); |
|
|
|
return ` |
|
<div class="rd-dd-investors" title="${escapeAttr(investors.join('、'))}"> |
|
${visible.map(name => { |
|
const detail = detailMap.get(normalizeInstitutionName(name)) || { name }; |
|
const evidence = assessInvestorChina(detail); |
|
const className = evidence.isChinese |
|
? 'rd-dd-investor-chip rd-dd-investor-chip-cn' |
|
: 'rd-dd-investor-chip'; |
|
const title = evidence.isChinese ? evidence.reasons.join(';') : name; |
|
|
|
return `<span class="${className}" title="${escapeAttr(title)}">${escapeHtml(name)}</span>`; |
|
}).join('')} |
|
${hiddenCount ? `<span class="rd-dd-more-chip">+${hiddenCount}</span>` : ''} |
|
</div> |
|
`; |
|
} |
|
|
|
function renderRelatedMembers(members) { |
|
if (!members.length) { |
|
return '<span class="rd-dd-muted">—</span>'; |
|
} |
|
|
|
return ` |
|
<div class="rd-dd-related-members"> |
|
${members.map(name => `<span class="rd-dd-related-member">${escapeHtml(name)}</span>`).join('')} |
|
</div> |
|
`; |
|
} |
|
|
|
function renderRelationDetails(members) { |
|
if (!members.length) return ''; |
|
|
|
return ` |
|
<details class="rd-dd-details"> |
|
<summary>展开关联明细</summary> |
|
<div class="rd-dd-detail-body"> |
|
${members.map(member => ` |
|
<div class="rd-dd-detail-member"> |
|
<strong>${escapeHtml(member.name)}</strong> |
|
<div>${escapeHtml(member.role || '未解析到职位')}</div> |
|
${member.chinaEvidence && member.chinaEvidence.isChinese |
|
? `<div><span class="rd-dd-mini-tag">中国成员</span> ${escapeHtml(member.chinaEvidence.reasons.join(';'))}</div>` |
|
: ''} |
|
<div> |
|
${(member.projects || []).length |
|
? (member.projects || []).map(project => escapeHtml(project.name)).join('、') |
|
: '未解析到关联项目'} |
|
</div> |
|
</div> |
|
`).join('')} |
|
</div> |
|
</details> |
|
`; |
|
} |
|
|
|
function getTeamMembersFromCurrentPage() { |
|
const teamSection = document.querySelector('#detail_section_essentials_team'); |
|
|
|
if (!teamSection) { |
|
return { |
|
members: [], |
|
reason: '' |
|
}; |
|
} |
|
|
|
const teamTitle = cleanText(teamSection.querySelector('h2')?.textContent || ''); |
|
if (teamTitle && teamTitle !== '团队') { |
|
return { |
|
members: [], |
|
reason: '' |
|
}; |
|
} |
|
|
|
const teamCards = Array.from(teamSection.querySelectorAll('.card-warp')) |
|
.filter(card => card.querySelector('a[href*="/member/"]')); |
|
|
|
if (!teamCards.length) { |
|
return { |
|
members: [], |
|
reason: '' |
|
}; |
|
} |
|
|
|
const members = teamCards |
|
.map(card => { |
|
const anchor = card.querySelector('a[href*="/member/"]'); |
|
if (!anchor) return null; |
|
|
|
const href = anchor.getAttribute('href') || ''; |
|
const url = toAbsoluteUrl(href); |
|
const cardName = extractMemberNameFromTeamCard(card, anchor); |
|
const urlName = extractNameFromRootDataUrl(url, 'member'); |
|
const name = cardName || urlName; |
|
const role = extractRoleFromTeamCard(card, name); |
|
const aliases = unique([cardName, urlName]); |
|
|
|
let path = ''; |
|
try { |
|
const u = new URL(url); |
|
path = u.pathname + u.search; |
|
} catch { |
|
path = url; |
|
} |
|
|
|
return { |
|
name, |
|
aliases, |
|
role, |
|
url, |
|
path, |
|
chinaEvidence: assessMemberChinaFromNames({ name, aliases, role }) |
|
}; |
|
}) |
|
.filter(Boolean) |
|
.filter(member => member.name && member.url.includes('/member/')); |
|
|
|
return { |
|
members: dedupeBy(members, member => normalizeUrlWithoutQueryNoise(member.url)), |
|
reason: '' |
|
}; |
|
} |
|
|
|
function extractMemberNameFromTeamCard(card, anchor) { |
|
const imgAlt = cleanText(anchor.querySelector('img[alt]')?.getAttribute('alt') || ''); |
|
if (imgAlt && !looksLikeNoise(imgAlt)) return imgAlt; |
|
|
|
const nameFromP = cleanText(anchor.querySelector('p.truncate, p.text-sm, p')?.textContent || ''); |
|
if (nameFromP && !looksLikeNoise(nameFromP)) return nameFromP; |
|
|
|
const clone = anchor.cloneNode(true); |
|
clone.querySelectorAll('.rd-dd-member-badge, .rd-dd-team-card-risk, img[alt="X"], img[alt="LinkedIn"]').forEach(el => el.remove()); |
|
|
|
const text = cleanText(clone.textContent || ''); |
|
if (text && !looksLikeNoise(text)) return text; |
|
|
|
const cardText = cleanText(card.textContent || ''); |
|
if (cardText && !looksLikeNoise(cardText)) return cardText.split(' ')[0]; |
|
|
|
return ''; |
|
} |
|
|
|
function extractRoleFromTeamCard(card, memberName) { |
|
const roleNode = card.querySelector('p.line-clamp-2, p.text-xs, .text-muted-foreground'); |
|
const role = cleanText(roleNode?.textContent || ''); |
|
|
|
if (role && role !== memberName && !looksLikeNoise(role)) return role; |
|
|
|
const texts = Array.from(card.querySelectorAll('p, span, div')) |
|
.map(el => cleanText(el.textContent)) |
|
.filter(Boolean) |
|
.filter(text => text !== memberName) |
|
.filter(text => text.length <= 120) |
|
.filter(text => !looksLikeNoise(text)); |
|
|
|
return texts.find(text => looksLikeRoleOrDescription(text)) || ''; |
|
} |
|
|
|
function extractProjectsFromMemberPage(doc) { |
|
const candidateRoot = doc.querySelector('main') || doc.body || doc; |
|
const projectAnchors = Array.from(candidateRoot.querySelectorAll('a[href*="/projects/detail/"]')); |
|
|
|
const projects = projectAnchors |
|
.map(anchor => { |
|
const href = anchor.getAttribute('href') || ''; |
|
const url = toAbsoluteUrl(href); |
|
const card = closestCard(anchor) || anchor; |
|
const name = extractNameFromRootDataUrl(url, 'projects/detail') || extractProjectName(anchor, href); |
|
const role = extractProjectRoleFromCard(card, name); |
|
const section = findNearestSectionTitle(anchor); |
|
|
|
return { |
|
name, |
|
role, |
|
section, |
|
url |
|
}; |
|
}) |
|
.filter(project => project.name && project.url.includes('/projects/detail/')); |
|
|
|
const importantFirst = projects.sort((a, b) => { |
|
const aScore = scoreProjectSection(a.section); |
|
const bScore = scoreProjectSection(b.section); |
|
return bScore - aScore; |
|
}); |
|
|
|
return dedupeProjects(importantFirst); |
|
} |
|
|
|
function extractProjectName(anchor, href) { |
|
const imgAlt = cleanText(anchor.querySelector('img[alt]')?.getAttribute('alt') || ''); |
|
|
|
const nameFromText = Array.from(anchor.querySelectorAll('p, h3, h4, span, div')) |
|
.map(el => cleanText(el.textContent)) |
|
.filter(text => text && text.length <= 80) |
|
.find(text => !looksLikeRoleOrDescription(text) && !looksLikeNoise(text)); |
|
|
|
if (nameFromText) return nameFromText; |
|
if (imgAlt) return imgAlt; |
|
|
|
try { |
|
const path = decodeURIComponent(new URL(toAbsoluteUrl(href)).pathname); |
|
return cleanText(path.split('/').filter(Boolean).pop() || ''); |
|
} catch { |
|
return cleanText(anchor.textContent || ''); |
|
} |
|
} |
|
|
|
function extractProjectRoleFromCard(card, projectName) { |
|
const texts = Array.from(card.querySelectorAll('p, span, div')) |
|
.map(el => cleanText(el.textContent)) |
|
.filter(Boolean) |
|
.filter(text => text !== projectName) |
|
.filter(text => text.length <= 140) |
|
.filter(text => !looksLikeNoise(text)); |
|
|
|
return texts.find(text => looksLikeRoleOrDescription(text)) || ''; |
|
} |
|
|
|
function extractProjectDetail(doc, baseProject) { |
|
const name = cleanText(doc.querySelector('h1')?.textContent || baseProject.name); |
|
const status = extractProjectStatus(doc); |
|
const staticScoreInfo = extractScoreFromDom(doc, name); |
|
const investorDetails = extractProjectInvestorDetails(doc); |
|
const teamMembers = extractProjectTeamMembers(doc); |
|
|
|
const detail = { |
|
...baseProject, |
|
name, |
|
status: status.text, |
|
statusType: status.type, |
|
logo: extractProjectLogo(doc, name), |
|
category: extractProjectCategory(doc), |
|
officialWebsite: extractOfficialWebsite(doc), |
|
xUrl: extractXUrl(doc), |
|
totalFunding: extractTotalFunding(doc), |
|
founded: extractFoundedTime(doc), |
|
location: extractProjectLocation(doc), |
|
score: staticScoreInfo.score, |
|
scoreCount: staticScoreInfo.count, |
|
xInfluence: extractXInfluence(doc), |
|
tags: extractProjectTags(doc), |
|
ecosystems: extractProjectEcosystems(doc), |
|
investors: investorDetails.map(investor => investor.name), |
|
investorDetails, |
|
teamMembers, |
|
relatedMemberEvidence: baseProject.relatedMemberEvidence || [] |
|
}; |
|
|
|
detail.chinaAssessment = assessChinaProject(detail); |
|
return detail; |
|
} |
|
|
|
function makeFailedProject(project, error) { |
|
return { |
|
...project, |
|
status: '', |
|
statusType: '', |
|
description: `项目详情读取失败:${error.message || String(error)}`, |
|
logo: '', |
|
officialWebsite: '', |
|
xUrl: '', |
|
totalFunding: '', |
|
founded: '', |
|
location: '', |
|
score: '', |
|
scoreCount: '', |
|
xInfluence: '', |
|
tags: [], |
|
ecosystems: [], |
|
investors: [], |
|
investorDetails: [], |
|
teamMembers: [], |
|
relatedMemberEvidence: [], |
|
chinaAssessment: makeEmptyChinaAssessment() |
|
}; |
|
} |
|
|
|
function mergeRelationIntoDetail(detail, relationProject) { |
|
return { |
|
...detail, |
|
role: relationProject.role || detail.role || '', |
|
section: relationProject.section || detail.section || '' |
|
}; |
|
} |
|
|
|
function attachRelatedMemberEvidence(project, member) { |
|
const relatedMemberEvidence = [ |
|
...(project.relatedMemberEvidence || []), |
|
{ |
|
name: member.name, |
|
aliases: member.aliases || [], |
|
role: member.role || '', |
|
url: member.url || '', |
|
chinaEvidence: member.chinaEvidence || assessMemberChinaFromNames(member) |
|
} |
|
]; |
|
|
|
const next = { |
|
...project, |
|
relatedMemberEvidence: dedupeBy( |
|
relatedMemberEvidence, |
|
item => normalizePersonName(item.name) |
|
) |
|
}; |
|
|
|
next.chinaAssessment = assessChinaProject(next); |
|
return next; |
|
} |
|
|
|
function getUniqueProjectsFromMembers(members) { |
|
const map = new Map(); |
|
|
|
for (const member of members) { |
|
for (const project of member.projects || []) { |
|
const key = getProjectKey(project); |
|
if (!key || map.has(key)) continue; |
|
map.set(key, project); |
|
} |
|
} |
|
|
|
return Array.from(map.values()); |
|
} |
|
|
|
function extractProjectStatus(doc) { |
|
const h1 = doc.querySelector('h1'); |
|
|
|
if (!h1) { |
|
return { |
|
text: '', |
|
type: '' |
|
}; |
|
} |
|
|
|
const titleRow = findProjectTitleRow(h1); |
|
const titleRowText = cleanText(titleRow ? titleRow.textContent : h1.parentElement?.textContent || ''); |
|
const explicitStatus = extractExplicitStatusFromTitleText(titleRowText, cleanText(h1.textContent)); |
|
|
|
if (explicitStatus) { |
|
return { |
|
text: explicitStatus, |
|
type: BAD_STATUS_KEYWORDS.includes(explicitStatus) ? 'danger' : 'ok' |
|
}; |
|
} |
|
|
|
const nearbyStatus = findNearbyStatusElement(h1); |
|
|
|
if (nearbyStatus) { |
|
return { |
|
text: nearbyStatus, |
|
type: BAD_STATUS_KEYWORDS.includes(nearbyStatus) ? 'danger' : 'ok' |
|
}; |
|
} |
|
|
|
return { |
|
text: '', |
|
type: '' |
|
}; |
|
} |
|
|
|
function findProjectTitleRow(h1) { |
|
let current = h1.parentElement; |
|
|
|
for (let i = 0; i < 4 && current; i += 1) { |
|
const text = cleanText(current.textContent || ''); |
|
const hasH1 = current.querySelector('h1'); |
|
|
|
if (hasH1 && text.length <= 180) return current; |
|
current = current.parentElement; |
|
} |
|
|
|
return h1.parentElement; |
|
} |
|
|
|
function extractExplicitStatusFromTitleText(titleRowText, projectName) { |
|
const text = cleanText(titleRowText).replace(projectName, '').trim(); |
|
if (!text) return ''; |
|
|
|
for (const keyword of EXPLICIT_STATUS_KEYWORDS) { |
|
const patterns = [ |
|
`(${keyword})`, |
|
`(${keyword})`, |
|
keyword |
|
]; |
|
|
|
if (patterns.some(pattern => text.includes(pattern))) return keyword; |
|
} |
|
|
|
return ''; |
|
} |
|
|
|
function findNearbyStatusElement(h1) { |
|
const parent = h1.parentElement; |
|
if (!parent) return ''; |
|
|
|
const nearbyElements = Array.from(parent.querySelectorAll('span, div')) |
|
.filter(el => el !== h1) |
|
.filter(el => { |
|
const text = cleanText(el.textContent || ''); |
|
return text.length > 0 && text.length <= 24; |
|
}); |
|
|
|
for (const el of nearbyElements) { |
|
const text = cleanText(el.textContent || '').replace(/[()()]/g, ''); |
|
if (EXPLICIT_STATUS_KEYWORDS.includes(text)) return text; |
|
} |
|
|
|
return ''; |
|
} |
|
|
|
function extractProjectLogo(doc, name) { |
|
const header = doc.querySelector('#base-info-header') || doc; |
|
const images = Array.from(header.querySelectorAll('img[src]')); |
|
|
|
const exact = images.find(img => cleanText(img.getAttribute('alt') || '') === name); |
|
if (exact) return toAbsoluteAssetUrl(exact.getAttribute('src') || ''); |
|
|
|
const avatarImg = header.querySelector('[data-slot="avatar"] img[src], img[data-slot="avatar-image"][src]'); |
|
if (avatarImg) return toAbsoluteAssetUrl(avatarImg.getAttribute('src') || ''); |
|
|
|
const firstMeaningful = images.find(img => { |
|
const src = img.getAttribute('src') || ''; |
|
return src && !src.includes('detail_icon') && !src.includes('icon-points'); |
|
}); |
|
|
|
return firstMeaningful ? toAbsoluteAssetUrl(firstMeaningful.getAttribute('src') || '') : ''; |
|
} |
|
|
|
function extractOfficialWebsite(doc) { |
|
const header = doc.querySelector('#base-info-header') || doc; |
|
const links = Array.from(header.querySelectorAll('a[href], button')); |
|
|
|
const official = links.find(a => { |
|
const href = a.getAttribute('href') || ''; |
|
const text = cleanText(a.textContent || ''); |
|
const imgSrc = a.querySelector('img[src]')?.getAttribute('src') || ''; |
|
|
|
return ( |
|
imgSrc.includes('official_website') || |
|
(!href.includes('x.com') && |
|
!href.includes('twitter.com') && |
|
!href.includes('linkedin.com') && |
|
!href.includes('github.com') && |
|
!href.includes('gitbook') && |
|
!href.includes('defillama') && |
|
/^https?:\/\//i.test(href) && |
|
text && |
|
text !== 'X') |
|
); |
|
}); |
|
|
|
return official ? official.getAttribute('href') || '' : ''; |
|
} |
|
|
|
function extractXUrl(doc) { |
|
const header = doc.querySelector('#base-info-header') || doc; |
|
const links = Array.from(header.querySelectorAll('a[href]')); |
|
|
|
const x = links.find(a => { |
|
const href = a.getAttribute('href') || ''; |
|
const imgSrc = a.querySelector('img[src]')?.getAttribute('src') || ''; |
|
const text = cleanText(a.textContent || ''); |
|
|
|
return href.includes('x.com') || href.includes('twitter.com') || imgSrc.includes('twitter') || text === 'X'; |
|
}); |
|
|
|
return x ? x.getAttribute('href') || '' : ''; |
|
} |
|
|
|
function extractProjectCategory(doc) { |
|
const header = doc.querySelector('#base-info-header'); |
|
if (!header) return ''; |
|
|
|
const h1 = header.querySelector('h1'); |
|
const paragraphs = Array.from(header.querySelectorAll('p')) |
|
.map(p => cleanText(p.textContent)) |
|
.filter(Boolean) |
|
.filter(text => text !== cleanText(h1?.textContent || '')); |
|
|
|
return paragraphs[0] || ''; |
|
} |
|
|
|
function extractProjectTags(doc) { |
|
return extractBadgeTextsByLabelRow(doc, { |
|
labelMatcher: text => /^标签\s*[::]?$/.test(text), |
|
linkSelector: 'a[href*="/projects?sd="]', |
|
fallbackBadgeSelector: 'span[data-slot="badge"]' |
|
}); |
|
} |
|
|
|
function extractProjectEcosystems(doc) { |
|
return extractBadgeTextsByLabelRow(doc, { |
|
labelMatcher: text => /^生态(?:\(\d+\))?\s*[::]?$/.test(text), |
|
linkSelector: 'a[href*="/ecosystemmap/"]', |
|
fallbackBadgeSelector: 'span[data-slot="badge"]' |
|
}); |
|
} |
|
|
|
function extractBadgeTextsByLabelRow(doc, options) { |
|
const detailSection = doc.querySelector('#detail_section_essentials_details') || doc; |
|
const rows = Array.from(detailSection.querySelectorAll('div.flex.min-w-0.items-baseline.gap-2, div.flex.min-w-0, div')); |
|
|
|
for (const row of rows) { |
|
const rowText = cleanText(row.textContent || ''); |
|
if (!rowText || rowText.length > 220) continue; |
|
|
|
const directLabel = Array.from(row.children) |
|
.map(child => cleanText(child.textContent || '').replace(/\s+/g, '')) |
|
.find(text => options.labelMatcher(text)); |
|
|
|
if (!directLabel) continue; |
|
|
|
let anchors = Array.from(row.querySelectorAll(options.linkSelector)); |
|
let values = anchors |
|
.map(anchor => cleanText(anchor.textContent || '')) |
|
.filter(Boolean) |
|
.filter(text => text.length <= 40) |
|
.filter(text => !looksLikeNoise(text)); |
|
|
|
if (!values.length && options.fallbackBadgeSelector) { |
|
values = Array.from(row.querySelectorAll(options.fallbackBadgeSelector)) |
|
.map(el => cleanText(el.textContent || '')) |
|
.filter(Boolean) |
|
.filter(text => text.length <= 40) |
|
.filter(text => !looksLikeNoise(text)); |
|
} |
|
|
|
return unique(values).slice(0, 12); |
|
} |
|
|
|
return []; |
|
} |
|
|
|
function extractProjectInvestorDetails(doc) { |
|
const financialSection = doc.querySelector('#detail_section_financials') || doc; |
|
const links = Array.from(financialSection.querySelectorAll('a[href*="/investors/detail/"]')); |
|
|
|
return dedupeBy( |
|
links.map(anchor => { |
|
const href = anchor.getAttribute('href') || ''; |
|
const url = toAbsoluteUrl(href); |
|
const imgAlt = cleanText(anchor.querySelector('img[alt]')?.getAttribute('alt') || ''); |
|
const text = cleanText(anchor.textContent || ''); |
|
const urlName = extractNameFromRootDataUrl(url, 'investors/detail'); |
|
const name = cleanText(imgAlt || text || urlName).replace(/\*$/, '').trim(); |
|
|
|
if (!name || name.length > 80 || looksLikeNoise(name)) return null; |
|
|
|
const investor = { |
|
name, |
|
aliases: unique([imgAlt, text.replace(/\*$/, '').trim(), urlName]), |
|
url |
|
}; |
|
investor.chinaEvidence = assessInvestorChina(investor); |
|
return investor; |
|
}).filter(Boolean), |
|
investor => normalizeInstitutionName(investor.name) |
|
).slice(0, 20); |
|
} |
|
|
|
function extractProjectInvestors(doc) { |
|
return extractProjectInvestorDetails(doc).map(investor => investor.name); |
|
} |
|
|
|
function extractTotalFunding(doc) { |
|
return findLabeledValue(doc, ['融资总额', '总融资', '融资金额']); |
|
} |
|
|
|
function extractFoundedTime(doc) { |
|
return findLabeledValue(doc, ['成立时间', '创立时间', '成立日期']); |
|
} |
|
|
|
function extractProjectLocation(doc) { |
|
return findLabeledValue(doc, ['所在地', '总部', '地区', '注册地']); |
|
} |
|
|
|
function extractXInfluence(doc) { |
|
const labels = Array.from(doc.querySelectorAll('span, div, a')) |
|
.filter(el => cleanText(el.textContent || '') === '𝕏 影响力' || cleanText(el.textContent || '') === 'X 影响力'); |
|
|
|
for (const label of labels) { |
|
const block = climb(label, 3) || label.parentElement || label; |
|
const text = cleanText(block.textContent || ''); |
|
const match = text.match(/(?:𝕏|X)\s*影响力\s*([0-9,.]+[Kk万]?)/); |
|
|
|
if (match) return match[1]; |
|
|
|
const numbers = text.match(/[0-9,.]+[Kk万]?/g); |
|
if (numbers && numbers.length) return numbers[0]; |
|
} |
|
|
|
const bodyText = cleanText(doc.body ? doc.body.textContent : ''); |
|
const bodyMatch = bodyText.match(/(?:𝕏|X)\s*影响力\s*([0-9,.]+[Kk万]?)/); |
|
|
|
return bodyMatch ? bodyMatch[1] : ''; |
|
} |
|
|
|
function extractScoreFromDom(doc, projectName) { |
|
const block = findRatingBlock(doc, projectName); |
|
if (!block) { |
|
return { |
|
score: '', |
|
count: '' |
|
}; |
|
} |
|
|
|
const score = extractRatingScoreFromBlock(block); |
|
const count = extractRatingCountFromBlock(block); |
|
|
|
return { |
|
score, |
|
count |
|
}; |
|
} |
|
|
|
function findRatingBlock(doc, projectName) { |
|
const exactHeading = `为${cleanText(projectName)}评分`; |
|
|
|
const headings = Array.from(doc.querySelectorAll('h1, h2, h3, h4, h5, div, span')) |
|
.filter(el => { |
|
const text = cleanText(el.textContent || ''); |
|
return text === exactHeading || text.includes(exactHeading); |
|
}) |
|
.sort((a, b) => cleanText(a.textContent).length - cleanText(b.textContent).length); |
|
|
|
for (const heading of headings) { |
|
let current = heading; |
|
|
|
for (let i = 0; i < 8 && current; i += 1) { |
|
const text = cleanText(current.textContent || ''); |
|
|
|
if ( |
|
text.includes(exactHeading) && |
|
text.includes('已评分') && |
|
text.length <= 5000 |
|
) { |
|
return current; |
|
} |
|
|
|
current = current.parentElement; |
|
} |
|
} |
|
|
|
return null; |
|
} |
|
|
|
function extractRatingScoreFromBlock(block) { |
|
const preferred = Array.from(block.querySelectorAll('[class*="text-lg"][class*="text-primary"], [class*="text-primary"]')) |
|
.map(el => cleanText(el.textContent || '')) |
|
.filter(isStrictRatingScore); |
|
|
|
if (preferred.length) { |
|
return preferred[preferred.length - 1]; |
|
} |
|
|
|
const candidates = Array.from(block.querySelectorAll('span, div')) |
|
.map(el => cleanText(el.textContent || '')) |
|
.filter(text => text.length <= 12) |
|
.filter(isStrictRatingScore); |
|
|
|
return candidates[candidates.length - 1] || ''; |
|
} |
|
|
|
function extractRatingCountFromBlock(block) { |
|
const exactElements = Array.from(block.querySelectorAll('span, div, p')) |
|
.map(el => cleanText(el.textContent || '')) |
|
.filter(isUsefulRatingCount); |
|
|
|
if (exactElements.length) { |
|
return exactElements[0]; |
|
} |
|
|
|
const text = cleanText(block.textContent || ''); |
|
const matches = text.match(/(?:\d{1,3}(?:,\d{3})+|\d+)(?:[Kk万])?\+?人已评分/g) || []; |
|
const usefulMatches = matches.filter(isUsefulRatingCount); |
|
|
|
if (!usefulMatches.length) return ''; |
|
|
|
return usefulMatches.sort((a, b) => b.length - a.length)[0]; |
|
} |
|
|
|
function isStrictRatingScore(text) { |
|
return /^(10(?:\.0)?|[1-9](?:\.[0-9])?)$/.test(cleanText(text)); |
|
} |
|
|
|
function isUsefulRatingCount(text) { |
|
const normalized = cleanText(text); |
|
|
|
if (!/^(?:\d{1,3}(?:,\d{3})+|\d+)(?:[Kk万])?\+?人已评分$/.test(normalized)) { |
|
return false; |
|
} |
|
|
|
if (/^0+人已评分$/.test(normalized)) { |
|
return false; |
|
} |
|
|
|
return true; |
|
} |
|
|
|
function findLabeledValue(doc, labels) { |
|
const root = doc.querySelector('main') || doc.body || doc; |
|
const elements = Array.from(root.querySelectorAll('span, div, p, td, th')); |
|
|
|
for (const el of elements) { |
|
const text = cleanText(el.textContent || ''); |
|
const directText = cleanText(getDirectText(el)); |
|
|
|
const matchedLabel = labels.find(label => { |
|
return text === label || |
|
text === `${label}:` || |
|
text === `${label}:` || |
|
directText === label || |
|
directText === `${label}:` || |
|
directText === `${label}:`; |
|
}); |
|
|
|
if (!matchedLabel) continue; |
|
|
|
const parent = el.parentElement; |
|
if (!parent) continue; |
|
|
|
const siblingValue = Array.from(parent.children) |
|
.filter(child => child !== el) |
|
.map(child => cleanText(child.textContent || '')) |
|
.filter(Boolean) |
|
.filter(value => !labels.some(label => value === label || value === `${label}:` || value === `${label}:`)) |
|
.find(value => value.length <= 80); |
|
|
|
if (siblingValue) return siblingValue; |
|
|
|
const parentText = cleanText(parent.textContent || ''); |
|
const cleaned = cleanText(parentText.replace(matchedLabel, '').replace(/^[::]/, '')); |
|
|
|
if (cleaned && cleaned.length <= 80 && cleaned !== parentText) return cleaned; |
|
} |
|
|
|
const bodyText = cleanText(root.textContent || ''); |
|
|
|
for (const label of labels) { |
|
const regex = new RegExp(`${escapeRegExp(label)}[::]?\\s*([^\\s]{1,40})`); |
|
const match = bodyText.match(regex); |
|
if (match) return match[1]; |
|
} |
|
|
|
return ''; |
|
} |
|
|
|
function getDirectText(el) { |
|
return Array.from(el.childNodes) |
|
.filter(node => node.nodeType === Node.TEXT_NODE) |
|
.map(node => node.textContent || '') |
|
.join(' '); |
|
} |
|
|
|
function computeGlobalStats(members) { |
|
const relationCount = members.reduce((sum, member) => sum + (member.projects || []).length, 0); |
|
const projectMap = new Map(); |
|
|
|
for (const member of members) { |
|
for (const project of member.projects || []) { |
|
const key = getProjectKey(project); |
|
if (!key) continue; |
|
|
|
const existed = projectMap.get(key); |
|
|
|
if (!existed) { |
|
projectMap.set(key, { |
|
...project, |
|
relatedMembers: [member.name] |
|
}); |
|
continue; |
|
} |
|
|
|
if (!existed.relatedMembers.includes(member.name)) { |
|
existed.relatedMembers.push(member.name); |
|
} |
|
|
|
mergeProjectFields(existed, project); |
|
} |
|
} |
|
|
|
const uniqueProjects = Array.from(projectMap.values()); |
|
const dangerProjects = uniqueProjects.filter(project => project.statusType === 'danger'); |
|
const chinaProjects = uniqueProjects.filter(project => project.chinaAssessment && project.chinaAssessment.isChinesePlate); |
|
|
|
return { |
|
memberCount: members.length, |
|
relationCount, |
|
uniqueProjectCount: uniqueProjects.length, |
|
duplicateRelationCount: Math.max(0, relationCount - uniqueProjects.length), |
|
dangerUniqueProjectCount: dangerProjects.length, |
|
chinaProjectCount: chinaProjects.length, |
|
uniqueProjects |
|
}; |
|
} |
|
|
|
function mergeProjectFields(target, source) { |
|
const fields = [ |
|
'status', |
|
'statusType', |
|
'logo', |
|
'officialWebsite', |
|
'xUrl', |
|
'totalFunding', |
|
'founded', |
|
'score', |
|
'scoreCount', |
|
'xInfluence', |
|
'category', |
|
'location' |
|
]; |
|
|
|
for (const field of fields) { |
|
if (!target[field] && source[field]) { |
|
target[field] = source[field]; |
|
} |
|
} |
|
|
|
target.tags = unique([...(target.tags || []), ...(source.tags || [])]); |
|
target.ecosystems = unique([...(target.ecosystems || []), ...(source.ecosystems || [])]); |
|
target.investors = unique([...(target.investors || []), ...(source.investors || [])]); |
|
target.investorDetails = dedupeBy( |
|
[...(target.investorDetails || []), ...(source.investorDetails || [])], |
|
investor => normalizeInstitutionName(investor.name) |
|
); |
|
target.teamMembers = dedupeBy( |
|
[...(target.teamMembers || []), ...(source.teamMembers || [])], |
|
member => normalizePersonName(member.name) |
|
); |
|
target.relatedMemberEvidence = dedupeBy( |
|
[...(target.relatedMemberEvidence || []), ...(source.relatedMemberEvidence || [])], |
|
member => normalizePersonName(member.name) |
|
); |
|
target.chinaAssessment = assessChinaProject(target); |
|
} |
|
|
|
function formatScore(project) { |
|
if (project.score && project.scoreCount) return `${project.score} · ${project.scoreCount}`; |
|
if (project.score) return project.score; |
|
if (project.scoreCount) return project.scoreCount; |
|
return ''; |
|
} |
|
|
|
function markTeamCards(members) { |
|
cleanupOldBadges(); |
|
|
|
const dangerByMember = new Map(); |
|
const chinaByMember = new Map(); |
|
|
|
for (const member of members || []) { |
|
const normalizedNames = unique([member.name, ...(member.aliases || [])]) |
|
.map(normalizePersonName) |
|
.filter(Boolean); |
|
const dangerProjects = dedupeProjects((member.projects || []).filter(project => project.statusType === 'danger')); |
|
|
|
for (const normalizedName of normalizedNames) { |
|
if (dangerProjects.length) dangerByMember.set(normalizedName, dangerProjects); |
|
if (member.chinaEvidence && member.chinaEvidence.isChinese) { |
|
chinaByMember.set(normalizedName, member.chinaEvidence); |
|
} |
|
} |
|
} |
|
|
|
const teamSection = document.querySelector('#detail_section_essentials_team'); |
|
if (!teamSection) return; |
|
|
|
const teamCards = Array.from(teamSection.querySelectorAll('.card-warp')); |
|
|
|
for (const card of teamCards) { |
|
const link = card.querySelector('a[href*="/member/"]'); |
|
if (!link) continue; |
|
if (link.getAttribute(CONFIG.processedAttr) === '1') continue; |
|
|
|
const nameFromCard = extractMemberNameFromTeamCard(card, link); |
|
const nameFromUrl = extractNameFromRootDataUrl(toAbsoluteUrl(link.getAttribute('href') || ''), 'member'); |
|
const normalizedNames = unique([nameFromCard, nameFromUrl]).map(normalizePersonName).filter(Boolean); |
|
const dangerProjects = normalizedNames.map(name => dangerByMember.get(name)).find(Boolean) || []; |
|
const chinaEvidence = normalizedNames.map(name => chinaByMember.get(name)).find(Boolean); |
|
|
|
if (!dangerProjects.length && !chinaEvidence) continue; |
|
|
|
card.style.position = 'relative'; |
|
const badges = document.createElement('span'); |
|
badges.className = 'rd-dd-team-card-badges'; |
|
|
|
if (chinaEvidence) { |
|
const chinaBadge = document.createElement('span'); |
|
chinaBadge.className = 'rd-dd-team-card-cn'; |
|
chinaBadge.textContent = '中国成员'; |
|
chinaBadge.title = chinaEvidence.reasons.join('\n'); |
|
badges.appendChild(chinaBadge); |
|
} |
|
|
|
if (dangerProjects.length) { |
|
const riskBadge = document.createElement('span'); |
|
riskBadge.className = 'rd-dd-team-card-risk'; |
|
riskBadge.textContent = `异常项目 ${dangerProjects.length}`; |
|
riskBadge.title = dangerProjects.map(project => `${project.name}:${project.status}`).join('\n'); |
|
badges.appendChild(riskBadge); |
|
} |
|
|
|
card.appendChild(badges); |
|
link.setAttribute(CONFIG.processedAttr, '1'); |
|
} |
|
} |
|
|
|
function cleanupOldBadges() { |
|
document.querySelectorAll( |
|
'.rd-dd-member-badge, .rd-dd-team-card-risk, .rd-dd-team-card-cn, .rd-dd-team-card-badges' |
|
).forEach(el => el.remove()); |
|
document.getElementById(CONFIG.currentProjectBadgeId)?.remove(); |
|
document.querySelectorAll(`[${CONFIG.processedAttr}]`).forEach(el => el.removeAttribute(CONFIG.processedAttr)); |
|
} |
|
|
|
function httpGet(url) { |
|
return new Promise((resolve, reject) => { |
|
GM_xmlhttpRequest({ |
|
method: 'GET', |
|
url, |
|
timeout: CONFIG.requestTimeout, |
|
headers: { |
|
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' |
|
}, |
|
onload(response) { |
|
if (response.status >= 200 && response.status < 300) { |
|
resolve(response.responseText || ''); |
|
return; |
|
} |
|
|
|
reject(new Error(`HTTP ${response.status}`)); |
|
}, |
|
onerror() { |
|
reject(new Error('网络请求失败')); |
|
}, |
|
ontimeout() { |
|
reject(new Error('请求超时')); |
|
} |
|
}); |
|
}); |
|
} |
|
|
|
function purgeLegacyLargeHtmlCache() { |
|
try { |
|
const alreadyPurged = GM_getValue(CONFIG.purgeFlag, false); |
|
if (alreadyPurged) return; |
|
|
|
const keys = GM_listValues(); |
|
let deleted = 0; |
|
|
|
for (const key of keys) { |
|
const valueKey = String(key); |
|
|
|
if ( |
|
valueKey.startsWith('rd-auto-dd:') || |
|
valueKey.startsWith('rd-cache:') |
|
) { |
|
GM_deleteValue(valueKey); |
|
deleted += 1; |
|
} |
|
} |
|
|
|
GM_setValue(CONFIG.purgeFlag, { |
|
time: Date.now(), |
|
deleted |
|
}); |
|
} catch (error) { |
|
console.warn('[RootData DD]', error); |
|
} |
|
} |
|
|
|
function clearCache() { |
|
try { |
|
const keys = GM_listValues(); |
|
|
|
for (const key of keys) { |
|
if ( |
|
String(key).startsWith('rd-auto-dd:') || |
|
String(key).startsWith('rd-auto-dd-score:') || |
|
String(key).startsWith('rd-cache:') || |
|
String(key).startsWith('rd-auto-dd-purged-large-html-cache') |
|
) { |
|
GM_deleteValue(key); |
|
} |
|
} |
|
} catch (error) { |
|
console.warn('[RootData DD]', error); |
|
} |
|
} |
|
|
|
function parseHtml(html) { |
|
return new DOMParser().parseFromString(html, 'text/html'); |
|
} |
|
|
|
function closestCard(el) { |
|
let current = el; |
|
|
|
for (let i = 0; i < 8 && current; i += 1) { |
|
if ( |
|
current.classList && |
|
( |
|
current.classList.contains('card-warp') || |
|
current.matches('[class*="card"]') || |
|
current.matches('[class*="Card"]') || |
|
current.matches('section') || |
|
current.matches('li') |
|
) |
|
) { |
|
return current; |
|
} |
|
|
|
current = current.parentElement; |
|
} |
|
|
|
return el.parentElement; |
|
} |
|
|
|
function extractProjectTeamMembers(doc) { |
|
const teamSection = doc.querySelector('#detail_section_essentials_team'); |
|
if (!teamSection) return []; |
|
|
|
const teamCards = Array.from(teamSection.querySelectorAll('.card-warp')) |
|
.filter(card => card.querySelector('a[href*="/member/"]')); |
|
|
|
return dedupeBy( |
|
teamCards.map(card => { |
|
const anchor = card.querySelector('a[href*="/member/"]'); |
|
if (!anchor) return null; |
|
|
|
const url = toAbsoluteUrl(anchor.getAttribute('href') || ''); |
|
const cardName = extractMemberNameFromTeamCard(card, anchor); |
|
const urlName = extractNameFromRootDataUrl(url, 'member'); |
|
const name = cardName || urlName; |
|
const role = extractRoleFromTeamCard(card, name); |
|
const member = { |
|
name, |
|
aliases: unique([cardName, urlName]), |
|
role, |
|
url |
|
}; |
|
member.chinaEvidence = assessMemberChinaFromNames(member); |
|
return member; |
|
}).filter(Boolean), |
|
member => normalizePersonName(member.name) |
|
).slice(0, CONFIG.maxMembers); |
|
} |
|
|
|
function extractMemberChinaEvidence(doc, member) { |
|
const profileNames = extractMemberProfileNames(doc, member); |
|
const location = extractMemberProfileLocation(doc); |
|
const profileText = extractMemberProfileIdentityText(doc); |
|
const nameEvidence = assessMemberChinaFromNames({ |
|
...member, |
|
aliases: unique([...(member.aliases || []), ...profileNames]) |
|
}); |
|
const reasons = [...nameEvidence.reasons]; |
|
let score = nameEvidence.score; |
|
|
|
if (containsChinaLocation(location)) { |
|
score += 6; |
|
reasons.unshift(`资料所在地:${location}`); |
|
} |
|
|
|
const identityKeyword = CHINA_IDENTITY_KEYWORDS.find(keyword => |
|
profileText.toLowerCase().includes(keyword.toLowerCase()) |
|
); |
|
|
|
if (identityKeyword) { |
|
score += 6; |
|
reasons.unshift(`资料明确提及:${identityKeyword}`); |
|
} |
|
|
|
return { |
|
isChinese: score >= CONFIG.chinaTeamMinScore, |
|
score, |
|
confidence: score >= 7 ? 'high' : score >= CONFIG.chinaTeamMinScore ? 'medium' : 'low', |
|
location, |
|
names: profileNames, |
|
reasons: unique(reasons).slice(0, CONFIG.maxChinaEvidenceNames) |
|
}; |
|
} |
|
|
|
function extractMemberProfileNames(doc, member) { |
|
const h1 = cleanText(doc.querySelector('h1')?.textContent || ''); |
|
const header = doc.querySelector('#base-info-header') || doc.querySelector('main') || doc.body; |
|
const imageNames = Array.from(header?.querySelectorAll('img[alt]') || []) |
|
.map(img => cleanText(img.getAttribute('alt') || '')) |
|
.filter(name => name && name.length <= 60 && !looksLikeNoise(name)); |
|
|
|
return unique([member.name, ...(member.aliases || []), h1, ...imageNames]).slice(0, 10); |
|
} |
|
|
|
function extractMemberProfileLocation(doc) { |
|
return findLabeledValue(doc, ['所在地', '居住地', '地区', '国籍', '国家', '常驻地']); |
|
} |
|
|
|
function extractMemberProfileIdentityText(doc) { |
|
const roots = [ |
|
doc.querySelector('#base-info-header'), |
|
doc.querySelector('#detail_section_essentials_details'), |
|
doc.querySelector('[id*="essentials_details"]') |
|
].filter(Boolean); |
|
|
|
return cleanText(roots.map(root => root.textContent || '').join(' ')).slice(0, 2000); |
|
} |
|
|
|
function assessMemberChinaFromNames(member) { |
|
const names = unique([member.name, ...(member.aliases || [])]).filter(Boolean); |
|
let best = { |
|
score: 0, |
|
reasons: [] |
|
}; |
|
|
|
for (const name of names) { |
|
const evidence = assessChineseName(name); |
|
if (evidence.score > best.score) best = evidence; |
|
} |
|
|
|
return { |
|
isChinese: best.score >= CONFIG.chinaTeamMinScore, |
|
score: best.score, |
|
confidence: best.score >= 6 ? 'high' : best.score >= CONFIG.chinaTeamMinScore ? 'medium' : 'low', |
|
location: '', |
|
names, |
|
reasons: unique(best.reasons).slice(0, CONFIG.maxChinaEvidenceNames) |
|
}; |
|
} |
|
|
|
function assessChineseName(name) { |
|
const value = cleanText(name) |
|
.replace(/[((].*?[))]/g, '') |
|
.replace(/\s+/g, ' ') |
|
.trim(); |
|
const reasons = []; |
|
let score = 0; |
|
|
|
const chineseOnly = value.replace(/[^\u3400-\u9fff]/g, ''); |
|
if (chineseOnly.length >= 2 && chineseOnly.length <= 5) { |
|
const compoundSurname = COMPOUND_CHINESE_SURNAMES.find(surname => chineseOnly.startsWith(surname)); |
|
const singleSurname = CHINESE_SURNAMES.has(chineseOnly.charAt(0)); |
|
const japaneseSurname = COMMON_JAPANESE_SURNAME_PREFIXES.find(surname => chineseOnly.startsWith(surname)); |
|
|
|
if (japaneseSurname) { |
|
score = 1; |
|
reasons.push(`汉字姓名疑似日文姓氏:${japaneseSurname}`); |
|
} else if (compoundSurname || (singleSurname && chineseOnly.length <= 3)) { |
|
score = 5; |
|
reasons.push(`中文姓名:${value}`); |
|
} else { |
|
score = 2; |
|
reasons.push(`姓名含汉字:${value}`); |
|
} |
|
} |
|
|
|
if (!chineseOnly && /^[a-zA-Z.' -]{3,60}$/.test(value)) { |
|
const tokens = value.toLowerCase().replace(/[^a-z -]/g, ' ').split(/[\s-]+/).filter(Boolean); |
|
const possibleSurname = tokens.find(token => PINYIN_SURNAMES.has(token)); |
|
|
|
if (tokens.length >= 2 && possibleSurname) { |
|
score = Math.max(score, 1); |
|
reasons.push(`英文名含常见拼音姓氏:${possibleSurname}`); |
|
} |
|
} |
|
|
|
return { score, reasons }; |
|
} |
|
|
|
function assessInvestorChina(investor) { |
|
const names = unique([investor.name, ...(investor.aliases || [])]).filter(Boolean); |
|
const reasons = []; |
|
let score = 0; |
|
|
|
for (const name of names) { |
|
const normalized = normalizeInstitutionName(name); |
|
if (NORMALIZED_CHINESE_INVESTOR_ALIASES.has(normalized)) { |
|
score = Math.max(score, 6); |
|
reasons.push(`命中中国/华人投资机构名单:${name}`); |
|
} |
|
|
|
const chineseText = name.replace(/[^\u3400-\u9fff]/g, ''); |
|
const looksInstitutional = /(基金|资本|创投|投资|实验室|控股|集团|证券|国际|资产|孵化器)/.test(name); |
|
|
|
if (chineseText.length >= 2 && looksInstitutional) { |
|
score = Math.max(score, 5); |
|
reasons.push(`中文投资机构名称:${name}`); |
|
} |
|
} |
|
|
|
if (investor.location && containsChinaLocation(investor.location)) { |
|
score = Math.max(score, 7); |
|
reasons.unshift(`机构所在地:${investor.location}`); |
|
} |
|
|
|
return { |
|
isChinese: score >= CONFIG.chinaInvestorMinScore, |
|
score, |
|
confidence: score >= 6 ? 'high' : score >= CONFIG.chinaInvestorMinScore ? 'medium' : 'low', |
|
reasons: unique(reasons).slice(0, CONFIG.maxChinaEvidenceNames) |
|
}; |
|
} |
|
|
|
function assessChinaProject(project) { |
|
const teamCandidates = dedupeBy( |
|
[ |
|
...(project.teamMembers || []), |
|
...(project.relatedMemberEvidence || []) |
|
].filter(Boolean), |
|
member => normalizePersonName(member.name) |
|
); |
|
const investorCandidates = (project.investorDetails && project.investorDetails.length) |
|
? project.investorDetails |
|
: (project.investors || []).map(name => ({ name })); |
|
|
|
const chineseMembers = teamCandidates |
|
.map(member => ({ |
|
...member, |
|
chinaEvidence: member.chinaEvidence || assessMemberChinaFromNames(member) |
|
})) |
|
.filter(member => member.chinaEvidence.isChinese); |
|
|
|
const chineseInvestors = investorCandidates |
|
.map(investor => ({ |
|
...investor, |
|
chinaEvidence: investor.chinaEvidence || assessInvestorChina(investor) |
|
})) |
|
.filter(investor => investor.chinaEvidence.isChinese); |
|
|
|
const isChinesePlate = chineseMembers.length > 0 && chineseInvestors.length > 0; |
|
const strongestMemberScore = Math.max(0, ...chineseMembers.map(member => member.chinaEvidence.score || 0)); |
|
const strongestInvestorScore = Math.max(0, ...chineseInvestors.map(investor => investor.chinaEvidence.score || 0)); |
|
const confidence = isChinesePlate && strongestMemberScore >= 5 && strongestInvestorScore >= 6 |
|
? 'high' |
|
: isChinesePlate |
|
? 'medium' |
|
: 'none'; |
|
|
|
return { |
|
isChinesePlate, |
|
confidence, |
|
chineseMembers: chineseMembers.map(member => ({ |
|
name: member.name, |
|
reasons: member.chinaEvidence.reasons || [] |
|
})), |
|
chineseInvestors: chineseInvestors.map(investor => ({ |
|
name: investor.name, |
|
reasons: investor.chinaEvidence.reasons || [] |
|
})), |
|
projectLocation: project.location || '' |
|
}; |
|
} |
|
|
|
function makeEmptyChinaAssessment() { |
|
return { |
|
isChinesePlate: false, |
|
confidence: 'none', |
|
chineseMembers: [], |
|
chineseInvestors: [], |
|
projectLocation: '' |
|
}; |
|
} |
|
|
|
function containsChinaLocation(text) { |
|
const value = cleanText(text).toLowerCase(); |
|
if (!value) return false; |
|
|
|
return CHINA_LOCATION_KEYWORDS.some(keyword => value.includes(keyword.toLowerCase())) || |
|
/\b(china|hong kong|macau|taiwan|beijing|shanghai|shenzhen|guangzhou|hangzhou|chengdu)\b/i.test(value); |
|
} |
|
|
|
function normalizeInstitutionName(name) { |
|
return cleanText(name) |
|
.replace(/\*+$/g, '') |
|
.replace(/[™®©]/g, '') |
|
.replace(/\b(ventures?|capital|labs?|partners?|holdings?|group|fund|investment|investments)\b/gi, match => match.toLowerCase()) |
|
.replace(/[\s._,&'"“”‘’()()\-—]+/g, '') |
|
.toLowerCase(); |
|
} |
|
|
|
function buildChinaAssessmentTooltip(assessment) { |
|
if (!assessment || !assessment.isChinesePlate) return ''; |
|
|
|
const memberText = assessment.chineseMembers |
|
.slice(0, CONFIG.maxChinaEvidenceNames) |
|
.map(member => `${member.name}${member.reasons.length ? `(${member.reasons.join(';')})` : ''}`) |
|
.join('、'); |
|
const investorText = assessment.chineseInvestors |
|
.slice(0, CONFIG.maxChinaEvidenceNames) |
|
.map(investor => `${investor.name}${investor.reasons.length ? `(${investor.reasons.join(';')})` : ''}`) |
|
.join('、'); |
|
|
|
return `中国团队:${memberText || '已命中'}\n中国投资方:${investorText || '已命中'}\n置信度:${assessment.confidence === 'high' ? '高' : '中'}`; |
|
} |
|
|
|
function buildMemberChinaTooltip(member) { |
|
const parts = []; |
|
if (member.role) parts.push(member.role); |
|
if (member.chinaEvidence && member.chinaEvidence.isChinese) { |
|
parts.push(...(member.chinaEvidence.reasons || [])); |
|
} |
|
return unique(parts).join(';'); |
|
} |
|
|
|
function renderProjectChinaBadge(project) { |
|
const assessment = project.chinaAssessment; |
|
if (!assessment || !assessment.isChinesePlate) return ''; |
|
|
|
return `<span class="rd-dd-project-cn-badge" title="${escapeAttr(buildChinaAssessmentTooltip(assessment))}">国人盘子</span>`; |
|
} |
|
|
|
function renderCurrentProjectChinaBanner(project) { |
|
const assessment = project && project.chinaAssessment; |
|
if (!assessment || !assessment.isChinesePlate) return ''; |
|
|
|
const members = assessment.chineseMembers |
|
.slice(0, CONFIG.maxChinaEvidenceNames) |
|
.map(item => item.name) |
|
.join('、'); |
|
const investors = assessment.chineseInvestors |
|
.slice(0, CONFIG.maxChinaEvidenceNames) |
|
.map(item => item.name) |
|
.join('、'); |
|
|
|
return ` |
|
<div class="rd-dd-china-banner" title="${escapeAttr(buildChinaAssessmentTooltip(assessment))}"> |
|
<strong>国人盘子</strong> |
|
<div>中国团队:${escapeHtml(members || '已识别')}</div> |
|
<div>中国投资方:${escapeHtml(investors || '已识别')}</div> |
|
</div> |
|
`; |
|
} |
|
|
|
function markCurrentProjectBadge(project) { |
|
document.getElementById(CONFIG.currentProjectBadgeId)?.remove(); |
|
|
|
const assessment = project && project.chinaAssessment; |
|
if (!assessment || !assessment.isChinesePlate) return; |
|
|
|
const h1 = document.querySelector('h1'); |
|
if (!h1) return; |
|
|
|
const badge = document.createElement('span'); |
|
badge.id = CONFIG.currentProjectBadgeId; |
|
badge.className = 'rd-dd-current-project-cn'; |
|
badge.textContent = '国人盘子'; |
|
badge.title = buildChinaAssessmentTooltip(assessment); |
|
|
|
const titleRow = findProjectTitleRow(h1); |
|
if (titleRow && titleRow !== h1 && titleRow.contains(h1)) { |
|
titleRow.appendChild(badge); |
|
} else { |
|
h1.insertAdjacentElement('afterend', badge); |
|
} |
|
} |
|
|
|
function findNearestSectionTitle(el) { |
|
let current = el; |
|
|
|
for (let depth = 0; depth < 10 && current; depth += 1) { |
|
let sibling = current.previousElementSibling; |
|
|
|
while (sibling) { |
|
const title = cleanText( |
|
sibling.querySelector('h2, h3, h4')?.textContent || |
|
(sibling.matches('h2, h3, h4') ? sibling.textContent : '') |
|
); |
|
|
|
if (title) return title; |
|
sibling = sibling.previousElementSibling; |
|
} |
|
|
|
current = current.parentElement; |
|
} |
|
|
|
const allText = cleanText(el.closest('section')?.textContent || ''); |
|
const matched = IMPORTANT_SECTIONS.find(section => allText.includes(section)); |
|
|
|
return matched || ''; |
|
} |
|
|
|
function scoreProjectSection(section) { |
|
const text = cleanText(section); |
|
|
|
if (!text) return 0; |
|
if (text.includes('工作经历')) return 100; |
|
if (text.includes('创办')) return 90; |
|
if (text.includes('参与')) return 80; |
|
if (text.includes('关联')) return 70; |
|
if (text.includes('投资组合')) return 60; |
|
if (text.includes('项目')) return 50; |
|
|
|
return 10; |
|
} |
|
|
|
function looksLikeRoleOrDescription(text) { |
|
const roleWords = [ |
|
'创始人', |
|
'联合创始人', |
|
'CEO', |
|
'CTO', |
|
'COO', |
|
'顾问', |
|
'合伙人', |
|
'贡献者', |
|
'工程师', |
|
'负责人', |
|
'主管', |
|
'经理', |
|
'任职', |
|
'曾任', |
|
'此前', |
|
'现在', |
|
'Founder', |
|
'Co-Founder', |
|
'Advisor', |
|
'Engineer', |
|
'Partner', |
|
'Core Contributor', |
|
'Contributor' |
|
]; |
|
|
|
return roleWords.some(word => text.includes(word)); |
|
} |
|
|
|
function looksLikeNoise(text) { |
|
const normalized = cleanText(text); |
|
|
|
const noiseWords = [ |
|
'打开项目页', |
|
'项目', |
|
'异常', |
|
'全部', |
|
'详情', |
|
'团队', |
|
'融资', |
|
'表现', |
|
'对比', |
|
'展开更多', |
|
'评论', |
|
'评分', |
|
'官网', |
|
'X主页', |
|
'LinkedIn', |
|
'GitHub', |
|
'Gitbook', |
|
'Defillama', |
|
'关注更新', |
|
'关注列表', |
|
'关注', |
|
'粉丝', |
|
'Following', |
|
'Followers' |
|
]; |
|
|
|
if (noiseWords.includes(normalized)) return true; |
|
if (/^异常项目\s*\d+$/i.test(normalized)) return true; |
|
if (/^[A-Z]异常项目\s*\d+$/i.test(normalized)) return true; |
|
|
|
return false; |
|
} |
|
|
|
function extractNameFromRootDataUrl(url, segment) { |
|
try { |
|
const u = new URL(url, location.origin); |
|
const parts = u.pathname.split('/').filter(Boolean); |
|
|
|
if (segment === 'member') { |
|
const index = parts.indexOf('member'); |
|
if (index >= 0 && parts[index + 1]) return cleanText(decodeURIComponent(parts[index + 1])); |
|
} |
|
|
|
if (segment === 'projects/detail') { |
|
const projectsIndex = parts.indexOf('projects'); |
|
if (projectsIndex >= 0 && parts[projectsIndex + 1] === 'detail' && parts[projectsIndex + 2]) { |
|
return cleanText(decodeURIComponent(parts[projectsIndex + 2])); |
|
} |
|
} |
|
|
|
if (segment === 'investors/detail') { |
|
const investorsIndex = parts.indexOf('investors'); |
|
if (investorsIndex >= 0 && parts[investorsIndex + 1] === 'detail' && parts[investorsIndex + 2]) { |
|
return cleanText(decodeURIComponent(parts[investorsIndex + 2])); |
|
} |
|
} |
|
|
|
return ''; |
|
} catch { |
|
return ''; |
|
} |
|
} |
|
|
|
function getProjectKey(project) { |
|
if (!project) return ''; |
|
|
|
try { |
|
const u = new URL(project.url, location.origin); |
|
const nameFromUrl = extractNameFromRootDataUrl(u.href, 'projects/detail'); |
|
|
|
if (nameFromUrl) { |
|
return `name:${normalizeProjectName(nameFromUrl)}`; |
|
} |
|
|
|
return `url:${u.origin}${u.pathname}`; |
|
} catch { |
|
return `name:${normalizeProjectName(project.name)}`; |
|
} |
|
} |
|
|
|
function climb(el, levels) { |
|
let current = el; |
|
|
|
for (let i = 0; i < levels && current; i += 1) { |
|
current = current.parentElement; |
|
} |
|
|
|
return current; |
|
} |
|
|
|
function toAbsoluteUrl(href) { |
|
return new URL(href, location.origin).href; |
|
} |
|
|
|
function toAbsoluteAssetUrl(src) { |
|
if (!src) return ''; |
|
return new URL(src, location.origin).href; |
|
} |
|
|
|
function normalizeUrlWithoutQueryNoise(url) { |
|
try { |
|
const u = new URL(url, location.origin); |
|
return `${u.origin}${u.pathname}${u.search}`; |
|
} catch { |
|
return url; |
|
} |
|
} |
|
|
|
function normalizeProjectName(name) { |
|
return cleanText(name).toLowerCase(); |
|
} |
|
|
|
function normalizePersonName(name) { |
|
return cleanText(name).toLowerCase(); |
|
} |
|
|
|
function dedupeProjects(projects) { |
|
return dedupeBy(projects, project => getProjectKey(project)); |
|
} |
|
|
|
function dedupeBy(items, getKey) { |
|
const seen = new Set(); |
|
const result = []; |
|
|
|
for (const item of items) { |
|
const key = getKey(item); |
|
if (!key || seen.has(key)) continue; |
|
|
|
seen.add(key); |
|
result.push(item); |
|
} |
|
|
|
return result; |
|
} |
|
|
|
function unique(items) { |
|
return Array.from(new Set(items.filter(Boolean))); |
|
} |
|
|
|
function cleanText(text) { |
|
return String(text || '') |
|
.replace(/\u00a0/g, ' ') |
|
.replace(/\s+/g, ' ') |
|
.trim(); |
|
} |
|
|
|
function escapeHtml(value) { |
|
return String(value || '') |
|
.replace(/&/g, '&') |
|
.replace(/</g, '<') |
|
.replace(/>/g, '>') |
|
.replace(/"/g, '"') |
|
.replace(/'/g, '''); |
|
} |
|
|
|
function escapeAttr(value) { |
|
return escapeHtml(value).replace(/`/g, '`'); |
|
} |
|
|
|
function escapeRegExp(value) { |
|
return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
|
} |
|
|
|
function sleep(ms) { |
|
return new Promise(resolve => setTimeout(resolve, ms)); |
|
} |
|
|
|
function watchSpaRouteChange() { |
|
const rerun = () => { |
|
if (location.href !== lastUrl) { |
|
lastUrl = location.href; |
|
abortToken += 1; |
|
isRunning = false; |
|
|
|
cleanupOldBadges(); |
|
|
|
const oldPanel = document.getElementById(CONFIG.panelId); |
|
if (oldPanel) oldPanel.remove(); |
|
|
|
setTimeout(main, 700); |
|
} |
|
}; |
|
|
|
const originalPushState = history.pushState; |
|
const originalReplaceState = history.replaceState; |
|
|
|
history.pushState = function (...args) { |
|
originalPushState.apply(this, args); |
|
setTimeout(rerun, 50); |
|
}; |
|
|
|
history.replaceState = function (...args) { |
|
originalReplaceState.apply(this, args); |
|
setTimeout(rerun, 50); |
|
}; |
|
|
|
window.addEventListener('popstate', () => { |
|
setTimeout(rerun, 50); |
|
}); |
|
|
|
const observer = new MutationObserver(() => { |
|
if (!isProjectDetailPage()) return; |
|
|
|
const hasPanel = document.getElementById(CONFIG.panelId); |
|
const hasTitle = getCurrentProjectName(); |
|
|
|
if (!hasPanel && hasTitle && !isRunning) { |
|
setTimeout(main, 500); |
|
} |
|
}); |
|
|
|
observer.observe(document.documentElement, { |
|
childList: true, |
|
subtree: true |
|
}); |
|
} |
|
|
|
purgeLegacyLargeHtmlCache(); |
|
setTimeout(main, 800); |
|
watchSpaRouteChange(); |
|
})(); |