You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

657 lines
26 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
namespace Tests\Unit;
use App\Services\Crawl\Adapters\FacultyListHtmlAdapter;
use Tests\TestCase;
class FacultyListHtmlAdapterTest extends TestCase
{
public function test_extracts_sjtu_college_teacher_list_without_email(): void
{
$html = <<<'HTML'
<div class="jssy-b">
<div class="title">电子信息与电气工程学院</div>
<div class="list">
<ul>
<li>
<a href="http://faculty.sjtu.edu.cn/bjshen/zh_CN/index.htm" target="_blank">
<div class="name">沈备军</div>
<p>所在单位:电子信息与电气工程学院</p>
<p>职称:副教授</p>
</a>
</li>
<li>
<a href="http://faculty.sjtu.edu.cn/xiabin/zh_CN/index.htm" target="_blank">
<div class="name">夏斌</div>
<p>所在单位:电子信息与电气工程学院</p>
<p>职称:教授</p>
</a>
</li>
</ul>
</div>
</div>
HTML;
$adapter = new FacultyListHtmlAdapter;
$method = new \ReflectionMethod($adapter, 'extractFromHtml');
$method->setAccessible(true);
$items = $method->invoke(
$adapter,
$html,
[],
'https://faculty.sjtu.edu.cn/xyjs_list.jsp?urltype=tsites.CollegeTeacherList&wbtreeid=1001&st=0&id=1701&lang=zh_CN',
);
$this->assertCount(2, $items);
$this->assertSame('沈备军', $items[0]->title);
$this->assertSame('http://faculty.sjtu.edu.cn/bjshen/zh_CN/index.htm', $items[0]->canonicalUrl);
$this->assertSame('上海交通大学', $items[0]->schoolName);
$this->assertSame('faculty_html_tsites', $items[0]->extra['platform']);
$this->assertSame('副教授', $items[0]->extra['academic_title']);
$this->assertSame('电子信息与电气工程学院', $items[0]->extra['college_name']);
$this->assertSame('副教授', $items[0]->extra['lead_author']['academic_title']);
$this->assertSame('夏斌', $items[1]->title);
}
public function test_extracts_teacher_when_profile_href_is_empty(): void
{
$html = <<<'HTML'
<script>u_u11_pic.addimg("/__local/x.png","/shaohaibin/zh_CN/index.htm","邵海滨","1");</script>
<div class="list"><ul>
<li><a href="" target="_blank">
<div class="name">邵海滨</div>
<p>所在单位:电子信息与电气工程学院</p>
<p>职称:副研究员</p>
</a></li>
</ul></div>
HTML;
$adapter = new FacultyListHtmlAdapter;
$method = new \ReflectionMethod($adapter, 'extractFromHtml');
$method->setAccessible(true);
$items = $method->invoke(
$adapter,
$html,
[],
'https://faculty.sjtu.edu.cn/xyjs_list.jsp?id=1701',
);
$this->assertCount(1, $items);
$this->assertSame('邵海滨', $items[0]->title);
$this->assertStringContainsString('shaohaibin', (string) $items[0]->canonicalUrl);
$this->assertSame('副研究员', $items[0]->extra['academic_title']);
$this->assertSame('电子信息与电气工程学院', $items[0]->extra['college_name']);
}
public function test_extracts_email_from_teacher_profile_html(): void
{
$html = '<li><strong>电子邮箱:</strong>bjshen@sjtu.edu.cn</li>';
$adapter = new FacultyListHtmlAdapter;
$method = new \ReflectionMethod($adapter, 'extractEmailFromProfileHtml');
$method->setAccessible(true);
$email = $method->invoke($adapter, $html);
$this->assertSame('bjshen@sjtu.edu.cn', $email);
}
public function test_apply_email_to_item_updates_lead_author(): void
{
$adapter = new FacultyListHtmlAdapter;
$method = new \ReflectionMethod($adapter, 'applyEmailToItem');
$method->setAccessible(true);
$item = $method->invoke(
$adapter,
new \App\Services\Crawl\CrawlItemDto(
externalId: 'faculty:test',
title: '沈备军',
canonicalUrl: 'http://faculty.sjtu.edu.cn/bjshen/zh_CN/index.htm',
extra: [
'lead_author' => [
'name' => '沈备军',
'email' => null,
'university_name' => '上海交通大学',
],
],
),
'bjshen@sjtu.edu.cn',
);
$this->assertSame('bjshen@sjtu.edu.cn', $item->extra['lead_author']['email']);
}
public function test_detects_total_pages_and_builds_pagenum_url(): void
{
$html = '<a href="?totalpage=20&PAGENUM=2&urltype=tsites.CollegeTeacherList&id=1701">下页</a>';
$adapter = new FacultyListHtmlAdapter;
$detect = new \ReflectionMethod($adapter, 'detectTotalPages');
$detect->setAccessible(true);
$this->assertSame(20, $detect->invoke($adapter, $html));
$build = new \ReflectionMethod($adapter, 'buildPageUrl');
$build->setAccessible(true);
$url = $build->invoke(
$adapter,
'https://faculty.sjtu.edu.cn/xyjs_list.jsp?urltype=tsites.CollegeTeacherList&wbtreeid=1001&st=0&id=1701&lang=zh_CN',
3,
$html,
);
$this->assertStringContainsString('PAGENUM=3', $url);
$this->assertStringContainsString('totalpage=20', $url);
}
public function test_extracts_smse_staff_panel_list(): void
{
$html = <<<'HTML'
<title>教师名录 - 上海交通大学材料科学与工程学院</title>
<div class="panel-head">
<div class="title">塑性成形技术与装备研究院</div>
</div>
<div class="panel-body">
<div class="staff-list">
<a href="/people/detail_new/20092" class="staff-item">陈军</a>
<a href="/people/detail_new/20111" class="staff-item">韩先洪</a>
</div>
</div>
HTML;
$adapter = new FacultyListHtmlAdapter;
$method = new \ReflectionMethod($adapter, 'extractFromHtml');
$method->setAccessible(true);
$items = $method->invoke(
$adapter,
$html,
[],
'https://smse.sjtu.edu.cn/people/staff_new/department',
);
$this->assertCount(2, $items);
$this->assertSame('陈军', $items[0]->title);
$this->assertSame('https://smse.sjtu.edu.cn/people/detail_new/20092', $items[0]->canonicalUrl);
$this->assertSame('上海交通大学', $items[0]->schoolName);
$this->assertSame('faculty_html_smse', $items[0]->extra['platform']);
$this->assertSame('塑性成形技术与装备研究院', $items[0]->extra['college_name']);
}
public function test_apply_profile_metadata_from_smse_detail_page(): void
{
$html = <<<'HTML'
<div class="people-name"><p>陈军</p><em>教授</em></div>
<div class="info jigou">所属二级机构:塑性成形技术与装备研究院</div>
<div class="info youxiangdizhi">电子邮箱jun_chen@sjtu.edu.cn</div>
<div class="panel-head">
<div class="title">研究方向</div>
<div class="icon"><i class="iconfont icon-add-circle"></i></div>
</div>
<div class="panel-body">
<ul>
<li>轻质高强薄板塑性成形与数控渐进成形</li>
<li>塑性变形力学模型及其多物理场数值仿真</li>
</ul>
</div>
HTML;
$adapter = new FacultyListHtmlAdapter;
$method = new \ReflectionMethod($adapter, 'applyProfileMetadataToItem');
$method->setAccessible(true);
$item = $method->invoke(
$adapter,
new \App\Services\Crawl\CrawlItemDto(
externalId: 'faculty:test',
title: '陈军',
canonicalUrl: 'https://smse.sjtu.edu.cn/people/detail_new/20092',
extra: [
'lead_author' => [
'name' => '陈军',
'email' => null,
'university_name' => '上海交通大学',
],
],
),
$html,
);
$this->assertSame('教授', $item->extra['lead_author']['academic_title']);
$this->assertSame('塑性成形技术与装备研究院', $item->extra['college_name']);
$this->assertSame(
['轻质高强薄板塑性成形与数控渐进成形', '塑性变形力学模型及其多物理场数值仿真'],
$item->extra['research_direction_names'],
);
}
public function test_apply_profile_metadata_from_see_detail_page(): void
{
$html = <<<'HTML'
<div class="js-info">
<p>办公电话021-34204298</p>
<p>电子邮件aiqian@sjtu.edu.cn</p>
</div>
<div class="js-box">
<div class="js-box-item">
<div class="name">研究兴趣</div>
<div class="txt"><p>电力系统元件建模, 电能质量, 分布式发电</p></div>
</div>
<div class="js-box-item">
<div class="name">教育背景</div>
<div class="txt"><p>1994.09 1998.07清华大学</p></div>
</div>
</div>
<div class="footer"><p>邮箱see@sjtu.edu.cn</p></div>
HTML;
$adapter = new FacultyListHtmlAdapter;
$method = new \ReflectionMethod($adapter, 'applyProfileMetadataToItem');
$method->setAccessible(true);
$item = $method->invoke(
$adapter,
new \App\Services\Crawl\CrawlItemDto(
externalId: 'faculty:see',
title: '艾芊',
canonicalUrl: 'https://see.sjtu.edu.cn/jiaoshiml/aiqian.html',
extra: ['lead_author' => ['name' => '艾芊']],
),
$html,
);
$this->assertSame('021-34204298', $item->extra['phone']);
$this->assertSame(['电力系统元件建模', '电能质量', '分布式发电'], $item->extra['research_direction_names']);
$emailMethod = new \ReflectionMethod($adapter, 'extractEmailFromProfileHtml');
$emailMethod->setAccessible(true);
$this->assertSame('aiqian@sjtu.edu.cn', $emailMethod->invoke($adapter, $html));
}
public function test_apply_profile_metadata_from_cs_and_frontier_pages(): void
{
$adapter = new FacultyListHtmlAdapter;
$method = new \ReflectionMethod($adapter, 'applyProfileMetadataToItem');
$method->setAccessible(true);
$csHtml = <<<'HTML'
<div class="dt"><p>邮箱byzang@sjtu.edu.cn</p></div>
<div class="js-dt">
<div class="item item2">
<div class="name"><p>个人简介</p></div>
<div class="txt"><p>上海交通大学特聘教授,主要研究领域为操作系统、分布式系统与系统安全。</p></div>
</div>
</div>
HTML;
$csItem = $method->invoke(
$adapter,
new \App\Services\Crawl\CrawlItemDto(
externalId: 'faculty:cs',
title: '臧斌宇',
canonicalUrl: 'https://www.cs.sjtu.edu.cn/jiaoshiml/zangbinyu.html',
extra: ['lead_author' => ['name' => '臧斌宇']],
),
$csHtml,
);
$this->assertStringContainsString('操作系统', (string) $csItem->extra['bio']);
$frontierHtml = <<<'HTML'
<div class="person-contact">
<span class="details-tag">电子邮件</span>
<span class="details-con"><a href="mailto:baoming@nju.edu.cn">baoming@nju.edu.cn</a></span>
</div>
<div class="person-contact">
<span class="details-tag">联系电话</span>
<span class="details-con">0512-68768786</span>
</div>
<div class="info-item">
<div class="info-subChannel"><img src="/x.png"><span>研究领域</span></div>
<div class="info-ctx"><div class="wp_articlecontent"><p>微纳材料与微纳系统</p></div></div>
</div>
<div class="info-item">
<div class="info-subChannel"><img src="/x.png"><span>个人简介</span></div>
<div class="info-ctx"><div class="wp_articlecontent"><p>2006.09-2010.07 厦门大学,本科;长期从事微纳材料研究。</p></div></div>
</div>
HTML;
$frontierItem = $method->invoke(
$adapter,
new \App\Services\Crawl\CrawlItemDto(
externalId: 'faculty:frontier',
title: '王保明',
canonicalUrl: 'https://frontier.nju.edu.cn/85/ef/c59286a689647/page.htm',
extra: ['lead_author' => ['name' => '王保明']],
),
$frontierHtml,
);
$this->assertSame('0512-68768786', $frontierItem->extra['phone']);
$this->assertSame(['微纳材料与微纳系统'], $frontierItem->extra['research_direction_names']);
$this->assertStringContainsString('厦门大学', (string) $frontierItem->extra['bio']);
}
public function test_extracts_ra_teacher_list_with_research_directions(): void
{
$html = <<<'HTML'
<title>专职教师-南京大学机器人与自动化学院</title>
<ul class="teacher">
<li>
<a href="http://ra.nju.edu.cn/szll/zzjs/20250901/i335910.html" title="周克敏" target="_blank">
<div class="xm">周克敏</div>
<div>职称:<span>教授</span></div>
<div>研究方向:<span>鲁棒控制,多目标优化控制</span></div>
</a>
</li>
</ul>
HTML;
$adapter = new FacultyListHtmlAdapter;
$method = new \ReflectionMethod($adapter, 'extractFromHtml');
$method->setAccessible(true);
$items = $method->invoke($adapter, $html, [], 'https://ra.nju.edu.cn/szll/zzjs/index.html');
$this->assertCount(1, $items);
$this->assertSame('周克敏', $items[0]->title);
$this->assertSame('http://ra.nju.edu.cn/szll/zzjs/20250901/i335910.html', $items[0]->canonicalUrl);
$this->assertSame(['鲁棒控制', '多目标优化控制'], $items[0]->extra['research_direction_names']);
}
public function test_item_needs_profile_enrich_when_research_missing(): void
{
$adapter = new FacultyListHtmlAdapter;
$method = new \ReflectionMethod($adapter, 'itemNeedsProfileEnrich');
$method->setAccessible(true);
$item = new \App\Services\Crawl\CrawlItemDto(
externalId: 'faculty:x',
title: '张三',
canonicalUrl: 'https://see.sjtu.edu.cn/jiaoshiml/zhangsan.html',
extra: [
'lead_author' => [
'name' => '张三',
'email' => 'zhangsan@sjtu.edu.cn',
],
],
);
$this->assertTrue($method->invoke($adapter, $item));
}
public function test_resolve_profile_enrich_max_caps_large_batches(): void
{
$adapter = new FacultyListHtmlAdapter;
$method = new \ReflectionMethod($adapter, 'resolveProfileEnrichMax');
$method->setAccessible(true);
$this->assertSame(200, $method->invoke($adapter, [], 500));
$this->assertSame(10, $method->invoke($adapter, ['profile_enrich_max' => 10], 500));
$this->assertSame(0, $method->invoke($adapter, ['skip_profile_enrich' => true], 500));
}
public function test_extracts_sais_js_list_from_ajax_content(): void
{
$html = <<<'HTML'
<title>教师名录-上海交通大学自动化与感知学院</title>
<div class="js-list">
<li><a href="https://sais.sjtu.edu.cn/faculty/baiyang.html" class="name">白洋</a></li>
<li><a href="https://sais.sjtu.edu.cn/faculty/chenxin.html" class="name">陈新</a></li>
</div>
HTML;
$adapter = new FacultyListHtmlAdapter;
$method = new \ReflectionMethod($adapter, 'extractFromAjaxTeacherContent');
$method->setAccessible(true);
$items = $method->invoke(
$adapter,
$html,
[],
'https://sais.sjtu.edu.cn/faculty.html',
'faculty',
);
$this->assertCount(2, $items);
$this->assertSame('白洋', $items[0]->title);
$this->assertSame('https://sais.sjtu.edu.cn/faculty/baiyang.html', $items[0]->canonicalUrl);
$this->assertSame('faculty_html_ajax', $items[0]->extra['platform']);
$this->assertSame('上海交通大学', $items[0]->schoolName);
}
public function test_extracts_cs_rc_item_teacher_list(): void
{
$html = <<<'HTML'
<title>教师名录-上海交通大学计算机学院(网络空间安全学院、密码学院)</title>
<div class="rc-item">
<div class="tit"><div class="name">并行与分布式系统研究所</div></div>
<div class="dt">
<p>所长:<a href="https://www.cs.sjtu.edu.cn/jiaoshiml/zangbinyu.html" target="_blank">臧斌宇</a></p>
<p><a href="https://www.cs.sjtu.edu.cn/jiaoshiml/chenhaibo.html" target="_blank">陈海波</a></p>
</div>
</div>
HTML;
$adapter = new FacultyListHtmlAdapter;
$method = new \ReflectionMethod($adapter, 'extractFromAjaxTeacherContent');
$method->setAccessible(true);
$items = $method->invoke(
$adapter,
$html,
[],
'https://www.cs.sjtu.edu.cn/jiaoshiml.html',
'jiaoshiml',
);
$this->assertCount(2, $items);
$this->assertSame('臧斌宇', $items[0]->title);
$this->assertSame('并行与分布式系统研究所', $items[0]->extra['college_name']);
$this->assertSame('https://www.cs.sjtu.edu.cn/jiaoshiml/chenhaibo.html', $items[1]->canonicalUrl);
}
public function test_response_body_from_pool_result_ignores_connection_exception(): void
{
$adapter = new FacultyListHtmlAdapter;
$method = new \ReflectionMethod($adapter, 'responseBodyFromPoolResult');
$method->setAccessible(true);
$this->assertNull($method->invoke($adapter, new \GuzzleHttp\Exception\ConnectException(
'Connection timed out',
new \GuzzleHttp\Psr7\Request('GET', 'https://faculty.sjtu.edu.cn/test'),
)));
$this->assertNull($method->invoke($adapter, null));
}
public function test_parses_icisee_ajax_teacher_config_without_cat_id(): void
{
$html = <<<'HTML'
<script>
$.ajax({
url: '/active/ajax_teacher_list.html',
type: 'post',
data: {page:page, cat_code:'jiaoshiml', yjszxfl:global_yjszxfl, name:global_name, zm:global_zm},
});
</script>
HTML;
$adapter = new FacultyListHtmlAdapter;
$method = new \ReflectionMethod($adapter, 'parseAjaxTeacherConfig');
$method->setAccessible(true);
$config = $method->invoke($adapter, $html, 'https://icisee.sjtu.edu.cn/jiaoshiml.html');
$this->assertSame('simple', $config['variant']);
$this->assertNull($config['cat_id']);
$this->assertSame('jiaoshiml', $config['cat_code']);
$this->assertSame('https://icisee.sjtu.edu.cn/active/ajax_teacher_list.html', $config['api_url']);
$this->assertTrue($config['uses_page']);
}
public function test_extracts_icisee_card_style_teacher_list(): void
{
$html = <<<'HTML'
<title>教师名录-上海交通大学集成电路学院(信息与电子工程学院)</title>
<a href="/jiaoshiml/caixinghan.html" target="_blank">
<div class="imgk"><img src="/upload/x.png" alt=""></div>
<div class="name">蔡星汉<span>教授</span><p class="line-2">微纳全重党支部书记</p></div>
</a>
<a href="/jiaoshiml/zhangsan.html" target="_blank">
<div class="imgk"><img src="/upload/y.png" alt=""></div>
<div class="name">张三<span>副教授</span></div>
</a>
HTML;
$adapter = new FacultyListHtmlAdapter;
$method = new \ReflectionMethod($adapter, 'extractFromAjaxTeacherContent');
$method->setAccessible(true);
$items = $method->invoke(
$adapter,
$html,
[],
'https://icisee.sjtu.edu.cn/jiaoshiml.html',
'jiaoshiml',
);
$this->assertCount(2, $items);
$this->assertSame('蔡星汉', $items[0]->title);
$this->assertSame('教授', $items[0]->extra['academic_title']);
$this->assertSame('https://icisee.sjtu.edu.cn/jiaoshiml/caixinghan.html', $items[0]->canonicalUrl);
$this->assertSame('张三', $items[1]->title);
$this->assertSame('副教授', $items[1]->extra['academic_title']);
$this->assertSame('faculty_html_ajax', $items[0]->extra['platform']);
}
public function test_detects_nju_teacher_home_page(): void
{
$html = '<body class="list faculty"><script src="/js/faculty.js"></script></body>';
$adapter = new FacultyListHtmlAdapter;
$method = new \ReflectionMethod($adapter, 'isNjuTeacherHomePage');
$method->setAccessible(true);
$this->assertTrue($method->invoke($adapter, $html));
$this->assertFalse($method->invoke($adapter, '<body class="list"><script src="/js/list.js"></script></body>'));
}
public function test_builds_nju_teacher_home_conditions_for_all_faculty(): void
{
$adapter = new FacultyListHtmlAdapter;
$method = new \ReflectionMethod($adapter, 'buildNjuTeacherHomeConditions');
$method->setAccessible(true);
$conditions = $method->invoke($adapter, null, null);
$this->assertCount(1, $conditions);
$this->assertSame('published', $conditions[0]['field']);
}
public function test_builds_nju_teacher_home_conditions_for_professor_category(): void
{
$adapter = new FacultyListHtmlAdapter;
$method = new \ReflectionMethod($adapter, 'buildNjuTeacherHomeConditions');
$method->setAccessible(true);
$conditions = $method->invoke($adapter, '教授', null);
$this->assertCount(2, $conditions);
$this->assertSame('exField2', $conditions[1]['field']);
$this->assertSame('教授', $conditions[1]['value']);
}
public function test_parses_nju_site_id_from_html(): void
{
$html = '<script src="/_js/jquery.min.js" sudy-wp-siteId="786"></script>';
$adapter = new FacultyListHtmlAdapter;
$method = new \ReflectionMethod($adapter, 'parseNjuSiteId');
$method->setAccessible(true);
$this->assertSame(786, $method->invoke($adapter, $html));
}
public function test_infers_college_from_meta_description(): void
{
$html = '<title>师资力量</title><meta name="description" content="南京大学智能科学与技术学院" >';
$adapter = new FacultyListHtmlAdapter;
$method = new \ReflectionMethod($adapter, 'inferCollegeFromPageTitle');
$method->setAccessible(true);
$this->assertSame('智能科学与技术学院', $method->invoke($adapter, $html));
}
public function test_extracts_sudy_news_faculty_list(): void
{
$html = <<<'HTML'
<title>师资力量-南京大学前沿科学学院</title>
<li class="wp_sublist sublist-1">
<h3 class="sublist_title"><span class="subcolumn-name">功能材料与智能制造研究院</span></h3>
<ul class="news_list list2">
<li class="news n1 clearfix">
<div class="news_title"><a href='/85/ef/c59286a689647/page.htm' title='王保明'>王保明</a></div>
</li>
</ul>
</li>
HTML;
$adapter = new FacultyListHtmlAdapter;
$method = new \ReflectionMethod($adapter, 'extractFromSudyNewsFacultyList');
$method->setAccessible(true);
$items = $method->invoke($adapter, $html, [], 'https://frontier.nju.edu.cn/zrjs/list.htm');
$this->assertCount(1, $items);
$this->assertSame('王保明', $items[0]->title);
$this->assertSame('功能材料与智能制造研究院', $items[0]->extra['college_name']);
$this->assertSame('faculty_html_sudy_news', $items[0]->extra['platform']);
}
public function test_extracts_ra_teacher_cards(): void
{
$html = <<<'HTML'
<title>专职教师-南京大学机器人与自动化学院</title>
<ul class="teacher">
<li>
<a href="http://ra.nju.edu.cn/szll/zzjs/20250901/i335910.html" title="周克敏">
<div class="data"><div class="name"><div class="xm">周克敏</div></div>
<div class="research pro">职称:<span>教授</span></div>
<div class="research">研究方向:<span>鲁棒控制</span></div></div>
</a>
</li>
</ul>
HTML;
$adapter = new FacultyListHtmlAdapter;
$method = new \ReflectionMethod($adapter, 'extractFromRaTeacherList');
$method->setAccessible(true);
$items = $method->invoke($adapter, $html, [], 'https://ra.nju.edu.cn/szll/zzjs/index.html');
$this->assertCount(1, $items);
$this->assertSame('周克敏', $items[0]->title);
$this->assertSame('教授', $items[0]->extra['academic_title']);
$this->assertSame('faculty_html_ra', $items[0]->extra['platform']);
}
public function test_extracts_vsb_faculty_table(): void
{
$html = <<<'HTML'
<title>专兼职教师-南京大学智能软件与工程学院</title>
<div class="zjzjs"><p><strong><span>教授</span></strong></p>
<table><tr><td><a href="zjzjs/yangkun.htm"><span>杨鲲</span></a></td><td><span>陶先平</span></td></tr></table>
<p><strong><span>副教授</span></strong></p>
<table><tr><td><a href="zjzjs/shaodong.htm"><span>邵栋</span></a></td></tr></table>
</div>
HTML;
$adapter = new FacultyListHtmlAdapter;
$method = new \ReflectionMethod($adapter, 'extractFromVsbFacultyTable');
$method->setAccessible(true);
$items = $method->invoke($adapter, $html, [], 'https://ise.nju.edu.cn/szll/zjzjs.htm');
$this->assertCount(3, $items);
$names = array_map(fn ($item) => $item->title, $items);
$this->assertContains('杨鲲', $names);
$this->assertContains('陶先平', $names);
$this->assertContains('邵栋', $names);
$titles = array_column(array_map(fn ($item) => $item->extra, $items), 'academic_title', null);
$this->assertSame('教授', $items[array_search('杨鲲', $names, true)]->extra['academic_title']);
$this->assertSame('副教授', $items[array_search('邵栋', $names, true)]->extra['academic_title']);
}
}