Skip to content

Instantly share code, notes, and snippets.

@discountry
Last active July 16, 2026 14:26
Show Gist options
  • Select an option

  • Save discountry/f41fbdb3e7fad1c37523a97e503fe51b to your computer and use it in GitHub Desktop.

Select an option

Save discountry/f41fbdb3e7fad1c37523a97e503fe51b to your computer and use it in GitHub Desktop.

RootData 团队成员项目状态自动尽调面板安装教程

一、安装 Tampermonkey 插件

Tampermonkey 也常被称为“油猴”,用于安装和运行浏览器用户脚本。

Chrome、Edge、Brave 浏览器

  1. 打开 Tampermonkey 官方网站
  2. 选择自己使用的浏览器
  3. 进入对应的浏览器插件商店
  4. 点击「添加至 Chrome」「获取」或「安装」
  5. 安装完成后,将 Tampermonkey 固定到浏览器工具栏

固定方法:

  1. 点击浏览器右上角的“扩展程序”图标
  2. 找到 Tampermonkey
  3. 点击旁边的固定图标

Firefox 浏览器

Firefox 不能使用 Chrome 扩展商店,需要从 Firefox 扩展商店安装:

  1. 打开 Firefox 版 Tampermonkey
  2. 点击「添加到 Firefox」
  3. 根据提示确认安装和权限

推荐使用:

  • Chrome
  • Edge
  • Brave
  • Firefox

二、允许 Tampermonkey 运行用户脚本

这是最容易遗漏的一步。

如果没有开启相关权限,即使已经正确安装和保存脚本,脚本也可能完全没有反应。

Chrome 设置方法

  1. 在浏览器地址栏输入:
chrome://extensions/
  1. 找到 Tampermonkey
  2. 点击「详情」
  3. 找到「允许用户脚本」
  4. 打开这个开关

然后继续检查「网站访问权限」:

  1. 在 Tampermonkey 详情页面找到「网站访问权限」
  2. 建议选择「在特定网站上」
  3. 添加以下地址:
https://cn.rootdata.com/*

也可以选择「在所有网站上」,但授予的权限范围会更大。

如果页面里没有「允许用户脚本」开关:

  1. 返回:
chrome://extensions/
  1. 打开页面右上角的「开发者模式」
  2. 完全关闭并重新打开浏览器

Chrome 138 及以上版本通常会在扩展详情中提供单独的「允许用户脚本」开关;部分旧版本则需要开启扩展程序页面的「开发者模式」。Tampermonkey 官方说明,在基于 Chromium 的浏览器中,需要启用其中一种权限才能执行用户脚本。

Edge 设置方法

  1. 在地址栏输入:
edge://extensions/
  1. 打开页面左侧或右侧的「开发人员模式」
  2. 找到 Tampermonkey
  3. 点击「详细信息」
  4. 如果能看到「允许用户脚本」,将其打开
  5. 将网站访问权限设置为允许访问:
https://cn.rootdata.com/*

设置完成后,完全关闭并重新打开 Edge。

Brave 设置方法

  1. 在地址栏输入:
brave://extensions/
  1. 找到 Tampermonkey
  2. 点击「详细信息」
  3. 打开「允许用户脚本」
  4. 如果没有这个选项,打开扩展程序页面右上角的「开发者模式」
  5. 将网站访问权限设置为允许访问:
https://cn.rootdata.com/*

设置完成后,重新启动 Brave。

Firefox 设置方法

Firefox 一般不需要设置 Chrome 中的「允许用户脚本」开关。

如果脚本无法运行:

  1. 在地址栏输入:
about:addons
  1. 点击「扩展」
  2. 找到 Tampermonkey
  3. 点击右侧三个点
  4. 进入「管理」
  5. 打开「权限」
  6. 确保 Tampermonkey 拥有访问网站数据的权限

如果需要在隐私窗口中使用,还要打开:

在隐私窗口中运行

三、导入脚本

  1. 点击浏览器右上角的 Tampermonkey 图标
  2. 点击「管理面板」
  3. 点击右上角的「添加新脚本」
  4. 删除编辑器里原本的默认代码
  5. 将收到的完整脚本代码全部粘贴进去
  6. 使用快捷键保存:
Mac:Command + S
Windows:Ctrl + S

保存成功后,会自动返回脚本管理页面。


四、确认脚本已经启用

进入 Tampermonkey 管理面板,找到:

RootData 团队成员项目状态自动尽调面板

确认脚本前面的开关已经打开。

正常情况下,开关应显示为启用状态,而不是灰色关闭状态。

同时确认脚本代码顶部包含类似下面的地址匹配规则:

// @match        https://cn.rootdata.com/projects/detail/*

这表示脚本只会在 RootData 项目详情页面运行,不会在首页或其他无关页面运行。


五、使用脚本

  1. 登录 RootData
  2. 打开任意项目详情页面,例如:
https://cn.rootdata.com/projects/detail/项目名称
  1. 等待页面加载完成
  2. 脚本会自动读取项目团队成员信息
  3. 页面中会显示团队成员关联项目的自动尽调面板

第一次运行时,脚本可能需要逐个读取团队成员及其关联项目,因此需要等待页面进度完成。


六、脚本没有出现时的检查方法

按照以下顺序检查:

1. 检查用户脚本权限

重新进入扩展程序管理页面,确认已经开启:

允许用户脚本

如果没有这个选项,确认已经开启:

开发者模式

2. 检查网站访问权限

确认 Tampermonkey 可以访问:

https://cn.rootdata.com/*

不要设置成「点击扩展程序时才允许」,否则页面打开时脚本可能无法自动运行。

3. 检查脚本开关

打开 Tampermonkey 管理面板,确认脚本开关处于启用状态。

4. 检查页面地址

脚本只能在以下格式的页面运行:

https://cn.rootdata.com/projects/detail/*

RootData 首页、融资列表页、机构页面和人物页面不会自动显示面板。

5. 刷新页面

设置权限或保存脚本以后,需要重新刷新 RootData 页面。

可以使用强制刷新:

Mac:Command + Shift + R
Windows:Ctrl + Shift + R

6. 重新启动浏览器

如果刚刚开启了「允许用户脚本」或「开发者模式」,请完全退出浏览器后重新打开。

只关闭当前标签页可能不会立即生效。

7. 检查是否使用隐私模式

如果是在无痕窗口或隐私窗口中打开 RootData,需要进入 Tampermonkey 扩展详情,单独允许它在无痕模式或隐私窗口中运行。

普通用户建议直接在正常浏览器窗口中使用。


七、正常运行的判断方法

脚本正常运行时通常会出现以下情况:

  1. RootData 项目详情页面中出现自动尽调面板
  2. 面板显示团队成员和项目读取进度
  3. Tampermonkey 图标中显示当前页面存在可运行的脚本
  4. 点击 Tampermonkey 图标,可以看到该脚本处于启用状态

如果页面没有团队成员信息,脚本可能不会继续抓取关联项目。这通常表示该项目团队匿名,或者 RootData 暂未收录团队资料。

// ==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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function escapeAttr(value) {
return escapeHtml(value).replace(/`/g, '&#096;');
}
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();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment