|
|
|
|
@ -183,7 +183,7 @@ class FacultyListHtmlAdapter implements CrawlerAdapterInterface
|
|
|
|
|
if ($enrichBudget <= 0) {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
if ($this->itemHasEmail($item) || ! $item->canonicalUrl) {
|
|
|
|
|
if (! $this->itemNeedsProfileEnrich($item)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
$fetchMap[$index] = $item;
|
|
|
|
|
@ -215,9 +215,11 @@ class FacultyListHtmlAdapter implements CrawlerAdapterInterface
|
|
|
|
|
foreach ($batchPending as $index => $item) {
|
|
|
|
|
$body = $this->responseBodyFromPoolResult($responses[(string) $index] ?? null);
|
|
|
|
|
if ($body !== null) {
|
|
|
|
|
$email = $this->extractEmailFromProfileHtml($body);
|
|
|
|
|
if ($email) {
|
|
|
|
|
$item = $this->applyEmailToItem($item, $email);
|
|
|
|
|
if (! $this->itemHasEmail($item)) {
|
|
|
|
|
$email = $this->extractEmailFromProfileHtml($body);
|
|
|
|
|
if ($email) {
|
|
|
|
|
$item = $this->applyEmailToItem($item, $email);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
$item = $this->applyProfileMetadataToItem($item, $body);
|
|
|
|
|
}
|
|
|
|
|
@ -229,7 +231,7 @@ class FacultyListHtmlAdapter implements CrawlerAdapterInterface
|
|
|
|
|
foreach ($items as $index => $item) {
|
|
|
|
|
if (isset($fetchedBodies[$index])) {
|
|
|
|
|
$result[] = $fetchedBodies[$index];
|
|
|
|
|
} elseif (! $this->itemHasEmail($item) && $item->canonicalUrl) {
|
|
|
|
|
} elseif ($this->itemNeedsProfileEnrich($item)) {
|
|
|
|
|
$result[] = $this->markItemProfileEnrichSkipped($item);
|
|
|
|
|
} else {
|
|
|
|
|
$result[] = $item;
|
|
|
|
|
@ -239,6 +241,62 @@ class FacultyListHtmlAdapter implements CrawlerAdapterInterface
|
|
|
|
|
return $result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function itemNeedsProfileEnrich(CrawlItemDto $item): bool
|
|
|
|
|
{
|
|
|
|
|
if (! $item->canonicalUrl) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ! $this->itemHasEmail($item)
|
|
|
|
|
|| ! $this->itemHasPhone($item)
|
|
|
|
|
|| ! $this->itemHasResearchDirections($item)
|
|
|
|
|
|| ! $this->itemHasBio($item);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function itemHasPhone(CrawlItemDto $item): bool
|
|
|
|
|
{
|
|
|
|
|
$lead = $item->extra['lead_author'] ?? null;
|
|
|
|
|
$phone = is_array($lead) ? trim((string) ($lead['phone'] ?? '')) : '';
|
|
|
|
|
if ($phone === '') {
|
|
|
|
|
$phone = trim((string) ($item->extra['phone'] ?? ''));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $phone !== '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function itemHasResearchDirections(CrawlItemDto $item): bool
|
|
|
|
|
{
|
|
|
|
|
$names = $item->extra['research_direction_names'] ?? null;
|
|
|
|
|
if (is_array($names) && $names !== []) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$lead = $item->extra['lead_author'] ?? null;
|
|
|
|
|
if (is_array($lead)) {
|
|
|
|
|
$leadNames = $lead['research_direction_names'] ?? null;
|
|
|
|
|
if (is_array($leadNames) && $leadNames !== []) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function itemHasBio(CrawlItemDto $item): bool
|
|
|
|
|
{
|
|
|
|
|
$bio = trim((string) ($item->extra['bio'] ?? ''));
|
|
|
|
|
if ($bio !== '') {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$lead = $item->extra['lead_author'] ?? null;
|
|
|
|
|
if (is_array($lead) && trim((string) ($lead['bio'] ?? '')) !== '') {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param array<string, mixed> $params
|
|
|
|
|
*/
|
|
|
|
|
@ -248,9 +306,9 @@ class FacultyListHtmlAdapter implements CrawlerAdapterInterface
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$configured = (int) ($params['profile_enrich_max'] ?? config('crawl.faculty.profile_enrich_max', 32));
|
|
|
|
|
$configured = (int) ($params['profile_enrich_max'] ?? config('crawl.faculty.profile_enrich_max', 200));
|
|
|
|
|
|
|
|
|
|
return max(0, min($itemCount, min(200, $configured)));
|
|
|
|
|
return max(0, min($itemCount, min(500, $configured)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
@ -264,7 +322,7 @@ class FacultyListHtmlAdapter implements CrawlerAdapterInterface
|
|
|
|
|
|
|
|
|
|
protected function markItemProfileEnrichSkipped(CrawlItemDto $item): CrawlItemDto
|
|
|
|
|
{
|
|
|
|
|
if ($this->itemHasEmail($item)) {
|
|
|
|
|
if (! $this->itemNeedsProfileEnrich($item)) {
|
|
|
|
|
return $item;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -349,17 +407,22 @@ class FacultyListHtmlAdapter implements CrawlerAdapterInterface
|
|
|
|
|
|
|
|
|
|
protected function extractEmailFromProfileHtml(string $html): ?string
|
|
|
|
|
{
|
|
|
|
|
$scoped = $this->profileContentHtml($html);
|
|
|
|
|
|
|
|
|
|
$labeledPatterns = [
|
|
|
|
|
'/电子邮箱[::]\s*<\/strong>\s*([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/u',
|
|
|
|
|
'/电子邮箱[::]\s*<\/(?:strong|span|b)>\s*([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/u',
|
|
|
|
|
'/电子邮箱[::]\s*([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/u',
|
|
|
|
|
'/电子信箱[::]\s*([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/u',
|
|
|
|
|
'/E-?mail[::]\s*([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/iu',
|
|
|
|
|
'/邮箱[::]\s*([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/u',
|
|
|
|
|
'/电子邮件[::]\s*([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/u',
|
|
|
|
|
'/E-?mail[::]\s*(?:<[^>]+>\s*)*([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/iu',
|
|
|
|
|
'/details-tag">\s*电子邮件\s*<\/span>\s*<span class="details-con">\s*(?:<a[^>]*>)?\s*([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/u',
|
|
|
|
|
'/电子邮箱[::]\s*<\/span>\s*([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/u',
|
|
|
|
|
'/邮箱[::]\s*([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/u',
|
|
|
|
|
'/mailto:([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/i',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
foreach ($labeledPatterns as $pattern) {
|
|
|
|
|
if (preg_match($pattern, $html, $match)) {
|
|
|
|
|
if (preg_match($pattern, $scoped, $match)) {
|
|
|
|
|
$email = CrawlAuthorParser::normalizeEmail($match[1]);
|
|
|
|
|
if ($email && ! $this->isNoiseEmail($email)) {
|
|
|
|
|
return $email;
|
|
|
|
|
@ -370,7 +433,7 @@ class FacultyListHtmlAdapter implements CrawlerAdapterInterface
|
|
|
|
|
$candidates = [];
|
|
|
|
|
if (preg_match_all(
|
|
|
|
|
'#([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})#',
|
|
|
|
|
$html,
|
|
|
|
|
$scoped,
|
|
|
|
|
$emailMatches,
|
|
|
|
|
)) {
|
|
|
|
|
foreach ($emailMatches[1] as $raw) {
|
|
|
|
|
@ -398,10 +461,16 @@ class FacultyListHtmlAdapter implements CrawlerAdapterInterface
|
|
|
|
|
|
|
|
|
|
protected function isNoiseEmail(string $email): bool
|
|
|
|
|
{
|
|
|
|
|
return (bool) preg_match(
|
|
|
|
|
'/^(noreply|no-reply|admin|webmaster|postmaster|root|support|service|info|contact)@/i',
|
|
|
|
|
$email,
|
|
|
|
|
);
|
|
|
|
|
$local = strtolower((string) strstr($email, '@', true));
|
|
|
|
|
|
|
|
|
|
if (preg_match(
|
|
|
|
|
'/^(noreply|no-reply|admin|webmaster|postmaster|root|support|service|info|contact|see|scs|soai|icisee|sais|smse|faculty|office|dean)$/i',
|
|
|
|
|
$local,
|
|
|
|
|
)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function fetchHtml(string $url): string
|
|
|
|
|
@ -501,6 +570,11 @@ class FacultyListHtmlAdapter implements CrawlerAdapterInterface
|
|
|
|
|
return $items;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$items = $this->extractFromSoaiFacultyList($html, $keywords, $sourceUrl);
|
|
|
|
|
if ($items !== []) {
|
|
|
|
|
return $items;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$items = $this->extractFromVsbFacultyTable($html, $keywords, $sourceUrl);
|
|
|
|
|
if ($items !== []) {
|
|
|
|
|
return $items;
|
|
|
|
|
@ -519,6 +593,76 @@ class FacultyListHtmlAdapter implements CrawlerAdapterInterface
|
|
|
|
|
return $this->extractFromStaffPanelList($html, $keywords, $sourceUrl);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 交大人工智能学院:/cn/facultydetails/... 卡片列表。
|
|
|
|
|
*
|
|
|
|
|
* @param list<string> $keywords
|
|
|
|
|
* @return list<CrawlItemDto>
|
|
|
|
|
*/
|
|
|
|
|
protected function extractFromSoaiFacultyList(string $html, array $keywords, string $sourceUrl): array
|
|
|
|
|
{
|
|
|
|
|
if (! preg_match_all(
|
|
|
|
|
'#<a\b([^>]*?/cn/facultydetails/[^\'"]+[^>]*)>([^<]+)</a>#u',
|
|
|
|
|
$html,
|
|
|
|
|
$matches,
|
|
|
|
|
PREG_SET_ORDER,
|
|
|
|
|
)) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$pageUniversity = $this->inferUniversityFromSource($sourceUrl, $html);
|
|
|
|
|
$defaultCollege = $this->inferCollegeFromPageTitle($html) ?? '人工智能学院';
|
|
|
|
|
$items = [];
|
|
|
|
|
$seen = [];
|
|
|
|
|
|
|
|
|
|
foreach ($matches as $match) {
|
|
|
|
|
$attrs = (string) $match[1];
|
|
|
|
|
$name = CrawlAuthorParser::cleanText($match[2]) ?? '';
|
|
|
|
|
if ($name === '' || ! $this->looksLikePersonName($name)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (! preg_match('#\bhref=[\'"]([^\'"]+)[\'"]#u', $attrs, $hrefMatch)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$href = html_entity_decode($hrefMatch[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
|
|
|
$profileUrl = $this->resolveUrl($href, $sourceUrl);
|
|
|
|
|
$dedupeKey = $profileUrl ?: ('name:'.md5($name));
|
|
|
|
|
if (isset($seen[$dedupeKey])) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$plain = trim($name.' '.($defaultCollege ?? ''));
|
|
|
|
|
if (! $this->matchesKeywords($plain, $keywords)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$academicTitle = null;
|
|
|
|
|
$windowStart = max(0, (int) strpos($html, $match[0]) - 50);
|
|
|
|
|
$window = substr($html, $windowStart, 500);
|
|
|
|
|
if (preg_match('#职称[::]\s*([^<]+)#u', $window, $titleMatch)) {
|
|
|
|
|
$academicTitle = CrawlAuthorParser::cleanText($titleMatch[1]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$seen[$dedupeKey] = true;
|
|
|
|
|
$items[] = $this->makeFacultyItem(
|
|
|
|
|
externalKey: 'faculty:'.md5($dedupeKey),
|
|
|
|
|
name: $name,
|
|
|
|
|
profileUrl: $profileUrl,
|
|
|
|
|
email: null,
|
|
|
|
|
affiliation: $defaultCollege,
|
|
|
|
|
universityName: $pageUniversity ?? '上海交通大学',
|
|
|
|
|
summary: $defaultCollege ? '单位:'.$defaultCollege : null,
|
|
|
|
|
keywords: $keywords,
|
|
|
|
|
academicTitle: $academicTitle,
|
|
|
|
|
platform: 'faculty_html_soai',
|
|
|
|
|
bio: null,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $items;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 南大 Sudy CMS:ul.news_list 内 news_title / news_title1 链接(frontier、ic 等)。
|
|
|
|
|
*
|
|
|
|
|
@ -656,7 +800,7 @@ class FacultyListHtmlAdapter implements CrawlerAdapterInterface
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (! preg_match_all(
|
|
|
|
|
'#<a\b([^>]*?)>.*?<div class="xm">([^<]+)</div>(.*?)</a>#su',
|
|
|
|
|
'#<li>\s*<a\b([^>]*?)>.*?<div class="xm">([^<]+)</div>(.*?)</a>#su',
|
|
|
|
|
$html,
|
|
|
|
|
$matches,
|
|
|
|
|
PREG_SET_ORDER,
|
|
|
|
|
@ -680,7 +824,12 @@ class FacultyListHtmlAdapter implements CrawlerAdapterInterface
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$profileUrl = $this->resolveUrl(html_entity_decode($hrefMatch[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'), $sourceUrl);
|
|
|
|
|
$href = html_entity_decode($hrefMatch[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
|
|
|
if (! $this->looksLikeTeacherProfileUrl($href, null)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$profileUrl = $this->resolveUrl($href, $sourceUrl);
|
|
|
|
|
$dedupeKey = $profileUrl ?: ('name:'.md5($name));
|
|
|
|
|
if (isset($seen[$dedupeKey])) {
|
|
|
|
|
continue;
|
|
|
|
|
@ -695,6 +844,7 @@ class FacultyListHtmlAdapter implements CrawlerAdapterInterface
|
|
|
|
|
if (preg_match('#研究方向:\s*<span>([^<]+)</span>#u', $tail, $fieldMatch)) {
|
|
|
|
|
$researchField = CrawlAuthorParser::cleanText($fieldMatch[1]);
|
|
|
|
|
}
|
|
|
|
|
$researchDirectionNames = $this->parseResearchDirectionNames($researchField ?? '');
|
|
|
|
|
|
|
|
|
|
$plain = trim($name.' '.($researchField ?? '').' '.($academicTitle ?? '').' '.($defaultCollege ?? ''));
|
|
|
|
|
if (! $this->matchesKeywords($plain, $keywords)) {
|
|
|
|
|
@ -719,7 +869,9 @@ class FacultyListHtmlAdapter implements CrawlerAdapterInterface
|
|
|
|
|
keywords: $keywords,
|
|
|
|
|
academicTitle: $academicTitle,
|
|
|
|
|
platform: 'faculty_html_ra',
|
|
|
|
|
bio: $researchField,
|
|
|
|
|
bio: null,
|
|
|
|
|
phone: null,
|
|
|
|
|
researchDirectionNames: $researchDirectionNames !== [] ? $researchDirectionNames : null,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -948,6 +1100,8 @@ class FacultyListHtmlAdapter implements CrawlerAdapterInterface
|
|
|
|
|
|
|
|
|
|
$academicTitle = CrawlAuthorParser::cleanText((string) ($art['exField2'] ?? ''));
|
|
|
|
|
$researchField = CrawlAuthorParser::cleanText((string) ($art['exField1'] ?? ''));
|
|
|
|
|
$phone = $this->normalizePhone((string) ($art['phone'] ?? ''));
|
|
|
|
|
$researchDirectionNames = $this->parseResearchDirectionNames($researchField ?? '');
|
|
|
|
|
$plain = trim($name.' '.($researchField ?? '').' '.($academicTitle ?? '').' '.($defaultCollege ?? ''));
|
|
|
|
|
if (! $this->matchesKeywords($plain, $keywords)) {
|
|
|
|
|
continue;
|
|
|
|
|
@ -971,7 +1125,9 @@ class FacultyListHtmlAdapter implements CrawlerAdapterInterface
|
|
|
|
|
keywords: $keywords,
|
|
|
|
|
academicTitle: $academicTitle,
|
|
|
|
|
platform: 'faculty_html_nju_wp',
|
|
|
|
|
bio: $researchField,
|
|
|
|
|
bio: null,
|
|
|
|
|
phone: $phone !== '' ? $phone : null,
|
|
|
|
|
researchDirectionNames: $researchDirectionNames !== [] ? $researchDirectionNames : null,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (count($items) >= $maxResults) {
|
|
|
|
|
@ -1517,15 +1673,23 @@ class FacultyListHtmlAdapter implements CrawlerAdapterInterface
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (preg_match('#/people/detail_new/\d+/?$#', $path)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (preg_match('#/cn/facultydetails/[^/]+/[^/]+/?$#', $path)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (preg_match('#/c\d+a\d+/page\.htm$#', $path)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (preg_match('#/(?:szll|zjzjs)/[^/]+\.(?:htm|html)$#', $path)) {
|
|
|
|
|
if (preg_match('#/(?:szll|zjzjs)(?:/[^/]+)*/[^/]+\.(?:htm|html)$#', $path)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (preg_match('#^(?:szll|zjzjs)/[^/]+\.(?:htm|html)$#', $path)) {
|
|
|
|
|
if (preg_match('#^(?:szll|zjzjs)(?:/[^/]+)*/[^/]+\.(?:htm|html)$#', $path)) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@ -1794,8 +1958,8 @@ class FacultyListHtmlAdapter implements CrawlerAdapterInterface
|
|
|
|
|
$lead = is_array($item->extra['lead_author'] ?? null) ? $item->extra['lead_author'] : [];
|
|
|
|
|
$changed = false;
|
|
|
|
|
|
|
|
|
|
if (empty($lead['academic_title']) && preg_match('/<em>\s*([^<]+?)\s*<\/em>/u', $html, $titleMatch)) {
|
|
|
|
|
$title = CrawlAuthorParser::cleanText($titleMatch[1]);
|
|
|
|
|
if (empty($lead['academic_title'])) {
|
|
|
|
|
$title = $this->extractAcademicTitleFromProfileHtml($html);
|
|
|
|
|
if ($title !== null && $title !== '') {
|
|
|
|
|
$lead['academic_title'] = $title;
|
|
|
|
|
$changed = true;
|
|
|
|
|
@ -1803,7 +1967,9 @@ class FacultyListHtmlAdapter implements CrawlerAdapterInterface
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (empty($lead['college']) && empty($lead['affiliation'])) {
|
|
|
|
|
$dept = $this->parseLabeledField($html, '所属二级机构');
|
|
|
|
|
$dept = $this->parseLabeledField($html, '所属二级机构')
|
|
|
|
|
?? $this->parseLabeledField($html, '所在单位')
|
|
|
|
|
?? $this->parseLabeledField($html, '所在研究所');
|
|
|
|
|
if ($dept !== null && $dept !== '') {
|
|
|
|
|
$lead['affiliation'] = $dept;
|
|
|
|
|
$lead['college'] = $dept;
|
|
|
|
|
@ -1811,6 +1977,32 @@ class FacultyListHtmlAdapter implements CrawlerAdapterInterface
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (empty($lead['phone'] ?? null) && empty($item->extra['phone'] ?? null)) {
|
|
|
|
|
$phone = $this->extractPhoneFromProfileHtml($html);
|
|
|
|
|
if ($phone !== null && $phone !== '') {
|
|
|
|
|
$lead['phone'] = $phone;
|
|
|
|
|
$changed = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (empty($lead['bio'] ?? null) && empty($item->extra['bio'] ?? null)) {
|
|
|
|
|
$bio = $this->extractBioFromProfileHtml($html);
|
|
|
|
|
if ($bio !== null && $bio !== '') {
|
|
|
|
|
$lead['bio'] = $bio;
|
|
|
|
|
$changed = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$existingDirections = $item->extra['research_direction_names'] ?? ($lead['research_direction_names'] ?? null);
|
|
|
|
|
if (! is_array($existingDirections) || $existingDirections === []) {
|
|
|
|
|
$directionText = $this->extractResearchDirectionTextFromProfileHtml($html);
|
|
|
|
|
$directionNames = $this->parseResearchDirectionNames($directionText ?? '');
|
|
|
|
|
if ($directionNames !== []) {
|
|
|
|
|
$lead['research_direction_names'] = $directionNames;
|
|
|
|
|
$changed = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (! $changed) {
|
|
|
|
|
return $item;
|
|
|
|
|
}
|
|
|
|
|
@ -1823,6 +2015,15 @@ class FacultyListHtmlAdapter implements CrawlerAdapterInterface
|
|
|
|
|
if (! empty($lead['college'])) {
|
|
|
|
|
$extra['college_name'] = $lead['college'];
|
|
|
|
|
}
|
|
|
|
|
if (! empty($lead['phone'])) {
|
|
|
|
|
$extra['phone'] = $lead['phone'];
|
|
|
|
|
}
|
|
|
|
|
if (! empty($lead['bio'])) {
|
|
|
|
|
$extra['bio'] = $lead['bio'];
|
|
|
|
|
}
|
|
|
|
|
if (! empty($lead['research_direction_names']) && is_array($lead['research_direction_names'])) {
|
|
|
|
|
$extra['research_direction_names'] = $lead['research_direction_names'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$authorsParsed = $item->authorsParsed;
|
|
|
|
|
if ($authorsParsed !== []) {
|
|
|
|
|
@ -1849,6 +2050,239 @@ class FacultyListHtmlAdapter implements CrawlerAdapterInterface
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function extractAcademicTitleFromProfileHtml(string $html): ?string
|
|
|
|
|
{
|
|
|
|
|
if (preg_match('/<em>\s*([^<]+?)\s*<\/em>/u', $html, $titleMatch)) {
|
|
|
|
|
$title = CrawlAuthorParser::cleanText($titleMatch[1]);
|
|
|
|
|
if ($title !== null && $title !== '') {
|
|
|
|
|
return $title;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach (['职称', '职务'] as $label) {
|
|
|
|
|
$title = $this->parseLabeledField($html, $label);
|
|
|
|
|
if ($title !== null && $title !== '') {
|
|
|
|
|
return $title;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (preg_match('#<div class="tit"[^>]*>.*?<span>([^<]+)</span>#su', $html, $match)) {
|
|
|
|
|
return CrawlAuthorParser::cleanText($match[1]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function extractPhoneFromProfileHtml(string $html): ?string
|
|
|
|
|
{
|
|
|
|
|
$scoped = $this->profileContentHtml($html);
|
|
|
|
|
|
|
|
|
|
$patterns = [
|
|
|
|
|
'/办公电话[::]\s*([0-9++\-—–()()\s]{6,40})/u',
|
|
|
|
|
'/联系电话[::]\s*([0-9++\-—–()()\s]{6,40})/u',
|
|
|
|
|
'/details-tag">\s*联系电话\s*<\/span>\s*<span class="details-con">\s*([^<]+)/u',
|
|
|
|
|
'/电话[::]\s*([0-9++\-—–()()\s]{6,40})/u',
|
|
|
|
|
'/Tel(?:ephone)?[::]\s*([0-9++\-—–()()\s]{6,40})/iu',
|
|
|
|
|
'/Phone[::]\s*([0-9++\-—–()()\s]{6,40})/iu',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
foreach ($patterns as $pattern) {
|
|
|
|
|
if (preg_match($pattern, $scoped, $match)) {
|
|
|
|
|
$phone = $this->normalizePhone($match[1]);
|
|
|
|
|
if ($phone !== '' && ! $this->isNoisePhone($phone)) {
|
|
|
|
|
return $phone;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function extractBioFromProfileHtml(string $html): ?string
|
|
|
|
|
{
|
|
|
|
|
$patterns = [
|
|
|
|
|
'#个人简介</(?:p|div|span)>\s*</div>\s*<div class="(?:txt|p|detail)"[^>]*>(.*?)</div>#su',
|
|
|
|
|
'#class="h3">\s*个人简介\s*</div>\s*<div class="p">(.*?)</div>#su',
|
|
|
|
|
'#class="name"><p>个人简介</p></div>\s*<div class="txt">(.*?)</div>#su',
|
|
|
|
|
'#info-subChannel"[^>]*>\s*<img[^>]*>\s*<span>\s*个人简介\s*</span>.*?<div class="info-ctx"[^>]*>(.*?)</div>\s*</div>#su',
|
|
|
|
|
'#个人简介[::]\s*</span>\s*<div class="grjj"[^>]*>(.*?)</div>#su',
|
|
|
|
|
'#class="jj"><span>个人简介[::]</span></div>\s*<div class="grjj"[^>]*>(.*?)</div>#su',
|
|
|
|
|
'#class="sz-jj[^"]*"[^>]*>.*?<div id="vsb_content[^"]*"[^>]*>(.*?)</div>#su',
|
|
|
|
|
'#主要学术成绩</[^>]+>.*?<div[^>]*>(.*?)</div>#su',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
foreach ($patterns as $pattern) {
|
|
|
|
|
if (preg_match($pattern, $html, $match)) {
|
|
|
|
|
$bio = $this->htmlToPlain($match[1]);
|
|
|
|
|
$bio = CrawlAuthorParser::cleanText($bio);
|
|
|
|
|
if ($bio !== null && mb_strlen($bio) >= 20) {
|
|
|
|
|
return Str::limit($bio, 2000, '');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 材料学院等:邮箱后的无标签长简介块
|
|
|
|
|
if (preg_match_all('#<div class="info[^"]*"[^>]*>(.*?)</div>#su', $html, $blocks)) {
|
|
|
|
|
foreach ($blocks[1] as $block) {
|
|
|
|
|
$plain = $this->htmlToPlain($block);
|
|
|
|
|
if (preg_match('/电子邮箱|通讯地址|所属二级机构|办公电话|联系电话/u', $plain)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
$plain = CrawlAuthorParser::cleanText($plain);
|
|
|
|
|
if ($plain !== null && mb_strlen($plain) >= 40) {
|
|
|
|
|
return Str::limit($plain, 2000, '');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function extractResearchDirectionTextFromProfileHtml(string $html): ?string
|
|
|
|
|
{
|
|
|
|
|
$patterns = [
|
|
|
|
|
// 材料学院 panel:研究方向 + ul>li
|
|
|
|
|
'#研究方向\s*</div>.*?panel-body[^>]*>\s*<ul>(.*?)</ul>#su',
|
|
|
|
|
// 交大电气等:js-box-item name + txt
|
|
|
|
|
'#<div class="js-box-item[^"]*"[^>]*>\s*<div class="name">\s*研究兴趣\s*</div>\s*<div class="txt">(.*?)</div>#su',
|
|
|
|
|
'#<div class="js-box-item[^"]*"[^>]*>\s*研究兴趣\s*(.*?)</div>#su',
|
|
|
|
|
// 自动化/集成电路:tit 研究方向 + detail/txt
|
|
|
|
|
'#<div class="tit"[^>]*>\s*(?:<p>)?\s*研究方向\s*(?:</p>)?\s*</div>\s*<div class="detail"[^>]*>\s*<div class="txt">(.*?)</div>#su',
|
|
|
|
|
'#研究方向</(?:p|div)></div>\s*<div class="detail"[^>]*>\s*<div class="txt">(.*?)</div>#su',
|
|
|
|
|
// 南大前沿:研究领域
|
|
|
|
|
'#info-subChannel"[^>]*>\s*<img[^>]*>\s*<span>\s*研究领域\s*</span>.*?<div class="info-ctx"[^>]*>(.*?)</div>\s*</div>#su',
|
|
|
|
|
// 南大集成电路:研究方向 + div.yj
|
|
|
|
|
'#研究方向[::]\s*</span>\s*<div class="yj">(.*?)</div>#su',
|
|
|
|
|
// 通用标签
|
|
|
|
|
'#研究(?:方向|领域|兴趣)[::]\s*</?(?:strong|span|b|label)?[^>]*>\s*([^<]{2,500})#u',
|
|
|
|
|
'#研究(?:方向|领域|兴趣)[::]\s*([^<\n]{2,500})#u',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
foreach ($patterns as $pattern) {
|
|
|
|
|
if (! preg_match($pattern, $html, $match)) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$chunk = (string) $match[1];
|
|
|
|
|
if (str_contains($chunk, '<li')) {
|
|
|
|
|
if (preg_match_all('#<li[^>]*>(.*?)</li>#su', $chunk, $lis)) {
|
|
|
|
|
$parts = [];
|
|
|
|
|
foreach ($lis[1] as $li) {
|
|
|
|
|
$text = CrawlAuthorParser::cleanText($this->htmlToPlain($li));
|
|
|
|
|
if ($text !== null && $text !== '') {
|
|
|
|
|
$parts[] = $text;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if ($parts !== []) {
|
|
|
|
|
return implode('、', $parts);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (preg_match_all('#<p[^>]*>(.*?)</p>#su', $chunk, $paragraphs) && count($paragraphs[1]) > 1) {
|
|
|
|
|
$parts = [];
|
|
|
|
|
$numberedParts = [];
|
|
|
|
|
foreach ($paragraphs[1] as $paragraph) {
|
|
|
|
|
$text = CrawlAuthorParser::cleanText($this->htmlToPlain($paragraph));
|
|
|
|
|
if ($text === null || $text === '') {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
$parts[] = $text;
|
|
|
|
|
if (preg_match('/^\d+[\.、.]/u', $text)) {
|
|
|
|
|
$numberedParts[] = preg_replace('/^\d+[\.、.]\s*/u', '', $text) ?? $text;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if ($numberedParts !== []) {
|
|
|
|
|
return implode('、', $numberedParts);
|
|
|
|
|
}
|
|
|
|
|
if ($parts !== []) {
|
|
|
|
|
return implode("\n", $parts);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$text = CrawlAuthorParser::cleanText($this->htmlToPlain($chunk));
|
|
|
|
|
if ($text !== null && $text !== '' && ! preg_match('/^(教育背景|工作经历|学术发表|项目资助)$/u', $text)) {
|
|
|
|
|
return $text;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @return list<string>
|
|
|
|
|
*/
|
|
|
|
|
protected function parseResearchDirectionNames(string $direction): array
|
|
|
|
|
{
|
|
|
|
|
$direction = trim($direction);
|
|
|
|
|
if ($direction === '') {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (preg_match_all('/\d+[\.、.]\s*([^0-9\n]{2,80}?)(?=\s*\d+[\.、.]|[;;]|$)/u', $direction, $numbered)) {
|
|
|
|
|
$parts = array_values(array_unique(array_filter(array_map(
|
|
|
|
|
fn (string $part) => trim($part, " \t\n\r\0\x0B;;,,。"),
|
|
|
|
|
$numbered[1],
|
|
|
|
|
), fn (string $part) => $part !== '' && mb_strlen($part) <= 80)));
|
|
|
|
|
if (count($parts) >= 2) {
|
|
|
|
|
return $parts;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$parts = preg_split('/[、,,;;\/\|\r\n]+/u', $direction) ?: [];
|
|
|
|
|
|
|
|
|
|
return array_values(array_unique(array_filter(array_map(
|
|
|
|
|
function (string $part): string {
|
|
|
|
|
$part = trim($part);
|
|
|
|
|
$part = preg_replace('/^\d+[\.、.]\s*/u', '', $part) ?? $part;
|
|
|
|
|
|
|
|
|
|
return trim($part, " \t\n\r\0\x0B;;,,。.");
|
|
|
|
|
},
|
|
|
|
|
$parts,
|
|
|
|
|
), fn (string $part) => $part !== '' && mb_strlen($part) <= 80)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function normalizePhone(string $phone): string
|
|
|
|
|
{
|
|
|
|
|
$phone = html_entity_decode($phone, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
|
|
|
$phone = trim(preg_replace('/\s+/u', ' ', $phone) ?? '');
|
|
|
|
|
$phone = trim($phone, " \t\n\r\0\x0B;;,,");
|
|
|
|
|
|
|
|
|
|
return $phone;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function isNoisePhone(string $phone): bool
|
|
|
|
|
{
|
|
|
|
|
$digits = preg_replace('/\D+/', '', $phone) ?? '';
|
|
|
|
|
if (strlen($digits) < 6) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 仅过滤明显占位号,保留 0 开头的座机号
|
|
|
|
|
return (bool) preg_match('/^(0{6,}|1{6,}|123456+)/', $digits);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected function profileContentHtml(string $html): string
|
|
|
|
|
{
|
|
|
|
|
$scoped = preg_replace(
|
|
|
|
|
'#<(?:footer|div)[^>]*(?:class|id)=[\'"][^\'"]*(?:footer|foot-top|foot_nav|foot-logo|copyright)[^\'"]*[\'"][^>]*>.*$#isu',
|
|
|
|
|
'',
|
|
|
|
|
$html,
|
|
|
|
|
);
|
|
|
|
|
if (! is_string($scoped) || $scoped === '') {
|
|
|
|
|
$scoped = $html;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 去掉页脚学院公共邮箱/电话残留
|
|
|
|
|
$scoped = preg_replace(
|
|
|
|
|
'#<(?:p|div)[^>]*>[^<]*(?:Copyright|版权所有|备案号)[\s\S]*$#iu',
|
|
|
|
|
'',
|
|
|
|
|
$scoped,
|
|
|
|
|
) ?? $scoped;
|
|
|
|
|
|
|
|
|
|
return $scoped;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param list<string> $keywords
|
|
|
|
|
* @return list<CrawlItemDto>
|
|
|
|
|
@ -1937,6 +2371,7 @@ class FacultyListHtmlAdapter implements CrawlerAdapterInterface
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param list<string> $keywords
|
|
|
|
|
* @param list<string>|null $researchDirectionNames
|
|
|
|
|
*/
|
|
|
|
|
protected function makeFacultyItem(
|
|
|
|
|
string $externalKey,
|
|
|
|
|
@ -1950,17 +2385,22 @@ class FacultyListHtmlAdapter implements CrawlerAdapterInterface
|
|
|
|
|
?string $academicTitle,
|
|
|
|
|
string $platform,
|
|
|
|
|
?string $bio = null,
|
|
|
|
|
?string $phone = null,
|
|
|
|
|
?array $researchDirectionNames = null,
|
|
|
|
|
): CrawlItemDto {
|
|
|
|
|
$college = $affiliation;
|
|
|
|
|
$directionNames = array_values(array_filter($researchDirectionNames ?? []));
|
|
|
|
|
$lead = [
|
|
|
|
|
'name' => $name,
|
|
|
|
|
'email' => $email,
|
|
|
|
|
'phone' => $phone,
|
|
|
|
|
'affiliation' => $college,
|
|
|
|
|
'college' => $college,
|
|
|
|
|
'university_name' => $universityName,
|
|
|
|
|
'academic_title' => $academicTitle,
|
|
|
|
|
'bio' => $bio,
|
|
|
|
|
'profile_url' => $profileUrl,
|
|
|
|
|
'research_direction_names' => $directionNames !== [] ? $directionNames : null,
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
return new CrawlItemDto(
|
|
|
|
|
@ -1975,7 +2415,9 @@ class FacultyListHtmlAdapter implements CrawlerAdapterInterface
|
|
|
|
|
'academic_title' => $academicTitle,
|
|
|
|
|
'college_name' => $college,
|
|
|
|
|
'bio' => $bio,
|
|
|
|
|
'phone' => $phone,
|
|
|
|
|
'profile_url' => $profileUrl,
|
|
|
|
|
'research_direction_names' => $directionNames !== [] ? $directionNames : null,
|
|
|
|
|
'lead_author' => $lead,
|
|
|
|
|
'keyword' => implode(' ', $keywords),
|
|
|
|
|
],
|
|
|
|
|
|