]+class="[^"]*title[^"]*"[^>]+title="([^"]+)"', 'IgnoreCase')
+ $title = if ($titleMatch.Success) { [System.Net.WebUtility]::HtmlDecode($titleMatch.Groups[1].Value) } else { '' }
+ [pscustomobject]@{ url = $href; title = $title; host = $candidateHost }
+ } | Select-Object -First 12)
+ } catch {
+ $errorText = $_.Exception.Message
+ }
+
+ $results.Add([pscustomobject]@{
+ id = $entry.id
+ name = $entry.name
+ category = $entry.category
+ referenceUrl = [string]$entry.referenceUrl
+ candidates = $candidates
+ searchError = $errorText
+ })
+
+ if (($index + 1) % 20 -eq 0 -or $index + 1 -eq $entries.Count) {
+ Write-Output ("Searched {0}/{1}" -f ($index + 1), $entries.Count)
+ }
+ Start-Sleep -Milliseconds 900
+}
+
+$results | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $OutputPath -Encoding UTF8
diff --git a/analysis/ziyan-url-recovery-20260908/collect_candidates_sm.ps1 b/analysis/ziyan-url-recovery-20260908/collect_candidates_sm.ps1
new file mode 100644
index 0000000..c69f73a
--- /dev/null
+++ b/analysis/ziyan-url-recovery-20260908/collect_candidates_sm.ps1
@@ -0,0 +1,55 @@
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$AssetPath,
+ [Parameter(Mandatory = $true)]
+ [string]$OutputPath
+)
+
+$ErrorActionPreference = 'Stop'
+$headers = @{ 'User-Agent' = 'Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 Chrome/122 Mobile Safari/537.36' }
+$blockedHosts = '(?i)(^|\.)(sm|uc|baidu|bing|sogou|sohu|zhihu|sulaixue|creditsailing|dakao8|kao900|gaoxiaoji|xsy-edu|zhila|wikipedia|douyin|bilibili|weixin|qq|csdn|toutiao|163)\.(com|cn|net|org)$'
+
+$catalog = Get-Content -LiteralPath $AssetPath -Raw | ConvertFrom-Json
+$entries = @($catalog.entries | Where-Object { $_.sourceType -eq 'ziyan' -and -not $_.url })
+$results = [System.Collections.Generic.List[object]]::new()
+
+for ($index = 0; $index -lt $entries.Count; $index++) {
+ $entry = $entries[$index]
+ $query = [uri]::EscapeDataString($entry.name + ' 教务系统 登录')
+ $searchUrl = "https://m.sm.cn/s?q=$query"
+ $candidates = @()
+ $errorText = ''
+ try {
+ $response = Invoke-WebRequest -Uri $searchUrl -UseBasicParsing -Headers $headers -TimeoutSec 25
+ $seen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
+ $candidates = @($response.Links | ForEach-Object {
+ $href = [System.Net.WebUtility]::HtmlDecode([string]$_.href)
+ if ($href -notmatch '^https?://') { return }
+ try { $uri = [uri]$href } catch { return }
+ $candidateHost = $uri.Host.ToLowerInvariant()
+ if (-not $candidateHost -or $candidateHost -match $blockedHosts -or -not $seen.Add($href)) { return }
+ $title = [System.Net.WebUtility]::HtmlDecode(
+ [regex]::Replace([string]$_.outerHTML, '<[^>]+>', '')
+ ).Trim()
+ [pscustomobject]@{ url = $href; title = $title; host = $candidateHost }
+ } | Select-Object -First 16)
+ } catch {
+ $errorText = $_.Exception.Message
+ }
+
+ $results.Add([pscustomobject]@{
+ id = $entry.id
+ name = $entry.name
+ category = $entry.category
+ referenceUrl = [string]$entry.referenceUrl
+ candidates = $candidates
+ searchError = $errorText
+ })
+
+ if (($index + 1) % 20 -eq 0 -or $index + 1 -eq $entries.Count) {
+ Write-Output ("Searched {0}/{1}" -f ($index + 1), $entries.Count)
+ }
+ Start-Sleep -Milliseconds 1000
+}
+
+$results | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $OutputPath -Encoding UTF8
diff --git a/analysis/ziyan-url-recovery-20260908/collect_candidates_so.ps1 b/analysis/ziyan-url-recovery-20260908/collect_candidates_so.ps1
new file mode 100644
index 0000000..c7cb09b
--- /dev/null
+++ b/analysis/ziyan-url-recovery-20260908/collect_candidates_so.ps1
@@ -0,0 +1,54 @@
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$AssetPath,
+ [Parameter(Mandatory = $true)]
+ [string]$OutputPath
+)
+
+$ErrorActionPreference = 'Stop'
+$headers = @{ 'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/122 Safari/537.36' }
+$blockedHosts = '(?i)(^|\.)(so|360|baidu|bing|sogou|sohu|zhihu|sulaixue|dakao8|kao900|gaoxiaoji|xsy-edu|zhila|wikipedia|douyin|bilibili|weixin|qq|csdn|toutiao|163)\.(com|cn|net|org)$'
+
+$catalog = Get-Content -LiteralPath $AssetPath -Raw | ConvertFrom-Json
+$entries = @($catalog.entries | Where-Object { $_.sourceType -eq 'ziyan' -and -not $_.url })
+$results = [System.Collections.Generic.List[object]]::new()
+
+for ($index = 0; $index -lt $entries.Count; $index++) {
+ $entry = $entries[$index]
+ $query = [uri]::EscapeDataString('"' + $entry.name + '" 教务系统 登录')
+ $searchUrl = "https://www.so.com/s?q=$query"
+ $candidates = @()
+ $errorText = ''
+ try {
+ $response = Invoke-WebRequest -Uri $searchUrl -UseBasicParsing -AllowInsecureRedirect -Headers $headers -TimeoutSec 25
+ $seen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
+ $candidates = @($response.Links | ForEach-Object {
+ $href = [System.Net.WebUtility]::HtmlDecode([string]$_.'data-mdurl')
+ if ($href -notmatch '^https?://') { return }
+ try { $uri = [uri]$href } catch { return }
+ $candidateHost = $uri.Host.ToLowerInvariant()
+ if (-not $candidateHost -or $candidateHost -match $blockedHosts -or -not $seen.Add($href)) { return }
+ $rawTitle = [regex]::Match([string]$_.outerHTML, '>(.*?)', 'IgnoreCase,Singleline').Groups[1].Value
+ $title = [System.Net.WebUtility]::HtmlDecode([regex]::Replace($rawTitle, '<[^>]+>', '')).Trim()
+ [pscustomobject]@{ url = $href; title = $title; host = $candidateHost }
+ } | Select-Object -First 12)
+ } catch {
+ $errorText = $_.Exception.Message
+ }
+
+ $results.Add([pscustomobject]@{
+ id = $entry.id
+ name = $entry.name
+ category = $entry.category
+ referenceUrl = [string]$entry.referenceUrl
+ candidates = $candidates
+ searchError = $errorText
+ })
+
+ if (($index + 1) % 20 -eq 0 -or $index + 1 -eq $entries.Count) {
+ Write-Output ("Searched {0}/{1}" -f ($index + 1), $entries.Count)
+ }
+ Start-Sleep -Milliseconds 1200
+}
+
+$results | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $OutputPath -Encoding UTF8
diff --git a/analysis/ziyan-url-recovery-20260908/collect_department_links.ps1 b/analysis/ziyan-url-recovery-20260908/collect_department_links.ps1
new file mode 100644
index 0000000..c930cd8
--- /dev/null
+++ b/analysis/ziyan-url-recovery-20260908/collect_department_links.ps1
@@ -0,0 +1,85 @@
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$InputDirectory,
+ [Parameter(Mandatory = $true)]
+ [string]$OutputPath,
+ [int]$ThrottleLimit = 8
+)
+
+$ErrorActionPreference = 'Stop'
+$records = @(Get-ChildItem -LiteralPath $InputDirectory -Filter 'official_links_*.json' |
+ Sort-Object Name |
+ ForEach-Object { Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json })
+
+$departments = foreach ($record in $records) {
+ foreach ($link in @($record.links)) {
+ if ([string]$link.text -notmatch '^(教务处|教务部|教学管理|教务管理|教学工作处|教务科研处|教科处|教务与科研处)(\s*\|.*)?$') { continue }
+ try { $uri = [uri][string]$link.url } catch { continue }
+ if ($uri.Scheme -notin @('http', 'https')) { continue }
+ [pscustomobject]@{
+ index = [int]$record.index
+ id = [string]$record.id
+ name = [string]$record.name
+ pageUrl = [string]$link.url
+ pageText = [string]$link.text
+ }
+ }
+}
+
+$results = $departments | ForEach-Object -Parallel {
+ $source = $_
+ $found = [System.Collections.Generic.List[object]]::new()
+ $errorText = ''
+ $statusCode = 0
+ $finalUrl = ''
+ try {
+ $headers = @{
+ 'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/128 Safari/537.36'
+ 'Accept-Language' = 'zh-CN,zh;q=0.9'
+ }
+ $response = Invoke-WebRequest -Uri ([string]$source.pageUrl) -UseBasicParsing -AllowInsecureRedirect -SkipCertificateCheck -MaximumRedirection 6 -TimeoutSec 22 -Headers $headers
+ $statusCode = [int]$response.StatusCode
+ $finalUrl = [string]$response.BaseResponse.RequestMessage.RequestUri.AbsoluteUri
+ $baseUri = [uri]$finalUrl
+ $seen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
+ $anchors = [regex]::Matches($response.Content, '
]*href=["''](?[^"'']+)["''][^>]*>(?[\s\S]*?)', 'IgnoreCase')
+ foreach ($anchor in $anchors) {
+ $rawHref = [System.Net.WebUtility]::HtmlDecode($anchor.Groups['href'].Value).Trim()
+ if (-not $rawHref -or $rawHref -match '^(javascript:|mailto:|tel:|#)') { continue }
+ try { $resolved = [uri]::new($baseUri, $rawHref) } catch { continue }
+ if ($resolved.Scheme -notin @('http', 'https')) { continue }
+ $label = [regex]::Replace($anchor.Groups['text'].Value, '<[^>]+>', ' ')
+ $label = [System.Net.WebUtility]::HtmlDecode($label)
+ $label = ($label -replace '\s+', ' ').Trim()
+ $url = $resolved.AbsoluteUri
+ $score = 0
+ if ($label -match '教务系统|教务管理系统|教务平台|学生入口|信息门户|智慧校园|数字校园|统一身份|师生登录|校园门户|融合门户|融校园') { $score += 12 }
+ if ($url -match '(?i)(^|[./_-])(jw|jwxt|jwgl|jwc|academic|student|portal|cas|authserver|ehall|e-hall|my)([./_:-]|$)') { $score += 7 }
+ if ($url -match '(?i)(login|slogin|default2?\.aspx|home\.aspx|jsxsd|jwweb|jwglxt|eams|lyuapServer)') { $score += 6 }
+ if ($score -ge 12 -and $seen.Add($url)) {
+ $found.Add([pscustomobject]@{ url = $url; text = $label; score = $score })
+ }
+ }
+ } catch {
+ $errorText = $_.Exception.Message
+ if ($_.Exception.Response -and $_.Exception.Response.RequestMessage) {
+ $finalUrl = [string]$_.Exception.Response.RequestMessage.RequestUri.AbsoluteUri
+ }
+ }
+
+ [pscustomobject]@{
+ index = [int]$source.index
+ id = [string]$source.id
+ name = [string]$source.name
+ pageText = [string]$source.pageText
+ pageUrl = [string]$source.pageUrl
+ finalUrl = $finalUrl
+ statusCode = $statusCode
+ links = @($found | Sort-Object score -Descending | Select-Object -First 20)
+ error = $errorText
+ }
+} -ThrottleLimit $ThrottleLimit
+
+$results = @($results | Sort-Object index, pageUrl)
+$results | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $OutputPath -Encoding UTF8
+Write-Output ("Crawled {0} teaching-office pages for {1} schools; {2} pages exposed candidate entry points." -f $results.Count, @($results | Select-Object -ExpandProperty index -Unique).Count, @($results | Where-Object { $_.links.Count -gt 0 }).Count)
diff --git a/analysis/ziyan-url-recovery-20260908/collect_official_links.ps1 b/analysis/ziyan-url-recovery-20260908/collect_official_links.ps1
new file mode 100644
index 0000000..1056836
--- /dev/null
+++ b/analysis/ziyan-url-recovery-20260908/collect_official_links.ps1
@@ -0,0 +1,98 @@
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$InputPath,
+ [Parameter(Mandatory = $true)]
+ [string]$OutputPath,
+ [int]$StartIndex = 0,
+ [int]$Count = 60,
+ [int]$ThrottleLimit = 8
+)
+
+$ErrorActionPreference = 'Stop'
+$entries = @(Get-Content -LiteralPath $InputPath -Raw | ConvertFrom-Json)
+$selection = @($entries | Where-Object { $_.index -ge $StartIndex } | Select-Object -First $Count)
+
+$results = $selection | ForEach-Object -Parallel {
+ $entry = $_
+ $links = [System.Collections.Generic.List[object]]::new()
+ $errorText = ''
+ $statusCode = 0
+ $finalUrl = ''
+
+ if ([string]::IsNullOrWhiteSpace([string]$entry.officialUrl)) {
+ $errorText = 'No official homepage is listed.'
+ } else {
+ try {
+ $headers = @{
+ 'User-Agent' = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/128 Safari/537.36'
+ 'Accept-Language' = 'zh-CN,zh;q=0.9'
+ }
+ $response = Invoke-WebRequest -Uri ([string]$entry.officialUrl) -UseBasicParsing -AllowInsecureRedirect -SkipCertificateCheck -MaximumRedirection 6 -TimeoutSec 20 -Headers $headers
+ $statusCode = [int]$response.StatusCode
+ $finalUrl = [string]$response.BaseResponse.RequestMessage.RequestUri.AbsoluteUri
+ $baseUri = [uri]$finalUrl
+ $seen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
+ $anchors = [regex]::Matches($response.Content, '
]*href=["''](?[^"'']+)["''][^>]*>(?[\s\S]*?)', 'IgnoreCase')
+
+ foreach ($anchor in $anchors) {
+ $rawHref = [System.Net.WebUtility]::HtmlDecode($anchor.Groups['href'].Value).Trim()
+ if (-not $rawHref -or $rawHref -match '^(javascript:|mailto:|tel:|#)') { continue }
+ try { $resolved = [uri]::new($baseUri, $rawHref) } catch { continue }
+ if ($resolved.Scheme -notin @('http', 'https')) { continue }
+
+ $label = [regex]::Replace($anchor.Groups['text'].Value, '<[^>]+>', ' ')
+ $label = [System.Net.WebUtility]::HtmlDecode($label)
+ $label = ($label -replace '\s+', ' ').Trim()
+ $url = $resolved.AbsoluteUri
+ $score = 0
+ $reasons = [System.Collections.Generic.List[string]]::new()
+
+ if ($label -match '教务系统|教务管理|教务平台|教务入口|学生入口|学生平台|信息门户|智慧校园|数字校园|统一身份|师生登录|校园门户|融合门户|融校园') {
+ $score += 12
+ $reasons.Add('anchor-login')
+ } elseif ($label -match '教务处|教学管理|学生服务|网上办事|校内服务|快速通道') {
+ $score += 4
+ $reasons.Add('anchor-related')
+ }
+
+ if ($url -match '(?i)(^|[./_-])(jw|jwxt|jwgl|jwc|academic|student|portal|cas|authserver|ehall|e-hall|my|vpn)([./_:-]|$)') {
+ $score += 7
+ $reasons.Add('url-pattern')
+ }
+ if ($url -match '(?i)(login|slogin|default2?\.aspx|home\.aspx|jsxsd|jwweb|jwglxt|eams|lyuapServer)') {
+ $score += 6
+ $reasons.Add('login-pattern')
+ }
+
+ if ($score -gt 0 -and $seen.Add($url)) {
+ $links.Add([pscustomobject]@{
+ url = $url
+ text = $label
+ score = $score
+ reasons = @($reasons)
+ })
+ }
+ }
+ } catch {
+ $errorText = $_.Exception.Message
+ if ($_.Exception.Response -and $_.Exception.Response.RequestMessage) {
+ $finalUrl = [string]$_.Exception.Response.RequestMessage.RequestUri.AbsoluteUri
+ }
+ }
+ }
+
+ [pscustomobject]@{
+ index = [int]$entry.index
+ id = [string]$entry.id
+ name = [string]$entry.name
+ officialUrl = [string]$entry.officialUrl
+ finalUrl = $finalUrl
+ statusCode = $statusCode
+ links = @($links | Sort-Object score -Descending | Select-Object -First 20)
+ error = $errorText
+ }
+} -ThrottleLimit $ThrottleLimit
+
+$results = @($results | Sort-Object index)
+$results | ConvertTo-Json -Depth 7 | Set-Content -LiteralPath $OutputPath -Encoding UTF8
+Write-Output ("Crawled {0} homepages; {1} returned candidate links; {2} failed or had no homepage." -f $results.Count, @($results | Where-Object { $_.links.Count -gt 0 }).Count, @($results | Where-Object error).Count)
diff --git a/analysis/ziyan-url-recovery-20260908/discover_same_domain_hosts.ps1 b/analysis/ziyan-url-recovery-20260908/discover_same_domain_hosts.ps1
new file mode 100644
index 0000000..f3cb122
--- /dev/null
+++ b/analysis/ziyan-url-recovery-20260908/discover_same_domain_hosts.ps1
@@ -0,0 +1,93 @@
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$InputPath,
+ [Parameter(Mandatory = $true)]
+ [string]$OutputPath,
+ [int]$ThrottleLimit = 64
+)
+
+$ErrorActionPreference = 'Stop'
+$entries = @(Get-Content -LiteralPath $InputPath -Raw | ConvertFrom-Json | Where-Object officialUrl)
+$prefixes = @(
+ 'jw', 'jwxt', 'jwgl', 'jwc', 'portal', 'cas', 'authserver', 'ehall', 'my', 'ids',
+ 'sso', 'uap', 'tyrz', 'i', 'one', 'smart', 'zhxy', 'jwapp', 'jiaowu', 'jwnew',
+ 'xk', 'xkxt', 'dean', 'student', 'service', 'urp',
+ 'jw1', 'jw2', 'jw3', 'jwg', 'jwmis', 'jws', 'jwweb', 'jwglxt', 'jwcxt',
+ 'jwa', 'jwb', 'edu', 'eams', 'xjw', 'xjwgl', 'xjwxt', 'xsjw', 'xsjwxt',
+ 'zfjw', 'zf', 'jxgl', 'jxt', 'jxjw', 'jwmh', 'hall', 'bsdt', 'campus',
+ 'digital', 'center', 'web', 'login', 'identity', 'id', 'auth', 'oauth',
+ 'graduate', 'yjs', 'yjsy', 'yjsgl', 'post', 'gmis', 'webvpn'
+)
+
+function Get-RootDomain([string]$hostName) {
+ $labels = @($hostName.Trim('.').ToLowerInvariant().Split('.'))
+ if ($labels.Count -lt 2) { return $hostName }
+ if ($labels.Count -ge 3 -and ($labels[-2] + '.' + $labels[-1]) -in @('edu.cn', 'com.cn', 'net.cn', 'org.cn')) {
+ return ($labels[-3..-1] -join '.')
+ }
+ return ($labels[-2..-1] -join '.')
+}
+
+$schools = foreach ($entry in $entries) {
+ try { $homepageUri = [uri][string]$entry.officialUrl } catch { continue }
+ if (-not $homepageUri.Host) { continue }
+ [pscustomobject]@{
+ index = [int]$entry.index
+ id = [string]$entry.id
+ name = [string]$entry.name
+ rootDomain = Get-RootDomain $homepageUri.Host
+ }
+}
+
+$roots = @($schools.rootDomain | Sort-Object -Unique)
+$hostTasks = foreach ($root in $roots) {
+ [pscustomobject]@{ rootDomain = $root; prefix = '_wildcard'; host = "no-such-courseschedule-probe.$root" }
+ foreach ($prefix in $prefixes) {
+ [pscustomobject]@{ rootDomain = $root; prefix = $prefix; host = "$prefix.$root" }
+ }
+}
+
+$resolved = $hostTasks | ForEach-Object -Parallel {
+ $task = $_
+ $addresses = @()
+ $errorText = ''
+ try {
+ $answer = [System.Net.Dns]::GetHostAddressesAsync([string]$task.host).WaitAsync([TimeSpan]::FromSeconds(4)).Result
+ $addresses = @($answer.IPAddressToString | Sort-Object -Unique)
+ } catch {
+ $errorText = $_.Exception.GetType().Name
+ }
+ [pscustomobject]@{
+ rootDomain = [string]$task.rootDomain
+ prefix = [string]$task.prefix
+ host = [string]$task.host
+ addresses = $addresses
+ error = $errorText
+ }
+} -ThrottleLimit $ThrottleLimit
+
+$wildcards = @{}
+foreach ($row in @($resolved | Where-Object prefix -eq '_wildcard')) {
+ $wildcards[$row.rootDomain] = @($row.addresses) -join ','
+}
+
+$result = foreach ($row in @($resolved | Where-Object prefix -ne '_wildcard')) {
+ if ($row.addresses.Count -eq 0) { continue }
+ $addressKey = @($row.addresses) -join ','
+ foreach ($school in @($schools | Where-Object rootDomain -eq $row.rootDomain)) {
+ [pscustomobject]@{
+ index = [int]$school.index
+ id = [string]$school.id
+ name = [string]$school.name
+ rootDomain = [string]$row.rootDomain
+ prefix = [string]$row.prefix
+ host = [string]$row.host
+ addresses = @($row.addresses)
+ wildcardMatch = ($wildcards[$row.rootDomain] -and $wildcards[$row.rootDomain] -eq $addressKey)
+ }
+ }
+}
+
+$result = @($result | Sort-Object index, prefix)
+$result | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $OutputPath -Encoding UTF8
+Write-Output ("Resolved {0} common subdomains for {1} schools; {2} differ from each root's wildcard answer." -f $result.Count, @($result.index | Sort-Object -Unique).Count, @($result | Where-Object { -not $_.wildcardMatch }).Count)
diff --git a/analysis/ziyan-url-recovery-20260908/fetch_official_homepages.ps1 b/analysis/ziyan-url-recovery-20260908/fetch_official_homepages.ps1
new file mode 100644
index 0000000..75bc0ac
--- /dev/null
+++ b/analysis/ziyan-url-recovery-20260908/fetch_official_homepages.ps1
@@ -0,0 +1,66 @@
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$AssetPath,
+ [Parameter(Mandatory = $true)]
+ [string]$OutputPath
+)
+
+$ErrorActionPreference = 'Stop'
+$dataset = 'RenzhiMind%2FEduCN'
+$headers = @{ 'User-Agent' = 'CourseSchedule-directory-research/1.0' }
+$rows = [System.Collections.Generic.List[object]]::new()
+
+foreach ($split in @(
+ @{ Name = 'general'; Count = 2919 },
+ @{ Name = 'adult'; Count = 248 }
+)) {
+ for ($offset = 0; $offset -lt $split.Count; $offset += 100) {
+ $apiUrl = "https://datasets-server.huggingface.co/rows?dataset=$dataset&config=default&split=$($split.Name)&offset=$offset&length=100"
+ $response = Invoke-RestMethod -Uri $apiUrl -Headers $headers -TimeoutSec 45
+ foreach ($item in $response.rows) {
+ $rows.Add($item.row)
+ }
+ }
+}
+
+$byName = @{}
+foreach ($row in $rows) {
+ $byName[[string]$row.'学校名称'] = $row
+}
+
+$aliases = @{
+ '东北石油大学 - 研究生' = '东北石油大学'
+ '甘肃警察职业学院' = '甘肃警察学院'
+ '邢台医学院(原邢台医学高等专科学校)' = '邢台医学院'
+}
+
+$catalog = Get-Content -LiteralPath $AssetPath -Raw | ConvertFrom-Json
+$entries = @($catalog.entries | Where-Object { $_.sourceType -eq 'ziyan' -and -not $_.url })
+$result = for ($index = 0; $index -lt $entries.Count; $index++) {
+ $entry = $entries[$index]
+ $datasetName = if ($aliases.ContainsKey([string]$entry.name)) {
+ $aliases[[string]$entry.name]
+ } else {
+ [string]$entry.name
+ }
+ $row = $byName[$datasetName]
+ $officialUrl = if ($null -ne $row -and [string]$row.'学校官网' -ne '-') {
+ [string]$row.'学校官网'
+ } else {
+ ''
+ }
+
+ [pscustomobject]@{
+ index = $index
+ id = [string]$entry.id
+ name = [string]$entry.name
+ category = [string]$entry.category
+ datasetName = $datasetName
+ schoolCode = if ($null -ne $row) { [string]$row.'学校标识码' } else { '' }
+ officialUrl = $officialUrl
+ referenceUrl = [string]$entry.referenceUrl
+ }
+}
+
+$result | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $OutputPath -Encoding UTF8
+Write-Output ("Matched {0}/{1} entries to the official-homepage dataset ({2} usable URLs)." -f @($result | Where-Object schoolCode).Count, $entries.Count, @($result | Where-Object officialUrl).Count)
diff --git a/analysis/ziyan-url-recovery-20260908/probe_official_candidates.ps1 b/analysis/ziyan-url-recovery-20260908/probe_official_candidates.ps1
new file mode 100644
index 0000000..7630b03
--- /dev/null
+++ b/analysis/ziyan-url-recovery-20260908/probe_official_candidates.ps1
@@ -0,0 +1,107 @@
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$InputDirectory,
+ [Parameter(Mandatory = $true)]
+ [string]$OutputPath,
+ [int]$ThrottleLimit = 8
+)
+
+$ErrorActionPreference = 'Stop'
+$records = @(Get-ChildItem -LiteralPath $InputDirectory -Filter 'official_links_*.json' |
+ Sort-Object Name |
+ ForEach-Object { Get-Content -LiteralPath $_.FullName -Raw | ConvertFrom-Json })
+$candidateLabels = '教务系统|教务管理系统|教务管理平台|教务平台|教务管理网|学生入口|统一身份认证|校园门户|信息门户|融合门户|融校园|智慧校园平台|数字校园平台'
+$blockedHosts = '(?i)(^|\.)(baidu|bing|so|sogou|360|video)\.(com|cn|net)$'
+
+$candidates = foreach ($record in $records) {
+ foreach ($link in @($record.links)) {
+ if ([int]$link.score -lt 12 -or [string]$link.text -notmatch $candidateLabels) { continue }
+ try { $uri = [uri][string]$link.url } catch { continue }
+ $candidateHost = $uri.Host.ToLowerInvariant()
+ if (-not $candidateHost -or $candidateHost -match $blockedHosts) { continue }
+ if ($candidateHost -match '^[0-9.]+$' -or $candidateHost -match '^(10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)') { continue }
+
+ [pscustomobject]@{
+ index = [int]$record.index
+ id = [string]$record.id
+ name = [string]$record.name
+ officialUrl = [string]$record.officialUrl
+ sourceText = [string]$link.text
+ sourceScore = [int]$link.score
+ url = [string]$link.url
+ }
+ }
+}
+
+$results = $candidates | ForEach-Object -Parallel {
+ $candidate = $_
+ $statusCode = 0
+ $finalUrl = ''
+ $title = ''
+ $contentLength = 0
+ $profile = 'topology'
+ $signals = [System.Collections.Generic.List[string]]::new()
+ $errorText = ''
+
+ try {
+ $headers = @{
+ 'User-Agent' = 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/128 Mobile Safari/537.36'
+ 'Accept-Language' = 'zh-CN,zh;q=0.9'
+ }
+ $response = Invoke-WebRequest -Uri ([string]$candidate.url) -UseBasicParsing -AllowInsecureRedirect -SkipCertificateCheck -MaximumRedirection 8 -TimeoutSec 25 -Headers $headers
+ $statusCode = [int]$response.StatusCode
+ $finalUrl = [string]$response.BaseResponse.RequestMessage.RequestUri.AbsoluteUri
+ $contentLength = [int]$response.Content.Length
+ $titleMatch = [regex]::Match($response.Content, '
]*>(?[\s\S]*?)', 'IgnoreCase')
+ $title = [System.Net.WebUtility]::HtmlDecode([regex]::Replace($titleMatch.Groups['title'].Value, '<[^>]+>', ' '))
+ $title = ($title -replace '\s+', ' ').Trim()
+ $haystack = ([string]$candidate.url + "`n" + $finalUrl + "`n" + $title + "`n" + $response.Content)
+
+ if ($haystack -match '(?i)(jwglxt|xtgl/login_slogin|正方(软件|教务)|zftal)') {
+ $profile = 'zhengfang'
+ $signals.Add('zhengfang')
+ } elseif ($haystack -match '(?i)(jsxsd|强智(科技|教务)|湖南强智)') {
+ $profile = 'qiangzhi'
+ $signals.Add('qiangzhi')
+ } elseif ($haystack -match '(?i)(jwweb|青果(软件|教务)|kingosoft)') {
+ $profile = 'kingosoft_new'
+ $signals.Add('kingosoft')
+ } elseif ($haystack -match '(?i)(/eams(?:/|\b)|EAMS教务)') {
+ $profile = 'eams'
+ $signals.Add('eams')
+ } elseif ($haystack -match '(?i)(\.jw\.chaoxing\.com|超星教务)') {
+ $profile = 'chaoxing'
+ $signals.Add('chaoxing')
+ }
+
+ if ($haystack -match '(?i)(login|登录|统一身份|cas|authserver|lyuapServer|教务)') { $signals.Add('login-page') }
+ if ($haystack -match '(?i)(课表|课程表|教学安排|我的课表)') { $signals.Add('timetable-copy') }
+ } catch {
+ $errorText = $_.Exception.Message
+ if ($_.Exception.Response -and $_.Exception.Response.RequestMessage) {
+ $finalUrl = [string]$_.Exception.Response.RequestMessage.RequestUri.AbsoluteUri
+ try { $statusCode = [int]$_.Exception.Response.StatusCode } catch { }
+ }
+ }
+
+ [pscustomobject]@{
+ index = [int]$candidate.index
+ id = [string]$candidate.id
+ name = [string]$candidate.name
+ officialUrl = [string]$candidate.officialUrl
+ sourceText = [string]$candidate.sourceText
+ sourceScore = [int]$candidate.sourceScore
+ url = [string]$candidate.url
+ statusCode = $statusCode
+ finalUrl = $finalUrl
+ title = $title
+ contentLength = $contentLength
+ profile = $profile
+ signals = @($signals)
+ error = $errorText
+ }
+} -ThrottleLimit $ThrottleLimit
+
+$results = @($results | Sort-Object index, @{ Expression = 'sourceScore'; Descending = $true })
+$results | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $OutputPath -Encoding UTF8
+Write-Output ("Probed {0} homepage-published candidates: {1} reached HTTP 2xx, {2} exposed a known platform fingerprint." -f $results.Count, @($results | Where-Object { $_.statusCode -ge 200 -and $_.statusCode -lt 300 }).Count, @($results | Where-Object { $_.profile -ne 'topology' }).Count)
diff --git a/analysis/ziyan-url-recovery-20260908/probe_same_domain_hosts.ps1 b/analysis/ziyan-url-recovery-20260908/probe_same_domain_hosts.ps1
new file mode 100644
index 0000000..a38cbff
--- /dev/null
+++ b/analysis/ziyan-url-recovery-20260908/probe_same_domain_hosts.ps1
@@ -0,0 +1,102 @@
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$InputPath,
+ [Parameter(Mandatory = $true)]
+ [string]$OutputPath,
+ [int]$ThrottleLimit = 20
+)
+
+$ErrorActionPreference = 'Stop'
+$records = @(Get-Content -LiteralPath $InputPath -Raw | ConvertFrom-Json | Where-Object { -not $_.wildcardMatch })
+$uniqueHosts = @($records | Group-Object host | ForEach-Object { $_.Group[0] })
+
+$probes = $uniqueHosts | ForEach-Object -Parallel {
+ $candidate = $_
+ $statusCode = 0
+ $requestedUrl = ''
+ $finalUrl = ''
+ $title = ''
+ $contentLength = 0
+ $profile = 'topology'
+ $signals = [System.Collections.Generic.List[string]]::new()
+ $errorText = ''
+
+ foreach ($scheme in @('https', 'http')) {
+ $requestedUrl = "${scheme}://$($candidate.host)/"
+ try {
+ $headers = @{
+ 'User-Agent' = 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 Chrome/128 Mobile Safari/537.36'
+ 'Accept-Language' = 'zh-CN,zh;q=0.9'
+ }
+ $response = Invoke-WebRequest -Uri $requestedUrl -UseBasicParsing -AllowInsecureRedirect -SkipCertificateCheck -MaximumRedirection 8 -TimeoutSec 15 -Headers $headers
+ $statusCode = [int]$response.StatusCode
+ $finalUrl = [string]$response.BaseResponse.RequestMessage.RequestUri.AbsoluteUri
+ $contentLength = [int]$response.Content.Length
+ $titleMatch = [regex]::Match($response.Content, ']*>(?[\s\S]*?)', 'IgnoreCase')
+ $title = [System.Net.WebUtility]::HtmlDecode([regex]::Replace($titleMatch.Groups['title'].Value, '<[^>]+>', ' '))
+ $title = ($title -replace '\s+', ' ').Trim()
+ $haystack = ($requestedUrl + "`n" + $finalUrl + "`n" + $title + "`n" + $response.Content)
+ if ($haystack -match '(?i)(jwglxt|xtgl/login_slogin|正方(软件|教务)|zftal)') {
+ $profile = 'zhengfang'; $signals.Add('zhengfang')
+ } elseif ($haystack -match '(?i)(jsxsd|强智(科技|教务)|湖南强智)') {
+ $profile = 'qiangzhi'; $signals.Add('qiangzhi')
+ } elseif ($haystack -match '(?i)(jwweb|青果(软件|教务)|kingosoft)') {
+ $profile = 'kingosoft_new'; $signals.Add('kingosoft')
+ } elseif ($haystack -match '(?i)(/eams(?:/|\b)|EAMS教务)') {
+ $profile = 'eams'; $signals.Add('eams')
+ } elseif ($haystack -match '(?i)(\.jw\.chaoxing\.com|超星教务)') {
+ $profile = 'chaoxing'; $signals.Add('chaoxing')
+ }
+ if ($haystack -match '(?i)(login|登录|统一身份|cas|authserver|lyuapServer|教务)') { $signals.Add('login-page') }
+ if ($haystack -match '(?i)(课表|课程表|教学安排|我的课表)') { $signals.Add('timetable-copy') }
+ $errorText = ''
+ break
+ } catch {
+ $errorText = $_.Exception.Message
+ if ($_.Exception.Response -and $_.Exception.Response.RequestMessage) {
+ $finalUrl = [string]$_.Exception.Response.RequestMessage.RequestUri.AbsoluteUri
+ try { $statusCode = [int]$_.Exception.Response.StatusCode } catch { }
+ }
+ }
+ }
+
+ [pscustomobject]@{
+ host = [string]$candidate.host
+ prefix = [string]$candidate.prefix
+ requestedUrl = $requestedUrl
+ statusCode = $statusCode
+ finalUrl = $finalUrl
+ title = $title
+ contentLength = $contentLength
+ profile = $profile
+ signals = @($signals)
+ error = $errorText
+ }
+} -ThrottleLimit $ThrottleLimit
+
+$byHost = @{}
+foreach ($probe in $probes) { $byHost[$probe.host] = $probe }
+$result = foreach ($record in $records) {
+ $probe = $byHost[[string]$record.host]
+ [pscustomobject]@{
+ index = [int]$record.index
+ id = [string]$record.id
+ name = [string]$record.name
+ rootDomain = [string]$record.rootDomain
+ prefix = [string]$record.prefix
+ host = [string]$record.host
+ addresses = @($record.addresses)
+ requestedUrl = [string]$probe.requestedUrl
+ statusCode = [int]$probe.statusCode
+ finalUrl = [string]$probe.finalUrl
+ title = [string]$probe.title
+ contentLength = [int]$probe.contentLength
+ profile = [string]$probe.profile
+ signals = @($probe.signals)
+ error = [string]$probe.error
+ }
+}
+
+$result = @($result | Sort-Object index, prefix)
+$result | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $OutputPath -Encoding UTF8
+Write-Output ("Probed {0} non-wildcard hosts for {1} schools; {2} returned HTTP 2xx and {3} had login/platform signals." -f $probes.Count, @($result.index | Sort-Object -Unique).Count, @($probes | Where-Object { $_.statusCode -ge 200 -and $_.statusCode -lt 300 }).Count, @($probes | Where-Object { $_.signals.Count -gt 0 }).Count)
diff --git a/analysis/ziyan-url-recovery-20260908/verified_urls.json b/analysis/ziyan-url-recovery-20260908/verified_urls.json
new file mode 100644
index 0000000..8611e74
--- /dev/null
+++ b/analysis/ziyan-url-recovery-20260908/verified_urls.json
@@ -0,0 +1,120 @@
+[
+ {"id":"wakeup_3fab48604a90b456","name":"安徽工业经济职业技术学院","url":"http://jw.ahiec.edu.cn:65534/eams/loginExt.action","profile":"eams","evidence":"http://www.ahiec.edu.cn/jwc/"},
+ {"id":"wakeup_7f277a002013dfc2","name":"安徽警官职业学院","url":"http://jw.ahjgxy.edu.cn/login.aspx","profile":"topology","evidence":"http://jw.ahjgxy.edu.cn/login.aspx"},
+ {"id":"wakeup_e2c77dd823aae3f1","name":"安徽三联学院","url":"https://jwxt.slu.edu.cn:4060/ahsljw/cas/login.action","profile":"kingosoft_new","evidence":"https://jiaowu.slu.edu.cn/"},
+ {"id":"wakeup_41acb501c9d4ecea","name":"安徽水利水电职业技术学院","url":"https://ehall.ahsdxy.edu.cn/","profile":"topology","evidence":"https://ehall.ahsdxy.edu.cn/"},
+ {"id":"wakeup_1de8e8d06fe2d636","name":"北京政法职业学院","url":"https://portal.bcpl.edu.cn/log/one/login.aspx","profile":"topology","evidence":"https://portal.bcpl.edu.cn/log/one/login.aspx"},
+ {"id":"wakeup_155981147cbf149e","name":"赤峰工业职业技术学院","url":"https://portal.cfgy.cn/","profile":"topology","evidence":"https://portal.cfgy.cn/"},
+ {"id":"wakeup_a5120eb1bbc3f4d4","name":"重庆电信职业学院","url":"https://jwgl.cqtc.edu.cn/","profile":"topology","evidence":"https://jwgl.cqtc.edu.cn/"},
+ {"id":"wakeup_972789748425acab","name":"长沙环境保护职业技术学院","url":"http://jwxt.cshbzy.com:18080/","profile":"topology","evidence":"https://www.hbcollege.com.cn/p1/index.html"},
+ {"id":"wakeup_0907a3cfcf685e64","name":"长沙卫生职业学院","url":"https://portal.cswszy.com/","profile":"topology","evidence":"https://portal.cswszy.com/"},
+ {"id":"wakeup_f1e7bb470c60f502","name":"大理农林职业技术学院","url":"http://jw.dlafc.edu.cn/","profile":"topology","evidence":"https://www.dlafc.edu.cn/"},
+ {"id":"wakeup_839b0306eadd4fe4","name":"大连医科大学中山学院","url":"https://jw.dmuzs.edu.cn/","profile":"topology","evidence":"https://jw.dmuzs.edu.cn/"},
+ {"id":"wakeup_3d75f8adc763cee2","name":"鄂尔多斯职业学院","url":"http://portal.open.ordosvc.cn:8009/frontend/center_portal/home/index.html","profile":"topology","evidence":"https://www.ordosvc.cn/"},
+ {"id":"wakeup_7366a5ef7ee17c9f","name":"甘肃警察学院","url":"https://jwxt.gsjcxy.edu.cn/admin/login","profile":"chaoxing","evidence":"https://www.gsjcxy.edu.cn/"},
+ {"id":"wakeup_fd3558fff133fb18","name":"甘肃警察职业学院","url":"https://jwxt.gsjcxy.edu.cn/admin/login","profile":"chaoxing","evidence":"https://www.gsjcxy.edu.cn/"},
+ {"id":"wakeup_49bb37b64a4073fd","name":"广东工商职业技术大学","url":"https://jw.gdbtu.edu.cn/","profile":"kingosoft_new","evidence":"https://www.gdbtu.edu.cn/"},
+ {"id":"wakeup_55c4a629ee20d6b2","name":"广西培贤国际职业学院","url":"https://course.peixianedu.com/","profile":"topology","evidence":"https://www.peixianedu.cn/"},
+ {"id":"wakeup_f3f1ed668e9c9190","name":"桂林信息工程职业学院","url":"https://igx.glcie.edu.cn:8000/","profile":"topology","evidence":"https://www.glcie.edu.cn/"},
+ {"id":"wakeup_f0e2d0239991725c","name":"合肥共达职业技术学院","url":"http://jwweb.hfgdxy.cn:8082/jwweb/","profile":"kingosoft_new","evidence":"https://www.hfgdxy.cn/"},
+ {"id":"wakeup_4cfb37021eb1a293","name":"河北旅游职业学院","url":"https://portal.hbly.edu.cn/","profile":"topology","evidence":"https://www.hbly.edu.cn/"},
+ {"id":"wakeup_8d5d0f50a784e6bb","name":"河北女子职业技术学院","url":"https://uap.hebnzxy.com/","profile":"topology","evidence":"https://uap.hebnzxy.com/"},
+ {"id":"wakeup_d51b5a65b3e68c98","name":"河北石油职业技术大学","url":"https://ehall.hebpu.edu.cn/","profile":"topology","evidence":"https://ehall.hebpu.edu.cn/"},
+ {"id":"wakeup_3dd6492079426e45","name":"河北石油职业技术学院","url":"https://ehall.hebpu.edu.cn/","profile":"topology","evidence":"https://ehall.hebpu.edu.cn/"},
+ {"id":"wakeup_2573a1b5fd82b492","name":"河北体育学院","url":"https://portal.hepec.edu.cn/","profile":"topology","evidence":"https://www.hepec.edu.cn/"},
+ {"id":"wakeup_4787af74f0ad04a2","name":"河北中医药大学","url":"https://portal.hebcm.edu.cn/","profile":"topology","evidence":"https://portal.hebcm.edu.cn/"},
+ {"id":"wakeup_31e31cf00f91330d","name":"河南司法警官职业学院","url":"https://portal.hnsfjy.edu.cn/","profile":"topology","evidence":"https://portal.hnsfjy.edu.cn/"},
+ {"id":"wakeup_7ae88dd32c7fac8c","name":"黑龙江农业职业技术学院","url":"https://hnzy.jw.chaoxing.com/admin/login/","profile":"chaoxing","evidence":"http://jw.hljnzy.edu.cn/"},
+ {"id":"wakeup_94d9d9370c6487f4","name":"衡阳科技职业学院","url":"https://hykjxy.cn/login/","profile":"topology","evidence":"https://www.hykjxy.cn/"},
+ {"id":"wakeup_d6f21b403ff62bb6","name":"湖南大众传媒职业技术学院","url":"http://ehall.hnmmc.cn/new/index.html","profile":"topology","evidence":"https://www.hnmmc.cn/"},
+ {"id":"wakeup_9563fa7b50d3e6d8","name":"惠州工程职业学院","url":"https://ehall.hzevc.edu.cn/","profile":"topology","evidence":"https://ehall.hzevc.edu.cn/"},
+ {"id":"wakeup_2d0012121ed9ff48","name":"江西传媒职业学院","url":"https://portal.jxcmzyxy.edu.cn/","profile":"topology","evidence":"https://www.jxcmzyxy.edu.cn/"},
+ {"id":"wakeup_97f7aa3d9f215d2c","name":"江西陶瓷工艺美术职业技术学院","url":"http://zfjw.jxgymy.com:86/sso/zfiotlogin","profile":"zhengfang","authenticationUrls":["http://zfca.jxgymy.com:8080/cas/"],"evidence":"https://jwc.jacti.edu.cn/szhxy"},
+ {"id":"wakeup_6a3cdee187dabdcc","name":"江西冶金职业技术学院","url":"https://zhxy.jxyjxy.com:8078/","profile":"topology","evidence":"https://www.jxyjxy.com/"},
+ {"id":"wakeup_7a517e526cbb4ce8","name":"揭阳职业技术学院","url":"http://portal.jyc.edu.cn/","profile":"topology","evidence":"https://www.jyc.edu.cn/"},
+ {"id":"wakeup_48ad4d2d03221acb","name":"荆州理工职业学院","url":"https://ehall.jzlg.edu.cn/index.html","profile":"eams","evidence":"https://www.jzlg.edu.cn/"},
+ {"id":"wakeup_130743487c2f214b","name":"辽宁工程职业学院","url":"https://lngczyxy.jw.chaoxing.com/admin/login","profile":"chaoxing","evidence":"https://www.lngczyxy.com/"},
+ {"id":"wakeup_e9d93e416b23eac5","name":"辽宁广告职业学院","url":"https://jwxt.ggxy.com/","profile":"topology","evidence":"https://jwxt.ggxy.com/"},
+ {"id":"wakeup_bda78bd9eb0bfda6","name":"娄底职业技术学院","url":"http://jwgl.ldzy.edu.cn/admin/login","profile":"chaoxing","evidence":"https://www.ldzy.edu.cn/"},
+ {"id":"wakeup_f00fad157efe473b","name":"罗定职业技术学院","url":"https://ehall.ldpoly.edu.cn/","profile":"topology","evidence":"https://ehall.ldpoly.edu.cn/"},
+ {"id":"wakeup_4bd96729303f930c","name":"洛阳师范学院","url":"https://i.lynu.edu.cn/","profile":"topology","evidence":"https://i.lynu.edu.cn/"},
+ {"id":"wakeup_49654df372dfc18e","name":"洛阳职业技术学院","url":"https://portal.lypt.edu.cn/","profile":"topology","evidence":"https://www.lypt.edu.cn/"},
+ {"id":"wakeup_c6e6ca06a64e7cf8","name":"民办合肥经济技术职业学院","url":"https://hfjjxy.jw.chaoxing.com/admin/login","profile":"chaoxing","evidence":"https://jwc.hfjjxy.edu.cn/"},
+ {"id":"wakeup_0e738c00509d49ff","name":"闽南理工学院","url":"https://zfjw.mnust.edu.cn:30443/jwglxt/xtgl/login_slogin.html","profile":"zhengfang","evidence":"https://jwc.mnust.edu.cn/"},
+ {"id":"wakeup_7b180ce22a109959","name":"宁德职业技术学院","url":"https://jwxt.ndzy.cn:8310/","profile":"topology","evidence":"https://www.ndzy.cn/"},
+ {"id":"wakeup_56386d6c178e5ead","name":"青海理工学院","url":"https://jw.qhit.edu.cn/","profile":"topology","evidence":"https://jw.qhit.edu.cn/"},
+ {"id":"wakeup_9fd91d6c8b16bc1c","name":"曲靖职业技术学院","url":"https://i.qjzyxy.com/","profile":"topology","evidence":"https://www.qjzyxy.com/"},
+ {"id":"wakeup_797aec176a54a695","name":"泉州工程职业技术学院","url":"http://jwxt.qzgcu.com/","profile":"qiangzhi","evidence":"https://www.qzgcu.com/"},
+ {"id":"wakeup_e3e1d63e2ef529c1","name":"泉州工艺美术职业学院","url":"http://jwxt.qzgymy.cn/admin/login","profile":"chaoxing","evidence":"https://www.qzgymy.cn/"},
+ {"id":"wakeup_9424f09329294b26","name":"山西财贸职业技术学院","url":"http://jw.sxcmvc.com/","profile":"topology","evidence":"http://www.sxcmvc.com/gljg/xzjg/jwc/bmgk.htm"},
+ {"id":"wakeup_33fb98b9691060c9","name":"山西水利职业技术学院","url":"http://jwgl.sxsy.com.cn/","profile":"kingosoft_new","evidence":"https://www.sxsy.com.cn/"},
+ {"id":"wakeup_7815ea33b4effc17","name":"陕西农林职业技术大学","url":"https://jwxt.ylvtc.com/jwglxt/xtgl/login_slogin.html","profile":"zhengfang","evidence":"https://www.ylvtc.com/"},
+ {"id":"wakeup_7192af027b124be6","name":"上海农林职业技术学院","url":"https://jw.shafc.edu.cn/","profile":"topology","evidence":"https://jw.shafc.edu.cn/"},
+ {"id":"wakeup_0332cfadd3c7a511","name":"韶关市职工大学","url":"https://portal.sggaoji.com/","profile":"topology","evidence":"https://portal.sggaoji.com/"},
+ {"id":"wakeup_00ae6f969bc8fc2c","name":"四川开放大学","url":"https://idm.scou.cn/api/fusionportal/ssoLogin","profile":"topology","evidence":"https://www.scou.cn/"},
+ {"id":"wakeup_9fc646b6a68393f9","name":"苏州城市学院","url":"https://jw.szcu.edu.cn/sso/ddlogin","profile":"topology","evidence":"https://jwc.szcu.edu.cn/"},
+ {"id":"wakeup_61d41e74fe3b5fda","name":"遂宁职业学院","url":"http://jw.suiningc.com/eams/loginExt.action","profile":"eams","evidence":"http://jw.suiningc.com/eams/loginExt.action"},
+ {"id":"wakeup_b6fc385fe4d5b302","name":"天津美术学院","url":"http://ehall.tjarts.edu.cn/new/index.html","profile":"topology","authenticationUrls":["http://ids.tjarts.edu.cn/authserver/"],"evidence":"https://www.tjarts.edu.cn/"},
+ {"id":"wakeup_2baffe8fb3cd9289","name":"铜仁幼儿师范高等专科学校","url":"http://zhxy.trpec.edu.cn/","profile":"topology","evidence":"https://www.trpec.edu.cn/"},
+ {"id":"wakeup_5658d1eaf8858bed","name":"温州职业技术学院","url":"https://portal.wzvtc.cn/dspaas","profile":"topology","evidence":"https://id.wzvtc.cn/cas/help/integrationSystem.html"},
+ {"id":"wakeup_c9d42cde4c2fd426","name":"芜湖职业技术大学","url":"https://jwxt.whit.edu.cn/jwgl/xlogin.jsp","profile":"topology","evidence":"https://jwc.whit.edu.cn/"},
+ {"id":"wakeup_0126883d07131937","name":"厦门安防科技职业学院","url":"https://jw.xmafkj.com:8081/","profile":"qiangzhi","evidence":"https://www.xmafkj.com/"},
+ {"id":"wakeup_82a76a4d3c57f4fe","name":"厦门开放大学","url":"https://ehall.xmcu.edu.cn/ywtb-portal/standard/index.html","profile":"topology","evidence":"https://www.xmcu.edu.cn/"},
+ {"id":"wakeup_af30ba83123c2def","name":"西安体育学院","url":"https://jwxt.xaipe.edu.cn/","profile":"topology","evidence":"https://www.xaipe.edu.cn/"},
+ {"id":"wakeup_183a0c4055d2c74b","name":"西安铁路职业技术学院","url":"https://portal.xatzy.cn/","profile":"topology","evidence":"https://www.xatzy.edu.cn/"},
+ {"id":"wakeup_82d55155bc2fb131","name":"西交利物浦大学","url":"https://ehall.xjtlu.edu.cn/login","profile":"topology","evidence":"https://www.xjtlu.edu.cn/"},
+ {"id":"wakeup_2dc2620396d294e8","name":"新疆建设职业技术学院","url":"https://portal.xjjsxy.net/","profile":"topology","evidence":"https://portal.xjjsxy.net/"},
+ {"id":"wakeup_645c895ba9f7f64a","name":"新疆生产建设兵团兴新职业技术学院","url":"https://jiaowu.btc.edu.cn/portal/","profile":"topology","evidence":"https://jiaowu.btc.edu.cn/"},
+ {"id":"wakeup_a6e572de8efb6b15","name":"烟台幼儿师范高等专科学校","url":"https://ehall.ytpec.edu.cn/","profile":"topology","evidence":"https://ehall.ytpec.edu.cn/"},
+ {"id":"wakeup_134fbc2f329dcfc4","name":"盐城幼儿师范高等专科学校","url":"http://ehall.yyz.edu.cn/","profile":"topology","evidence":"https://www.yyz.edu.cn/"},
+ {"id":"wakeup_931d71da3d091650","name":"宜宾工业职业技术学院","url":"https://portal.ybipc.edu.cn/","profile":"topology","evidence":"https://portal.ybipc.edu.cn/"},
+ {"id":"wakeup_669ccac94e51741c","name":"宜宾医药健康职业学院","url":"https://portal.ybmh.edu.cn/","profile":"topology","evidence":"https://portal.ybmh.edu.cn/"},
+ {"id":"wakeup_70a764ee05f7b263","name":"永城职业学院","url":"http://my.ycvc.edu.cn/","profile":"topology","evidence":"https://www.ycvc.edu.cn/"},
+ {"id":"wakeup_3638916c01b61d35","name":"云南科技信息职业学院","url":"https://i.ynkexin.cn/","profile":"topology","evidence":"https://i.ynkexin.cn/"},
+ {"id":"wakeup_a33935e4d86bb27a","name":"漳州科技职业学院","url":"https://jwweb.tfzzkj.cn/zzkjjw/","profile":"kingosoft_new","evidence":"https://www.tftc.edu.cn/"},
+ {"id":"wakeup_f52b90a5e776611e","name":"浙江特殊教育职业学院","url":"http://jw.zcse.edu.cn/","profile":"topology","evidence":"https://www.zcse.edu.cn/jyjx1/jxgl.htm"},
+ {"id":"wakeup_df9e6e74af5ddec4","name":"中国社会科学院大学","url":"https://ehall.ucass.edu.cn/","profile":"topology","evidence":"https://www.ucass.edu.cn/"},
+ {"id":"wakeup_fb32ed3417d6d217","name":"资阳环境科技职业学院","url":"https://portal.zycest.edu.cn/onlineServiceClient/","profile":"topology","evidence":"https://www.zycest.edu.cn/"},
+ {"id":"wakeup_de9b893e981b5558","name":"自贡职业技术学院","url":"https://www.zhxywpt.cn/web/web/web/index","profile":"topology","evidence":"https://www.zgzyjsxy.com/"},
+ {"id":"wakeup_8804e8f3817f03db","name":"安徽粮食工程职业学院","url":"https://jwxt.ahly.edu.cn/jsxsd/","profile":"qiangzhi","evidence":"https://www.ahly.edu.cn/"},
+ {"id":"wakeup_660c5fe5d0d1a358","name":"安徽职业技术大学","url":"http://ehall.uta.edu.cn/","profile":"topology","authenticationUrls":["http://authserver.uta.edu.cn/authserver/"],"evidence":"https://www.uta.edu.cn/"},
+ {"id":"wakeup_50bd80c5bb7682fb","name":"毕节医学高等专科学校","url":"https://bjyxxx.jw.chaoxing.com/admin/login","profile":"chaoxing","evidence":"https://www.bijiemc.cn/"},
+ {"id":"wakeup_b11c35a367f78039","name":"德阳农业科技职业学院","url":"http://edu.dynky.cn:8083/default2.aspx","profile":"zhengfang","evidence":"https://www.dynky.cn/"},
+ {"id":"wakeup_14dc7af6a534629c","name":"德州学院","url":"https://service.dzu.edu.cn/","profile":"topology","evidence":"https://www.dzu.edu.cn/"},
+ {"id":"wakeup_d43da0de7a31ea1a","name":"东北石油大学 - 研究生","url":"https://portal.nepu.edu.cn/","profile":"topology","evidence":"https://www.nepu.edu.cn/"},
+ {"id":"wakeup_407862ca89bbef49","name":"福州墨尔本理工职业学院","url":"http://student.fmp.edu.cn/builds/","profile":"topology","evidence":"https://www.fmp.edu.cn/"},
+ {"id":"wakeup_138b811013862ff1","name":"广州体育职业技术学院","url":"http://portal.gztzy.edu.cn:8090/","profile":"topology","authenticationUrls":["https://cas.gztzy.edu.cn:8091/lyuapServer/"],"evidence":"https://www.gztzy.edu.cn/"},
+ {"id":"wakeup_4f10653802e02f08","name":"海南健康管理职业技术学院","url":"http://my.hainhmc.edu.cn/","profile":"topology","authenticationUrls":["http://cas.hainhmc.edu.cn/cas/"],"evidence":"https://www.hainhmc.edu.cn/"},
+ {"id":"wakeup_f4ad300fffb65b47","name":"邯郸科技职业学院","url":"http://jw.hdkz.edu.cn:8103/","profile":"topology","evidence":"https://hdkz.edu.cn/"},
+ {"id":"wakeup_7b4c65ae423dbf06","name":"湖北铁道运输职业学院","url":"http://zhxy.hcrt.edu.cn:88/wcsas/","profile":"topology","evidence":"https://www.hcrt.edu.cn/"},
+ {"id":"wakeup_d118b6aa58b8f85f","name":"江西机电职业技术学院","url":"http://jwgl.jxjdxy.edu.cn:8889/","profile":"topology","evidence":"https://www.jxjdxy.edu.cn/"},
+ {"id":"wakeup_aeeb6ab0e95061d8","name":"江西科技职业学院","url":"http://portal.jxkeda.com:8280/home.aspx","profile":"kingosoft_new","evidence":"http://www.jxkeda.com/"},
+ {"id":"wakeup_b3c465f4a9c3b11b","name":"开封工程职业学院","url":"https://bg.kfevc.edu.cn:20000/","profile":"topology","evidence":"https://www.kfevc.edu.cn/"},
+ {"id":"wakeup_319b6255a6f47840","name":"民办合肥滨湖职业技术学院","url":"https://vp.hfbhxy.edu.cn/","profile":"topology","evidence":"https://www.hfbhxy.edu.cn/"},
+ {"id":"wakeup_2a6344cdb8fd1407","name":"民办四川天一学院","url":"http://jw.sctianyi.net:8088/","profile":"zhengfang","evidence":"https://www.sctianyi.net/"},
+ {"id":"wakeup_b98ef526d4d73574","name":"内蒙古鸿德文理学院","url":"https://login.honder.com:55233/","profile":"topology","evidence":"https://www.honder.com/news/info21604.html"},
+ {"id":"wakeup_0ef62cd4dd432c17","name":"平凉职业技术学院","url":"http://bsdt.plvtc.cn:22080/jsxsd/","profile":"qiangzhi","evidence":"https://www.plvtc.cn/"},
+ {"id":"wakeup_38a94f8dc04fbaef","name":"三亚城市职业学院","url":"http://jw.sycsxy.cn:7688/jwglxt/xtgl/login_slogin.html","profile":"zhengfang","evidence":"https://www.sycsxy.cn/"},
+ {"id":"wakeup_3ff69da3ea680880","name":"山西体育职业学院","url":"https://enjoya.sxptc.com:1443/login/qfid","profile":"topology","authenticationUrls":["https://enjoyd.sxptc.com:1443/realms/sxptc/"],"evidence":"https://www.sxptc.com/"},
+ {"id":"wakeup_ee4899f487aab456","name":"山西中医药大学","url":"https://jwxt.sxtcm.edu.cn/sso/jziotlogin","profile":"zhengfang","evidence":"https://www.sxtcm.edu.cn/index.htm"},
+ {"id":"wakeup_459a6aa12b80222a","name":"陕西科技大学镐京学院","url":"http://jwgl.sxhju.cn:8002/hjxyjw/cas/login.action","profile":"kingosoft_new","evidence":"https://www.sxhju.cn/index.htm"},
+ {"id":"wakeup_3a1e7a61714c5841","name":"四平职业大学","url":"http://jwxt.spvu.edu.cn/spzy/Login.aspx","profile":"topology","evidence":"http://www.spvu.edu.cn/index.asp"},
+ {"id":"wakeup_bea1917d2a403b4d","name":"铁岭师范高等专科学校","url":"http://jw.tlsz.com.cn:7100/","profile":"topology","authenticationUrls":["https://cas.tlsz.com.cn/cas/"],"evidence":"https://www.tlsz.com.cn/"},
+ {"id":"wakeup_8b160bcee4965d72","name":"中国青年政治学院","url":"https://portal.zytx.org.cn/","profile":"topology","evidence":"https://www.cyu.edu.cn/"},
+ {"id":"wakeup_24ddb15d86a47ce2","name":"北京戏曲艺术职业学院","url":"https://portal.bjxx.com.cn/","profile":"topology","authenticationUrls":["https://sso.bjxx.com.cn/"],"evidence":"https://www.bjxx.com.cn/"},
+ {"id":"wakeup_99d0bafc26b81dbb","name":"湖南环境生物职业技术学院","url":"https://portal.hnebp.edu.cn/","profile":"topology","evidence":"https://www.hnebp.edu.cn/"},
+ {"id":"wakeup_0f8105c8bca8c8d5","name":"德州工程职业学院","url":"http://jw.qmxy.com/jwglxt/xtgl/login_slogin.html","profile":"zhengfang","evidence":"https://dzgcxy.com/2025/0116/c23a2738/page.htm"},
+ {"id":"wakeup_f105b3e4b11a920d","name":"川北幼儿师范高等专科学校","url":"https://ehall.cbyz.edu.cn:16016/","profile":"topology","evidence":"https://www.cbyz.edu.cn/"},
+ {"id":"wakeup_6284e5c1ace4c29d","name":"沧州师范学院","url":"https://eip.caztc.edu.cn/EIP/nonlogin/homePage.htm","profile":"topology","evidence":"https://www.caztc.edu.cn/"},
+ {"id":"wakeup_a368b3a244bb6c0d","name":"兰州科技职业学院","url":"https://portal.lk.scedu.tech/onlineServiceClient/","profile":"topology","evidence":"https://www.lzkjedu.com/"},
+ {"id":"wakeup_47dc08f6c97c44f1","name":"聊城科技职业学院","url":"https://lckj.jw.chaoxing.com/admin/login","profile":"chaoxing","evidence":"https://www.lckjzyxy.edu.cn/index.html"},
+ {"id":"wakeup_09ded0102697dbf0","name":"六安应用科技职业学院","url":"https://ahlajsxy.ehall.chaoxing.com/","profile":"topology","evidence":"https://lastc.edu.cn/index.html"},
+ {"id":"wakeup_f68b4c9d92cb4005","name":"山西管理职业学院","url":"https://ehall.sxglzyxy.com.cn/sxgl","profile":"topology","evidence":"https://www.sxglzyxy.com.cn/"},
+ {"id":"wakeup_52b22d6c8ba7fdb0","name":"南阳医学高等专科学校","url":"https://ywtb.nymc.edu.cn/","profile":"topology","evidence":"https://www.nymc.edu.cn/szxy.htm"},
+ {"id":"wakeup_aba9076cab1c6baf","name":"山西经贸职业学院","url":"https://eportal.sxemc.edu.cn/","profile":"topology","evidence":"https://www.sxemc.edu.cn/"},
+ {"id":"wakeup_12048acf57d5d448","name":"安徽体育运动职业技术学院","url":"https://xxmh.ahty.edu.cn:1443/","profile":"topology","evidence":"https://www.ahty.edu.cn/"},
+ {"id":"wakeup_1887f40ab5c4a154","name":"河北艺术职业学院","url":"https://ywtb.hebart.edu.cn/","profile":"topology","authenticationUrls":["https://sfrz.hebart.edu.cn/authserver/"],"evidence":"https://www.hebart.com/"},
+ {"id":"wakeup_963e910849c99298","name":"陇南师范学院","url":"https://ic.lntc.edu.cn/","profile":"topology","authenticationUrls":["https://id.lntc.edu.cn/"],"evidence":"https://www.lntc.edu.cn/"},
+ {"id":"wakeup_b255e0c19fff1868","name":"辽宁铁道职业技术学院","url":"https://ifw.lntdxy.com/EIP/nonlogin/homePage.htm","profile":"topology","authenticationUrls":["https://cas.lntdxy.com/ZAS/"],"evidence":"https://lntdxy.com/"},
+ {"id":"wakeup_31ada1d6dcd691b7","name":"怒江职业技术学院","url":"https://njzj.hall.chaoxing.com/","profile":"topology","authenticationUrls":["https://njzjcas.libsou.com/cas/"],"evidence":"https://www.njzyjsxy.cn/"}
+]
diff --git a/app/build.gradle b/app/build.gradle
index b5b49a3..90181f8 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -50,6 +50,10 @@ dependencies {
implementation 'androidx.customview:customview:1.1.0'
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.viewpager2:viewpager2:1.0.0'
+ implementation 'androidx.dynamicanimation:dynamicanimation:1.1.0'
+
+ // Rive mascot runtime
+ implementation 'app.rive:rive-android:9.13.10'
// Room Database
implementation 'androidx.room:room-runtime:2.6.0'
diff --git a/app/src/androidTest/java/com/courseschedule/ui/DateHeaderViewTest.kt b/app/src/androidTest/java/com/courseschedule/ui/DateHeaderViewTest.kt
new file mode 100644
index 0000000..e5df8d8
--- /dev/null
+++ b/app/src/androidTest/java/com/courseschedule/ui/DateHeaderViewTest.kt
@@ -0,0 +1,146 @@
+package com.courseschedule.ui
+
+import android.animation.ValueAnimator
+import android.graphics.Bitmap
+import android.view.ViewGroup
+import androidx.core.view.doOnLayout
+import androidx.core.view.drawToBitmap
+import androidx.test.core.app.ActivityScenario
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.courseschedule.R
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Assume.assumeTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+@RunWith(AndroidJUnit4::class)
+class DateHeaderViewTest {
+
+ @Test
+ fun onlyChangedDigitsMoveAndUnchangedPrefixIsPixelIdentical() {
+ assumeTrue(ValueAnimator.areAnimatorsEnabled())
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+ isolateHeader(scenario)
+ scenario.onActivity { activity ->
+ val header = activity.findViewById(R.id.dateHeader)
+ header.setDate("2026/10/12", "第10周 · 周一", animate = true)
+ assertSettled(header)
+ }
+ InstrumentationRegistry.getInstrumentation().waitForIdleSync()
+ scenario.onActivity { activity ->
+ val header = activity.findViewById(R.id.dateHeader)
+ val row = rows(header).first()
+ val before = row.drawToBitmap()
+ val prefixWidth = row.motion.glyphs.single { it.slot == DateDigitMotion.Slot(4, 1) }.fromX.toInt()
+ header.setDate("2026/10/19", "第11周 · 周一", animate = true)
+ row.advance(0.5f)
+ val after = row.drawToBitmap()
+ assertTrue("Unchanged year/month must not move or fade", Bitmap.createBitmap(
+ before, 0, 0, prefixWidth, before.height
+ ).sameAs(Bitmap.createBitmap(after, 0, 0, prefixWidth, after.height)))
+ assertEquals(setOf(DateDigitMotion.Slot(4, 0)),
+ row.motion.glyphs.filter { it.fromY != it.toY }.map { it.slot }.toSet())
+ assertEquals(setOf(DateDigitMotion.Slot(1, 0)),
+ rows(header)[1].motion.glyphs.filter { it.fromY != it.toY }.map { it.slot }.toSet())
+
+ val state = row.motion.glyphs to row.motion.progress
+ header.setDate("2026/10/19", "第11周 · 周一", animate = false)
+ assertEquals(state, row.motion.glyphs to row.motion.progress)
+ }
+ Thread.sleep(450L)
+ scenario.onActivity { activity ->
+ val header = activity.findViewById(R.id.dateHeader)
+ assertEquals("2026/10/19,第11周 · 周一", header.contentDescription)
+ assertSettled(header)
+ header.setDate("2026/12/28", "第18周 · 周一", animate = false)
+ assertSettled(header)
+ }
+ }
+ }
+
+ @Test
+ fun rapidRetargetReverseAndDetachKeepTheLatestTextAndNeverBlank() {
+ assumeTrue(ValueAnimator.areAnimatorsEnabled())
+ ActivityScenario.launch(MainActivity::class.java).use { scenario ->
+ isolateHeader(scenario)
+ scenario.onActivity { activity ->
+ val header = activity.findViewById(R.id.dateHeader)
+ header.setDate("2026/10/12", "第5周 · 周一", animate = false)
+ header.setDate("2026/10/19", "第6周 · 周一", animate = true)
+ }
+ Thread.sleep(80L)
+ scenario.onActivity { activity ->
+ val header = activity.findViewById(R.id.dateHeader)
+ assertOpacity(header)
+ header.setDate("2026/10/26", "第7周 · 周一", animate = true)
+ assertOpacity(header)
+ assertEquals("2026/10/26", header.dateText)
+ }
+ Thread.sleep(60L)
+ scenario.onActivity { activity ->
+ val header = activity.findViewById(R.id.dateHeader)
+ header.setDate("2026/10/12", "第5周 · 周一", animate = true, forward = false)
+ assertOpacity(header)
+ }
+ Thread.sleep(450L)
+ scenario.onActivity { activity ->
+ val header = activity.findViewById(R.id.dateHeader)
+ assertEquals("2026/10/12", header.dateText)
+ assertEquals("第5周 · 周一", header.summaryText)
+ assertSettled(header)
+ header.setDate("2026/11/2", "第8周 · 周一", animate = true)
+ val parent = header.parent as ViewGroup
+ val index = parent.indexOfChild(header)
+ val params = header.layoutParams
+ parent.removeView(header)
+ assertSettled(header)
+ assertEquals("2026/11/2", header.dateText)
+ parent.addView(header, index, params)
+ }
+ }
+ }
+
+ private fun isolateHeader(scenario: ActivityScenario) {
+ val laidOut = CountDownLatch(1)
+ scenario.onActivity { activity ->
+ // Keep Room/semester observers from replacing the test strings asynchronously.
+ val original = activity.findViewById(R.id.dateHeader)
+ val parent = original.parent as ViewGroup
+ val index = parent.indexOfChild(original)
+ val params = original.layoutParams
+ parent.removeView(original)
+ val header = DateHeaderView(activity).apply {
+ id = R.id.dateHeader
+ doOnLayout { laidOut.countDown() }
+ }
+ parent.addView(header, index, params)
+ }
+ assertTrue("Date header was not laid out", laidOut.await(5L, TimeUnit.SECONDS))
+ }
+
+ private fun rows(header: DateHeaderView) = (0..1).map { header.getChildAt(it) as DateHeaderView.DigitRow }
+
+ private fun assertOpacity(header: DateHeaderView) {
+ rows(header).forEach { row ->
+ row.motion.glyphs.groupBy { it.slot }.values.forEach { glyphs ->
+ assertEquals(1f, glyphs.sumOf { it.alpha(row.motion.progress).toDouble() }.toFloat(), 0.001f)
+ }
+ }
+ }
+
+ private fun assertSettled(header: DateHeaderView) {
+ rows(header).forEach { row ->
+ assertFalse(row.motion.isMoving)
+ assertEquals(row.text.toString(), row.motion.glyphs.joinToString("") { it.text })
+ row.motion.glyphs.forEach {
+ assertEquals(0f, it.y(row.motion.progress), 0f)
+ assertEquals(1f, it.alpha(row.motion.progress), 0f)
+ }
+ }
+ }
+}
diff --git a/app/src/androidTest/java/com/courseschedule/ui/importdata/GenericImportUiTest.kt b/app/src/androidTest/java/com/courseschedule/ui/importdata/GenericImportUiTest.kt
new file mode 100644
index 0000000..baa3dc1
--- /dev/null
+++ b/app/src/androidTest/java/com/courseschedule/ui/importdata/GenericImportUiTest.kt
@@ -0,0 +1,328 @@
+package com.courseschedule.ui.importdata
+
+import android.os.SystemClock
+import android.security.NetworkSecurityPolicy
+import android.view.View
+import android.view.ViewGroup
+import android.webkit.WebResourceRequest
+import android.webkit.WebResourceResponse
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import android.widget.TextView
+import androidx.core.widget.doAfterTextChanged
+import androidx.lifecycle.ViewModelProvider
+import androidx.test.core.app.ActivityScenario
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions.*
+import androidx.test.espresso.assertion.ViewAssertions.matches
+import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
+import androidx.test.espresso.matcher.ViewMatchers.*
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.courseschedule.R
+import com.courseschedule.ui.settings.SettingsActivity
+import com.courseschedule.viewmodel.CourseViewModel
+import com.google.gson.JsonParser
+import org.hamcrest.Matchers.allOf
+import org.json.JSONObject
+import org.json.JSONTokener
+import org.junit.Assert.*
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.ByteArrayInputStream
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicReference
+
+@RunWith(AndroidJUnit4::class)
+class GenericImportUiTest {
+ @Test fun cleartextRedirectsDoNotUseADomainAllowlist() {
+ val policy = NetworkSecurityPolicy.getInstance()
+ assertTrue(policy.isCleartextTrafficPermitted("jw.sdufe.edu.cn"))
+ assertTrue(policy.isCleartextTrafficPermitted("example.com"))
+ assertTrue(policy.isCleartextTrafficPermitted("127.0.0.1"))
+ }
+
+ @Suppress("DEPRECATION")
+ @Test fun academicWebViewAllowsCrossDomainWebLinksAndConfirmsSpecialSchemes() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val intent = AcademicWebImportActivity.schoolIntent(context, AcademicSchools.SWPU)
+ ActivityScenario.launch(intent).use { scenario ->
+ scenario.onActivity { activity ->
+ val web = activity.findViewById(R.id.webView)
+ web.stopLoading()
+ val client = web.webViewClient
+ assertFalse(client.shouldOverrideUrlLoading(
+ web, "https://login.vendor.example/sso"
+ ))
+ assertFalse(client.shouldOverrideUrlLoading(
+ web, "http://webvpn.vendor.example/login"
+ ))
+ assertTrue(client.shouldOverrideUrlLoading(
+ web, "vendor-sso://login/callback"
+ ))
+ }
+ onView(withText(R.string.academic_external_link_title)).check(matches(isDisplayed()))
+ }
+ }
+
+ @Test fun settingsShowsPublicRepositoryAnnouncementAndClickableAddressCard() {
+ ActivityScenario.launch(SettingsActivity::class.java).use { scenario ->
+ scenario.onActivity { activity ->
+ assertEquals("https://github.com/cakeni/CourseSchedule",
+ activity.findViewById(R.id.tvProjectUrl).text.toString())
+ assertTrue(activity.findViewById(R.id.cardOpenSource).isClickable)
+ assertTrue(activity.findViewById(R.id.cardOpenSource).isLongClickable)
+ }
+ }
+ }
+
+ @Test fun schoolImportOpensSearchableDirectoryInsteadOfAskingForAWebsite() {
+ ActivityScenario.launch(ImportActivity::class.java).use { scenario ->
+ val ready = CountDownLatch(1)
+ scenario.onActivity { activity ->
+ ViewModelProvider(activity)[CourseViewModel::class.java].currentSemester.observe(activity) {
+ if (it != null) ready.countDown()
+ }
+ }
+ assertTrue(ready.await(5, TimeUnit.SECONDS))
+ onView(withId(R.id.cardImportSchool)).perform(click())
+ onView(withId(R.id.etSchoolSearch)).check(matches(isDisplayed()))
+ .perform(replaceText("西南石油"), closeSoftKeyboard())
+ onView(withText("西南石油大学")).check(matches(isDisplayed()))
+ onView(withId(R.id.etSchoolSearch)).perform(replaceText("xinan"), closeSoftKeyboard())
+ onView(withText("西南石油大学")).check(matches(isDisplayed()))
+ onView(withId(R.id.btnRequestSchool)).check(matches(withText(R.string.academic_request_school)))
+ onView(withText(R.string.academic_generic_url_hint)).check(doesNotExist())
+ }
+ }
+
+ @Test fun bundledDirectoryKeepsFullCoverageButOnlyOpensSafeReviewedOrigins() {
+ val context = InstrumentationRegistry.getInstrumentation().targetContext
+ val entries = AcademicSchoolDirectory.entries(context)
+ val raw = context.assets.open("academic_school_directory.json").bufferedReader().use {
+ JsonParser.parseReader(it).asJsonObject.getAsJsonArray("entries")
+ }
+ val rejected = raw.mapNotNull { element ->
+ val item = element.asJsonObject
+ if (item.get("support")?.asString == "adapter_required") return@mapNotNull null
+ val profile = GenericAcademicImport.profile(item.get("profile")?.asString) ?: return@mapNotNull null
+ runCatching {
+ GenericAcademicImport.createCatalog(profile, item.get("url").asString,
+ item.get("cleartext")?.asBoolean == true,
+ item.getAsJsonArray("authenticationUrls")?.map { it.asString }.orEmpty())
+ }.exceptionOrNull()?.let { "${item.get("name").asString}: ${it.message}" }
+ }
+ assertTrue("bundled rows rejected at runtime: ${rejected.joinToString(" | ")}", rejected.isEmpty())
+ assertEquals(raw.size(), entries.size)
+ assertTrue("directory size=${entries.size}", entries.size >= 3_500)
+ assertEquals("西南石油大学", entries.first().name)
+ assertTrue(entries.first().verified)
+ assertTrue(entries.any { it.name == "浙江大学" })
+ assertTrue(entries.any { it.name == "北京大学" })
+ // The built-in NUAA graduate route replaces its adapter-required catalog row.
+ assertEquals(2_682, entries.count { it.canImport })
+ assertEquals(884, entries.count { !it.canImport })
+ assertEquals(764, entries.count { it.needsUserUrl })
+ assertEquals(120, entries.count { !it.canImport && !it.needsUserUrl })
+ assertEquals(1_088, entries.count { it.isCloudOnly })
+ // The built-in SDUFE route upgrades the catalog's HTTP entry to WebVPN HTTPS.
+ assertEquals(934, entries.count { it.allowCleartext })
+ entries.filter { it.canImport }.forEach { entry ->
+ val school = entry.toAcademicSchool()
+ assertNotNull(entry.name, school)
+ assertTrue(entry.name, school!!.loginUrl.startsWith("https://") ||
+ entry.allowCleartext && school.loginUrl.startsWith("http://"))
+ assertTrue(entry.name, school.allowsNavigation(school.loginUrl))
+ }
+ entries.filterNot { it.canImport }.forEach { assertNull(it.name, it.toAcademicSchool()) }
+ val zju = entries.first { it.name == "浙江大学" }
+ assertEquals("zdbk.zju.edu.cn", zju.host)
+ assertTrue(zju.url.startsWith("https://"))
+ val nuaa = entries.first { it.name == "南京航空航天大学" }
+ assertEquals("nuaa", nuaa.builtInSchoolId)
+ assertEquals("aao-eas.nuaa.edu.cn", nuaa.host)
+ assertTrue(nuaa.canImport)
+ val nuaaGraduate = entries.first { it.name == "南京航空航天大学 - 研究生" }
+ assertEquals("nuaa_graduate", nuaaGraduate.builtInSchoolId)
+ assertEquals("graduate.nuaa.edu.cn", nuaaGraduate.host)
+ assertTrue(nuaaGraduate.canImport)
+ }
+
+ @Test fun genericSystemRowsAskForTheSchoolUrlInsteadOfClaimingTheyNeedAnAdapter() {
+ AcademicSchoolDirectory.entries(InstrumentationRegistry.getInstrumentation().targetContext)
+ ActivityScenario.launch(SchoolPickerActivity::class.java).use { scenario ->
+ val loaded = CountDownLatch(1)
+ scenario.onActivity { activity ->
+ val count = activity.findViewById(R.id.tvSchoolCount)
+ val loading = activity.getString(R.string.academic_school_loading)
+ if (count.text.toString() != loading) loaded.countDown()
+ count.doAfterTextChanged { if (it.toString() != loading) loaded.countDown() }
+ }
+ assertTrue("school directory did not finish loading", loaded.await(10, TimeUnit.SECONDS))
+ onView(withId(R.id.chipCommonSystem)).perform(click())
+ onView(withId(R.id.etSchoolSearch)).perform(replaceText("强智教务"), closeSoftKeyboard())
+ onView(allOf(withId(R.id.tvSchoolName), withText("强智教务"))).perform(click())
+ onView(withId(R.id.etGenericUrl)).check(matches(isDisplayed()))
+ onView(withText(R.string.academic_trust_and_open)).check(matches(isDisplayed()))
+ onView(withText(R.string.academic_adapter_required_title)).check(doesNotExist())
+ }
+ }
+
+ @Test fun genericHtmlCaptureCopiesOnlyScheduleMarkupAndParsesTheSnapshot() {
+ val school = GenericAcademicImport.create("urp", "https://jw.example.edu.cn/student/table")
+ withFixtureWebView(school) { web ->
+ val fixture = """
+ PRIVATE_OUTSIDE_TABLE
+ | 课程名称 | 星期 | 节次 | 周次 | 教师 | 教室 |
+ | 测试课程 | 星期二 | 3-4节 | 1-2,5-6周 | 测试教师 | 测试教室 |
+
+ """.trimIndent()
+ evaluate(web, "document.body.innerHTML = " + JSONObject.quote(fixture))
+ val payload = JSONObject(evaluate(web, AcademicCaptureScript.create(school)) as String)
+ assertFalse(payload.has("error"))
+ assertFalse(payload.toString().contains("PRIVATE_"))
+ assertFalse(payload.getString("html").contains("
+ assertEquals("undefined", evaluate(web, "typeof window.CourseScheduleBridge"))
+ evaluate(web, MOCK_FETCH)
+ val token = "fixture-request"
+ val basePath = GenericAcademicImport.wiseduBasePath(school.loginUrl)
+ evaluate(web, AcademicWebImportActivity.ScriptHolder.fetchScript(token, "", basePath))
+ val result = awaitResult(web, token)
+ assertFalse(result.has("error"))
+ val parsed = AcademicSchools.parse(school, result.getString("json"), 20)
+ assertEquals("测试课程", parsed.courses.first().courseName)
+ assertEquals(listOf(1 to 2, 4 to 5), parsed.courses.map { it.startWeek to it.endWeek })
+ val paths = evaluate(web, "JSON.stringify(window.testRequestedPaths)").toString()
+ assertTrue(paths.contains("/http/course-upstream/jwapp/sys/wdkb/modules/xskcb/xskcb.do"))
+ assertFalse(result.toString().contains("PRIVATE_PASSWORD"))
+ assertEquals("undefined", evaluate(web, "typeof window['__courseSchedule_fixture-request']"))
+ assertEquals("{}", evaluate(web, AcademicWebImportActivity.ScriptHolder.pollResultScript(token)))
+ }
+ }
+
+ @Test fun genericActivityDoesNotRegisterANativeBridgeOrRetainPastedTickets() {
+ val school = GenericAcademicImport.create(AcademicSystem.WISEDU,
+ "https://jw.fixture.example/jwapp/?ticket=PRIVATE_TICKET#PRIVATE_FRAGMENT")
+ val intent = AcademicWebImportActivity.schoolIntent(
+ InstrumentationRegistry.getInstrumentation().targetContext, school)
+ assertFalse(intent.toUri(0).contains("PRIVATE_"))
+ ActivityScenario.launch(intent).use { scenario ->
+ lateinit var web: WebView
+ scenario.onActivity { activity ->
+ web = activity.findViewById(R.id.webView)
+ web.stopLoading()
+ }
+ assertEquals("undefined", evaluate(web, "typeof window.CourseScheduleBridge"))
+ }
+ }
+
+ @Test fun intentRoundTripPreservesAdapterAndThreeIndependentScopes() {
+ val original = GenericAcademicImport.createCatalog(
+ profile = GenericAcademicImport.profile("qiangzhi")!!,
+ address = "https://portal.example.edu.cn/login?ticket=PRIVATE",
+ allowCleartext = false,
+ authenticationUrls = listOf("https://auth.example.edu.cn/authserver/"),
+ timetableUrls = listOf("https://jw.example.edu.cn/jsxsd/"),
+ adapterId = AcademicAdapterRegistry.QIANGZHI_2024
+ )
+ val intent = AcademicWebImportActivity.schoolIntent(
+ InstrumentationRegistry.getInstrumentation().targetContext,
+ original
+ )
+ assertFalse(intent.toUri(0).contains("PRIVATE"))
+ val restored = AcademicWebImportActivity.schoolFromIntent(intent)!!
+ assertEquals(AcademicAdapterRegistry.QIANGZHI_2024, restored.adapterId)
+ assertEquals(original.loginPrefixes, restored.loginPrefixes)
+ assertEquals(original.authenticationPrefixes, restored.authenticationPrefixes)
+ assertEquals(original.timetablePrefixes, restored.timetablePrefixes)
+ assertTrue(restored.allowsNavigation("https://portal.example.edu.cn/login"))
+ assertTrue(restored.allowsNavigation("https://auth.example.edu.cn/authserver/login"))
+ assertFalse(restored.allowsTimetable("https://auth.example.edu.cn/authserver/login"))
+ assertTrue(restored.allowsTimetable("https://jw.example.edu.cn/jsxsd/xskb/list.do"))
+ }
+
+ @Test fun existingWiseduScriptUsesPageLocalResultAndKeepsVerifiedPaths() {
+ withFixtureWebView(AcademicSchools.SWPU) { web ->
+ assertEquals("undefined", evaluate(web, "typeof window.CourseScheduleBridge"))
+ evaluate(web, MOCK_FETCH)
+ evaluate(web, AcademicWebImportActivity.ScriptHolder.fetchScript("legacy", "2026-2027-1"))
+ val result = awaitResult(web, "legacy")
+ assertFalse(result.has("error"))
+ assertEquals("测试课程", AcademicSchools.parse(AcademicSchools.SWPU, result.getString("json"), 20)
+ .courses.first().courseName)
+ val paths = evaluate(web, "JSON.stringify(window.testRequestedPaths)").toString()
+ assertTrue(paths.contains("/jwapp/sys/wdkb/modules/xskcb/xskcb.do"))
+ }
+ }
+
+ private fun awaitResult(web: WebView, token: String): JSONObject =
+ waitForObject(web, AcademicWebImportActivity.ScriptHolder.pollResultScript(token))
+
+ private fun waitForObject(web: WebView, script: String): JSONObject {
+ val deadline = SystemClock.elapsedRealtime() + 6000
+ while (SystemClock.elapsedRealtime() < deadline) {
+ val result = JSONObject(evaluate(web, script).toString())
+ if (result.has("json") || result.has("error")) return result
+ Thread.sleep(75)
+ }
+ throw AssertionError("Synthetic Wisedu response did not finish")
+ }
+
+ private fun evaluate(web: WebView, script: String): Any? {
+ val ready = CountDownLatch(1)
+ val result = AtomicReference()
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ web.evaluateJavascript(script) { result.set(it); ready.countDown() }
+ }
+ assertTrue(ready.await(5, TimeUnit.SECONDS))
+ return JSONTokener(result.get()).nextValue()
+ }
+
+ @android.annotation.SuppressLint("SetJavaScriptEnabled")
+ private fun withFixtureWebView(school: AcademicSchool, action: (WebView) -> Unit) {
+ ActivityScenario.launch(ImportActivity::class.java).use { scenario ->
+ lateinit var web: WebView
+ val loaded = CountDownLatch(1)
+ scenario.onActivity { activity ->
+ web = WebView(activity)
+ web.settings.javaScriptEnabled = true
+ activity.findViewById(android.R.id.content).addView(web,
+ ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
+ web.webViewClient = object : WebViewClient() {
+ override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest?): WebResourceResponse =
+ WebResourceResponse("text/html", "UTF-8", ByteArrayInputStream(ByteArray(0)))
+ override fun onPageFinished(view: WebView?, url: String?) { loaded.countDown() }
+ }
+ web.loadDataWithBaseURL(school.loginUrl,
+ "", "text/html", "UTF-8", null)
+ }
+ try {
+ assertTrue(loaded.await(5, TimeUnit.SECONDS))
+ action(web)
+ } finally {
+ scenario.onActivity { (web.parent as? ViewGroup)?.removeView(web); web.destroy() }
+ }
+ }
+ }
+
+ private val MOCK_FETCH = """
+ window.testRequestedPaths = [];
+ window.fetch = function(url, options) {
+ window.testRequestedPaths.push(String(url));
+ const data = String(url).indexOf('dqxnxq.do') >= 0 ? {term:'2026-2027-1'} :
+ {rows:[{KCM:'测试课程',SKJS:'教师',JASMC:'教室',SKXQ:'2',KSJC:'3',JSJC:'4',SKZC:'110110'}]};
+ return Promise.resolve({ok:true,status:200,text:function(){return Promise.resolve(JSON.stringify(data));}});
+ };
+ """.trimIndent()
+}
diff --git a/app/src/androidTest/java/com/courseschedule/ui/importdata/QiangzhiWebCaptureTest.kt b/app/src/androidTest/java/com/courseschedule/ui/importdata/QiangzhiWebCaptureTest.kt
new file mode 100644
index 0000000..e194dba
--- /dev/null
+++ b/app/src/androidTest/java/com/courseschedule/ui/importdata/QiangzhiWebCaptureTest.kt
@@ -0,0 +1,133 @@
+package com.courseschedule.ui.importdata
+
+import android.annotation.SuppressLint
+import android.view.ViewGroup
+import android.webkit.WebResourceRequest
+import android.webkit.WebResourceResponse
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.test.core.app.ActivityScenario
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.json.JSONObject
+import org.json.JSONTokener
+import org.junit.Assert.*
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.ByteArrayInputStream
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicReference
+
+@RunWith(AndroidJUnit4::class)
+class QiangzhiWebCaptureTest {
+ private val school = AcademicSchools.SDUFE
+ private val fixture = """
+
+ PRIVATE_OUTSIDE_TABLE
+
+
+
+ """.trimIndent()
+
+ @Test fun capturesOnlyTimetableAndSelectedTermWithoutNativeBridgeOrCredentials() {
+ val result = capture(school.timetableUrl + "fixture", fixture)
+ assertFalse(result.has("error"))
+ assertEquals("2026-2027-1", result.getString("term"))
+ assertFalse(result.toString().contains("PRIVATE_"))
+ assertFalse(result.getString("html").contains(""
+ val result = capture("https://webvpn.sdufe.edu.cn/", wrapper,
+ mapOf(trusted to fixture, untrusted to fixture.replace("测试课程", "不可信课程")))
+ assertFalse(result.has("error"))
+ assertEquals(trusted, result.getString("sourceUrl"))
+ assertTrue(result.getString("html").contains("测试课程"))
+ assertFalse(result.getString("html").contains("不可信课程"))
+ }
+
+ @Test fun neverTreatsAnInputAsTheSemesterSelector() {
+ val html = fixture.replace(
+ "",
+ ""
+ )
+ val result = capture(school.timetableUrl + "fixture", html)
+ assertFalse(result.has("error"))
+ assertEquals("", result.getString("term"))
+ assertFalse(result.toString().contains("2026-2027-1"))
+ }
+
+ @Test fun refusesIdentityPagesAndReportsInaccessibleFrames() {
+ assertEquals("table", capture(school.loginUrl, fixture).getString("error"))
+ val foreign = "https://untrusted.example/fixture"
+ val result = capture(school.timetableUrl,
+ "", mapOf(foreign to fixture))
+ assertEquals("frame", result.getString("error"))
+ }
+
+ @Test fun genericWebVpnSnapshotNeverReadsAnApprovedAuthenticationResource() {
+ val generic = GenericAcademicImport.create(AcademicSystem.QIANGZHI_HTML,
+ "https://webvpn.example.edu.cn/http/course-upstream/table")
+ val auth = "https://webvpn.example.edu.cn/https/auth-upstream/login"
+ val allowed = GenericAcademicImport.allowAuthentication(generic, auth)
+ assertEquals("table", capture(auth, fixture, selectedSchool = allowed).getString("error"))
+ assertFalse(capture(generic.loginUrl, fixture, selectedSchool = allowed).has("error"))
+ }
+
+ @Test fun oversizedTrustedDomIsRejectedBeforeNativeParsing() {
+ val repeated = (1..120).joinToString("") { "${"x".repeat(4096)}" }
+ val oversized = fixture.replace("