diff --git a/.github/workflows/cloudflare-backup.yml b/.github/workflows/cloudflare-backup.yml index a258494..ff2698f 100644 --- a/.github/workflows/cloudflare-backup.yml +++ b/.github/workflows/cloudflare-backup.yml @@ -33,7 +33,7 @@ jobs: - name: Validate backup configuration before installing dependencies shell: bash env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_D1_BACKUP_TOKEN: ${{ secrets.CLOUDFLARE_D1_BACKUP_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} run: | node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/cloudflare/backup-preflight.ts --phase credentials @@ -44,7 +44,7 @@ jobs: - name: Verify read access to both remote D1 databases shell: bash env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_D1_BACKUP_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} run: | set -euo pipefail @@ -61,7 +61,7 @@ jobs: - name: Export catalog and pipeline databases shell: bash env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_D1_BACKUP_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} run: | set -euo pipefail @@ -115,7 +115,7 @@ jobs: - name: Upload daily and monthly copies shell: bash env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_D1_BACKUP_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} run: | set -euo pipefail @@ -131,12 +131,45 @@ jobs: day="$(date -u +%F)" month="$(date -u +%Y-%m)" - upload_object "studyinchina-releases/backups/daily/$day/catalog.sql.gz" "$RUNNER_TEMP/catalog.sql.gz" --content-type="application/sql" --content-encoding="gzip" - upload_object "studyinchina-releases/backups/daily/$day/pipeline.sql.gz" "$RUNNER_TEMP/pipeline.sql.gz" --content-type="application/sql" --content-encoding="gzip" - upload_object "studyinchina-releases/backups/daily/$day/sha256.txt" "$RUNNER_TEMP/backup-sha256.txt" --content-type="text/plain" - upload_object "studyinchina-releases/backups/monthly/$month/catalog.sql.gz" "$RUNNER_TEMP/catalog.sql.gz" --content-type="application/sql" --content-encoding="gzip" - upload_object "studyinchina-releases/backups/monthly/$month/pipeline.sql.gz" "$RUNNER_TEMP/pipeline.sql.gz" --content-type="application/sql" --content-encoding="gzip" - upload_object "studyinchina-releases/backups/monthly/$month/sha256.txt" "$RUNNER_TEMP/backup-sha256.txt" --content-type="text/plain" + upload_object "studyinchina-backups/backups/daily/$day/raw-v1/catalog.sql.gz" "$RUNNER_TEMP/catalog.sql.gz" --content-type="application/gzip" --content-encoding="identity" + upload_object "studyinchina-backups/backups/daily/$day/raw-v1/pipeline.sql.gz" "$RUNNER_TEMP/pipeline.sql.gz" --content-type="application/gzip" --content-encoding="identity" + upload_object "studyinchina-backups/backups/daily/$day/raw-v1/sha256.txt" "$RUNNER_TEMP/backup-sha256.txt" --content-type="text/plain" + upload_object "studyinchina-backups/backups/monthly/$month/raw-v1/catalog.sql.gz" "$RUNNER_TEMP/catalog.sql.gz" --content-type="application/gzip" --content-encoding="identity" + upload_object "studyinchina-backups/backups/monthly/$month/raw-v1/pipeline.sql.gz" "$RUNNER_TEMP/pipeline.sql.gz" --content-type="application/gzip" --content-encoding="identity" + upload_object "studyinchina-backups/backups/monthly/$month/raw-v1/sha256.txt" "$RUNNER_TEMP/backup-sha256.txt" --content-type="text/plain" + + - name: Read back and cryptographically verify daily checkpoint + shell: bash + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_D1_BACKUP_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + set -euo pipefail + day="$(date -u +%F)" + readback="$RUNNER_TEMP/backup-readback" + mkdir -p "$readback" + download_object() { + local object="$1" + local file="$2" + if ! npx wrangler r2 object get "$object" --file "$file" --remote; then + echo "::error title=R2 backup readback failed::Unable to read back ${object}. The checkpoint must not be counted toward RPO." + exit 1 + fi + } + download_object "studyinchina-backups/backups/daily/$day/raw-v1/catalog.sql.gz" "$readback/catalog.sql.gz" + download_object "studyinchina-backups/backups/daily/$day/raw-v1/pipeline.sql.gz" "$readback/pipeline.sql.gz" + download_object "studyinchina-backups/backups/daily/$day/raw-v1/sha256.txt" "$readback/backup-sha256.txt" + node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/cloudflare/backup-preflight.ts \ + --phase artifacts --directory "$readback" \ + | tee "$RUNNER_TEMP/backup-verification.json" + + - name: Upload machine-readable backup verification + uses: actions/upload-artifact@v6 + with: + name: d1-backup-verification-${{ github.run_id }} + path: ${{ runner.temp }}/backup-verification.json + if-no-files-found: error + retention-days: 35 - name: Explain an incomplete backup if: ${{ failure() }} @@ -150,7 +183,7 @@ jobs: echo '- Configuration failure: add or correct the named GitHub Actions repository secret, then rerun.' echo '- D1 access failure: verify token scope, account, database names and Cloudflare availability.' echo '- Export/artifact failure: inspect the first failing step; nothing is uploaded before checksum verification.' - echo '- R2 upload failure: treat the checkpoint as incomplete even if some objects were written, then rerun the whole job.' + echo '- R2 upload/readback failure: treat the checkpoint as incomplete even if some objects were written, then rerun the whole job.' echo echo 'Runbook: `docs/backup-and-restore.md#failure-semantics-and-triage`.' } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/cloudflare-restore-drill.yml b/.github/workflows/cloudflare-restore-drill.yml index 3f37abc..7c59e32 100644 --- a/.github/workflows/cloudflare-restore-drill.yml +++ b/.github/workflows/cloudflare-restore-drill.yml @@ -22,10 +22,13 @@ jobs: name: Restore both D1 backups into local isolated databases runs-on: ubuntu-latest timeout-minutes: 60 + environment: cloudflare-restore-drill steps: - name: Check out repository uses: actions/checkout@v6 + with: + persist-credentials: false - name: Use Node.js 24 uses: actions/setup-node@v6 @@ -33,6 +36,14 @@ jobs: node-version: 24 cache: npm + - name: Validate protected restore credentials before installing dependencies + shell: bash + env: + CLOUDFLARE_D1_RESTORE_TOKEN: ${{ secrets.CLOUDFLARE_D1_RESTORE_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + run: | + node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/cloudflare/backup-preflight.ts --phase restore-credentials + - name: Install dependencies run: npm ci @@ -52,7 +63,7 @@ jobs: - name: Download private monthly backup shell: bash env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_D1_RESTORE_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} BACKUP_MONTH: ${{ steps.backup.outputs.month }} run: | @@ -60,9 +71,9 @@ jobs: test -n "$CLOUDFLARE_ACCOUNT_ID" input="$RUNNER_TEMP/restore-input" mkdir -p "$input" - npx wrangler r2 object get "studyinchina-releases/backups/monthly/$BACKUP_MONTH/catalog.sql.gz" --file "$input/catalog.sql.gz" --remote - npx wrangler r2 object get "studyinchina-releases/backups/monthly/$BACKUP_MONTH/pipeline.sql.gz" --file "$input/pipeline.sql.gz" --remote - npx wrangler r2 object get "studyinchina-releases/backups/monthly/$BACKUP_MONTH/sha256.txt" --file "$input/backup-sha256.txt" --remote + npx wrangler r2 object get "studyinchina-backups/backups/monthly/$BACKUP_MONTH/raw-v1/catalog.sql.gz" --file "$input/catalog.sql.gz" --remote + npx wrangler r2 object get "studyinchina-backups/backups/monthly/$BACKUP_MONTH/raw-v1/pipeline.sql.gz" --file "$input/pipeline.sql.gz" --remote + npx wrangler r2 object get "studyinchina-backups/backups/monthly/$BACKUP_MONTH/raw-v1/sha256.txt" --file "$input/backup-sha256.txt" --remote # This step intentionally receives no Cloudflare credentials. The script # supports local isolated D1 only and cannot overwrite a remote database. diff --git a/.github/workflows/vercel-production-alias.yml b/.github/workflows/vercel-production-alias.yml index 91af004..2a2b86b 100644 --- a/.github/workflows/vercel-production-alias.yml +++ b/.github/workflows/vercel-production-alias.yml @@ -4,11 +4,12 @@ on: deployment_status: permissions: + actions: read contents: read concurrency: group: vercel-production-alias - cancel-in-progress: true + cancel-in-progress: false jobs: promote: @@ -17,7 +18,7 @@ jobs: github.event.deployment_status.state == 'success' && github.event.deployment.environment == 'Production' runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 40 env: DEPLOYMENT_SHA: ${{ github.event.deployment.sha }} DEPLOYMENT_URL: ${{ github.event.deployment_status.environment_url }} @@ -47,8 +48,70 @@ jobs: echo 'matches=false' >> "$GITHUB_OUTPUT" echo "::notice::Deployment ${DEPLOYMENT_SHA} is not current main ${main_sha}; the stable alias will not be changed." - - name: Require stable-alias credential + - name: Wait for successful CI on the exact deployment SHA if: steps.main.outputs.matches == 'true' + id: ci + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/runs?branch=main&event=push&per_page=20" + for attempt in $(seq 1 60); do + response="$(curl --fail --silent --show-error \ + --header 'Accept: application/vnd.github+json' \ + --header "Authorization: Bearer ${GITHUB_TOKEN}" \ + --header 'X-GitHub-Api-Version: 2022-11-28' \ + "${api}")" + if jq -e --arg sha "${DEPLOYMENT_SHA}" \ + '.workflow_runs | any(.head_sha == $sha and .status == "completed" and .conclusion == "success")' \ + <<< "${response}" >/dev/null; then + echo 'passed=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + if jq -e --arg sha "${DEPLOYMENT_SHA}" \ + '.workflow_runs | any(.head_sha == $sha and .status == "completed" and (.conclusion | IN("failure", "cancelled", "timed_out", "action_required")))' \ + <<< "${response}" >/dev/null; then + echo "::error title=Production promotion blocked::CI completed unsuccessfully for deployment SHA ${DEPLOYMENT_SHA}; the stable alias was not changed." + exit 1 + fi + echo "Waiting for successful CI on ${DEPLOYMENT_SHA} (${attempt}/60)." + sleep 30 + done + echo "::error title=Production promotion timed out::No successful main push CI run was observed for deployment SHA ${DEPLOYMENT_SHA}; the stable alias was not changed." + exit 1 + + - name: Reconfirm deployment SHA is still current main + if: >- + steps.main.outputs.matches == 'true' && + steps.ci.outputs.passed == 'true' + id: current + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + ref_api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/git/ref/heads/main" + current_sha="$(curl --fail --silent --show-error \ + --header 'Accept: application/vnd.github+json' \ + --header "Authorization: Bearer ${GITHUB_TOKEN}" \ + --header 'X-GitHub-Api-Version: 2022-11-28' \ + "${ref_api}" | jq -r '.object.sha // empty')" + if ! [[ "${current_sha}" =~ ^[0-9a-f]{40}$ ]]; then + echo '::error title=Production promotion blocked::GitHub did not return a valid current main SHA; the stable alias was not changed.' + exit 1 + fi + if [[ "${DEPLOYMENT_SHA}" == "${current_sha}" ]]; then + echo 'matches=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + echo 'matches=false' >> "$GITHUB_OUTPUT" + echo "::notice::Main advanced to ${current_sha} while CI was running; deployment ${DEPLOYMENT_SHA} will not receive the stable alias." + + - name: Require stable-alias credential + if: >- + steps.current.outputs.matches == 'true' && + steps.ci.outputs.passed == 'true' id: credential shell: bash env: @@ -71,7 +134,8 @@ jobs: - name: Validate deployment URL if: >- - steps.main.outputs.matches == 'true' && + steps.current.outputs.matches == 'true' && + steps.ci.outputs.passed == 'true' && steps.credential.outputs.configured == 'true' shell: bash run: | @@ -83,7 +147,8 @@ jobs: - name: Verify immutable deployment release API if: >- - steps.main.outputs.matches == 'true' && + steps.current.outputs.matches == 'true' && + steps.ci.outputs.passed == 'true' && steps.credential.outputs.configured == 'true' shell: bash run: | @@ -91,7 +156,9 @@ jobs: release_api="${DEPLOYMENT_URL%/}/api/v1/releases/current" for attempt in 1 2 3 4 5 6; do if curl --fail --silent --show-error "${release_api}" \ - | jq -e '.data.id and .data.recordCounts.programs' >/dev/null; then + | jq -e --arg sha "${DEPLOYMENT_SHA}" \ + '.data.deploymentSha == $sha and .data.id + and (.data.publicCounts.programs | type == "number" and . > 0)' >/dev/null; then exit 0 fi sleep 10 @@ -101,41 +168,121 @@ jobs: - name: Use Node.js 24 if: >- - steps.main.outputs.matches == 'true' && + steps.current.outputs.matches == 'true' && + steps.ci.outputs.passed == 'true' && steps.credential.outputs.configured == 'true' uses: actions/setup-node@v6 with: node-version: 24 - - name: Promote stable production alias + - name: Promote stable production alias transaction and verify release API if: >- - steps.main.outputs.matches == 'true' && + steps.current.outputs.matches == 'true' && + steps.ci.outputs.passed == 'true' && steps.credential.outputs.configured == 'true' shell: bash env: + GITHUB_TOKEN: ${{ github.token }} VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} run: | set -euo pipefail + + previous_target='' + mutation_attempted=false + transaction_committed=false + + github_main_sha() { + curl --fail --silent --show-error \ + --header 'Accept: application/vnd.github+json' \ + --header "Authorization: Bearer ${GITHUB_TOKEN}" \ + --header 'X-GitHub-Api-Version: 2022-11-28' \ + "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/git/ref/heads/main" \ + | jq -r '.object.sha // empty' + } + + current_stable_target() { + npx --yes vercel@58.0.0 alias list \ + --format json \ + --limit 100 \ + --scope henry-yangs-projects-c9706eac \ + --token "${VERCEL_TOKEN}" \ + | jq -er ' + [.aliases[] | select(.alias == "studyinchina.vercel.app")] as $matches + | if ($matches | length) == 1 then $matches[0].url + else error("stable alias must have exactly one current target") + end + ' + } + + rollback_on_failure() { + status=$? + trap - EXIT + if [[ "${status}" -eq 0 || "${mutation_attempted}" != 'true' || "${transaction_committed}" == 'true' ]]; then + exit "${status}" + fi + + echo "::error title=Stable alias transaction failed::Rolling studyinchina.vercel.app back to ${previous_target}." + if ! npx --yes vercel@58.0.0 alias set \ + "${previous_target}" \ + studyinchina.vercel.app \ + --scope henry-yangs-projects-c9706eac \ + --token "${VERCEL_TOKEN}"; then + echo '::error title=Stable alias rollback failed::The previous target could not be restored; immediate operator action is required.' + exit 1 + fi + + for attempt in 1 2 3 4 5 6; do + restored_target="$(current_stable_target || true)" + if [[ "${restored_target}" == "${previous_target}" ]]; then + echo "::notice title=Stable alias rollback verified::studyinchina.vercel.app again targets ${previous_target}." + exit "${status}" + fi + sleep 10 + done + + echo "::error title=Stable alias rollback verification failed::Expected ${previous_target}, but the stable alias did not return to that immutable target. Immediate operator action is required." + exit 1 + } + + trap rollback_on_failure EXIT + + previous_target="$(current_stable_target)" + if ! [[ "${previous_target}" =~ ^studyinchina-[a-z0-9-]+\.vercel\.app$ ]]; then + echo "Unexpected previous stable-alias target: ${previous_target}" >&2 + exit 1 + fi + + final_main_sha="$(github_main_sha || true)" + if ! [[ "${final_main_sha}" =~ ^[0-9a-f]{40}$ ]] || [[ "${final_main_sha}" != "${DEPLOYMENT_SHA}" ]]; then + echo "::error title=Production promotion blocked::Main is ${final_main_sha:-unavailable}, not deployment ${DEPLOYMENT_SHA}; the stable alias was not changed." + exit 1 + fi + + mutation_attempted=true npx --yes vercel@58.0.0 alias set \ "${DEPLOYMENT_URL}" \ studyinchina.vercel.app \ --scope henry-yangs-projects-c9706eac \ --token "${VERCEL_TOKEN}" - - name: Verify stable alias release API - if: >- - steps.main.outputs.matches == 'true' && - steps.credential.outputs.configured == 'true' - shell: bash - run: | - set -euo pipefail + post_promotion_main_sha="$(github_main_sha || true)" + if ! [[ "${post_promotion_main_sha}" =~ ^[0-9a-f]{40}$ ]] || [[ "${post_promotion_main_sha}" != "${DEPLOYMENT_SHA}" ]]; then + echo "::error title=Production promotion raced with main::Main advanced to ${post_promotion_main_sha:-unavailable} while the alias was changing." + exit 1 + fi + for attempt in 1 2 3 4 5 6; do if curl --fail --silent --show-error \ https://studyinchina.vercel.app/api/v1/releases/current \ - | jq -e '.data.id and .data.recordCounts.programs' >/dev/null; then + | jq -e --arg sha "${DEPLOYMENT_SHA}" \ + '.data.deploymentSha == $sha + and (.data.id | type == "string" and length > 0) + and (.data.publicCounts.programs | type == "number" and . > 0)' >/dev/null; then + transaction_committed=true + echo "::notice title=Stable alias promotion verified::studyinchina.vercel.app serves deployment ${DEPLOYMENT_SHA}." exit 0 fi sleep 10 done - echo 'The stable production alias did not pass the release API smoke test.' >&2 + echo 'The stable production alias did not pass the release API smoke test; the previous immutable target will be restored.' >&2 exit 1 diff --git a/.gitignore b/.gitignore index 05cc49e..692fd37 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ artifacts/official-harvest/ .wrangler/ .vercel/ .wrangler-*/ +.codex-patches/ diff --git a/README.md b/README.md index 4ece4cf..0d0345b 100644 --- a/README.md +++ b/README.md @@ -35,12 +35,12 @@ The product is built around three promises:
-| **266** universities | **1,234** programs | **356** scholarships | **62** cities | +| **266** universities | **1,233** programs | **351** scholarships | **62** cities | |:---:|:---:|:---:|:---:|
-The public catalogue also contains **256 published admission-cycle records** and is backed by **2,070 registered official source records**. Snapshot evaluated for **2026-08-10** with `npm run quality:platform-scorecard`. +The public catalogue contains **52 published cycle or fee-reference records** and is backed by **2,070 registered official source records**. A published record is not automatically an open application window; the decision-oriented metrics below are evaluated for **2026-08-16** with `npm run quality:platform-scorecard`.
Open the honest data-depth scorecard @@ -50,12 +50,15 @@ Record count is not the same as record completeness. These are the current depth | Quality indicator | Current baseline | Next gate | |---|---:|---:| | Universities below three published programs | **8** | 0 | -| Programs with a current public cycle | 253 / 1,234 · **20.50%** | ≥ 70% | -| Programs with duration | **61.59%** | ≥ 90% | -| Programs with an official application route | **50.89%** | ≥ 80% | -| Programs with known teaching language | **85.09%** | ≥ 95% | -| Programs with eligibility/language evidence | **6.00%** | ≥ 50% | -| Universities connected to scholarships | 207 / 266 | ≥ 230 | +| Verified international-program identities | 1,233 / 1,233 · **100%** | Maintain 100% | +| Programs with a fresh 30-day disposition | 49 / 1,233 · **3.97%** | ≥ 70% | +| Programs with dated or rolling admissions | 49 / 1,233 · **3.97%** | Report honestly | +| Programs open or upcoming on the evaluation date | 10 / 1,233 · **0.81%** | Report honestly | +| Programs with duration | **61.64%** | ≥ 90% | +| Programs with an official application route | **50.85%** | ≥ 80% | +| Programs with known teaching language | **85.08%** | ≥ 95% | +| Programs with eligibility/language evidence | **6.16%** | ≥ 50% | +| Universities connected to scholarships | 204 / 266 | ≥ 230 | | Cities with reviewed coordinates | 27 / 62 | 62 / 62 | | Source Manifests registered | 10 / 266 | 266 / 266 | | Completed V2 Source Manifests | 0 / 266 | 266 / 266 | @@ -66,6 +69,20 @@ The raw compatibility dataset contains 272 universities, 1,255 programs and 384
+## Current trust-platform milestone + +The current release moves the project from a large static directory toward a measurable decision platform: + +- quality reporting now separates verified identity, fresh disposition, dated or rolling admissions, and active or upcoming applications; +- the program explorer exposes “Open now” and “Upcoming” as first-level routes, while language switching preserves semantic filters and resets release-bound cursors; +- Favorites loads only requested IDs through a four-program comparison projection instead of serializing the entire catalogue to the browser; +- Release metadata distinguishes raw and public counts, the data-check date, evaluation date, activation time, backend and Vercel deployment SHA; +- ten pilot universities now use strict Source Manifest V2 ledgers in `in_progress` state—none is mislabeled as fully reconciled; +- daily and monthly `raw-v1` backups for both D1 databases were uploaded to private R2, read back byte-for-byte and restored into isolated local databases in **101.198 seconds** with zero foreign-key violations; +- Catalog remains on the JSON compatibility backend while D1 Shadow parity, credentials and rollback evidence are completed. + +This milestone deliberately exposes the freshness gap: a fact becoming stale reduces the public count instead of silently remaining visible. That makes the lower current figures a trustworthy operational signal, not a regression hidden by optimistic counting. + ## What applicants can do - Browse universities, programs, scholarships and student cities in one coherent interface. @@ -273,7 +290,7 @@ See [`docs/platform-rollout.md`](./docs/platform-rollout.md), [`docs/backup-and- ```mermaid flowchart LR - N["Now
266 public universities"] --> D["Data depth
70% current-cycle coverage"] + N["Now
266 public universities"] --> D["Data depth
70% fresh dispositions"] D --> T["Trust coverage
266 manifests + reconciliations"] T --> C["D1 cutover
3 releases / 72h shadow parity"] C --> F["500 universities
after 2 healthy monthly cycles"] @@ -283,9 +300,10 @@ flowchart LR Near-term work is measured by: - raising the remaining 8 sparse universities to 3–5 verified international-student programs or a documented `limited` reconciliation; -- increasing current-cycle coverage from 20.50% to at least 70%; -- reaching 90% duration, 80% official application-route and 95% teaching-language coverage; -- expanding scholarship-connected institutions from 207 to at least 230; +- increasing fresh-disposition coverage from 3.97% to at least 70%, without counting date-free fee references as application cycles; +- reaching the six-week gates of 75% duration, 65% official application-route, 90% teaching-language and 25% requirements coverage; +- continuing after that toward the expansion gates of 90% duration, 80% application-route, 95% teaching-language and 50% requirements coverage; +- expanding scholarship-connected institutions from 204 to at least 230; - completing 266 Source Manifests and 266 catalogue reconciliations; - completing three matching shadow releases over at least 72 hours before Production switches to D1; - passing two full monthly update cycles before expansion to 500, then 1,000+ institutions. diff --git a/content/data/admission-cycles.json b/content/data/admission-cycles.json index fe4fe01..1cabfff 100644 --- a/content/data/admission-cycles.json +++ b/content/data/admission-cycles.json @@ -737,9 +737,9 @@ "sourceIds": [ "src-program-review-88495cf206e1" ], - "verifiedAt": "2026-08-02", - "reviewAfter": "2026-08-09", - "status": "stale", + "verifiedAt": "2026-08-10", + "reviewAfter": "2026-08-17", + "status": "verified", "id": "cycle-2026-a6e5661b86ff", "programId": "program-soochow-university-international-chinese-language-teachers-scholarship-o", "academicYear": "2026-2027", @@ -1745,9 +1745,9 @@ "sourceIds": [ "src-shnu-iclt-2026" ], - "verifiedAt": "2026-07-28", - "reviewAfter": "2026-08-04", - "status": "stale", + "verifiedAt": "2026-08-10", + "reviewAfter": "2026-08-17", + "status": "verified", "id": "cycle-2027-shnu-iclt-one-semester-spring", "programId": "program-shanghai-normal-university-iclt-one-semester-language", "academicYear": "2026-2027", @@ -1955,9 +1955,9 @@ "sourceIds": [ "src-wku-international-admissions-2027" ], - "verifiedAt": "2026-07-28", - "reviewAfter": "2026-08-04", - "status": "stale", + "verifiedAt": "2026-08-10", + "reviewAfter": "2026-08-17", + "status": "verified", "id": "cycle-2027-wenzhou-kean-university-finance-bs-spring-transfer", "programId": "program-wenzhou-kean-university-finance-bs", "academicYear": "2026-2027", @@ -1981,9 +1981,9 @@ "sourceIds": [ "src-wku-international-admissions-2027" ], - "verifiedAt": "2026-07-28", - "reviewAfter": "2026-08-04", - "status": "stale", + "verifiedAt": "2026-08-10", + "reviewAfter": "2026-08-17", + "status": "verified", "id": "cycle-2027-wenzhou-kean-university-global-business-bs-spring-transfer", "programId": "program-wenzhou-kean-university-global-business-bs", "academicYear": "2026-2027", @@ -2007,9 +2007,9 @@ "sourceIds": [ "src-wku-international-admissions-2027" ], - "verifiedAt": "2026-07-28", - "reviewAfter": "2026-08-04", - "status": "stale", + "verifiedAt": "2026-08-10", + "reviewAfter": "2026-08-17", + "status": "verified", "id": "cycle-2027-wenzhou-kean-university-computer-science-bs-spring-transfer", "programId": "program-wenzhou-kean-university-computer-science-bs", "academicYear": "2026-2027", @@ -2033,9 +2033,9 @@ "sourceIds": [ "src-wku-international-admissions-2027" ], - "verifiedAt": "2026-07-28", - "reviewAfter": "2026-08-04", - "status": "stale", + "verifiedAt": "2026-08-10", + "reviewAfter": "2026-08-17", + "status": "verified", "id": "cycle-2027-wenzhou-kean-university-biology-cell-molecular-bs-spring-transfer", "programId": "program-wenzhou-kean-university-biology-cell-molecular-bs", "academicYear": "2026-2027", @@ -2059,9 +2059,9 @@ "sourceIds": [ "src-wku-international-admissions-2027" ], - "verifiedAt": "2026-07-28", - "reviewAfter": "2026-08-04", - "status": "stale", + "verifiedAt": "2026-08-10", + "reviewAfter": "2026-08-17", + "status": "verified", "id": "cycle-2027-wenzhou-kean-university-architecture-bfa-spring-transfer", "programId": "program-wenzhou-kean-university-architecture-bfa", "academicYear": "2026-2027", @@ -3201,9 +3201,9 @@ "sourceIds": [ "src-schwarzman-application-2027" ], - "verifiedAt": "2026-08-07", - "reviewAfter": "2026-08-10", - "status": "verified" + "verifiedAt": "2026-08-10", + "reviewAfter": "2026-08-13", + "status": "stale" }, { "id": "cycle-gdut-chinese-language-training-rolling", @@ -3224,7 +3224,7 @@ ], "verifiedAt": "2026-08-07", "reviewAfter": "2026-08-14", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-local-strong-ahmu-m-anesthesiology-2026-2027-other", @@ -3324,7 +3324,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-csw-ahu-finance-bachelor-2026-2027-other-fee-reference", @@ -3346,7 +3346,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-csw-ahu-international-chinese-education-bachelor-2026-2027-other-fee-reference", @@ -3368,7 +3368,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-csw-ahu-software-engineering-bachelor-2026-2027-other-fee-reference", @@ -3390,7 +3390,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-chinese-degree-bfsu-international-chinese-education-bachelor-2026-2027-autumn", @@ -3720,7 +3720,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-ne-bisu-chinese-language-bachelor-2025-2026-spring-fee-reference", @@ -3742,7 +3742,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-ne-bisu-international-chinese-education-master-2026-2027-other-fee-reference", @@ -3764,7 +3764,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-ne-bisu-chinese-language-nondegree-2025-2026-spring-fee-reference", @@ -3785,7 +3785,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-ne-bjtu-logistics-engineering-master-en-2026-2027-other-fee-reference", @@ -3806,7 +3806,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-ne-bjtu-rail-transit-signal-bachelor-en-2026-2027-other-fee-reference", @@ -3827,7 +3827,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-ne-bjtu-transportation-planning-doctorate-2026-2027-other-fee-reference", @@ -3848,7 +3848,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-chinese-degree-blcu-international-chinese-education-doctorate-2026-2027-autumn", @@ -3978,7 +3978,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-bsu-international-chinese-education-bachelor-2026-2027-autumn-fee-reference", @@ -3999,7 +3999,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-bsu-international-chinese-education-master-2026-2027-autumn", @@ -4026,7 +4026,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-bsu-wushu-training-2026-2027-autumn", @@ -4052,7 +4052,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-ne-btbu-business-administration-bachelor-2026-2027-autumn", @@ -4078,7 +4078,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-ne-btbu-finance-bachelor-2026-2027-autumn", @@ -4104,7 +4104,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-ne-btbu-international-economics-trade-bachelor-2026-2027-autumn", @@ -4130,7 +4130,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-ne-btbu-polymer-materials-bachelor-2026-2027-autumn", @@ -4156,7 +4156,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave8-2-bwu-international-trade-economics-bachelor-2026-2027-other-fee-reference", @@ -4250,7 +4250,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-cad-drama-film-doctorate-2026-2027-autumn", @@ -4277,7 +4277,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-csw-ccnu-chinese-literature-icl-bachelor-2026-2027-autumn", @@ -4303,7 +4303,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-csw-ccnu-international-chinese-education-master-2026-2027-autumn", @@ -4329,7 +4329,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-ccom-music-master-2026-2027-autumn", @@ -4355,7 +4355,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-ccom-music-doctorate-2026-2027-autumn", @@ -4381,7 +4381,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-ccom-general-visiting-music-2025-2026-spring-fee-reference", @@ -4402,7 +4402,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-ccom-general-visiting-music-2026-2027-autumn-fee-reference", @@ -4423,7 +4423,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-ccom-senior-visiting-music-2025-2026-spring-fee-reference", @@ -4444,7 +4444,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-ccom-senior-visiting-music-2026-2027-autumn-fee-reference", @@ -4465,7 +4465,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-sparse-depth-0808-csu-computing-science-bachelor-2026-2027-other-fee-reference", @@ -4486,7 +4486,7 @@ ], "verifiedAt": "2026-08-08", "reviewAfter": "2026-08-15", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-sparse-depth-0808-csu-mechanical-engineering-bachelor-2026-2027-other-fee-reference", @@ -4507,7 +4507,7 @@ ], "verifiedAt": "2026-08-08", "reviewAfter": "2026-08-15", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave3-cust-b-software-engineering-en-2026-2027-other-fee-reference", @@ -4550,7 +4550,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave8-2-cuit-atmospheric-science-master-2026-2027-autumn-fee-reference", @@ -4635,7 +4635,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-scws-cuit-social-work-master-2026-2027-autumn-fee-reference", @@ -4656,7 +4656,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave4-cdut-b-computer-science-and-technology-2026-2027-autumn-fee-reference", @@ -4762,7 +4762,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecma-caa-product-design-bachelor-2026-2027-autumn-fee-reference", @@ -4784,7 +4784,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-sparse-depth-0808-ccmusic-composition-bachelor-2026-2027-other-fee-reference", @@ -4805,7 +4805,7 @@ ], "verifiedAt": "2026-08-08", "reviewAfter": "2026-08-15", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-sparse-depth-0808-ccmusic-music-education-vocal-bachelor-2026-2027-other-fee-reference", @@ -4826,7 +4826,7 @@ ], "verifiedAt": "2026-08-08", "reviewAfter": "2026-08-15", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-sparse-depth-0808-ccmusic-conducting-bachelor-2026-2027-other-fee-reference", @@ -4847,7 +4847,7 @@ ], "verifiedAt": "2026-08-08", "reviewAfter": "2026-08-15", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-cfau-international-economics-trade-bachelor-2026-2027-autumn", @@ -4873,7 +4873,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-cfau-international-law-bachelor-2026-2027-autumn", @@ -4899,7 +4899,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-remaining-cjlu-b-business-administration-en-2026-2027-other", @@ -5050,7 +5050,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecs-cumt-chinese-one-year-2026-2027-autumn-fee-reference", @@ -5071,7 +5071,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecs-cumt-safety-science-master-2026-2027-autumn-fee-reference", @@ -5094,7 +5094,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave5-depth-cumtb-artificial-intelligence-bachelor-2026-2027-other-fee-reference", @@ -5250,7 +5250,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-ne-upc-chinese-language-semester-2026-2027-autumn-fee-reference", @@ -5272,7 +5272,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-ne-upc-petroleum-engineering-bachelor-en-2026-2027-autumn-fee-reference", @@ -5295,7 +5295,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-ne-upc-foundation-semester-2026-2027-autumn-fee-reference", @@ -5317,7 +5317,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-cupl-chinese-language-2025-2026-autumn", @@ -5343,7 +5343,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-cupl-law-bachelor-2026-2027-autumn", @@ -5369,7 +5369,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave4-cqmu-b-medical-imaging-2026-2027-autumn", @@ -5604,7 +5604,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-jzh-cuz-digital-media-technology-bachelor-2026-2027-autumn", @@ -5630,7 +5630,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-jzh-cuz-iclt-master-2026-2027-autumn", @@ -5657,7 +5657,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave8-2-cuz-international-journalism-communication-bachelor-2026-2027-autumn-fee-reference", @@ -5721,7 +5721,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-breadth-dlmu-foundation-college-preparatory-2026-2027-autumn-fee-reference", @@ -5743,7 +5743,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-breadth-dlmu-foundation-college-preparatory-2026-2027-other", @@ -5770,7 +5770,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-ne-dlmu-marine-engineering-bachelor-2026-2027-other-fee-reference", @@ -5792,7 +5792,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-ne-dlmu-nautical-science-bachelor-2026-2027-other-fee-reference", @@ -5814,7 +5814,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-local-dmu-b-clinical-medicine-cn-2026-2027-other-fee-reference", @@ -5993,7 +5993,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-dufe-chinese-language-2026-2027-spring", @@ -6138,7 +6138,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-scws-dgut-integrated-circuit-engineering-master-2026-2027-autumn", @@ -6165,7 +6165,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave3-dgut-international-business-master-2026-2027-autumn-fee-reference", @@ -6215,7 +6215,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave3-dgut-software-engineering-master-2026-2027-autumn-fee-reference", @@ -6420,7 +6420,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave8-ecupl-chinese-language-training-2026-2027-other-fee-reference", @@ -6467,7 +6467,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave8-ecupl-international-commercial-law-bachelor-2026-2027-autumn", @@ -6519,7 +6519,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecs-ecust-business-chinese-2026-2027-autumn", @@ -6545,7 +6545,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecs-ecust-intensive-chinese-2026-2027-autumn", @@ -6571,7 +6571,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecs-ecust-standard-chinese-2026-2027-autumn", @@ -6597,7 +6597,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-chinese-degree-fudan-chinese-language-foreign-bachelor-2026-2027-autumn", @@ -6737,7 +6737,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-csw-fzu-electrical-theory-new-technology-doctorate-2026-2027-autumn-fee-reference", @@ -6758,7 +6758,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-csw-fzu-geotechnical-engineering-master-2026-2027-autumn-fee-reference", @@ -6779,7 +6779,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-csw-fzu-municipal-engineering-master-2026-2027-autumn-fee-reference", @@ -6800,7 +6800,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-0805-scw-gdou-aquaculture-bachelor-2026-2027-other-fee-reference", @@ -6821,7 +6821,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-0805-scw-gdou-tcsol-bachelor-2026-2027-other-fee-reference", @@ -6842,7 +6842,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-csw-gdufe-digital-economy-master-2026-2027-autumn-fee-reference", @@ -6864,7 +6864,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-local-strong-gdufe-b-international-business-en-2026-2027-other", @@ -6913,7 +6913,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-local-strong-gdufe-b-international-economics-trade-en-2026-2027-other", @@ -6962,7 +6962,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-csw-gdufe-international-trade-doctorate-2026-2027-autumn-fee-reference", @@ -6984,7 +6984,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-gdufs-chinese-thai-translation-bachelor-2026-2027-other-fee-reference", @@ -7219,7 +7219,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-csw-gxnu-chinese-language-literature-doctorate-2026-2027-autumn-fee-reference", @@ -7240,7 +7240,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-csw-gxnu-computer-science-technology-master-2026-2027-autumn-fee-reference", @@ -7261,7 +7261,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-sparse-depth-0808-gxu-chinese-language-major-bachelor-2026-2027-other-fee-reference", @@ -7282,7 +7282,7 @@ ], "verifiedAt": "2026-08-08", "reviewAfter": "2026-08-15", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-sparse-depth-0808-gxu-chinese-language-student-2026-2027-other-fee-reference", @@ -7303,7 +7303,7 @@ ], "verifiedAt": "2026-08-08", "reviewAfter": "2026-08-15", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave3-gxtcmu-acupuncture-tuina-bachelor-2026-2027-autumn", @@ -7428,7 +7428,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecma-gafa-chinese-painting-bachelor-2026-2027-autumn-fee-reference", @@ -7449,7 +7449,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecma-gafa-visual-communication-bachelor-2026-2027-autumn-fee-reference", @@ -7470,7 +7470,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-local-gzucm-b-tcm-cn-2026-2027-other-fee-reference", @@ -7518,7 +7518,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-local-strong-glmu-b-clinical-medicine-cn-2026-2027-other", @@ -7570,7 +7570,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-scws-glmu-medical-laboratory-technology-bachelor-2026-2027-autumn-fee-reference", @@ -7591,7 +7591,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-scws-glmu-nursing-bachelor-2026-2027-autumn-fee-reference", @@ -7612,7 +7612,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-local-strong-glmu-b-pharmacy-cn-2026-2027-other", @@ -7659,7 +7659,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave3-guet-ai-english-bachelor-2026-2027-other-fee-reference", @@ -7765,7 +7765,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-0805-scw-glut-cs-bachelor-english-2026-2027-other-fee-reference", @@ -7787,7 +7787,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-0805-scw-glut-long-chinese-year-2026-2027-other-fee-reference", @@ -7808,7 +7808,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-0805-scw-glut-tcsol-bachelor-2026-2027-other-fee-reference", @@ -7830,7 +7830,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-local-strong-gmu-b-clinical-medicine-cn-2026-2027-other", @@ -8059,7 +8059,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-scws-hmu-public-health-professional-master-2026-2027-other-fee-reference", @@ -8080,7 +8080,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-scws-hmu-traditional-chinese-medicine-bachelor-2026-2027-other-fee-reference", @@ -8101,7 +8101,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-scws-hainnu-international-chinese-education-master-2026-2027-autumn-fee-reference", @@ -8122,7 +8122,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-scws-hainnu-international-journalism-bachelor-2026-2027-autumn-fee-reference", @@ -8143,7 +8143,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-scws-hainnu-mechanical-automation-bachelor-2026-2027-autumn-fee-reference", @@ -8164,7 +8164,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-scws-hainnu-standard-chinese-language-2026-2027-other-fee-reference", @@ -8185,7 +8185,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-scws-hainnu-tcsol-bachelor-2026-2027-autumn-fee-reference", @@ -8206,7 +8206,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-clw-sw-hainanu-chinese-culture-semester-2026-2027-spring", @@ -8624,7 +8624,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nnw-hebtu-international-chinese-education-doctorate-2026-2027-autumn-fee-reference", @@ -8646,7 +8646,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nnw-hebtu-international-chinese-education-master-2026-2027-autumn-fee-reference", @@ -8668,7 +8668,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-hebut-business-administration-bachelor-2026-2027-autumn-fee-reference", @@ -8689,7 +8689,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-hebut-electrical-engineering-doctorate-2026-2027-autumn-fee-reference", @@ -8710,7 +8710,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-hebut-electronic-science-technology-bachelor-2026-2027-autumn-fee-reference", @@ -8731,7 +8731,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-hebut-international-chinese-education-bachelor-2026-2027-autumn-fee-reference", @@ -8752,7 +8752,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecs-hfut-biomedical-engineering-bachelor-2026-2027-autumn-fee-reference", @@ -8773,7 +8773,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecs-hfut-civil-engineering-bachelor-2026-2027-autumn-fee-reference", @@ -8794,7 +8794,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecs-hfut-international-economics-bachelor-2026-2027-autumn-fee-reference", @@ -8815,7 +8815,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave4-fastpack-hlju-chinese-language-literature-bachelor-2026-2027-autumn-fee-reference", @@ -8886,7 +8886,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-csw-henu-mathematics-doctorate-2026-2027-autumn", @@ -8913,7 +8913,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-csw-henu-software-engineering-master-2026-2027-autumn", @@ -8940,7 +8940,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-csw-henu-traditional-chinese-sports-doctorate-2026-2027-autumn", @@ -8967,7 +8967,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave3-haut-m-applied-economics-2026-2027-autumn", @@ -9422,7 +9422,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-ne-imu-chinese-one-semester-spring-2026-2027-spring", @@ -9448,7 +9448,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-local-strong-just-b-business-administration-en-2026-2027-other", @@ -9521,7 +9521,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-jzh-just-management-science-doctorate-2026-2027-autumn-fee-reference", @@ -9542,7 +9542,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-jzh-just-mba-master-2026-2027-autumn-fee-reference", @@ -9563,7 +9563,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-jzh-just-naval-architecture-master-2026-2027-autumn-fee-reference", @@ -9584,7 +9584,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave8-jlu-chinese-language-bachelor-2026-2027-autumn-fee-reference", @@ -9989,7 +9989,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nnw-lnnu-chinese-language-study-2026-2027-other-fee-reference", @@ -10010,7 +10010,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-lnnu-information-management-systems-bachelor-2026-2027-autumn-fee-reference", @@ -10031,7 +10031,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-lnnu-international-business-bachelor-2026-2027-autumn-fee-reference", @@ -10052,7 +10052,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nnw-lnnu-international-chinese-education-master-2026-2027-other-fee-reference", @@ -10073,7 +10073,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave4-depth-lnu-finance-master-english-2026-2027-autumn-fee-reference", @@ -10211,7 +10211,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-scws-ncu-chinese-language-training-2026-2027-other-fee-reference", @@ -10232,7 +10232,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecma-njau-food-science-bachelor-2026-2027-autumn", @@ -10258,7 +10258,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecma-njau-seed-science-bachelor-2026-2027-autumn", @@ -10284,7 +10284,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecma-njfu-forest-genetics-master-2026-2027-autumn-fee-reference", @@ -10305,7 +10305,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecma-njfu-wood-science-bachelor-2026-2027-autumn-fee-reference", @@ -10326,7 +10326,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecma-njmu-clinical-medicine-chinese-bachelor-2026-2027-autumn-fee-reference", @@ -10347,7 +10347,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecs-njtech-chemical-engineering-doctorate-2026-2027-other-fee-reference", @@ -10369,7 +10369,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecs-njtech-chemical-engineering-bachelor-2026-2027-other-fee-reference", @@ -10392,7 +10392,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecs-njtech-iclt-bachelor-2026-2027-other-fee-reference", @@ -10414,7 +10414,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecs-njtech-pharmacology-master-2026-2027-other-fee-reference", @@ -10436,7 +10436,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-nju-2026-bachelor-tcsol-2026-2027-autumn", @@ -10514,7 +10514,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecs-nuaa-software-engineering-master-2026-2027-autumn", @@ -10540,7 +10540,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecs-nuaa-transportation-engineering-master-2026-2027-autumn", @@ -10566,7 +10566,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecma-njucm-materia-medica-pharmacy-bachelor-2026-2027-autumn-fee-reference", @@ -10587,7 +10587,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecma-njucm-health-preservation-bachelor-2026-2027-autumn-fee-reference", @@ -10608,7 +10608,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-jzh-nufe-accounting-bachelor-2026-2027-autumn-fee-reference", @@ -10629,7 +10629,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave8-nufe-applied-economics-master-2026-2027-autumn-fee-reference", @@ -10671,7 +10671,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave8-nufe-business-administration-bachelor-2026-2027-autumn-fee-reference", @@ -10713,7 +10713,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-jzh-nufe-chinese-literature-bachelor-2026-2027-autumn-fee-reference", @@ -10734,7 +10734,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-jzh-nuist-chinese-literature-bachelor-2026-2027-autumn", @@ -10760,7 +10760,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-jzh-nuist-iclt-semester-2026-2027-autumn", @@ -10786,7 +10786,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-jzh-nuist-iclt-semester-2026-2027-spring", @@ -10812,7 +10812,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-jzh-nuist-iclt-one-year-2026-2027-autumn", @@ -10838,7 +10838,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-jzh-nuist-iclt-master-2026-2027-autumn", @@ -10864,7 +10864,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-jzh-njupt-bba-bachelor-2026-2027-autumn-fee-reference", @@ -10887,7 +10887,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-jzh-njupt-business-administration-master-2026-2027-autumn-fee-reference", @@ -10910,7 +10910,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecs-ntu-chinese-semester-2026-2027-other-fee-reference", @@ -10932,7 +10932,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-local-strong-ntu-b-mbbs-en-2026-2027-other-fee-reference", @@ -10995,7 +10995,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecs-ntu-iclt-bachelor-2026-2027-other-fee-reference", @@ -11016,7 +11016,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecs-ntu-pharmacy-bachelor-2026-2027-other-fee-reference", @@ -11037,7 +11037,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-remaining-nbu-nondegree-chinese-language-2026-2027-other", @@ -11083,7 +11083,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nnw-nxmu-clinical-medicine-master-2026-2027-other-fee-reference", @@ -11104,7 +11104,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nnw-nxmu-mbbs-english-bachelor-2026-2027-other-fee-reference", @@ -11125,7 +11125,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-ncepu-chinese-language-2026-2027-autumn-fee-reference", @@ -11146,7 +11146,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-ncepu-electrical-engineering-doctorate-2026-2027-autumn-fee-reference", @@ -11167,7 +11167,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-ncepu-new-energy-science-engineering-bachelor-2026-2027-autumn-fee-reference", @@ -11188,7 +11188,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-ncepu-power-engineering-thermophysics-master-2026-2027-autumn-fee-reference", @@ -11209,7 +11209,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-local-strong-ncut-b-architecture-en-2026-2027-other", @@ -11313,7 +11313,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-ne-neau-food-science-doctorate-2026-2027-autumn", @@ -11339,7 +11339,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-ne-neau-international-chinese-education-master-2026-2027-autumn", @@ -11366,7 +11366,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-ne-neau-tcsol-bachelor-2026-2027-autumn-fee-reference", @@ -11387,7 +11387,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nnw-neepu-computer-science-bachelor-2026-2027-autumn-fee-reference", @@ -11408,7 +11408,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nnw-neepu-electrical-engineering-bachelor-2026-2027-autumn-fee-reference", @@ -11429,7 +11429,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nnw-neepu-energy-power-engineering-bachelor-2026-2027-autumn-fee-reference", @@ -11450,7 +11450,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-ne-nefu-ecology-master-2026-2027-autumn-fee-reference", @@ -11472,7 +11472,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-ne-nefu-environmental-engineering-master-2026-2027-autumn-fee-reference", @@ -11494,7 +11494,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-ne-nefu-forestry-bachelor-2026-2027-autumn-fee-reference", @@ -11515,7 +11515,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-nenu-2026-bachelor-tcsol-2026-2027-autumn", @@ -11698,7 +11698,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-scws-nwnu-international-chinese-education-master-2026-2027-other-fee-reference", @@ -11719,7 +11719,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-scws-nwnu-international-chinese-education-doctorate-2026-2027-other-fee-reference", @@ -11740,7 +11740,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nnw-nwupl-chinese-language-2026-2027-autumn-fee-reference", @@ -11761,7 +11761,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave8-2-nwupl-cross-border-ecommerce-bachelor-2026-2027-autumn", @@ -11808,7 +11808,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nnw-nwupl-law-doctorate-2026-2027-autumn-fee-reference", @@ -11829,7 +11829,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave8-2-nwupl-law-bachelor-2026-2027-autumn", @@ -12335,7 +12335,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-local-strong-qust-language-chinese-2026-2027-other-fee-reference", @@ -12378,7 +12378,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave5-west-snnu-curriculum-instruction-doctorate-english-2026-2027-autumn", @@ -12714,7 +12714,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecma-sdutcm-materia-medica-doctorate-2026-2027-autumn-fee-reference", @@ -12735,7 +12735,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecma-sdutcm-tcm-bachelor-2026-2027-autumn-fee-reference", @@ -12756,7 +12756,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecma-shcm-art-theory-doctorate-2026-2027-autumn-fee-reference", @@ -12777,7 +12777,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecma-shcm-music-therapy-master-2026-2027-autumn-fee-reference", @@ -12798,7 +12798,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-jzh-lixin-business-administration-bachelor-2026-2027-other-fee-reference", @@ -12819,7 +12819,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-jzh-lixin-chinese-literature-bachelor-2026-2027-other-fee-reference", @@ -12840,7 +12840,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-jzh-lixin-chinese-semester-2026-2027-other-fee-reference", @@ -12861,7 +12861,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave8-lixin-finance-bachelor-2026-2027-other-fee-reference", @@ -12926,7 +12926,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-local-shmtu-b-marine-navigation-bilingual-2026-2027-other-fee-reference", @@ -13092,7 +13092,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-jzh-shou-food-science-master-2026-2027-autumn", @@ -13118,7 +13118,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-jzh-shou-long-chinese-2026-2027-autumn", @@ -13144,7 +13144,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-jzh-shou-marine-science-doctorate-2026-2027-autumn", @@ -13170,7 +13170,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-root-sta-acting-bachelor-2026-2027-autumn-fee-reference", @@ -13191,7 +13191,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-root-sta-chinese-language-semester-2025-2026-spring-fee-reference", @@ -13212,7 +13212,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-root-sta-dance-chinese-bachelor-2026-2027-autumn-fee-reference", @@ -13233,7 +13233,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-root-sta-digital-media-art-bachelor-2026-2027-autumn-fee-reference", @@ -13254,7 +13254,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-root-sta-intercultural-communication-master-2026-2027-autumn", @@ -13282,7 +13282,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-root-sta-directing-bachelor-2026-2027-autumn-fee-reference", @@ -13303,7 +13303,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-shu-2026-bachelor-icle-2026-2027-autumn", @@ -13376,7 +13376,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-jzh-suep-electrical-engineering-doctorate-2026-2027-autumn-fee-reference", @@ -13397,7 +13397,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave8-2-suep-electrical-engineering-automation-bachelor-2026-2027-autumn-fee-reference", @@ -13439,7 +13439,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave8-2-suep-international-economy-trade-bachelor-2026-2027-autumn-fee-reference", @@ -13481,7 +13481,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecma-shupl-iclte-bachelor-2026-2027-other-fee-reference", @@ -13502,7 +13502,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecma-shupl-international-politics-bachelor-2026-2027-other-fee-reference", @@ -13523,7 +13523,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecma-sus-sport-rehabilitation-doctorate-2026-2027-autumn", @@ -13549,7 +13549,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-ecma-sus-traditional-chinese-sports-master-2026-2027-autumn", @@ -13575,7 +13575,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-sxu-foundation-2026-2027-autumn-fee-reference", @@ -13596,7 +13596,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-sxu-chinese-language-2026-2027-autumn", @@ -13622,7 +13622,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave4-medicine-sxtcm-acupuncture-massage-bachelor-2026-2027-autumn", @@ -13755,7 +13755,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-synu-international-chinese-education-bachelor-2026-2027-autumn", @@ -13782,7 +13782,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-synu-international-chinese-education-master-2026-2027-autumn", @@ -13808,7 +13808,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-synu-one-year-chinese-study-2026-2027-autumn", @@ -13834,7 +13834,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave4-medicine-syphu-medicinal-chemistry-master-2026-2027-other-fee-reference", @@ -14159,7 +14159,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-0805-scw-scfai-art-studies-master-2026-2027-other-fee-reference", @@ -14180,7 +14180,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-0805-scw-scfai-painting-bachelor-2026-2027-other-fee-reference", @@ -14201,7 +14201,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-local-sisu-chinese-language-year-2026-2027-other", @@ -14462,7 +14462,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-jzh-suda-long-chinese-year-2026-2027-autumn", @@ -14476,18 +14476,18 @@ "tuitionPeriod": "academic-year", "tuitionStatus": "confirmed", "evidenceBasis": "cycle-specific", - "factScope": "partial", - "applicationFeeCny": null, + "factScope": "complete", + "applicationFeeCny": 500, "notes": { - "en": "The official long-term Chinese notice lists the academic-year option and CNY 17,000 annual tuition; the published 2026 application window ends 30 September.", - "zh": "官方长期汉语通知列出一学年选项和每学年17000元学费,2026年申请窗口截至9月30日。", - "ru": "Официальная страница указывает годовой вариант, плату 17 000 юаней и срок подачи 30 сентября 2026 года." + "en": "The official long-term Chinese notice lists the academic-year option, CNY 17,000 tuition, a CNY 500 application fee and a September 30, 2026 deadline.", + "zh": "官方长期汉语通知列出一学年选项、17000元学费、500元报名费及2026年9月30日截止日期。", + "ru": "Официальная страница указывает годовой вариант, плату 17 000 юаней, регистрационный сбор 500 юаней и срок 30 сентября 2026 года." }, "sourceIds": [ "src-gap-program-mve-jzh-suda-long-chinese-year" ], - "verifiedAt": "2026-08-03", - "reviewAfter": "2026-08-10", + "verifiedAt": "2026-08-10", + "reviewAfter": "2026-08-17", "status": "verified" }, { @@ -14502,18 +14502,18 @@ "tuitionPeriod": "semester", "tuitionStatus": "confirmed", "evidenceBasis": "cycle-specific", - "factScope": "partial", - "applicationFeeCny": null, + "factScope": "complete", + "applicationFeeCny": 500, "notes": { - "en": "The official 2026 notice publishes the semester dates, CNY 8,500 tuition and an application window ending 30 September 2026.", - "zh": "苏州大学2026年官方通知公布了长期汉语一学期项目、8500元学费及2026年9月30日截止日期。", - "ru": "Официальное объявление 2026 года подтверждает семестровую программу, плату 8 500 юаней и срок 30 сентября 2026 года." + "en": "The official 2026 notice publishes the semester dates, CNY 8,500 tuition, a CNY 500 application fee and a September 30, 2026 deadline.", + "zh": "苏州大学2026年官方通知公布长期汉语一学期项目、8500元学费、500元报名费及2026年9月30日截止日期。", + "ru": "Официальное объявление 2026 года подтверждает семестровую программу, плату 8 500 юаней, регистрационный сбор 500 юаней и срок 30 сентября 2026 года." }, "sourceIds": [ "src-gap-program-mve-jzh-suda-long-chinese-semester" ], - "verifiedAt": "2026-08-03", - "reviewAfter": "2026-08-10", + "verifiedAt": "2026-08-10", + "reviewAfter": "2026-08-17", "status": "verified" }, { @@ -14537,7 +14537,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-chinese-degree-scnu-chinese-language-bachelor-2026-2027-autumn-fee-reference", @@ -14910,7 +14910,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-tyut-foundation-2026-2027-autumn-fee-reference", @@ -14931,7 +14931,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-tyut-mechanical-engineering-master-2026-2027-autumn-fee-reference", @@ -14952,7 +14952,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-tyut-teaching-chinese-bachelor-2026-2027-autumn-fee-reference", @@ -14973,7 +14973,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-tgu-artificial-intelligence-bachelor-2026-2027-autumn", @@ -14999,7 +14999,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-tgu-chinese-language-bachelor-2026-2027-autumn", @@ -15025,7 +15025,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-tgu-chinese-language-general-scholar-2026-2027-other-fee-reference", @@ -15046,7 +15046,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-nss-tgu-performance-model-bachelor-2026-2027-autumn", @@ -15072,7 +15072,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-local-tjfsu-chinese-language-autumn-2026-2027-other-fee-reference", @@ -15458,7 +15458,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-ne-tjutcm-chinese-materia-medica-doctorate-2026-2027-autumn-fee-reference", @@ -15479,7 +15479,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-ne-tjutcm-international-chinese-education-master-2026-2027-autumn-fee-reference", @@ -15500,7 +15500,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-prog-mew-ne-tjutcm-chinese-medicine-bachelor-2026-2027-autumn-fee-reference", @@ -15521,7 +15521,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave6-ucas-biology-master-2026-2027-autumn-fee-reference", @@ -16256,7 +16256,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-csw-xtu-communication-engineering-bachelor-2026-2027-autumn-fee-reference", @@ -16278,7 +16278,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-csw-xtu-finance-bachelor-2026-2027-autumn-fee-reference", @@ -16300,7 +16300,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-csw-xtu-philosophy-bachelor-2026-2027-autumn-fee-reference", @@ -16322,7 +16322,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-local-strong-xjau-b-animal-medicine-2026-2027-other", @@ -16428,7 +16428,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-scws-xjnu-international-chinese-education-bachelor-2026-2027-autumn", @@ -16454,7 +16454,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-scws-xjnu-international-chinese-education-master-2026-2027-autumn", @@ -16480,7 +16480,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-scws-xjnu-linguistics-applied-linguistics-master-2026-2027-autumn", @@ -16506,7 +16506,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave5-west-xju-computer-science-master-english-2026-2027-autumn-fee-reference", @@ -16692,7 +16692,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-scws-yangtze-crop-science-master-2026-2027-autumn-fee-reference", @@ -16713,7 +16713,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mew-scws-yangtze-mechanical-engineering-master-2026-2027-autumn-fee-reference", @@ -16734,7 +16734,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-local-strong-yangtzeu-m-oil-gas-well-engineering-en-2026-2027-other-fee-reference", @@ -16782,7 +16782,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-jzh-yzu-biotechnology-bachelor-2026-2027-autumn", @@ -16810,7 +16810,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-jzh-yzu-mechanical-design-bachelor-2026-2027-autumn", @@ -16838,7 +16838,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-local-strong-yzu-b-software-engineering-en-2026-2027-other", @@ -16918,7 +16918,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-wave8-ytu-applied-economics-master-2026-2027-other-fee-reference", @@ -17385,7 +17385,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-local-strong-zcmu-b-clinical-medicine-2026-2027-other", @@ -17432,7 +17432,7 @@ ], "verifiedAt": "2026-08-05", "reviewAfter": "2026-08-12", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-local-zjgsu-b-accounting-en-2026-2027-other-fee-reference", @@ -17766,7 +17766,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-local-strong-zust-b-computer-science-en-2026-2027-other-fee-reference", @@ -17814,7 +17814,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-mve-jzh-zust-iclt-semester-2026-2027-autumn", @@ -17831,15 +17831,15 @@ "factScope": "dates-only", "applicationFeeCny": null, "notes": { - "en": "ZUST confirms a five-month semester route. The March 2027 intake remains open until October 31, 2026.", - "zh": "浙科大确认五个月的一学期研修;2027年3月入学申请截至2026年10月31日。", - "ru": "ZUST подтверждает пятимесячную программу; набор на март 2027 открыт до 31 октября 2026 года." + "en": "ZUST confirms a five-month semester route; the September 2026 intake closed on May 15, 2026. The separate March 2027 cycle is recorded independently.", + "zh": "浙科大确认五个月的一学期研修;2026年9月入学申请已于2026年5月15日截止,2027年3月周期另行记录。", + "ru": "ZUST подтверждает пятимесячную программу; набор сентября 2026 года закрыт 15 мая 2026 года, а цикл марта 2027 года указан отдельно." }, "sourceIds": [ "src-gap-program-mve-jzh-zust-iclt-semester" ], - "verifiedAt": "2026-08-03", - "reviewAfter": "2026-08-10", + "verifiedAt": "2026-08-10", + "reviewAfter": "2026-08-17", "status": "verified" }, { @@ -17864,8 +17864,8 @@ "sourceIds": [ "src-gap-program-mve-jzh-zust-iclt-semester" ], - "verifiedAt": "2026-08-03", - "reviewAfter": "2026-08-10", + "verifiedAt": "2026-08-10", + "reviewAfter": "2026-08-17", "status": "verified" }, { @@ -17912,8 +17912,8 @@ "sourceIds": [ "src-gap-program-mve-jzh-zust-iclt-master" ], - "verifiedAt": "2026-08-03", - "reviewAfter": "2026-08-10", + "verifiedAt": "2026-08-10", + "reviewAfter": "2026-08-17", "status": "verified" }, { @@ -17936,7 +17936,7 @@ ], "verifiedAt": "2026-08-08", "reviewAfter": "2026-08-15", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-sparse-depth-0808-zuel-chinese-language-2026-2027-other-fee-reference", @@ -17957,7 +17957,7 @@ ], "verifiedAt": "2026-08-08", "reviewAfter": "2026-08-15", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-sparse-depth-0808-zuel-international-law-english-master-2026-2027-other-fee-reference", @@ -17978,7 +17978,7 @@ ], "verifiedAt": "2026-08-08", "reviewAfter": "2026-08-15", - "status": "verified" + "status": "stale" }, { "id": "cycle-gap-sparse-depth-0808-zuel-imba-master-2026-2027-other-fee-reference", @@ -17999,6 +17999,6 @@ ], "verifiedAt": "2026-08-08", "reviewAfter": "2026-08-15", - "status": "verified" + "status": "stale" } ] diff --git a/content/data/programs.json b/content/data/programs.json index 7675199..f2e14b3 100644 --- a/content/data/programs.json +++ b/content/data/programs.json @@ -2675,8 +2675,8 @@ "sourceIds": [ "src-program-review-88495cf206e1" ], - "verifiedAt": "2026-07-26", - "reviewAfter": "2026-08-25", + "verifiedAt": "2026-08-10", + "reviewAfter": "2026-09-09", "status": "verified", "id": "program-soochow-university-international-chinese-language-teachers-scholarship-o", "slug": "soochow-university-international-chinese-language-teachers-scholarship-o", @@ -2691,11 +2691,16 @@ "teachingLanguages": [ "Chinese" ], - "durationMonths": null, + "durationMonths": 5, "programUrl": "https://oversea.suda.edu.cn/oversea_en/bb/fd/c11345a441341/page.htm", "applyUrl": "https://oversea.suda.edu.cn/oversea_en/11341/list.htm", - "languageRequirements": [], - "verificationScope": "identity" + "languageRequirements": [ + { + "test": "HSK", + "minimum": "HSK Level 3: 180; an HSKK score is also required for the International Chinese Language Education / Chinese Language and Literature one-semester track." + } + ], + "verificationScope": "facts" }, { "sourceIds": [ @@ -12059,7 +12064,7 @@ "languageRequirements": [ { "test": "other", - "minimum": "Official English proficiency evidence is required unless the applicant qualifies for the English-medium study exemption." + "minimum": "TOEFL iBT 100 on the 120-point scale (or 5 on the 6-point scale), IELTS 7, Duolingo 130, or Cambridge C1/C2 185; waived for native English speakers or applicants with at least two years in an English-speaking post-secondary program." } ], "verificationScope": "complete", @@ -12121,9 +12126,9 @@ "ru": "Онлайн-заявка и академические документы" }, { - "en": "Essays, recommendations and required video", - "zh": "申请文书、推荐信及规定视频", - "ru": "Эссе, рекомендации и обязательное видео" + "en": "Essays and three recommendations; the one-minute video is highly recommended, not required.", + "zh": "申请文书与三封推荐信;一分钟视频为强烈建议提交,并非必交。", + "ru": "Эссе и три рекомендации; минутное видео настоятельно рекомендуется, но не является обязательным." } ], "campus": { @@ -12137,9 +12142,9 @@ "src-schwarzman-application-2027", "src-thu-graduate-programs-in-english-current" ], - "verifiedAt": "2026-07-29", - "reviewAfter": "2026-08-28", - "status": "verified" + "verifiedAt": "2026-08-10", + "reviewAfter": "2026-08-13", + "status": "stale" }, { "id": "program-guangdong-university-of-technology-e-commerce-bachelor", @@ -34719,13 +34724,18 @@ "durationMonths": 5, "programUrl": "https://ies.zust.edu.cn/info/1271/4219.htm", "applyUrl": "https://isam.zust.edu.cn/", - "languageRequirements": [], + "languageRequirements": [ + { + "test": "HSK", + "minimum": "HSK Level 3: 180; an HSKK score is also required for the International Chinese Language Education / Chinese Language and Literature one-semester track." + } + ], "verificationScope": "facts", "sourceIds": [ "src-gap-program-mve-jzh-zust-iclt-semester" ], - "verifiedAt": "2026-08-03", - "reviewAfter": "2026-09-02", + "verifiedAt": "2026-08-10", + "reviewAfter": "2026-09-09", "status": "verified" }, { @@ -34772,13 +34782,18 @@ "durationMonths": 24, "programUrl": "https://ies.zust.edu.cn/info/1271/4219.htm", "applyUrl": "https://isam.zust.edu.cn/", - "languageRequirements": [], + "languageRequirements": [ + { + "test": "HSK", + "minimum": "HSK Level 5: 210 and HSKK Intermediate: 60." + } + ], "verificationScope": "facts", "sourceIds": [ "src-gap-program-mve-jzh-zust-iclt-master" ], - "verifiedAt": "2026-08-03", - "reviewAfter": "2026-09-02", + "verifiedAt": "2026-08-10", + "reviewAfter": "2026-09-09", "status": "verified" }, { diff --git a/content/data/scholarships.json b/content/data/scholarships.json index 37bf6fc..09d526f 100644 --- a/content/data/scholarships.json +++ b/content/data/scholarships.json @@ -2513,9 +2513,9 @@ "src-schwarzman-program-current", "src-schwarzman-application-2027" ], - "verifiedAt": "2026-08-07", - "reviewAfter": "2026-08-10", - "status": "verified" + "verifiedAt": "2026-08-10", + "reviewAfter": "2026-08-13", + "status": "stale" }, { "id": "scholarship-jnu-guangdong-government-international-students", @@ -3389,7 +3389,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "sch-gap-sparse-depth-0808-csu-university-scholarship", @@ -4677,7 +4677,7 @@ ], "verifiedAt": "2026-08-04", "reviewAfter": "2026-08-11", - "status": "verified" + "status": "stale" }, { "id": "sch-gap-local-dufe-iclts", @@ -6312,8 +6312,8 @@ "sourceIds": [ "src-gap-scholarship-mew-scws-hainnu-iclt-scholarship-2026" ], - "verifiedAt": "2026-08-04", - "reviewAfter": "2026-08-11", + "verifiedAt": "2026-08-10", + "reviewAfter": "2026-08-17", "status": "verified" }, { @@ -7250,7 +7250,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "sch-gap-wave8-jsu-presidential-scholarship", @@ -8219,7 +8219,7 @@ ], "verifiedAt": "2026-08-03", "reviewAfter": "2026-08-10", - "status": "verified" + "status": "stale" }, { "id": "sch-gap-mve-jzh-njupt-csc-high-level-2026", @@ -9174,9 +9174,9 @@ "src-gap-scholarship-pku-depth-international-chinese-language-teachers-scholarship", "src-gap-scholarship-pku-depth-international-chinese-language-teachers-scholarship-support-1" ], - "verifiedAt": "2026-07-30", - "reviewAfter": "2026-08-06", - "status": "stale" + "verifiedAt": "2026-08-10", + "reviewAfter": "2026-08-17", + "status": "verified" }, { "id": "sch-gap-pku-depth-law-international-student-scholarship", @@ -10430,9 +10430,9 @@ "sourceIds": [ "src-gap-scholarship-sch-mew-nss-synu-iclts" ], - "verifiedAt": "2026-08-04", - "reviewAfter": "2026-08-11", - "status": "verified" + "verifiedAt": "2026-08-10", + "reviewAfter": "2026-08-13", + "status": "stale" }, { "id": "sch-gap-wave3-sztu-guangdong-government-scholarship", @@ -10889,9 +10889,9 @@ "sourceIds": [ "src-gap-scholarship-sch-sisu-iclt-2026" ], - "verifiedAt": "2026-08-02", - "reviewAfter": "2026-08-09", - "status": "stale" + "verifiedAt": "2026-08-10", + "reviewAfter": "2026-08-17", + "status": "verified" }, { "id": "sch-gap-sch-sisu-moe-chongqing-joint-2026", @@ -11008,26 +11008,28 @@ "uni-soochow-university" ], "programIds": [ - "prog-gap-mve-jzh-suda-iclt-one-year" + "prog-gap-mve-jzh-suda-iclt-one-year", + "program-soochow-university-international-chinese-language-teachers-scholarship-o" ], "coverage": { - "tuition": "unknown", - "accommodation": "unknown", - "insurance": "unknown", + "tuition": "full", + "accommodation": "full", + "insurance": true, "stipendCnyPerMonth": null }, "deadline": "2026-10-31", "applicationUrl": "https://oversea.suda.edu.cn/oversea_en/bb/fd/c11345a441341/page.htm", "summary": { - "en": "The official 2026 guide confirms Soochow University as a host, closes the September one-year route on May 15, and keeps the March 2027 one-semester route open until October 31, 2026; funding details were not promoted without direct page evidence.", - "zh": "2026年官方指南确认苏州大学为接收院校:9月一学年项目已于5月15日截止,2027年3月一学期项目开放至2026年10月31日;未直接显示的资助细节不作推断。", - "ru": "Официальное руководство 2026 подтверждает Университет Сучжоу: годичный набор сентября закрыт 15 мая, а семестровый набор марта 2027 открыт до 31 октября 2026 года; неподтверждённые детали финансирования не выводятся." + "en": "Soochow University's 2026 guide keeps the March 2027 one-semester route open until October 31, 2026. The official CLEC guide confirms tuition, on-campus accommodation, living allowance and medical insurance coverage; allowance rates vary by category.", + "zh": "苏州大学2026年指南显示,2027年3月一学期项目开放至2026年10月31日。中外语言交流合作中心官方办法确认资助包含学费、校内住宿、生活费和综合医疗保险,生活费标准按项目类别区分。", + "ru": "Руководство Университета Сучжоу оставляет набор на март 2027 года открытым до 31 октября 2026 года. Правила CLEC подтверждают покрытие обучения, проживания, пособия и медицинской страховки; размер пособия зависит от категории." }, "sourceIds": [ - "src-gap-scholarship-mve-jzh-suda-iclt-scholarship" + "src-gap-scholarship-mve-jzh-suda-iclt-scholarship", + "src-gov-clec" ], - "verifiedAt": "2026-08-03", - "reviewAfter": "2026-08-10", + "verifiedAt": "2026-08-10", + "reviewAfter": "2026-08-17", "status": "verified" }, { @@ -11087,16 +11089,16 @@ "deadline": "2026-09-01", "applicationUrl": "https://gdic.scau.edu.cn/2026/0421/c11155a432798/page.htm", "summary": { - "en": "SCAU's official notice publishes all three award amounts and a September 1, 2026 deadline, which remains open as checked on August 3.", - "zh": "华南农业大学官方通知公布本科、硕士、博士三档金额,截止2026年9月1日;截至8月3日仍可申请。", - "ru": "Официальное уведомление SCAU публикует три размера выплат и срок 1 сентября 2026 года; на 3 августа прием открыт." + "en": "SCAU's official notice publishes one-time awards of CNY 10,000 for bachelor's, CNY 20,000 for master's and CNY 30,000 for doctoral students, with a September 1, 2026 deadline.", + "zh": "华南农业大学官方通知公布一次性奖励:本科10000元、硕士20000元、博士30000元,截止2026年9月1日。", + "ru": "Официальное уведомление SCAU устанавливает разовые выплаты 10 000 юаней бакалаврам, 20 000 магистрантам и 30 000 докторантам; срок — 1 сентября 2026 года." }, "sourceIds": [ "src-gap-scholarship-mew-csw-scau-guangdong-government-scholarship-2026" ], - "verifiedAt": "2026-08-03", - "reviewAfter": "2026-08-10", - "status": "verified" + "verifiedAt": "2026-08-10", + "reviewAfter": "2026-08-13", + "status": "stale" }, { "id": "sch-gap-chinese-scholarship-scnu-iclt-degree-programs-2026", @@ -13203,23 +13205,24 @@ "prog-gap-mve-jzh-zust-iclt-semester" ], "coverage": { - "tuition": "unknown", - "accommodation": "unknown", - "insurance": "unknown", + "tuition": "full", + "accommodation": "full", + "insurance": true, "stipendCnyPerMonth": null }, "deadline": "2026-10-31", "applicationUrl": "https://ies.zust.edu.cn/info/1271/4219.htm", "summary": { - "en": "ZUST's official guide confirms the scholarship categories and a future October 31 deadline for March 2027 admission; unquoted funding components remain unset.", - "zh": "浙科大官方指南确认奖学金类别及2027年3月入学的2026年10月31日截止日期;未直接引述的资助内容暂不填写。", - "ru": "Официальное руководство ZUST подтверждает категории и срок 31 октября 2026 года для марта 2027; неподтверждённое финансирование не указано." + "en": "ZUST's official guide confirms the categories and October 31, 2026 deadline for March 2027 admission. The official CLEC guide confirms tuition, on-campus accommodation, living allowance and medical insurance coverage; allowance rates vary by category.", + "zh": "浙科大官方指南确认项目类别及2027年3月入学的2026年10月31日截止日期。中外语言交流合作中心官方办法确认资助包含学费、校内住宿、生活费和综合医疗保险,生活费标准按项目类别区分。", + "ru": "Руководство ZUST подтверждает категории и срок 31 октября 2026 года для марта 2027. Правила CLEC подтверждают покрытие обучения, проживания, пособия и медицинской страховки; размер пособия зависит от категории." }, "sourceIds": [ - "src-gap-scholarship-mve-jzh-zust-iclt-scholarship" + "src-gap-scholarship-mve-jzh-zust-iclt-scholarship", + "src-gov-clec" ], - "verifiedAt": "2026-08-03", - "reviewAfter": "2026-08-10", + "verifiedAt": "2026-08-10", + "reviewAfter": "2026-08-17", "status": "verified" }, { diff --git a/content/data/sources.json b/content/data/sources.json index 39af8ff..6f8be44 100644 --- a/content/data/sources.json +++ b/content/data/sources.json @@ -537,7 +537,7 @@ "kind": "government", "language": "other", "official": true, - "accessedAt": "2026-07-19" + "accessedAt": "2026-08-10" }, { "id": "src-program-pku-chinese-language-summer-2026", @@ -1587,7 +1587,7 @@ "kind": "program", "language": "other", "official": true, - "accessedAt": "2026-08-02" + "accessedAt": "2026-08-10" }, { "id": "src-program-review-106a1853d635", @@ -3037,7 +3037,7 @@ "kind": "program", "language": "zh", "official": true, - "accessedAt": "2026-07-28" + "accessedAt": "2026-08-10" }, { "id": "src-xisu-regular-chinese-language", @@ -3247,7 +3247,7 @@ "kind": "admissions", "language": "en", "official": true, - "accessedAt": "2026-07-28" + "accessedAt": "2026-08-10" }, { "id": "src-wku-finance-bs", @@ -4637,7 +4637,7 @@ "kind": "program", "language": "en", "official": true, - "accessedAt": "2026-07-29" + "accessedAt": "2026-08-10" }, { "id": "src-thu-advanced-computing-current", @@ -4677,7 +4677,7 @@ "kind": "program", "language": "en", "official": true, - "accessedAt": "2026-08-07" + "accessedAt": "2026-08-10" }, { "id": "src-schwarzman-application-2027", @@ -4687,7 +4687,7 @@ "kind": "admissions", "language": "en", "official": true, - "accessedAt": "2026-08-07" + "accessedAt": "2026-08-10" }, { "id": "src-uni-guangdong-university-of-technology", @@ -14477,7 +14477,7 @@ "kind": "program", "language": "en", "official": true, - "accessedAt": "2026-08-03" + "accessedAt": "2026-08-10" }, { "id": "src-gap-program-mve-jzh-suda-long-chinese-semester", @@ -14487,7 +14487,7 @@ "kind": "program", "language": "en", "official": true, - "accessedAt": "2026-08-03" + "accessedAt": "2026-08-10" }, { "id": "src-gap-program-mew-csw-scau-plant-protection-professional-master", @@ -16987,7 +16987,7 @@ "kind": "program", "language": "en", "official": true, - "accessedAt": "2026-08-03" + "accessedAt": "2026-08-10" }, { "id": "src-gap-program-local-strong-zust-b-international-economics-trade-en", @@ -17017,7 +17017,7 @@ "kind": "program", "language": "en", "official": true, - "accessedAt": "2026-08-03" + "accessedAt": "2026-08-10" }, { "id": "src-gap-program-sparse-depth-0808-zzu-architecture-master", @@ -18297,7 +18297,7 @@ "kind": "scholarship", "language": "en", "official": true, - "accessedAt": "2026-08-04" + "accessedAt": "2026-08-10" }, { "id": "src-gap-scholarship-mew-scws-hainnu-hainan-government-scholarship", @@ -19217,7 +19217,7 @@ "kind": "scholarship", "language": "en", "official": true, - "accessedAt": "2026-07-30" + "accessedAt": "2026-08-10" }, { "id": "src-gap-scholarship-pku-depth-international-chinese-language-teachers-scholarship-support-1", @@ -19227,7 +19227,7 @@ "kind": "scholarship", "language": "en", "official": true, - "accessedAt": "2026-07-30" + "accessedAt": "2026-08-10" }, { "id": "src-gap-scholarship-pku-depth-pku-scholarship-international-students", @@ -19697,7 +19697,7 @@ "kind": "scholarship", "language": "zh", "official": true, - "accessedAt": "2026-08-04" + "accessedAt": "2026-08-10" }, { "id": "src-gap-scholarship-wave3-sztu-guangdong-government-scholarship", @@ -19867,7 +19867,7 @@ "kind": "scholarship", "language": "zh", "official": true, - "accessedAt": "2026-08-02" + "accessedAt": "2026-08-10" }, { "id": "src-gap-scholarship-sch-sisu-moe-chongqing-joint-2026", @@ -19907,7 +19907,7 @@ "kind": "scholarship", "language": "en", "official": true, - "accessedAt": "2026-08-03" + "accessedAt": "2026-08-10" }, { "id": "src-gap-scholarship-mve-jzh-suda-international-scholarship", @@ -19927,7 +19927,7 @@ "kind": "scholarship", "language": "en", "official": true, - "accessedAt": "2026-08-03" + "accessedAt": "2026-08-10" }, { "id": "src-gap-scholarship-chinese-scholarship-scnu-iclt-degree-programs-2026", @@ -20657,7 +20657,7 @@ "kind": "scholarship", "language": "en", "official": true, - "accessedAt": "2026-08-03" + "accessedAt": "2026-08-10" }, { "id": "src-gap-scholarship-mve-jzh-zust-outstanding-freshman-2026", diff --git a/content/source-manifests/pilot/fudan-university.json b/content/source-manifests/pilot/fudan-university.json index ae03eab..881c855 100644 --- a/content/source-manifests/pilot/fudan-university.json +++ b/content/source-manifests/pilot/fudan-university.json @@ -1,8 +1,10 @@ { - "version": 1, + "version": 2, "institutionId": "uni-fudan-university", "catalogStatus": "existing", + "manifestStatus": "in_progress", "checkedAt": "2026-07-20", + "officialHosts": ["iso.fudan.edu.cn","istudent.fudan.edu.cn"], "sources": [ { "version": 1, "id": "fudan-intl-admissions-home", "institutionId": "uni-fudan-university", "entityType": "university", "sourceCategory": "international_admissions_home", @@ -82,5 +84,28 @@ { "sourceCategory": "program_detail", "status": "discovery_pending", "note": "Program pages will be enumerated from the registered catalog anchors in a later discovery crawl." }, { "sourceCategory": "contacts", "status": "discovery_pending", "note": "A distinct stable official admissions contact page was not confirmed in this pilot review." }, { "sourceCategory": "catalog_anchor", "status": "registered", "sourceIds": ["fudan-admissions-catalog-anchor"] } - ] + ], + "catalogReconciliation": { + "scope": "representative_international_programs", + "status": "in_progress", + "entries": [ + { + "sourceId": "fudan-university-scholarship-index", + "officialKey": "sch-gap-chinese-scholarship-fudan-iclt-degree-programs-2026", + "officialName": "Fudan 2026 International Chinese Language Teachers Scholarship — Degree Programs", + "entityType": "scholarship", + "status": "pending", + "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending." + }, + { + "sourceId": "fudan-intl-admissions-home", + "officialKey": "scholarship-fudan-university-international-student", + "officialName": "Fudan University Scholarship for International Students", + "entityType": "scholarship", + "status": "pending", + "note": "Current public catalog identity is linked only to an official source on the same host and in the same category; exact page evidence and catalog completeness remain pending." + } + ], + "note": "V2 migration preserves the 2026-07-20 source audit. 2 exact-URL or same-host/same-category catalog relationships are queued for evidence review; all other identities and full-directory reconciliation remain unresolved." + } } diff --git a/content/source-manifests/pilot/harbin-institute-of-technology.json b/content/source-manifests/pilot/harbin-institute-of-technology.json index 4abdb0b..7809011 100644 --- a/content/source-manifests/pilot/harbin-institute-of-technology.json +++ b/content/source-manifests/pilot/harbin-institute-of-technology.json @@ -1,8 +1,10 @@ { - "version": 1, + "version": 2, "institutionId": "uni-harbin-institute-of-technology", "catalogStatus": "existing", + "manifestStatus": "in_progress", "checkedAt": "2026-07-20", + "officialHosts": ["hit.at0086.cn","studyathit.hit.edu.cn"], "sources": [ { "version": 1, "id": "hit-intl-admissions-home", "institutionId": "uni-harbin-institute-of-technology", "entityType": "university", "sourceCategory": "international_admissions_home", @@ -76,5 +78,52 @@ { "sourceCategory": "program_detail", "status": "discovery_pending", "note": "Program-level pages will be discovered from the registered catalog anchors." }, { "sourceCategory": "contacts", "status": "discovery_pending", "note": "A distinct stable official admissions contact page was not confirmed in this pilot review." }, { "sourceCategory": "catalog_anchor", "status": "registered", "sourceIds": ["hit-admissions-catalog-anchor"] } - ] + ], + "catalogReconciliation": { + "scope": "representative_international_programs", + "status": "in_progress", + "entries": [ + { + "sourceId": "hit-non-degree-catalog", + "officialKey": "prog-gap-wave8-hit-long-term-chinese-language", + "officialName": "Long-Term Chinese Language Program", + "entityType": "program", + "status": "pending", + "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending." + }, + { + "sourceId": "hit-masters-catalog", + "officialKey": "program-harbin-institute-of-technology-civil-engineering-master", + "officialName": "Civil Engineering", + "entityType": "program", + "status": "pending", + "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending." + }, + { + "sourceId": "hit-masters-catalog", + "officialKey": "program-harbin-institute-of-technology-mechanical-engineering-master", + "officialName": "Mechanical Engineering", + "entityType": "program", + "status": "pending", + "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending." + }, + { + "sourceId": "hit-intl-admissions-home", + "officialKey": "scholarship-harbin-institute-of-technology", + "officialName": "Harbin Institute of Technology Scholarship", + "entityType": "scholarship", + "status": "pending", + "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending." + }, + { + "sourceId": "hit-intl-admissions-home", + "officialKey": "scholarship-heilongjiang-government", + "officialName": "Heilongjiang Provincial Government Scholarship", + "entityType": "scholarship", + "status": "pending", + "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending." + } + ], + "note": "V2 migration preserves the 2026-07-20 source audit. 5 exact-URL or same-host/same-category catalog relationships are queued for evidence review; all other identities and full-directory reconciliation remain unresolved." + } } diff --git a/content/source-manifests/pilot/nanjing-university.json b/content/source-manifests/pilot/nanjing-university.json index e68ab62..0826680 100644 --- a/content/source-manifests/pilot/nanjing-university.json +++ b/content/source-manifests/pilot/nanjing-university.json @@ -1,8 +1,10 @@ { - "version": 1, + "version": 2, "institutionId": "uni-nanjing-university", "catalogStatus": "existing", + "manifestStatus": "in_progress", "checkedAt": "2026-07-20", + "officialHosts": ["hwxy.nju.edu.cn","nju.17gz.org"], "sources": [ { "version": 1, "id": "nju-intl-admissions-home", "institutionId": "uni-nanjing-university", "entityType": "university", "sourceCategory": "international_admissions_home", @@ -76,5 +78,36 @@ { "sourceCategory": "program_detail", "status": "discovery_pending", "note": "Program-level pages will be discovered from the registered catalog anchors." }, { "sourceCategory": "contacts", "status": "discovery_pending", "note": "A distinct stable official admissions contact page was not confirmed in this pilot review." }, { "sourceCategory": "catalog_anchor", "status": "registered", "sourceIds": ["nju-admissions-catalog-anchor"] } - ] + ], + "catalogReconciliation": { + "scope": "representative_international_programs", + "status": "in_progress", + "entries": [ + { + "sourceId": "nju-2026-chinese-government-scholarship", + "officialKey": "sch-gap-nju-2026-iclts-degree-coverage", + "officialName": "International Chinese Language Teachers Scholarship — NJU", + "entityType": "scholarship", + "status": "pending", + "note": "Current public catalog identity is linked only to an official source on the same host and in the same category; exact page evidence and catalog completeness remain pending." + }, + { + "sourceId": "nju-intl-admissions-home", + "officialKey": "scholarship-jiangsu-government", + "officialName": "Jiangsu Government Scholarship", + "entityType": "scholarship", + "status": "pending", + "note": "Current public catalog identity is linked only to an official source on the same host and in the same category; exact page evidence and catalog completeness remain pending." + }, + { + "sourceId": "nju-intl-admissions-home", + "officialKey": "scholarship-nanjing-university-international-student", + "officialName": "Nanjing University Scholarship for International Students", + "entityType": "scholarship", + "status": "pending", + "note": "Current public catalog identity is linked only to an official source on the same host and in the same category; exact page evidence and catalog completeness remain pending." + } + ], + "note": "V2 migration preserves the 2026-07-20 source audit. 3 exact-URL or same-host/same-category catalog relationships are queued for evidence review; all other identities and full-directory reconciliation remain unresolved." + } } diff --git a/content/source-manifests/pilot/peking-university.json b/content/source-manifests/pilot/peking-university.json index 84f97b9..f799964 100644 --- a/content/source-manifests/pilot/peking-university.json +++ b/content/source-manifests/pilot/peking-university.json @@ -1,8 +1,10 @@ { - "version": 1, + "version": 2, "institutionId": "uni-peking-university", "catalogStatus": "existing", + "manifestStatus": "in_progress", "checkedAt": "2026-07-20", + "officialHosts": ["isd.pku.edu.cn","www.isd.pku.edu.cn","www.studyatpku.com"], "sources": [ { "version": 1, "id": "pku-intl-admissions-home", "institutionId": "uni-peking-university", "entityType": "university", "sourceCategory": "international_admissions_home", @@ -94,5 +96,84 @@ { "sourceCategory": "program_detail", "status": "discovery_pending", "note": "Program-level pages will be discovered from the registered catalog anchor in a later crawl." }, { "sourceCategory": "contacts", "status": "registered", "sourceIds": ["pku-admissions-contacts"] }, { "sourceCategory": "catalog_anchor", "status": "registered", "sourceIds": ["pku-admissions-catalog-anchor"] } - ] + ], + "catalogReconciliation": { + "scope": "representative_international_programs", + "status": "in_progress", + "entries": [ + { + "sourceId": "pku-intl-admissions-home", + "officialKey": "program-peking-university-chinese-literature-bachelor", + "officialName": "Chinese Language and Literature", + "entityType": "program", + "status": "pending", + "note": "Current public catalog identity is linked only to an official source on the same host and in the same category; exact page evidence and catalog completeness remain pending." + }, + { + "sourceId": "pku-intl-admissions-home", + "officialKey": "program-peking-university-international-relations-master", + "officialName": "International Relations", + "entityType": "program", + "status": "pending", + "note": "Current public catalog identity is linked only to an official source on the same host and in the same category; exact page evidence and catalog completeness remain pending." + }, + { + "sourceId": "pku-2026-university-scholarships", + "officialKey": "sch-gap-pku-depth-beijing-government-scholarship", + "officialName": "Beijing Government Scholarship for International Students at PKU", + "entityType": "scholarship", + "status": "pending", + "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending." + }, + { + "sourceId": "pku-2026-university-scholarships", + "officialKey": "sch-gap-pku-depth-cgs-advanced-graduate", + "officialName": "Chinese Government Scholarship — Advanced Graduate Program at PKU", + "entityType": "scholarship", + "status": "pending", + "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending." + }, + { + "sourceId": "pku-2026-university-scholarships", + "officialKey": "sch-gap-pku-depth-cs-international-phd-full-support", + "officialName": "School of Computer Science International PhD Full Financial Support", + "entityType": "scholarship", + "status": "pending", + "note": "Current public catalog identity is linked only to an official source on the same host and in the same category; exact page evidence and catalog completeness remain pending." + }, + { + "sourceId": "pku-2026-university-scholarships", + "officialKey": "sch-gap-pku-depth-international-chinese-language-teachers-scholarship", + "officialName": "International Chinese Language Teachers Scholarship at PKU", + "entityType": "scholarship", + "status": "pending", + "note": "Current public catalog identity is linked only to an official source on the same host and in the same category; exact page evidence and catalog completeness remain pending." + }, + { + "sourceId": "pku-2026-university-scholarships", + "officialKey": "sch-gap-pku-depth-law-international-student-scholarship", + "officialName": "PKU Law International Student Scholarship", + "entityType": "scholarship", + "status": "pending", + "note": "Current public catalog identity is linked only to an official source on the same host and in the same category; exact page evidence and catalog completeness remain pending." + }, + { + "sourceId": "pku-2026-government-scholarships", + "officialKey": "sch-gap-pku-depth-yenching-fellowship", + "officialName": "Yenching Fellowship", + "entityType": "scholarship", + "status": "pending", + "note": "Current public catalog identity is linked only to an official source on the same host and in the same category; exact page evidence and catalog completeness remain pending." + }, + { + "sourceId": "pku-2026-university-scholarships", + "officialKey": "scholarship-peking-university-international-student", + "officialName": "Peking University Scholarship for International Students", + "entityType": "scholarship", + "status": "pending", + "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending." + } + ], + "note": "V2 migration preserves the 2026-07-20 source audit. 9 exact-URL or same-host/same-category catalog relationships are queued for evidence review; all other identities and full-directory reconciliation remain unresolved." + } } diff --git a/content/source-manifests/pilot/shanghai-jiao-tong-university.json b/content/source-manifests/pilot/shanghai-jiao-tong-university.json index 8fc93db..1ab9332 100644 --- a/content/source-manifests/pilot/shanghai-jiao-tong-university.json +++ b/content/source-manifests/pilot/shanghai-jiao-tong-university.json @@ -1,8 +1,10 @@ { - "version": 1, + "version": 2, "institutionId": "uni-shanghai-jiao-tong-university", "catalogStatus": "existing", + "manifestStatus": "in_progress", "checkedAt": "2026-07-20", + "officialHosts": ["apply.sjtu.edu.cn","isc.sjtu.edu.cn"], "sources": [ { "version": 1, "id": "sjtu-intl-admissions-home", "institutionId": "uni-shanghai-jiao-tong-university", "entityType": "university", "sourceCategory": "international_admissions_home", @@ -76,5 +78,20 @@ { "sourceCategory": "program_detail", "status": "discovery_pending", "note": "Program-level pages will be discovered from the registered catalog anchors." }, { "sourceCategory": "contacts", "status": "discovery_pending", "note": "A distinct stable official admissions contact page was not confirmed in this pilot review." }, { "sourceCategory": "catalog_anchor", "status": "registered", "sourceIds": ["sjtu-admissions-catalog-anchor"] } - ] + ], + "catalogReconciliation": { + "scope": "representative_international_programs", + "status": "in_progress", + "entries": [ + { + "sourceId": "sjtu-admissions-catalog-anchor", + "officialKey": "scholarship-shanghai-jiao-tong-university", + "officialName": "Shanghai Jiao Tong University Scholarship", + "entityType": "scholarship", + "status": "pending", + "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending." + } + ], + "note": "V2 migration preserves the 2026-07-20 source audit. 1 exact-URL or same-host/same-category catalog relationships are queued for evidence review; all other identities and full-directory reconciliation remain unresolved." + } } diff --git a/content/source-manifests/pilot/sun-yat-sen-university.json b/content/source-manifests/pilot/sun-yat-sen-university.json index ae08ea9..d392286 100644 --- a/content/source-manifests/pilot/sun-yat-sen-university.json +++ b/content/source-manifests/pilot/sun-yat-sen-university.json @@ -1,8 +1,10 @@ { - "version": 1, + "version": 2, "institutionId": "uni-sun-yat-sen-university", "catalogStatus": "existing", + "manifestStatus": "in_progress", "checkedAt": "2026-07-20", + "officialHosts": ["apply.sysu.edu.cn","iso.sysu.edu.cn"], "sources": [ { "version": 1, "id": "sysu-intl-admissions-home", "institutionId": "uni-sun-yat-sen-university", "entityType": "university", "sourceCategory": "international_admissions_home", @@ -82,5 +84,36 @@ { "sourceCategory": "program_detail", "status": "discovery_pending", "note": "Program-level pages will be discovered from the registered catalog anchors." }, { "sourceCategory": "contacts", "status": "discovery_pending", "note": "A distinct stable official admissions contact page was not confirmed in this pilot review." }, { "sourceCategory": "catalog_anchor", "status": "registered", "sourceIds": ["sysu-application-guide-anchor"] } - ] + ], + "catalogReconciliation": { + "scope": "representative_international_programs", + "status": "in_progress", + "entries": [ + { + "sourceId": "sysu-2026-non-degree-program", + "officialKey": "program-sun-yat-sen-university-2026-one-academic-year-pre-university-program-fou", + "officialName": "2026 One-Academic-Year Pre-university Program", + "entityType": "program", + "status": "pending", + "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending." + }, + { + "sourceId": "sysu-2026-non-degree-program", + "officialKey": "program-sun-yat-sen-university-one-semester-pre-university-program-foundation", + "officialName": "One-Semester Pre-university Program", + "entityType": "program", + "status": "pending", + "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending." + }, + { + "sourceId": "sysu-2026-university-scholarship", + "officialKey": "sch-gap-clw-sw-sysu-international-student-scholarship-2026", + "officialName": "SYSU Scholarship for International Students 2026", + "entityType": "scholarship", + "status": "pending", + "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending." + } + ], + "note": "V2 migration preserves the 2026-07-20 source audit. 3 exact-URL or same-host/same-category catalog relationships are queued for evidence review; all other identities and full-directory reconciliation remain unresolved." + } } diff --git a/content/source-manifests/pilot/tsinghua-university.json b/content/source-manifests/pilot/tsinghua-university.json index 4c30581..a9d15fb 100644 --- a/content/source-manifests/pilot/tsinghua-university.json +++ b/content/source-manifests/pilot/tsinghua-university.json @@ -1,8 +1,10 @@ { - "version": 1, + "version": 2, "institutionId": "uni-tsinghua-university", "catalogStatus": "existing", + "manifestStatus": "in_progress", "checkedAt": "2026-07-20", + "officialHosts": ["apply.join-tsinghua.edu.cn","international.join-tsinghua.edu.cn","yz.tsinghua.edu.cn","yzbm.tsinghua.edu.cn"], "sources": [ { "version": 1, @@ -230,5 +232,28 @@ { "sourceCategory": "program_detail", "status": "discovery_pending", "note": "Program-level pages will be discovered from the registered catalog anchors in a later crawl." }, { "sourceCategory": "contacts", "status": "registered", "sourceIds": ["thu-graduate-admissions-contacts"] }, { "sourceCategory": "catalog_anchor", "status": "registered", "sourceIds": ["thu-graduate-catalog-anchor"] } - ] + ], + "catalogReconciliation": { + "scope": "representative_international_programs", + "status": "in_progress", + "entries": [ + { + "sourceId": "thu-intl-admissions-home", + "officialKey": "program-tsinghua-university-business-administration-master", + "officialName": "Business Administration", + "entityType": "program", + "status": "pending", + "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending." + }, + { + "sourceId": "thu-intl-admissions-home", + "officialKey": "scholarship-tsinghua-university", + "officialName": "Tsinghua University Scholarship", + "entityType": "scholarship", + "status": "pending", + "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending." + } + ], + "note": "V2 migration preserves the 2026-07-20 source audit. 2 exact-URL or same-host/same-category catalog relationships are queued for evidence review; all other identities and full-directory reconciliation remain unresolved." + } } diff --git a/content/source-manifests/pilot/ustc.json b/content/source-manifests/pilot/ustc.json index acf356d..307681a 100644 --- a/content/source-manifests/pilot/ustc.json +++ b/content/source-manifests/pilot/ustc.json @@ -1,8 +1,10 @@ { - "version": 1, + "version": 2, "institutionId": "uni-university-of-science-and-technology-of-china", "catalogStatus": "existing", + "manifestStatus": "in_progress", "checkedAt": "2026-07-20", + "officialHosts": ["ic.ustc.edu.cn","isa.ustc.edu.cn"], "sources": [ { "version": 1, "id": "ustc-intl-admissions-home", "institutionId": "uni-university-of-science-and-technology-of-china", "entityType": "university", "sourceCategory": "international_admissions_home", @@ -88,5 +90,36 @@ { "sourceCategory": "program_detail", "status": "discovery_pending", "note": "Program-level pages will be discovered from the registered catalog anchors." }, { "sourceCategory": "contacts", "status": "registered", "sourceIds": ["ustc-admissions-contacts"] }, { "sourceCategory": "catalog_anchor", "status": "registered", "sourceIds": ["ustc-admissions-catalog-anchor"] } - ] + ], + "catalogReconciliation": { + "scope": "representative_international_programs", + "status": "in_progress", + "entries": [ + { + "sourceId": "ustc-intl-admissions-home", + "officialKey": "program-university-of-science-and-technology-of-china-chemistry-bachelor", + "officialName": "Chemistry", + "entityType": "program", + "status": "pending", + "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending." + }, + { + "sourceId": "ustc-intl-admissions-home", + "officialKey": "program-university-of-science-and-technology-of-china-physics-bachelor", + "officialName": "Physics", + "entityType": "program", + "status": "pending", + "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending." + }, + { + "sourceId": "ustc-government-scholarship", + "officialKey": "sch-gap-wave8-ustc-youth-excellence-full-scholarship", + "officialName": "USTC Youth of Excellence Scheme Full Scholarship", + "entityType": "scholarship", + "status": "pending", + "note": "Current public catalog identity is linked only to an official source on the same host and in the same category; exact page evidence and catalog completeness remain pending." + } + ], + "note": "V2 migration preserves the 2026-07-20 source audit. 3 exact-URL or same-host/same-category catalog relationships are queued for evidence review; all other identities and full-directory reconciliation remain unresolved." + } } diff --git a/content/source-manifests/pilot/wuhan-university.json b/content/source-manifests/pilot/wuhan-university.json index 33ba159..02075a8 100644 --- a/content/source-manifests/pilot/wuhan-university.json +++ b/content/source-manifests/pilot/wuhan-university.json @@ -1,8 +1,10 @@ { - "version": 1, + "version": 2, "institutionId": "uni-wuhan-university", "catalogStatus": "existing", + "manifestStatus": "in_progress", "checkedAt": "2026-07-20", + "officialHosts": ["admission.whu.edu.cn","en.whu.edu.cn"], "sources": [ { "version": 1, "id": "whu-intl-admissions-home", "institutionId": "uni-wuhan-university", "entityType": "university", "sourceCategory": "international_admissions_home", @@ -52,5 +54,28 @@ { "sourceCategory": "program_detail", "status": "discovery_pending", "note": "Program-level pages require a later access-approved discovery crawl." }, { "sourceCategory": "contacts", "status": "source_unavailable", "sourceIds": ["whu-admissions-contacts"], "note": "The official notices/contact anchor rejected automated access during review and is disabled pending policy verification." }, { "sourceCategory": "catalog_anchor", "status": "source_unavailable", "sourceIds": ["whu-admissions-catalog-anchor"], "note": "The official catalog anchor rejected automated access during review and is disabled pending policy verification." } - ] + ], + "catalogReconciliation": { + "scope": "representative_international_programs", + "status": "in_progress", + "entries": [ + { + "sourceId": "whu-application-portal", + "officialKey": "scholarship-hubei-provincial-international-student", + "officialName": "Hubei Provincial Scholarship for International Students", + "entityType": "scholarship", + "status": "pending", + "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending." + }, + { + "sourceId": "whu-application-portal", + "officialKey": "scholarship-wuhan-university-international-student", + "officialName": "Wuhan University Scholarship for International Students", + "entityType": "scholarship", + "status": "pending", + "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending." + } + ], + "note": "V2 migration preserves the 2026-07-20 source audit. 2 exact-URL or same-host/same-category catalog relationships are queued for evidence review; all other identities and full-directory reconciliation remain unresolved." + } } diff --git a/content/source-manifests/pilot/zhejiang-university.json b/content/source-manifests/pilot/zhejiang-university.json index 1249d80..d9ce5d6 100644 --- a/content/source-manifests/pilot/zhejiang-university.json +++ b/content/source-manifests/pilot/zhejiang-university.json @@ -1,8 +1,10 @@ { - "version": 1, + "version": 2, "institutionId": "uni-zhejiang-university", "catalogStatus": "existing", + "manifestStatus": "in_progress", "checkedAt": "2026-07-20", + "officialHosts": ["iczu.zju.edu.cn","intlstudent.zju.edu.cn"], "sources": [ { "version": 1, "id": "zju-intl-admissions-home", "institutionId": "uni-zhejiang-university", "entityType": "university", "sourceCategory": "international_admissions_home", @@ -88,5 +90,44 @@ { "sourceCategory": "program_detail", "status": "discovery_pending", "note": "Program-level pages will be discovered from the registered catalog anchors." }, { "sourceCategory": "contacts", "status": "registered", "sourceIds": ["zju-admissions-contacts"] }, { "sourceCategory": "catalog_anchor", "status": "registered", "sourceIds": ["zju-application-guide-anchor"] } - ] + ], + "catalogReconciliation": { + "scope": "representative_international_programs", + "status": "in_progress", + "entries": [ + { + "sourceId": "zju-intl-admissions-home", + "officialKey": "program-zhejiang-university-business-administration-master", + "officialName": "Business Administration", + "entityType": "program", + "status": "pending", + "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending." + }, + { + "sourceId": "zju-intl-admissions-home", + "officialKey": "program-zhejiang-university-computer-science-bachelor", + "officialName": "Computer Science and Technology", + "entityType": "program", + "status": "pending", + "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending." + }, + { + "sourceId": "zju-intl-admissions-home", + "officialKey": "scholarship-zhejiang-provincial-government", + "officialName": "Zhejiang Provincial Government Scholarship", + "entityType": "scholarship", + "status": "pending", + "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending." + }, + { + "sourceId": "zju-intl-admissions-home", + "officialKey": "scholarship-zhejiang-university-international-student", + "officialName": "Zhejiang University Scholarship for International Students", + "entityType": "scholarship", + "status": "pending", + "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending." + } + ], + "note": "V2 migration preserves the 2026-07-20 source audit. 4 exact-URL or same-host/same-category catalog relationships are queued for evidence review; all other identities and full-directory reconciliation remain unresolved." + } } diff --git a/docs/backup-and-restore.md b/docs/backup-and-restore.md index 85bf176..e323066 100644 --- a/docs/backup-and-restore.md +++ b/docs/backup-and-restore.md @@ -8,7 +8,13 @@ 1. 使用 Cloudflare 只读/备份权限枚举普通数据表,并导出 `studyinchina-catalog` 和 `studyinchina-pipeline` 的数据。 2. 生成 `catalog.sql.gz`、`pipeline.sql.gz` 和 `backup-sha256.txt`。 -3. 上传到私有 R2 的 `backups/daily/YYYY-MM-DD/` 和 `backups/monthly/YYYY-MM/`。 +3. 上传到私有 R2 的 `backups/daily/YYYY-MM-DD/raw-v1/` 和 `backups/monthly/YYYY-MM/raw-v1/`。专用 Bucket 为 `studyinchina-backups`。 +4. The workflow reads the three daily objects back from R2 and revalidates gzip and SHA-256; only a successful readback creates an RPO checkpoint. + +`raw-v1` 是原始字节格式的版本标识。两个 `.sql.gz` 对象必须同时写入 +`Content-Type: application/gzip` 和 `Content-Encoding: identity`;否则 Wrangler +可能在读回时透明解压,导致读回内容与上传文件的 SHA-256 不一致。旧的无 +`raw-v1` 对象不能作为有效恢复点,也不能被恢复演练自动选用。 R2 生命周期由 `npm run cloudflare:retention` 配置。备份 Token 应只具备 D1 导出和目标 R2 Bucket 写入所需的最小权限。 @@ -16,18 +22,18 @@ Catalog 使用 FTS5;Wrangler 不能把包含虚拟表的 D1 直接导出为完 ## GitHub Actions configuration -每日备份要求仓库 Actions secrets 中同时存在 `CLOUDFLARE_API_TOKEN` 与 `CLOUDFLARE_ACCOUNT_ID`。只应在 GitHub 的隐藏输入框或 `gh secret set` 的隐藏提示符中输入值;不得把值放入命令参数、工作流输出、Issue 或仓库文件。 +Daily backup requires repository secrets `CLOUDFLARE_D1_BACKUP_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`. The token is restricted to D1 Read and write access to the private `studyinchina-backups` Bucket. -工作流在安装 npm 依赖和访问 Cloudflare 之前运行无第三方依赖的配置检查。它只报告缺少的 secret 名称,不回显值。配置通过后,还会分别读取两个 D1 的远程元数据,以提前区分“凭据存在但权限、账号或数据库名称错误”与“导出过程失败”。 +Quarterly restore uses the protected `cloudflare-restore-drill` Environment. Its `CLOUDFLARE_D1_RESTORE_TOKEN` Environment secret has read-only access to that Bucket and is never shared with daily backup. Enable a required reviewer for this Environment. -需要的最小外部配置和隐藏输入命令见 [`operations/data-maintenance.md`](./operations/data-maintenance.md#required-github-actions-secrets)。设置后手动重跑一次 `Cloudflare D1 backup`,只有完整导出、校验和六个 R2 对象上传全部成功,才能把该次运行记作新的恢复点。 +Enter secret values only through hidden GitHub or `gh secret set` prompts. The dependency-free preflight prints missing secret names but never values. A checkpoint is valid only after both databases export, local checksum verification, all six uploads, and daily-object readback succeed. ## Failure semantics and triage - 红色且失败于配置检查:必要的 repository secret 缺失或账号 ID 格式无效;没有创建备份。 - 红色且失败于远程 D1 检查:token 无权访问目标账号/数据库、名称错误或 Cloudflare 不可用;没有开始导出。 - 红色且失败于导出或 artifact 校验:不得使用部分文件,且不会开始 R2 上传。 -- 红色且失败于 R2 上传:即使已经写入部分对象,也不能把该日期视为完整恢复点;应修复后整项重跑。 +- 红色且失败于 R2 上传或读回:即使已经写入部分对象,也不能把该日期视为完整恢复点;应修复后整项重跑。若读回文件不是 gzip 原始字节,确认对象位于 `raw-v1/` 且使用 `Content-Encoding: identity`。 - 任务显示 `runner_id=0` 且没有步骤:GitHub-hosted runner 从未分配,属于执行平台取消/排队问题,不是 D1 失败;应直接重跑并继续按 24 小时 RPO 计时。 任何失败或取消都不满足 `RPO <= 24 小时`。工作流会在可执行失败时写入 Job Summary,但在 runner 未分配的情况下没有代码能够运行,因此必须依靠 Actions 告警和人工重跑。 @@ -65,12 +71,14 @@ npm run cloudflare:restore-drill -- -BackupDirectory C:\secure\studyinchina-back ## 季度自动演练 -`.github/workflows/cloudflare-restore-drill.yml` 在每年 1、4、7、10 月执行,也支持手动选择 `YYYY-MM` 备份。工作流只从私有 R2 下载备份,然后运行本地恢复;恢复步骤不会收到 Cloudflare 凭据,也没有远程 D1 写入命令。 +`.github/workflows/cloudflare-restore-drill.yml` 在每年 1、4、7、10 月执行,也支持手动选择 `YYYY-MM` 备份。工作流只从私有 R2 下载备份,然后运行本地恢复;恢复步骤不会收到 Cloudflare 凭据,也没有远程 D1 写入命令。 The job is protected by the `cloudflare-restore-drill` Environment, and only the download step receives its read-only token. 演练通过后上传 JSON 报告,保留 90 天。失败时应按以下顺序处理: 1. 检查对象是否来自同一月份以及 checksum 文件是否匹配。 2. 判断是导出不完整、压缩文件损坏、数据库约束失败,还是 Schema/Release 语义失败。 + 若对象不在 `backups/monthly/YYYY-MM/raw-v1/`,先将其视为不受支持的旧格式, + 不要通过跳过 gzip 或 SHA 校验来恢复。 3. 在 24 小时内选择前一日或前一月备份重试,并记录可恢复时间点。 4. 如果两个连续备份均不可恢复,立即暂停低优先级采集与 Release 切换,优先修复备份链路。 diff --git a/docs/operations/backup-restore-drill-2026-08-10.md b/docs/operations/backup-restore-drill-2026-08-10.md new file mode 100644 index 0000000..c9476ae --- /dev/null +++ b/docs/operations/backup-restore-drill-2026-08-10.md @@ -0,0 +1,47 @@ +# D1 backup and isolated restore evidence — 2026-08-10 + +## Scope + +This record documents the first end-to-end backup proof for both StudyInChina D1 databases. It is operational evidence, not a claim that scheduled backup SLOs are already met. + +- Source databases: `studyinchina-catalog` and `studyinchina-pipeline` +- Private R2 bucket: `studyinchina-backups` +- Format: `raw-v1` +- Daily prefix: `backups/daily/2026-08-10/raw-v1/` +- Monthly prefix: `backups/monthly/2026-08/raw-v1/` +- Lifecycle: daily checkpoints expire after 35 days; monthly checkpoints expire after 370 days + +The bucket has no public development URL or custom domain. The restore ran locally without Cloudflare credentials and could not write to either remote D1 database. + +## Export and readback + +| Database | SQL bytes | gzip bytes | SHA-256 | +|---|---:|---:|---| +| Catalog | 18,828,924 | 2,028,382 | `2bda279fc280697da2403e99f53ac22f52cb7db7601929e16febf92bdeb3d90f` | +| Pipeline | 21,529,431 | 2,873,901 | `0fa12ac2f94b1c86754b25efe593c0836a6ac054f234e179833bbd51a90ec4bc` | + +All six daily and monthly objects were uploaded with `Content-Encoding: identity`, downloaded again, checked for the gzip magic header and verified against the stored checksum manifest. Both daily and monthly readbacks matched byte-for-byte. + +This explicit encoding is required because an R2 object whose `.gz` content is labelled as encoded gzip can otherwise be transparently decompressed during download, which changes the bytes and invalidates disaster-recovery hashes. + +## Isolated restore result + +Status: **passed** +Mode: **local-isolated** +Elapsed time: **101.198 seconds** + +| Database | Total restore | Bulk data import | Foreign-key violations | Integrity | Additional checks | +|---|---:|---:|---:|---|---| +| Catalog | 51.766 s | 1.259 s | 0 | `ok` | 14 triggers; FTS 3,931 / 3,931 | +| Pipeline | 49.376 s | 1.879 s | 0 | `ok` | 80 triggers; sources 100; jobs 231; snapshots 71; candidates 65 | + +The Catalog verifier now compares restored entity counts with the active Release `counts_json`. It does not assume that every entity class must be non-zero; this matters because the current D1 Release legitimately reports zero program cycles. Institutions, programs and scholarships retain a non-zero safety gate. + +The restored Catalog currently reflects the existing D1 Release rather than the newer Git JSON catalogue. Its active projection contains 6 institutions, 1,006 programs, 0 program cycles and 55 scholarships. This is useful recovery evidence, but it also confirms that Production must remain on the JSON backend until three Shadow Releases complete with zero critical differences. + +## SLO interpretation + +- The measured restore time is below the internal current-scale target of 60 minutes and the external RTO objective of 4 hours. +- One verified checkpoint does not satisfy the required 7/7 daily-backup streak or the 24-hour RPO over time. +- Scheduled GitHub backup and restore workflows remain fail-closed until the dedicated backup token, protected restore environment/token and Vercel alias token are configured through hidden repository settings. +- No Production Catalog pointer or public alias was changed during this exercise. diff --git a/docs/operations/data-maintenance.md b/docs/operations/data-maintenance.md index 28f2d6f..b8feeca 100644 --- a/docs/operations/data-maintenance.md +++ b/docs/operations/data-maintenance.md @@ -31,15 +31,22 @@ URLs exceeds the scheduled weekly capacity. ## Required GitHub Actions secrets -The Vercel Git integration deploys `main` without an Action secret. Cloudflare -backup, restore and remote Pipeline import require these repository secrets: +The Vercel Git integration deploys `main` without an Action secret. Configure +the daily backup and stable-alias repository secrets through hidden prompts: ```powershell -gh secret set CLOUDFLARE_API_TOKEN --repo computersciencefreshmen/StudyInChina +gh secret set CLOUDFLARE_D1_BACKUP_TOKEN --repo computersciencefreshmen/StudyInChina gh secret set CLOUDFLARE_ACCOUNT_ID --repo computersciencefreshmen/StudyInChina gh secret set VERCEL_TOKEN --repo computersciencefreshmen/StudyInChina ``` +Create the private backup-only R2 Bucket `studyinchina-backups`. Create a protected +GitHub Environment named `cloudflare-restore-drill`, enable a required reviewer, +and add `CLOUDFLARE_D1_RESTORE_TOKEN` as an Environment secret. The backup token +needs only D1 Read plus write access to that Bucket; the restore token needs only +read access to that Bucket. Remote deployment/import credentials remain separate +from both backup credentials. + Enter each value only at the hidden prompt. Never place a token in a command argument, committed file, issue, log or workflow output. @@ -54,15 +61,26 @@ are therefore two separate signals. When a successful Production deployment does not match the current `main` SHA, the alias workflow remains a deliberate no-op and records a notice. When it does -match `main`, the workflow first validates the immutable Vercel deployment URL -and its `/api/v1/releases/current` response. Only a healthy candidate may receive -the stable alias; the same endpoint is checked again through -`studyinchina.vercel.app` after promotion. A missing token, invalid URL, failed -candidate smoke test, failed alias command or failed stable-alias smoke test is a -red workflow and requires operator action. - -The Cloudflare token should be limited to the StudyInChina account and only the -D1/R2/Workers capabilities required by the workflows. `MINIMAX_API_KEY` remains +match `main`, the workflow waits for the `ci.yml` main push run for that exact +SHA to complete successfully. A failed, cancelled or timed-out CI run blocks +promotion; Vercel Ready arriving before CI therefore cannot win the race. Only +then does the workflow validate the immutable Vercel deployment URL +and its `/api/v1/releases/current` response. Immediately before mutation, the +workflow records the current immutable target of `studyinchina.vercel.app` and +re-reads the GitHub `main` ref in the same shell step. Only a healthy candidate +that is still the exact current SHA may receive the stable alias. It re-reads +`main` again immediately after the Vercel command; if `main` advanced during the +mutation, it restores the recorded target and fails. Concurrent promotion runs +are serialized and an active mutation is never cancelled by a later deployment +status event. The release endpoint is then checked again through the stable +alias, including the exact deployment SHA and a positive numeric public program +count. A missing token, invalid URL, failed candidate smoke, failed or rolled-back +alias command, or failed stable smoke is a red workflow and requires operator +action. + +Each Cloudflare token is limited to the StudyInChina account and one operational +role. The generic `CLOUDFLARE_API_TOKEN` is intentionally not consumed by backup +or restore workflows. `MINIMAX_API_KEY` remains a Cloudflare Worker secret; it is not needed by the deterministic GitHub refresh job. @@ -76,13 +94,51 @@ refresh job. - Smoke-test `/api/v1/releases/current`, `/api/v1/programs` and `/api/v1/scholarships`. +## Machine-readable P0 reliability audit + +`scripts/operations/evaluate-p0-reliability.mjs` evaluates an explicit local +observation document and never fetches production state itself. A collector or +operator must supply real timestamps and counts from these named sources: + +- the successful D1 backup readback verification artifact; +- the active Catalog Release activation record; +- the hourly ingestion scheduler heartbeat; +- current Cloudflare Queue DLQ metrics; +- the Pipeline D1 pending outbox query. + +The observation document uses format +`studyinchina.p0-reliability-observations`, version `1`. Every section includes a +bounded `source` identifier. Empty DLQ and outbox observations explicitly use a +zero count and a `null` oldest timestamp; missing values are never inferred as +zero. + +Run the evaluator without credentials or network access: + +```powershell +node scripts/operations/evaluate-p0-reliability.mjs ` + --input C:\secure\studyinchina-p0-observations.json ` + --output C:\secure\studyinchina-p0-audit.json +``` + +Exit code `0` means every observation passed. Exit code `1` means a value was +missing, inconsistent or outside its threshold: verified backup age at most 26 +hours, active Release age at most 48 hours, scheduler heartbeat age at most 90 +minutes, DLQ backlog exactly zero, and oldest pending outbox event younger than +168 hours. The observation document itself must be no older than 15 minutes. +The evaluator output is evidence, not telemetry collection; never populate it +with guessed values or reuse a previous zero-backlog observation. + ## Monthly checks - Verify every public dynamic record has a future `reviewAfter`. - Review all deadlines inside the 45-day window and all recurring-rule cycles. - Re-run source discovery for new program and scholarship notices. -- Confirm the daily D1 backup has both `catalog.sql.gz`, `pipeline.sql.gz` and - a SHA-256 manifest; retain monthly copies for 12 months. +- Confirm the daily D1 backup has `catalog.sql.gz`, `pipeline.sql.gz` and the + SHA-256 manifest together under `backups/daily/YYYY-MM-DD/raw-v1/` in + `studyinchina-backups`; verify that the readback artifact proves raw gzip + bytes and matching hashes before counting the checkpoint toward RPO. Restore + drills must consume `backups/monthly/YYYY-MM/raw-v1/`; retain monthly copies + for 12 months. - Review infrastructure usage against the ¥60/¥80/¥95 cost thresholds. ## Adding a school diff --git a/docs/operations/freshness-reverification-2026-08-10-wave-1.md b/docs/operations/freshness-reverification-2026-08-10-wave-1.md new file mode 100644 index 0000000..3079974 --- /dev/null +++ b/docs/operations/freshness-reverification-2026-08-10-wave-1.md @@ -0,0 +1,25 @@ +# Freshness reverification — 2026-08-10, wave 1 + +## Purpose + +This is a deliberately small, high-confidence review of applicant-facing deadlines, fees, requirements and scholarship coverage. Every accepted value was checked against a live official HTTPS source on 2026-08-10. Search results and aggregators were not used as evidence, and a historical value was not copied into a current cycle. + +The review cadence follows `docs/content-maintenance.md`: three days when a live deadline is within 45 days, seven days for other current admission and scholarship sources, and 30 days for stable program requirements. + +## Accepted facts + +| Record group | Official evidence | Accepted fields | Next review | +|---|---|---|---| +| Schwarzman Scholars, class of 2027–2028 | `https://www.schwarzmanscholars.org/admissions/application-instructions/`; `https://www.schwarzmanscholars.org/program-experience/`; Tsinghua's English-program list | Global route open 2026-04-08 through 2026-09-09; one-year full scholarship; exact English thresholds; one-minute video is recommended, not required | 2026-08-13 | +| Soochow long-term Chinese | `https://oversea.suda.edu.cn/11295/list.htm` | 2026-09-30 deadline; CNY 8,500 per semester or CNY 17,000 per academic year; CNY 500 application fee | 2026-08-17 | +| Soochow ICLT Scholarship | `https://oversea.suda.edu.cn/oversea_en/bb/fd/c11345a441341/page.htm`; CLEC 2026 official guideline PDF | March 2027 one-semester route closes 2026-10-31; five-month duration; HSK 3 score 180 plus HSKK; tuition, accommodation, living allowance and medical-insurance coverage | 2026-08-17 | +| SCAU Guangdong Government Scholarship | `https://gdic.scau.edu.cn/2026/0421/c11155a432798/page.htm` | 2026-09-01 deadline; one-time awards of CNY 10,000 / 20,000 / 30,000 for bachelor's / master's / doctoral students | 2026-08-13 | +| ZUST ICLT Scholarship | `https://ies.zust.edu.cn/info/1271/4219.htm`; CLEC 2026 official guideline PDF | March 2027 semester deadline 2026-10-31; master HSK 5 score 210 and HSKK 60; semester HSK 3 score 180 plus HSKK; tuition, accommodation, living allowance and medical-insurance coverage | 2026-08-17 for cycles/scholarship; 2026-09-09 for program requirements | + +The CLEC guideline is the already registered source `src-gov-clec` at `https://pmplatform.chinese.cn/tmp/2026/2/6/94005b2e-f2e9-438e-85e7-12212f0e9968.pdf`. Its allowance amounts vary by scholarship category, so the single `stipendCnyPerMonth` field remains `null` instead of collapsing multiple tiers into one misleading value. + +## Rejected / unresolved fact + +NUIST's current official page says the one-semester route can start in March 2027, but its deadline list labels October 29 as applying to “March 2026”. The CLEC 2026 guideline instead states March 2027 with a general October 31 deadline. The existing NUIST October 29 value was not extended or rewritten in this wave. Its `reviewAfter` remains 2026-08-10 and it requires a second institution-specific official source or a corrected NUIST notice before another verified horizon is assigned. + +This is a high-risk date association issue, not evidence that the scholarship or program identity is invalid. diff --git a/docs/operations/freshness-reverification-2026-08-10-wave-2.md b/docs/operations/freshness-reverification-2026-08-10-wave-2.md new file mode 100644 index 0000000..2695413 --- /dev/null +++ b/docs/operations/freshness-reverification-2026-08-10-wave-2.md @@ -0,0 +1,27 @@ +# Freshness reverification — 2026-08-10, wave 2 + +## Scope and decision rule + +This wave intentionally stops at ten existing high-value records. It does not discover new entities or broaden program eligibility. Each accepted value was re-read on a live, already-registered official HTTPS page on 2026-08-10. A record was returned to `verified` only when the institution page still stated the same intake, deadline and any published fee or coverage value without ambiguity. + +## Accepted records and evidence locators + +| Records | Official source | Exact evidence locator | Published disposition | Next review | +|---|---|---|---|---| +| Five Wenzhou-Kean spring-transfer cycles: Finance, Global Business, Computer Science, Biology, Architecture | `https://admission.wku.edu.cn/en/internationalstudents` | `Undergraduate International Admissions` → `Prepare Your Application` → `Application Deadlines`; `Tuition & Fees (2026-2027 Academic Year)` → `Annual Fees` and `One-time Fees` | Spring 2027 remains transfer-only; deadline 2026-11-01; tuition CNY 68,000 per academic year; application fee CNY 400 | 2026-08-17 | +| Shanghai Normal one-semester ICLT, spring 2027 | `https://iao.shnu.edu.cn/ec/35/c20435a846901/page.htm` | `二、奖学金类别及申请条件` → `3.一学期研修生`; `四、申请截止日期` → item 2 | Five-month spring 2027 route; deadline 2026-10-31; cycle remains `dates-only` | 2026-08-17 | +| PKU International Chinese Language Teachers Scholarship | `https://www.isd.pku.edu.cn/cn/scholarship/detail.php?id=6`; supporting degree notice `https://www.isd.pku.edu.cn/cn/scholarship/detail.php?id=666` | General visiting/pre-university notice → `资助内容` and `申请时间` items 1–2; degree notice → `资助内容` and `申请时间` | February 2027 non-degree route deadline remains 2026-10-31; tuition, double-room accommodation, living allowance and medical insurance remain confirmed | 2026-08-17 | +| Sichuan International Studies University ICLT Scholarship | `https://studyinsisu.sisu.edu.cn/jxj/gjzwjsjxj/5e77e5b50f64418eb76adedd432736f9.htm` | `三、奖学金资助内容及标准`; `四、奖学金申请流程` → item 5 | Funding components remain confirmed; latest published future deadline remains 2026-10-31 | 2026-08-17 | +| Shenyang Normal University ICLT Scholarship | `https://cie.synu.edu.cn/2026/0415/c5760a112915/page.htm` | `五、申请截止日期` → item 3; `七、资助内容及标准` | December four-week route remains open until 2026-09-15; tuition, accommodation, living allowance and insurance remain confirmed | 2026-08-13 | +| Hainan Normal University ICLT Scholarship | `https://webplus.hainnu.edu.cn/_s74/2025/1110/c1345a160838/page.psp` | `7. Scholarship Programs` → item 2 | March entry deadline remains 2026-10-31; coverage stays `unknown` because this accessible institution text does not state benefit amounts/components | 2026-08-17 | + +The five Wenzhou-Kean entries share one institution-level admissions fact set. They remain separate records only because their existing program identities and slugs are distinct; this wave does not add programs or infer program-specific exceptions. + +## Withheld and conflicting candidates + +- Shenzhen University's registered ICLT page timed out during the live check. Its record was not refreshed from cached or search-result text. +- Central China Normal University's registered scholarship page also timed out and was not refreshed. +- Sichuan Normal University's Chinese text says the future intake is March 2027, while the English rendering on the same page says March 2025. No record from that page was promoted in this wave; the mismatch requires a corrected notice or a second institution-specific source. +- NUIST remains excluded for the date-association conflict documented in wave 1. + +These exclusions are intentional: source availability or internal conflict is not converted into a guessed current fact. diff --git a/docs/operations/pilot-source-manifest-v2-audit.md b/docs/operations/pilot-source-manifest-v2-audit.md new file mode 100644 index 0000000..c5a6250 --- /dev/null +++ b/docs/operations/pilot-source-manifest-v2-audit.md @@ -0,0 +1,77 @@ +# Pilot Source Manifest V2 migration audit + +Audit date: 2026-08-10 +Formal source check date retained from V1: 2026-07-20 +Disposition: `in_progress` / fail-closed + +## Outcome + +The ten pilot institution manifests now use the institution-level +`SourceManifestV2` contract. This is a trust-ledger migration, not a claim that +the official catalogue has been fully reconciled. + +| Institution | Preserved official sources | Pending reconciliation seeds | +|---|---:|---:| +| Fudan University | 10 | 2 | +| Harbin Institute of Technology | 9 | 5 | +| Nanjing University | 9 | 3 | +| Peking University | 12 | 9 | +| Shanghai Jiao Tong University | 9 | 1 | +| Sun Yat-sen University | 10 | 3 | +| Tsinghua University | 14 | 2 | +| University of Science and Technology of China | 11 | 3 | +| Wuhan University | 5 | 2 | +| Zhejiang University | 11 | 4 | +| **Total** | **100** | **34** | + +The public trust ledger therefore reports: + +- 10 formal V2 manifests in progress; +- 0 legacy V1 upgrade paths remaining in the pilot; +- 0 complete formal catalogue reconciliations; +- 0 publication-eligible pilot manifests. + +## Locked migration invariants + +The migration preserves each manifest's existing: + +- institution ID and catalogue status; +- source IDs, official URLs, allowlists, schedules and extraction contracts; +- `enabled` and `robots` safety semantics; +- sixteen-category coverage ledger; +- 2026-07-20 source check date. + +The nested fetch manifests intentionally remain V1 because that is the format +consumed by the ingestion Worker. V2 is the institution-level contract around +those sources. + +`officialHosts` is the normalized union of the existing source and redirect +allowlists. No new fetch host was introduced. + +## Reconciliation seeding policy + +The 2026-08-10 disabled candidate cohort was used only as an audit index. A +catalogue identity was seeded into a formal manifest only when its candidate +source could be mapped to an existing pilot source by either: + +1. the exact normalized official URL; or +2. the same official host and the same source category. + +Every seeded item remains `pending`. Same-host mappings explicitly state that +exact page evidence is still missing. Candidate-only identities with neither +mapping were omitted instead of being guessed. + +## Remaining gates + +Before any pilot manifest can become `complete`, a reviewer must still: + +- resolve all 34 pending identities with entity-level evidence; +- reconcile every official catalogue item to a terminal disposition; +- resolve 61 `discovery_pending` coverage categories; +- re-check the four `source_unavailable` categories where access permits; +- remove all audit-only markers and pending entries; +- pass the existing checksum-bound promotion review and full manifest + validation. + +Until then, `isCatalogReconciliationComplete()` remains false and the +promotion gate stays closed. diff --git a/docs/source-manifest-cohort-candidates.md b/docs/source-manifest-cohort-candidates.md index e4e53b8..6a258e0 100644 --- a/docs/source-manifest-cohort-candidates.md +++ b/docs/source-manifest-cohort-candidates.md @@ -78,3 +78,9 @@ short-lived review artifact. Promotion remains a separate evidence-review action. Candidate artifacts must never be copied wholesale into the formal manifest directory. + +A `representative_international_programs` scope is discovery evidence, never a +complete catalogue reconciliation, even if an upstream artifact incorrectly +labels it `complete`. The completion and publication gates accept only +`full_official_catalog`, or an explicitly final `limited_official_catalog`, +with terminal reconciliation entries and no pending outcomes. diff --git a/package.json b/package.json index 4e1d501..0d65424 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "pipeline:build-source-manifest-candidates": "tsx scripts/ingestion/build-source-manifest-cohort.ts", "pipeline:verify-source-manifest-candidates": "tsx scripts/ingestion/build-source-manifest-cohort.ts --verify-artifact", "pipeline:promote-source-manifest-candidate": "tsx scripts/ingestion/promote-source-manifest-candidate.ts", + "pipeline:manifest-trust-ledger": "tsx scripts/ingestion/build-source-manifest-trust-ledger.ts", "pipeline:build-sources": "tsx scripts/ingestion/build-source-import.ts", "benchmark:catalog": "tsx scripts/catalog/benchmark-catalog.ts", "benchmark:catalog:smoke": "tsx scripts/catalog/benchmark-catalog.ts --institutions 25 --programs 1000 --cycles 3000 --iterations 50 --warmup 10 --output .benchmark/catalog-performance-smoke.json", @@ -54,6 +55,7 @@ "check:program-coverage:strict": "tsx scripts/quality/check-program-coverage.ts --mode strict --minimum 1", "quality:inventory-untracked": "tsx scripts/quality/inventory-untracked-assets.ts", "quality:synthetic-regression": "tsx scripts/quality/run-synthetic-regression.ts", + "quality:reliability": "node scripts/operations/evaluate-p0-reliability.mjs", "quality:platform-scorecard": "tsx scripts/quality/platform-data-quality.ts" }, "dependencies": { diff --git a/scripts/cloudflare/backup-preflight.ts b/scripts/cloudflare/backup-preflight.ts index 166fcac..981b8e9 100644 --- a/scripts/cloudflare/backup-preflight.ts +++ b/scripts/cloudflare/backup-preflight.ts @@ -11,7 +11,9 @@ export const BACKUP_DATABASES = [ 'studyinchina-catalog', 'studyinchina-pipeline', ] as const -export const BACKUP_BUCKET = 'studyinchina-releases' +export const BACKUP_BUCKET = 'studyinchina-backups' +export const BACKUP_TOKEN_SECRET = 'CLOUDFLARE_D1_BACKUP_TOKEN' +export const RESTORE_TOKEN_SECRET = 'CLOUDFLARE_D1_RESTORE_TOKEN' export const BACKUP_CONFIGURATION_DOC = 'docs/backup-and-restore.md#github-actions-configuration' const BACKUP_FILES = ['catalog.sql.gz', 'pipeline.sql.gz'] as const @@ -25,11 +27,11 @@ export type BackupArtifactReport = { export function validateBackupCredentials( environment: Readonly>, ): { databases: number; bucket: string } { - const token = environment.CLOUDFLARE_API_TOKEN?.trim() + const token = environment[BACKUP_TOKEN_SECRET]?.trim() const accountId = environment.CLOUDFLARE_ACCOUNT_ID?.trim() if (!token || !accountId) { const missing = [ - !token ? 'CLOUDFLARE_API_TOKEN' : undefined, + !token ? BACKUP_TOKEN_SECRET : undefined, !accountId ? 'CLOUDFLARE_ACCOUNT_ID' : undefined, ].filter((name): name is string => Boolean(name)) throw new Error( @@ -46,6 +48,30 @@ export function validateBackupCredentials( return { databases: BACKUP_DATABASES.length, bucket: BACKUP_BUCKET } } +export function validateRestoreCredentials( + environment: Readonly>, +): { bucket: string } { + const token = environment[RESTORE_TOKEN_SECRET]?.trim() + const accountId = environment.CLOUDFLARE_ACCOUNT_ID?.trim() + if (!token || !accountId) { + const missing = [ + !token ? RESTORE_TOKEN_SECRET : undefined, + !accountId ? 'CLOUDFLARE_ACCOUNT_ID' : undefined, + ].filter((name): name is string => Boolean(name)) + throw new Error( + `Missing required protected-environment secret(s): ${missing.join(', ')}. ` + + `Configure them before rerunning; see ${BACKUP_CONFIGURATION_DOC}. No restore artifact was downloaded.`, + ) + } + if (!/^[0-9a-f]{32}$/iu.test(accountId)) { + throw new Error( + 'CLOUDFLARE_ACCOUNT_ID must be a 32-character hexadecimal identifier. ' + + `See ${BACKUP_CONFIGURATION_DOC}. No restore artifact was downloaded.`, + ) + } + return { bucket: BACKUP_BUCKET } +} + function escapeWorkflowCommand(value: string): string { return value .replaceAll('%', '%25') @@ -134,6 +160,11 @@ function main(): void { process.stdout.write(`${JSON.stringify({ ok: true, phase, ...result })}\n`) return } + if (phase === 'restore-credentials') { + const result = validateRestoreCredentials(process.env) + process.stdout.write(`${JSON.stringify({ ok: true, phase, ...result })}\n`) + return + } if (phase === 'artifacts') { const directory = argument(args, '--directory') if (!directory) throw new Error('--directory is required for artifact preflight') @@ -141,7 +172,7 @@ function main(): void { process.stdout.write(`${JSON.stringify({ ok: true, phase, artifacts })}\n`) return } - throw new Error('Use --phase credentials or --phase artifacts') + throw new Error('Use --phase credentials, --phase restore-credentials or --phase artifacts') } if (isMainModule()) { diff --git a/scripts/cloudflare/configure-retention.ps1 b/scripts/cloudflare/configure-retention.ps1 index 15be13e..f09d4bd 100644 --- a/scripts/cloudflare/configure-retention.ps1 +++ b/scripts/cloudflare/configure-retention.ps1 @@ -1,5 +1,5 @@ param( - [string]$Bucket = 'studyinchina-releases' + [string]$Bucket = 'studyinchina-backups' ) $ErrorActionPreference = 'Stop' diff --git a/scripts/cloudflare/import-restored-d1.mjs b/scripts/cloudflare/import-restored-d1.mjs new file mode 100644 index 0000000..ec4bcdc --- /dev/null +++ b/scripts/cloudflare/import-restored-d1.mjs @@ -0,0 +1,134 @@ +import { constants as bufferConstants } from 'node:buffer' +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { basename, join, resolve } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { performance } from 'node:perf_hooks' + +const [stateDirectory, kind, sqlPath] = process.argv.slice(2) + +if (!stateDirectory || !['catalog', 'pipeline'].includes(kind) || !sqlPath) { + throw new Error( + 'Usage: node import-restored-d1.mjs ', + ) +} + +const markerTable = kind === 'catalog' ? 'catalog_releases' : 'records' + +function sqliteFiles(directory) { + const files = [] + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + if (entry.isDirectory()) files.push(...sqliteFiles(path)) + if (entry.isFile() && entry.name.endsWith('.sqlite') && entry.name !== 'metadata.sqlite') { + files.push(path) + } + } + return files +} + +function findIsolatedDatabase(directory) { + const matches = [] + for (const path of sqliteFiles(resolve(directory))) { + const database = new DatabaseSync(path) + try { + const marker = database + .prepare("SELECT COUNT(*) AS count FROM sqlite_schema WHERE type = 'table' AND name = ?") + .get(markerTable) + if (marker.count === 1) matches.push({ path, database }) + else database.close() + } catch (error) { + database.close() + throw error + } + } + + if (matches.length !== 1) { + for (const match of matches) match.database.close() + throw new Error(`Expected one isolated ${kind} SQLite file, found ${matches.length}`) + } + return matches[0] +} + +function hasExplicitTransaction(sql) { + return /^\s*(?:BEGIN(?:\s+(?:DEFERRED|IMMEDIATE|EXCLUSIVE))?(?:\s+TRANSACTION)?|COMMIT(?:\s+TRANSACTION)?|END(?:\s+TRANSACTION)?|ROLLBACK(?:\s+TRANSACTION)?|SAVEPOINT\s+|RELEASE\s+)\b/imu.test( + sql, + ) +} + +function rollbackIfNeeded(database) { + if (database.isTransaction) database.exec('ROLLBACK') +} + +function importData(database, sql) { + database.exec('PRAGMA foreign_keys = ON;') + const foreignKeys = database.prepare('PRAGMA foreign_keys').get() + if (Number(foreignKeys.foreign_keys) !== 1) { + throw new Error('Unable to enable foreign-key enforcement for the isolated restore') + } + + if (hasExplicitTransaction(sql)) { + try { + database.exec(sql) + if (database.isTransaction) { + database.exec('ROLLBACK') + throw new Error('Backup SQL left an explicit transaction open') + } + return 'archive' + } catch (error) { + rollbackIfNeeded(database) + throw error + } + } + + database.exec('BEGIN IMMEDIATE; PRAGMA defer_foreign_keys = TRUE;') + try { + database.exec(sql) + database.exec('COMMIT') + return 'wrapper' + } catch (error) { + rollbackIfNeeded(database) + throw error + } +} + +function main() { + const resolvedSqlPath = resolve(sqlPath) + const sqlBytes = statSync(resolvedSqlPath).size + if (sqlBytes <= 0) throw new Error('Data-only SQL backup is empty') + if (sqlBytes > bufferConstants.MAX_STRING_LENGTH) { + throw new Error( + `Data-only SQL backup is too large for the local bulk importer (${sqlBytes} bytes)`, + ) + } + + const sql = readFileSync(resolvedSqlPath, 'utf8') + if (Buffer.byteLength(sql, 'utf8') !== sqlBytes) { + throw new Error('Data-only SQL backup is not valid UTF-8 text') + } + + const { path, database } = findIsolatedDatabase(stateDirectory) + const startedAt = performance.now() + try { + const transactionMode = importData(database, sql) + process.stdout.write( + JSON.stringify({ + databaseFile: basename(path), + engine: 'node:sqlite', + transactionMode, + sqlBytes, + elapsedMs: Math.round((performance.now() - startedAt) * 1000) / 1000, + }), + ) + } finally { + rollbackIfNeeded(database) + database.close() + } +} + +try { + main() +} catch (error) { + const message = error instanceof Error ? error.message : String(error) + process.stderr.write(`Local D1 bulk import failed: ${message}\n`) + process.exitCode = 1 +} diff --git a/scripts/cloudflare/restore-drill.ps1 b/scripts/cloudflare/restore-drill.ps1 index 81bce59..76e0e5f 100644 --- a/scripts/cloudflare/restore-drill.ps1 +++ b/scripts/cloudflare/restore-drill.ps1 @@ -50,6 +50,11 @@ if (-not (Test-Path -LiteralPath $localVerifier -PathType Leaf)) { throw 'Local D1 verifier is missing.' } +$localImporter = Join-Path (Join-Path (Join-Path $repositoryRoot 'scripts') 'cloudflare') 'import-restored-d1.mjs' +if (-not (Test-Path -LiteralPath $localImporter -PathType Leaf)) { + throw 'Local D1 bulk importer is missing.' +} + # Keep all Wrangler state inside the disposable drill directory. The script has # no remote mode and every D1 invocation below includes --local explicitly. $env:XDG_CONFIG_HOME = Join-Path $runDirectory 'xdg-config' @@ -151,6 +156,23 @@ function Expand-GzipBounded { } } +function Write-RestoreProgress { + param( + [string]$Kind, + [string]$Phase, + [string]$Status, + [long]$ElapsedMilliseconds = -1 + ) + + $elapsed = if ($ElapsedMilliseconds -ge 0) { + " elapsedMs=$ElapsedMilliseconds" + } else { + '' + } + $timestamp = (Get-Date).ToUniversalTime().ToString('o') + [Console]::Error.WriteLine("[restore-drill] timestamp=$timestamp database=$Kind phase=$Phase status=$Status$elapsed") +} + function Invoke-Wrangler { param([string[]]$Arguments) @@ -217,7 +239,11 @@ function Initialize-IsolatedSchema { if ($migrationFiles.Count -eq 0) { throw "No migrations found for $Kind." } + $migrationIndex = 0 foreach ($migration in $migrationFiles) { + $migrationIndex += 1 + $migrationWatch = [System.Diagnostics.Stopwatch]::StartNew() + Write-RestoreProgress $Kind "migration-$migrationIndex-$($migration.Name)" 'started' Invoke-Wrangler @( 'd1', 'execute', $Binding, '--local', @@ -226,6 +252,8 @@ function Initialize-IsolatedSchema { '--file', $migration.FullName, '--yes' ) | Out-Null + $migrationWatch.Stop() + Write-RestoreProgress $Kind "migration-$migrationIndex-$($migration.Name)" 'completed' $migrationWatch.ElapsedMilliseconds } # A data-only export may contain rows that would correctly be rejected by @@ -311,6 +339,35 @@ function Invoke-LocalDatabaseVerifier { } } +function Invoke-LocalDataImporter { + param( + [string]$Kind, + [string]$SqlPath + ) + + $previousErrorPreference = $ErrorActionPreference + try { + $ErrorActionPreference = 'Continue' + $output = @(& $node --no-warnings $localImporter $stateDirectory $Kind $SqlPath 2>&1 | ForEach-Object { "$_" }) + $exitCode = $LASTEXITCODE + } finally { + $ErrorActionPreference = $previousErrorPreference + } + $json = ($output -join "`n").Trim() + if ($exitCode -ne 0) { + throw "Local SQLite data import failed for $Kind`n$json" + } + try { + $result = $json | ConvertFrom-Json + } catch { + throw "Local SQLite data importer returned invalid JSON for $Kind`n$json" + } + if ([string]$result.engine -ne 'node:sqlite') { + throw "Local SQLite data importer returned an unexpected engine for $Kind" + } + return $result +} + function Test-RestoredDatabase { param([System.Collections.IDictionary]$Definition) @@ -318,6 +375,11 @@ function Test-RestoredDatabase { $binding = [string]$Definition.binding $archiveName = "$kind.sql.gz" $archivePath = Join-Path $backupRoot $archiveName + $databaseWatch = [System.Diagnostics.Stopwatch]::StartNew() + $phaseDurations = [ordered]@{} + Write-RestoreProgress $kind 'database' 'started' + $phaseWatch = [System.Diagnostics.Stopwatch]::StartNew() + if (-not (Test-Path -LiteralPath $archivePath -PathType Leaf)) { throw "Backup archive is missing: $archiveName" } @@ -330,28 +392,56 @@ function Test-RestoredDatabase { if ($actualSha256 -ne $expectedSha256) { throw "SHA-256 mismatch for $archiveName" } + $phaseWatch.Stop() + $phaseDurations['archiveVerification'] = $phaseWatch.ElapsedMilliseconds + Write-RestoreProgress $kind 'archive-verification' 'completed' $phaseWatch.ElapsedMilliseconds + $phaseWatch.Restart() + Write-RestoreProgress $kind 'decompression' 'started' $sqlPath = Join-Path $runDirectory "$kind.sql" $uncompressedBytes = Expand-GzipBounded $archivePath $sqlPath $MaxUncompressedBytes if ($uncompressedBytes -le 0) { throw "Backup archive is empty after decompression: $archiveName" } + $phaseWatch.Stop() + $phaseDurations['decompression'] = $phaseWatch.ElapsedMilliseconds + Write-RestoreProgress $kind 'decompression' 'completed' $phaseWatch.ElapsedMilliseconds + $phaseWatch.Restart() + Write-RestoreProgress $kind 'schema-initialization' 'started' $triggerRows = @(Initialize-IsolatedSchema $binding $kind) - Invoke-Wrangler @( - 'd1', 'execute', $binding, - '--local', - '--persist-to', $stateDirectory, - '--config', $configPath, - '--file', $sqlPath, - '--yes' - ) | Out-Null + $phaseWatch.Stop() + $phaseDurations['schemaInitialization'] = $phaseWatch.ElapsedMilliseconds + Write-RestoreProgress $kind 'schema-initialization' 'completed' $phaseWatch.ElapsedMilliseconds + + $phaseWatch.Restart() + Write-RestoreProgress $kind 'data-import' 'started' + $dataImport = Invoke-LocalDataImporter $kind $sqlPath + if ([long]$dataImport.sqlBytes -ne [long]$uncompressedBytes) { + throw "$kind data importer byte count does not match decompressed backup" + } + $phaseWatch.Stop() + $phaseDurations['dataImport'] = $phaseWatch.ElapsedMilliseconds + Write-RestoreProgress $kind 'data-import' 'completed' $phaseWatch.ElapsedMilliseconds + + $phaseWatch.Restart() + Write-RestoreProgress $kind 'trigger-search-restore' 'started' Restore-IsolatedTriggersAndSearch $binding $kind $triggerRows + $phaseWatch.Stop() + $phaseDurations['triggerAndSearchRestore'] = $phaseWatch.ElapsedMilliseconds + Write-RestoreProgress $kind 'trigger-search-restore' 'completed' $phaseWatch.ElapsedMilliseconds # D1 deliberately blocks integrity_check through its query API. The verifier # opens only Wrangler's isolated local SQLite file in read-only mode so both # integrity_check and foreign_key_check can still be executed exactly. + $phaseWatch.Restart() + Write-RestoreProgress $kind 'verification' 'started' $verification = Invoke-LocalDatabaseVerifier $kind + $phaseWatch.Stop() + $phaseDurations['verification'] = $phaseWatch.ElapsedMilliseconds + Write-RestoreProgress $kind 'verification' 'completed' $phaseWatch.ElapsedMilliseconds + $databaseWatch.Stop() + $phaseDurations['total'] = $databaseWatch.ElapsedMilliseconds $databaseReport = [ordered]@{ kind = $kind isolatedDatabaseName = [string]$Definition.databaseName @@ -363,6 +453,14 @@ function Test-RestoredDatabase { integrityCheck = [string]$verification.integrityCheck coreTables = @($verification.coreTables) triggerCount = [long]$verification.triggerCount + dataImport = [ordered]@{ + engine = [string]$dataImport.engine + transactionMode = [string]$dataImport.transactionMode + sqlBytes = [long]$dataImport.sqlBytes + elapsedMs = [double]$dataImport.elapsedMs + databaseFile = [string]$dataImport.databaseFile + } + phaseDurationsMs = $phaseDurations } if ($kind -eq 'catalog') { @@ -389,6 +487,7 @@ function Test-RestoredDatabase { } } + Write-RestoreProgress $kind 'database' 'completed' $databaseWatch.ElapsedMilliseconds return $databaseReport } diff --git a/scripts/cloudflare/verify-restored-d1.mjs b/scripts/cloudflare/verify-restored-d1.mjs index 703170e..5e6be2f 100644 --- a/scripts/cloudflare/verify-restored-d1.mjs +++ b/scripts/cloudflare/verify-restored-d1.mjs @@ -108,6 +108,7 @@ try { cr.release_status, cr.data_date, cr.generated_at, + cr.counts_json, (SELECT COUNT(*) FROM institutions WHERE release_id = rp.current_release_id) AS institutions, (SELECT COUNT(*) FROM programs WHERE release_id = rp.current_release_id) AS programs, (SELECT COUNT(*) FROM program_cycles WHERE release_id = rp.current_release_id) AS program_cycles, @@ -122,8 +123,35 @@ try { } const release = releases[0] if (release.release_status !== 'active') throw new Error('Catalog current release is not active') - for (const countName of ['institutions', 'programs', 'program_cycles', 'scholarships']) { - if (release[countName] <= 0) throw new Error(`Catalog current release has no ${countName} rows`) + + let declaredCounts + try { + declaredCounts = JSON.parse(release.counts_json) + } catch { + throw new Error('Catalog current release has invalid counts_json') + } + const countContracts = [ + ['institutions', 'universities'], + ['programs', 'programs'], + ['program_cycles', 'admissionCycles'], + ['scholarships', 'scholarships'], + ] + for (const [actualName, declaredName] of countContracts) { + const actual = Number(release[actualName]) + const declared = Number(declaredCounts[declaredName]) + if (!Number.isSafeInteger(declared) || declared < 0) { + throw new Error(`Catalog current release has invalid declared ${declaredName} count`) + } + if (actual !== declared) { + throw new Error( + `Catalog current release ${actualName} count mismatch: expected ${declared}, restored ${actual}`, + ) + } + } + for (const countName of ['institutions', 'programs', 'scholarships']) { + if (release[countName] <= 0) { + throw new Error(`Catalog current release has no ${countName} rows`) + } } const fts = database diff --git a/scripts/ingestion/build-source-import.ts b/scripts/ingestion/build-source-import.ts index 5182e9e..20db147 100644 --- a/scripts/ingestion/build-source-import.ts +++ b/scripts/ingestion/build-source-import.ts @@ -2,9 +2,11 @@ import { mkdirSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { + isCatalogReconciliationComplete, validateSourceManifestDirectory, type SourceManifestRecord, } from '../source-manifest-registry' +import type { SourceManifestV1 } from '../../workers/ingestion/src/types' type SqlValue = string | number | null @@ -16,6 +18,28 @@ export type SourceImportArtifacts = { generatedAt: string } +export type NormalizedSourceManifestImport = { + institutionId: string + manifestVersion: 1 | 2 + checkedAt: string + catalogReconciliationComplete: boolean + sources: readonly SourceManifestV1[] +} + +export function normalizeSourceManifestsForImport( + records: readonly SourceManifestRecord[], +): NormalizedSourceManifestImport[] { + return records.map((record) => ({ + institutionId: record.institutionId, + manifestVersion: record.version, + checkedAt: record.checkedAt, + // V1 never claimed reconciliation completeness. A V2 in_progress + // envelope is importable, but remains explicitly incomplete. + catalogReconciliationComplete: isCatalogReconciliationComplete(record), + sources: record.sources, + })) +} + function sqlValue(value: SqlValue) { if (value === null) return 'NULL' if (typeof value === 'number') return String(value) @@ -27,10 +51,11 @@ export function buildPilotSourceImport( generatedAt = new Date().toISOString(), ): SourceImportArtifacts { if (Number.isNaN(Date.parse(generatedAt))) throw new Error('generatedAt must be an ISO timestamp') - const sources = records + const normalized = normalizeSourceManifestsForImport(records) + const sources = normalized .flatMap((record) => record.sources) .sort((left, right) => left.id.localeCompare(right.id)) - const institutionIds = [...new Set(records.map((record) => record.institutionId))].sort() + const institutionIds = [...new Set(normalized.map((record) => record.institutionId))].sort() const sourceIds = new Set() for (const source of sources) { if (sourceIds.has(source.id)) throw new Error(`Duplicate source id: ${source.id}`) diff --git a/scripts/ingestion/build-source-manifest-trust-ledger.ts b/scripts/ingestion/build-source-manifest-trust-ledger.ts new file mode 100644 index 0000000..e931321 --- /dev/null +++ b/scripts/ingestion/build-source-manifest-trust-ledger.ts @@ -0,0 +1,532 @@ +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, +} from 'node:fs' +import { dirname, isAbsolute, relative, resolve, sep } from 'node:path' +import { pathToFileURL } from 'node:url' +import { selectPublishedData } from '../../src/lib/data/publication' +import { bundleSchema } from '../../src/lib/data/schema' +import type { DataBundle } from '../../src/lib/data/types' +import { + isCatalogReconciliationComplete, + loadSourceManifestFiles, + validateSourceManifests, + type SourceManifestRecord, + type SourceManifestV2, +} from '../source-manifest-registry' +import { + buildCurrentSourceManifestCohort, + type CandidateManifestFile, + type SourceManifestCohortBuild, +} from './build-source-manifest-cohort' + +export const SOURCE_MANIFEST_TRUST_LEDGER_STATUSES = [ + 'complete', + 'in_progress', + 'limited_official_catalog', +] as const + +export type SourceManifestTrustLedgerStatus = + (typeof SOURCE_MANIFEST_TRUST_LEDGER_STATUSES)[number] + +export const SOURCE_MANIFEST_UPGRADE_STAGES = [ + 'reconciled_complete', + 'formal_v2_reconciliation_required', + 'legacy_v1_upgrade_required', + 'candidate_review_required', + 'limited_catalog_review_required', + 'official_source_discovery_required', +] as const + +export type SourceManifestUpgradeStage = + (typeof SOURCE_MANIFEST_UPGRADE_STAGES)[number] + +export type SourceManifestLedgerUniversity = { + id: string + slug: string + name: { + en: string | null + zh: string | null + } +} + +export type SourceManifestTrustLedgerEntry = { + institutionId: string + slug: string + name: SourceManifestLedgerUniversity['name'] + status: SourceManifestTrustLedgerStatus + upgradeStage: SourceManifestUpgradeStage + formalManifest: { + registered: boolean + version: 1 | 2 | null + state: 'not_registered' | 'legacy_v1' | 'in_progress' | 'complete' + checkedAt: string | null + } + candidate: { + available: boolean + classification: + | 'none' + | 'catalog_linked_candidate' + | 'limited_official_catalog_candidate' + cohortId: string | null + fileName: string | null + scope: SourceManifestV2['catalogReconciliation']['scope'] | null + sourceCount: number + reconciliationEntryCount: number + safelyDisabled: boolean + } + reconciliation: { + state: + | 'complete' + | 'in_progress' + | 'legacy_v1_not_reconciled' + | 'candidate_is_not_reconciliation' + | 'not_started' + basis: 'formal_v2' | 'legacy_v1' | 'candidate_only' | 'none' + } + gates: { + publicationEligible: boolean + candidateEvidenceOnly: boolean + requiresHumanReview: boolean + } +} + +export type SourceManifestTrustLedgerReport = { + format: 'studyinchina.source-manifest-trust-ledger' + formatVersion: 1 + checkedAt: string + scope: 'public_catalog' + disposition: 'audit_only' + policy: { + publicScope: string + candidateBoundary: string + legacyBoundary: string + formalImportGate: string + } + summary: { + publicUniversities: number + ledgerEntries: number + statusCounts: Record + formalManifestRecords: number + formalRecordsOutsidePublicCatalog: number + legacyV1UpgradePaths: number + formalV2InProgress: number + completeFormalReconciliations: number + candidateCoverage: number + candidateRecordsOutsidePublicCatalog: number + candidateOnlyRecords: number + formalCandidateOverlap: number + officialSourceDiscoveryRequired: number + candidateCohort: { + cohortId: string + officialTargets: number + militaryExcluded: number + eligibleTargets: number + candidateManifests: number + catalogLinkedCandidates: number + limitedOfficialCatalogCandidates: number + exactOfficialHttpsSources: number + } + } + entries: SourceManifestTrustLedgerEntry[] +} + +export type BuildSourceManifestTrustLedgerInput = { + checkedAt: string + publicUniversities: SourceManifestLedgerUniversity[] + formalManifests: SourceManifestRecord[] + candidateBuild: SourceManifestCohortBuild +} + +type SourceManifestTrustLedgerCli = { + checkedAt: string + outputPath?: string +} + +const CLI_USAGE = [ + 'Usage:', + ' --checked-at ', + ' --checked-at --output ', +].join('\n') + +function assertUniqueMap( + values: T[], + keyOf: (value: T) => string, + label: string, +): Map { + const result = new Map() + for (const value of values) { + const key = keyOf(value) + if (!key) throw new Error(`${label} contains an empty identity`) + if (result.has(key)) throw new Error(`${label} repeats ${key}`) + result.set(key, value) + } + return result +} + +function candidateIsSafelyDisabled(candidate: CandidateManifestFile): boolean { + return candidate.manifest.manifestStatus === 'in_progress' + && candidate.manifest.catalogReconciliation.status === 'in_progress' + && candidate.manifest.catalogReconciliation.entries.every( + (entry) => entry.status === 'pending', + ) + && candidate.manifest.sources.every( + (source) => source.enabled === false && source.robots.mode === 'blocked', + ) +} + +function statusFor( + formal: SourceManifestRecord | undefined, + candidate: CandidateManifestFile | undefined, +): SourceManifestTrustLedgerStatus { + if (formal && isCatalogReconciliationComplete(formal)) return 'complete' + const formalIsLimited = formal?.version === 2 + && formal.catalogReconciliation.scope === 'limited_official_catalog' + const candidateIsLimited = candidate?.manifest.catalogReconciliation.scope + === 'limited_official_catalog' + return formalIsLimited || candidateIsLimited + ? 'limited_official_catalog' + : 'in_progress' +} + +function upgradeStageFor( + formal: SourceManifestRecord | undefined, + candidate: CandidateManifestFile | undefined, + status: SourceManifestTrustLedgerStatus, +): SourceManifestUpgradeStage { + if (status === 'complete') return 'reconciled_complete' + if (formal?.version === 2) return 'formal_v2_reconciliation_required' + if (formal?.version === 1) return 'legacy_v1_upgrade_required' + if (status === 'limited_official_catalog') { + return 'limited_catalog_review_required' + } + if (candidate) return 'candidate_review_required' + return 'official_source_discovery_required' +} + +function reconciliationFor( + formal: SourceManifestRecord | undefined, + candidate: CandidateManifestFile | undefined, +): SourceManifestTrustLedgerEntry['reconciliation'] { + if (formal?.version === 2) { + return { + state: isCatalogReconciliationComplete(formal) ? 'complete' : 'in_progress', + basis: 'formal_v2', + } + } + if (formal?.version === 1) { + return { state: 'legacy_v1_not_reconciled', basis: 'legacy_v1' } + } + if (candidate) { + return { state: 'candidate_is_not_reconciliation', basis: 'candidate_only' } + } + return { state: 'not_started', basis: 'none' } +} + +function formalManifestFor( + formal: SourceManifestRecord | undefined, +): SourceManifestTrustLedgerEntry['formalManifest'] { + if (!formal) { + return { + registered: false, + version: null, + state: 'not_registered', + checkedAt: null, + } + } + if (formal.version === 1) { + return { + registered: true, + version: 1, + state: 'legacy_v1', + checkedAt: formal.checkedAt, + } + } + return { + registered: true, + version: 2, + state: isCatalogReconciliationComplete(formal) ? 'complete' : 'in_progress', + checkedAt: formal.checkedAt, + } +} + +export function buildSourceManifestTrustLedger( + input: BuildSourceManifestTrustLedgerInput, +): SourceManifestTrustLedgerReport { + const publicUniversityById = assertUniqueMap( + input.publicUniversities, + (university) => university.id, + 'Public university catalog', + ) + const formalByInstitution = assertUniqueMap( + input.formalManifests, + (manifest) => manifest.institutionId, + 'Formal source manifests', + ) + const candidateByInstitution = assertUniqueMap( + input.candidateBuild.candidates, + (candidate) => candidate.manifest.institutionId, + 'Source-manifest candidate cohort', + ) + + for (const candidate of candidateByInstitution.values()) { + if (!candidateIsSafelyDisabled(candidate)) { + throw new Error( + `Candidate ${candidate.manifest.institutionId} is not safely disabled and pending`, + ) + } + } + + const entries = [...publicUniversityById.values()] + .sort((left, right) => left.id.localeCompare(right.id, 'en')) + .map((university): SourceManifestTrustLedgerEntry => { + const formal = formalByInstitution.get(university.id) + const candidate = candidateByInstitution.get(university.id) + const status = statusFor(formal, candidate) + const upgradeStage = upgradeStageFor(formal, candidate, status) + const complete = status === 'complete' + const candidateScope = candidate?.manifest.catalogReconciliation.scope ?? null + const safelyDisabled = candidate ? candidateIsSafelyDisabled(candidate) : false + return { + institutionId: university.id, + slug: university.slug, + name: university.name, + status, + upgradeStage, + formalManifest: formalManifestFor(formal), + candidate: { + available: Boolean(candidate), + classification: !candidate + ? 'none' + : candidateScope === 'limited_official_catalog' + ? 'limited_official_catalog_candidate' + : 'catalog_linked_candidate', + cohortId: candidate ? input.candidateBuild.gapReport.cohortId : null, + fileName: candidate?.fileName ?? null, + scope: candidateScope, + sourceCount: candidate?.manifest.sources.length ?? 0, + reconciliationEntryCount: + candidate?.manifest.catalogReconciliation.entries.length ?? 0, + safelyDisabled, + }, + reconciliation: reconciliationFor(formal, candidate), + gates: { + publicationEligible: complete, + candidateEvidenceOnly: Boolean(candidate) && !complete, + requiresHumanReview: !complete, + }, + } + }) + + const statusCounts = Object.fromEntries( + SOURCE_MANIFEST_TRUST_LEDGER_STATUSES.map((status) => [ + status, + entries.filter((entry) => entry.status === status).length, + ]), + ) as Record + const publicIds = new Set(publicUniversityById.keys()) + const publicFormalRecords = input.formalManifests.filter( + (manifest) => publicIds.has(manifest.institutionId), + ) + const publicCandidates = input.candidateBuild.candidates.filter( + (candidate) => publicIds.has(candidate.manifest.institutionId), + ) + const formalCandidateOverlap = publicCandidates.filter( + (candidate) => formalByInstitution.has(candidate.manifest.institutionId), + ).length + + return { + format: 'studyinchina.source-manifest-trust-ledger', + formatVersion: 1, + checkedAt: input.checkedAt, + scope: 'public_catalog', + disposition: 'audit_only', + policy: { + publicScope: + 'Every university visible under the production publication rules for checkedAt has exactly one ledger entry.', + candidateBoundary: + 'A disabled V2 candidate is discovery evidence only; it never counts as catalog reconciliation or publication approval.', + legacyBoundary: + 'A legacy V1 pilot manifest is an explicit V2 upgrade path and never counts as a complete reconciliation.', + formalImportGate: + 'Only a separately reviewed formal V2 manifest with complete coverage, complete reconciliation, no pending entries, and no audit-only markers may pass the existing promotion gate.', + }, + summary: { + publicUniversities: entries.length, + ledgerEntries: entries.length, + statusCounts, + formalManifestRecords: publicFormalRecords.length, + formalRecordsOutsidePublicCatalog: + input.formalManifests.length - publicFormalRecords.length, + legacyV1UpgradePaths: entries.filter( + (entry) => entry.formalManifest.state === 'legacy_v1', + ).length, + formalV2InProgress: entries.filter( + (entry) => entry.formalManifest.version === 2 + && entry.formalManifest.state === 'in_progress', + ).length, + completeFormalReconciliations: statusCounts.complete, + candidateCoverage: publicCandidates.length, + candidateRecordsOutsidePublicCatalog: + input.candidateBuild.candidates.length - publicCandidates.length, + candidateOnlyRecords: publicCandidates.length - formalCandidateOverlap, + formalCandidateOverlap, + officialSourceDiscoveryRequired: entries.filter( + (entry) => entry.upgradeStage === 'official_source_discovery_required', + ).length, + candidateCohort: { + cohortId: input.candidateBuild.gapReport.cohortId, + officialTargets: input.candidateBuild.summary.officialTargets, + militaryExcluded: input.candidateBuild.summary.militaryExcluded, + eligibleTargets: input.candidateBuild.summary.eligibleTargets, + candidateManifests: input.candidateBuild.summary.candidateManifests, + catalogLinkedCandidates: + input.candidateBuild.summary.catalogLinkedManifests, + limitedOfficialCatalogCandidates: + input.candidateBuild.summary.reconciliationFallbackManifests, + exactOfficialHttpsSources: + input.candidateBuild.summary.exactOfficialHttpsSources, + }, + }, + entries, + } +} + +function loadCatalog(repositoryRoot: string): DataBundle { + const read = (name: string): unknown => JSON.parse(readFileSync( + resolve(repositoryRoot, 'content', 'data', `${name}.json`), + 'utf8', + )) as unknown + return bundleSchema.parse({ + sources: read('sources'), + cities: read('cities'), + universities: read('universities'), + programs: read('programs'), + admissionCycles: read('admission-cycles'), + scholarships: read('scholarships'), + }) +} + +export function buildCurrentSourceManifestTrustLedger( + checkedAt: string, + repositoryRoot = resolve('.'), +): SourceManifestTrustLedgerReport { + const root = resolve(repositoryRoot) + const publicCatalog = selectPublishedData(loadCatalog(root), checkedAt) + const formalManifestDirectory = resolve(root, 'content', 'source-manifests') + const formalManifests = validateSourceManifests( + loadSourceManifestFiles(formalManifestDirectory), + resolve(root, 'content', 'data', 'universities.json'), + ) + const { build } = buildCurrentSourceManifestCohort(checkedAt, root) + return buildSourceManifestTrustLedger({ + checkedAt, + publicUniversities: publicCatalog.universities.map((university) => ({ + id: university.id, + slug: university.slug, + name: { + en: university.name.en ?? null, + zh: university.name.zh ?? null, + }, + })), + formalManifests, + candidateBuild: build, + }) +} + +export function parseSourceManifestTrustLedgerCli( + argv: string[], +): SourceManifestTrustLedgerCli { + let checkedAt: string | undefined + let outputPath: string | undefined + const seen = new Set() + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]! + if (seen.has(argument)) { + throw new Error(`Duplicate CLI option: ${argument}\n${CLI_USAGE}`) + } + seen.add(argument) + if (argument !== '--checked-at' && argument !== '--output') { + throw new Error(`Unknown CLI option: ${argument}\n${CLI_USAGE}`) + } + const value = argv[index + 1] + if (!value || value.startsWith('--')) { + throw new Error(`Missing value for ${argument}\n${CLI_USAGE}`) + } + index += 1 + if (argument === '--checked-at') checkedAt = value + else outputPath = value + } + if (!checkedAt || Number.isNaN(Date.parse(`${checkedAt}T00:00:00Z`))) { + throw new Error(`--checked-at requires a real YYYY-MM-DD date\n${CLI_USAGE}`) + } + if (new Date(`${checkedAt}T00:00:00Z`).toISOString().slice(0, 10) !== checkedAt) { + throw new Error(`--checked-at requires a real YYYY-MM-DD date\n${CLI_USAGE}`) + } + return { checkedAt, ...(outputPath ? { outputPath } : {}) } +} + +function isInside(parent: string, child: string): boolean { + const path = relative(parent, child) + return path === '' + || (path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path)) +} + +export function writeSourceManifestTrustLedger( + report: SourceManifestTrustLedgerReport, + outputPath: string, + repositoryRoot = resolve('.'), +): string { + const output = resolve(outputPath) + if (isInside(resolve(repositoryRoot), output)) { + throw new Error('Trust-ledger output must remain outside the repository') + } + if (existsSync(output)) { + throw new Error('Trust-ledger output must not overwrite an existing file') + } + mkdirSync(dirname(output), { recursive: true }) + writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`, { + encoding: 'utf8', + flag: 'wx', + }) + return output +} + +function runCli(): void { + const options = parseSourceManifestTrustLedgerCli(process.argv.slice(2)) + const repositoryRoot = resolve('.') + const report = buildCurrentSourceManifestTrustLedger( + options.checkedAt, + repositoryRoot, + ) + if (options.outputPath) { + const output = writeSourceManifestTrustLedger( + report, + options.outputPath, + repositoryRoot, + ) + process.stdout.write(`${JSON.stringify({ + output, + checkedAt: report.checkedAt, + summary: report.summary, + })}\n`) + return + } + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`) +} + +if ( + process.argv[1] + && import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) { + try { + runCli() + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n`, + ) + process.exitCode = 1 + } +} diff --git a/scripts/operations/evaluate-p0-reliability.d.mts b/scripts/operations/evaluate-p0-reliability.d.mts new file mode 100644 index 0000000..0119091 --- /dev/null +++ b/scripts/operations/evaluate-p0-reliability.d.mts @@ -0,0 +1,51 @@ +export type P0ReliabilityStatus = 'pass' | 'fail' | 'unobserved' + +export type P0ReliabilityCheck = { + id: string + status: P0ReliabilityStatus + source: string | null + observedAt: string | null + value: unknown + threshold: { + operator: string + value: number + unit: string + } + detail: string +} + +export type P0ReliabilityAudit = { + format: string + formatVersion: 1 + status: 'pass' | 'fail' + evaluatedAt: string + observation: { + formatValid: boolean + formatVersionValid: boolean + observedAt: string | null + } + thresholds: typeof P0_RELIABILITY_THRESHOLDS + summary: Record + checks: P0ReliabilityCheck[] +} + +export const P0_OBSERVATION_FORMAT: string +export const P0_AUDIT_FORMAT: string +export const P0_RELIABILITY_THRESHOLDS: Readonly<{ + observationMaxAgeMinutes: 15 + backupMaxAgeHours: 26 + releaseMaxAgeHours: 48 + schedulerMaxAgeMinutes: 90 + dlqMaxBacklogCount: 0 + outboxMaxAgeHours: 168 +}> + +export function evaluateP0Reliability( + observation: unknown, + now?: string | number | Date, +): P0ReliabilityAudit + +export function parseArguments(args: string[]): { + inputPath: string + outputPath: string | null +} diff --git a/scripts/operations/evaluate-p0-reliability.mjs b/scripts/operations/evaluate-p0-reliability.mjs new file mode 100644 index 0000000..bc6372e --- /dev/null +++ b/scripts/operations/evaluate-p0-reliability.mjs @@ -0,0 +1,356 @@ +#!/usr/bin/env node + +import { readFile, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' + +export const P0_OBSERVATION_FORMAT = 'studyinchina.p0-reliability-observations' +export const P0_AUDIT_FORMAT = 'studyinchina.p0-reliability-audit' +export const P0_RELIABILITY_THRESHOLDS = Object.freeze({ + observationMaxAgeMinutes: 15, + backupMaxAgeHours: 26, + releaseMaxAgeHours: 48, + schedulerMaxAgeMinutes: 90, + dlqMaxBacklogCount: 0, + outboxMaxAgeHours: 168, +}) + +function isRecord(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +function source(value) { + if (typeof value !== 'string') return null + const normalized = value.trim() + if (!/^[a-z0-9][a-z0-9:._/-]{1,199}$/iu.test(normalized)) return null + return normalized +} + +function timestamp(value) { + if (typeof value !== 'string') return null + const milliseconds = Date.parse(value) + if (!Number.isFinite(milliseconds)) return null + return { iso: new Date(milliseconds).toISOString(), milliseconds } +} + +function rounded(value) { + return Math.round(value * 1_000) / 1_000 +} + +function unobserved(id, detail, threshold, evidenceSource = null) { + return { + id, + status: 'unobserved', + source: evidenceSource, + observedAt: null, + value: null, + threshold, + detail, + } +} + +function ageCheck({ + id, + section, + field, + nowMilliseconds, + divisor, + maximum, + threshold, +}) { + if (!isRecord(section)) { + return unobserved(id, 'Required observation section is missing.', threshold) + } + const evidenceSource = source(section.source) + if (!evidenceSource) { + return unobserved(id, 'A bounded machine-readable observation source is required.', threshold) + } + const observed = timestamp(section[field]) + if (!observed || observed.milliseconds > nowMilliseconds) { + return unobserved( + id, + 'The required observation timestamp is missing, invalid, or in the future.', + threshold, + evidenceSource, + ) + } + const rawAge = (nowMilliseconds - observed.milliseconds) / divisor + const age = rounded(rawAge) + const passed = rawAge <= maximum + return { + id, + status: passed ? 'pass' : 'fail', + source: evidenceSource, + observedAt: observed.iso, + value: age, + threshold, + detail: passed + ? 'Observed value is inside the reliability threshold.' + : 'Observed value is older than the reliability threshold.', + } +} + +function dlqCheck(section) { + const threshold = { operator: 'eq', value: P0_RELIABILITY_THRESHOLDS.dlqMaxBacklogCount, unit: 'messages' } + if (!isRecord(section)) { + return unobserved('dlq_backlog', 'Required DLQ observation is missing.', threshold) + } + const evidenceSource = source(section.source) + if (!evidenceSource) { + return unobserved( + 'dlq_backlog', + 'A bounded machine-readable observation source is required.', + threshold, + ) + } + if (!Number.isSafeInteger(section.backlogCount) || section.backlogCount < 0) { + return unobserved( + 'dlq_backlog', + 'DLQ backlogCount must be a non-negative safe integer.', + threshold, + evidenceSource, + ) + } + if (section.backlogCount === 0 && section.oldestMessageAt !== null) { + return unobserved( + 'dlq_backlog', + 'An empty DLQ must explicitly report oldestMessageAt as null.', + threshold, + evidenceSource, + ) + } + const passed = section.backlogCount === 0 + return { + id: 'dlq_backlog', + status: passed ? 'pass' : 'fail', + source: evidenceSource, + observedAt: null, + value: section.backlogCount, + threshold, + detail: passed + ? 'No unhandled DLQ messages were observed.' + : 'One or more unhandled DLQ messages were observed.', + } +} + +function outboxCheck(section, nowMilliseconds) { + const threshold = { + operator: 'lt', + value: P0_RELIABILITY_THRESHOLDS.outboxMaxAgeHours, + unit: 'hours', + } + if (!isRecord(section)) { + return unobserved('outbox_backlog_age', 'Required outbox observation is missing.', threshold) + } + const evidenceSource = source(section.source) + if (!evidenceSource) { + return unobserved( + 'outbox_backlog_age', + 'A bounded machine-readable observation source is required.', + threshold, + ) + } + if (!Number.isSafeInteger(section.backlogCount) || section.backlogCount < 0) { + return unobserved( + 'outbox_backlog_age', + 'Outbox backlogCount must be a non-negative safe integer.', + threshold, + evidenceSource, + ) + } + if (section.backlogCount === 0) { + if (section.oldestPendingAt !== null) { + return unobserved( + 'outbox_backlog_age', + 'An empty outbox must explicitly report oldestPendingAt as null.', + threshold, + evidenceSource, + ) + } + return { + id: 'outbox_backlog_age', + status: 'pass', + source: evidenceSource, + observedAt: null, + value: { backlogCount: 0, oldestAgeHours: null }, + threshold, + detail: 'No pending outbox events were observed.', + } + } + + const oldest = timestamp(section.oldestPendingAt) + if (!oldest || oldest.milliseconds > nowMilliseconds) { + return unobserved( + 'outbox_backlog_age', + 'A non-empty outbox requires a valid, non-future oldestPendingAt.', + threshold, + evidenceSource, + ) + } + const rawAgeHours = (nowMilliseconds - oldest.milliseconds) / 3_600_000 + const ageHours = rounded(rawAgeHours) + const passed = rawAgeHours < P0_RELIABILITY_THRESHOLDS.outboxMaxAgeHours + return { + id: 'outbox_backlog_age', + status: passed ? 'pass' : 'fail', + source: evidenceSource, + observedAt: oldest.iso, + value: { backlogCount: section.backlogCount, oldestAgeHours: ageHours }, + threshold, + detail: passed + ? 'The oldest pending outbox event is inside the reliability threshold.' + : 'The oldest pending outbox event reached or exceeded the reliability threshold.', + } +} + +export function evaluateP0Reliability(observation, now = new Date()) { + const evaluatedAt = new Date(now) + if (Number.isNaN(evaluatedAt.getTime())) { + throw new TypeError('Evaluation time must be a valid date.') + } + const nowMilliseconds = evaluatedAt.getTime() + const input = isRecord(observation) ? observation : {} + const contractValid = input.format === P0_OBSERVATION_FORMAT && input.formatVersion === 1 + const checks = [ + contractValid + ? { + id: 'input_contract', + status: 'pass', + source: 'observation-document', + observedAt: null, + value: { formatVersion: 1 }, + threshold: { operator: 'eq', value: 1, unit: 'format-version' }, + detail: 'Observation document uses the supported explicit contract.', + } + : unobserved( + 'input_contract', + 'Observation format or formatVersion is missing or unsupported.', + { operator: 'eq', value: 1, unit: 'format-version' }, + 'observation-document', + ), + ageCheck({ + id: 'observation_freshness', + section: { source: 'observation-document', observedAt: input.observedAt }, + field: 'observedAt', + nowMilliseconds, + divisor: 60_000, + maximum: P0_RELIABILITY_THRESHOLDS.observationMaxAgeMinutes, + threshold: { + operator: 'lte', + value: P0_RELIABILITY_THRESHOLDS.observationMaxAgeMinutes, + unit: 'minutes', + }, + }), + ageCheck({ + id: 'backup_age', + section: input.backup, + field: 'lastVerifiedAt', + nowMilliseconds, + divisor: 3_600_000, + maximum: P0_RELIABILITY_THRESHOLDS.backupMaxAgeHours, + threshold: { + operator: 'lte', + value: P0_RELIABILITY_THRESHOLDS.backupMaxAgeHours, + unit: 'hours', + }, + }), + ageCheck({ + id: 'release_age', + section: input.release, + field: 'lastActivatedAt', + nowMilliseconds, + divisor: 3_600_000, + maximum: P0_RELIABILITY_THRESHOLDS.releaseMaxAgeHours, + threshold: { + operator: 'lte', + value: P0_RELIABILITY_THRESHOLDS.releaseMaxAgeHours, + unit: 'hours', + }, + }), + ageCheck({ + id: 'scheduler_heartbeat_age', + section: input.scheduler, + field: 'lastHeartbeatAt', + nowMilliseconds, + divisor: 60_000, + maximum: P0_RELIABILITY_THRESHOLDS.schedulerMaxAgeMinutes, + threshold: { + operator: 'lte', + value: P0_RELIABILITY_THRESHOLDS.schedulerMaxAgeMinutes, + unit: 'minutes', + }, + }), + dlqCheck(input.dlq), + outboxCheck(input.outbox, nowMilliseconds), + ] + const summary = checks.reduce( + (counts, check) => ({ ...counts, [check.status]: counts[check.status] + 1 }), + { pass: 0, fail: 0, unobserved: 0 }, + ) + return { + format: P0_AUDIT_FORMAT, + formatVersion: 1, + status: summary.fail === 0 && summary.unobserved === 0 ? 'pass' : 'fail', + evaluatedAt: evaluatedAt.toISOString(), + observation: { + formatValid: input.format === P0_OBSERVATION_FORMAT, + formatVersionValid: input.formatVersion === 1, + observedAt: timestamp(input.observedAt)?.iso ?? null, + }, + thresholds: P0_RELIABILITY_THRESHOLDS, + summary, + checks, + } +} + +export function parseArguments(args) { + const options = { inputPath: null, outputPath: null } + for (let index = 0; index < args.length; index += 1) { + const flag = args[index] + if (flag !== '--input' && flag !== '--output') { + throw new Error(`Unknown argument: ${flag}`) + } + const value = args[index + 1] + if (!value || value.startsWith('--')) throw new Error(`${flag} requires a path`) + const key = flag === '--input' ? 'inputPath' : 'outputPath' + if (options[key]) throw new Error(`${flag} may be provided only once`) + options[key] = value + index += 1 + } + if (!options.inputPath) throw new Error('--input is required') + if (options.outputPath && resolve(options.outputPath) === resolve(options.inputPath)) { + throw new Error('--output must not overwrite the observation input') + } + return options +} + +function isMainModule() { + const entry = process.argv[1] + return Boolean(entry && pathToFileURL(resolve(entry)).href === import.meta.url) +} + +async function main() { + let options + let report + try { + options = parseArguments(process.argv.slice(2)) + const observation = JSON.parse(await readFile(resolve(options.inputPath), 'utf8')) + report = evaluateP0Reliability(observation) + } catch { + report = evaluateP0Reliability(undefined) + } + + const serialized = `${JSON.stringify(report, null, 2)}\n` + process.stdout.write(serialized) + if (options?.outputPath) { + await writeFile(resolve(options.outputPath), serialized, { encoding: 'utf8', flag: 'wx' }) + } + if (report.status !== 'pass') process.exitCode = 1 +} + +if (isMainModule()) { + main().catch(() => { + process.stderr.write('P0 reliability audit could not emit a machine-readable report.\n') + process.exitCode = 1 + }) +} diff --git a/scripts/quality/platform-data-quality.ts b/scripts/quality/platform-data-quality.ts index 4ba6ae8..048530e 100644 --- a/scripts/quality/platform-data-quality.ts +++ b/scripts/quality/platform-data-quality.ts @@ -3,6 +3,7 @@ import { writeFile } from 'node:fs/promises' import { join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { bundleSchema } from '../../src/lib/data/schema' +import { getApplicationState } from '../../src/lib/data/admission' import { getTodayDate } from '../../src/lib/data/freshness' import { selectPublishedData } from '../../src/lib/data/publication' import type { DataBundle } from '../../src/lib/data/types' @@ -15,6 +16,8 @@ import { export const FOUR_WEEK_QUALITY_THRESHOLDS = { publicUniversities: 257, schoolsBelowThreePrograms: 0, + freshDispositionCoveragePct: 70, + // Deprecated compatibility target; the gate uses freshDispositionCoveragePct. currentCycleCoveragePct: 70, durationCoveragePct: 90, applicationUrlCoveragePct: 80, @@ -38,7 +41,7 @@ export type QualityGate = { } export type PlatformDataQualityScorecard = { - schemaVersion: 1 + schemaVersion: 2 generatedAt: string evaluatedForDate: string metrics: { @@ -52,8 +55,19 @@ export type PlatformDataQualityScorecard = { programCoverage: { schoolsBelowThreePrograms: number schoolIdsBelowThreePrograms: string[] + programsWithVerifiedIdentity: number + identityCoveragePct: number + programsWithFreshDisposition: number + freshDispositionCoveragePct: number + programsWithDatedOrRollingCycle: number + datedOrRollingCoveragePct: number + programsActiveOrUpcoming: number + activeUpcomingCoveragePct: number + // Deprecated alias of programsWithDatedOrRollingCycle. programsWithCurrentCycle: number + // Deprecated alias of datedOrRollingCoveragePct. currentCycleCoveragePct: number + currentCycleCoverageSemantics: 'deprecated_alias_of_dated_or_rolling' programsWithDuration: number durationCoveragePct: number programsWithApplicationUrl: number @@ -112,6 +126,21 @@ function percent(numerator: number, denominator: number): number { return Math.round((numerator / denominator) * 10_000) / 100 } +function shiftIsoDate(value: string, days: number): string { + const date = new Date(`${value}T00:00:00.000Z`) + date.setUTCDate(date.getUTCDate() + days) + return date.toISOString().slice(0, 10) +} + +function isDateFreeFeeReference( + cycle: DataBundle['admissionCycles'][number], +): boolean { + return cycle.tuitionStatus === 'reference' + && cycle.opensOn === null + && cycle.closesOn === null + && cycle.dateStatus !== 'rolling' +} + function allAuditedRecords(bundle: DataBundle) { return [ ...bundle.cities, @@ -154,8 +183,24 @@ export function buildPlatformDataQualityScorecard( .filter((university) => (programCounts.get(university.id) ?? 0) < 3) .map((university) => university.id) .sort() - const programsWithCycles = new Set( - publicBundle.admissionCycles.map((cycle) => cycle.programId), + const admissionCycles = publicBundle.admissionCycles.filter( + (cycle) => !isDateFreeFeeReference(cycle), + ) + const freshDispositionCutoff = shiftIsoDate(today, -30) + const programsWithFreshDisposition = new Set( + admissionCycles + .filter((cycle) => cycle.verifiedAt >= freshDispositionCutoff && cycle.verifiedAt <= today) + .map((cycle) => cycle.programId), + ) + const programsWithDatedOrRollingCycle = new Set( + admissionCycles + .filter((cycle) => cycle.dateStatus === 'rolling' || cycle.opensOn !== null || cycle.closesOn !== null) + .map((cycle) => cycle.programId), + ) + const programsActiveOrUpcoming = new Set( + admissionCycles + .filter((cycle) => ['open', 'upcoming', 'rolling'].includes(getApplicationState(cycle, today))) + .map((cycle) => cycle.programId), ) const programsWithDuration = publicBundle.programs.filter( (program) => program.durationMonths !== null && program.durationMonths > 0, @@ -219,8 +264,17 @@ export function buildPlatformDataQualityScorecard( programCoverage: { schoolsBelowThreePrograms: schoolIdsBelowThreePrograms.length, schoolIdsBelowThreePrograms, - programsWithCurrentCycle: programsWithCycles.size, - currentCycleCoveragePct: percent(programsWithCycles.size, programTotal), + programsWithVerifiedIdentity: programTotal, + identityCoveragePct: percent(programTotal, programTotal), + programsWithFreshDisposition: programsWithFreshDisposition.size, + freshDispositionCoveragePct: percent(programsWithFreshDisposition.size, programTotal), + programsWithDatedOrRollingCycle: programsWithDatedOrRollingCycle.size, + datedOrRollingCoveragePct: percent(programsWithDatedOrRollingCycle.size, programTotal), + programsActiveOrUpcoming: programsActiveOrUpcoming.size, + activeUpcomingCoveragePct: percent(programsActiveOrUpcoming.size, programTotal), + programsWithCurrentCycle: programsWithDatedOrRollingCycle.size, + currentCycleCoveragePct: percent(programsWithDatedOrRollingCycle.size, programTotal), + currentCycleCoverageSemantics: 'deprecated_alias_of_dated_or_rolling', programsWithDuration, durationCoveragePct: percent(programsWithDuration, programTotal), programsWithApplicationUrl, @@ -262,7 +316,7 @@ export function buildPlatformDataQualityScorecard( const checks = [ atLeast('publicUniversities', metrics.publicRecords.universities), atMost('schoolsBelowThreePrograms', metrics.programCoverage.schoolsBelowThreePrograms), - atLeast('currentCycleCoveragePct', metrics.programCoverage.currentCycleCoveragePct), + atLeast('freshDispositionCoveragePct', metrics.programCoverage.freshDispositionCoveragePct), atLeast('durationCoveragePct', metrics.programCoverage.durationCoveragePct), atLeast('applicationUrlCoveragePct', metrics.programCoverage.applicationUrlCoveragePct), atLeast('teachingLanguageCoveragePct', metrics.programCoverage.teachingLanguageCoveragePct), @@ -278,7 +332,7 @@ export function buildPlatformDataQualityScorecard( const passed = checks.filter((check) => check.passed).length return { - schemaVersion: 1, + schemaVersion: 2, generatedAt: options.generatedAt ?? new Date().toISOString(), evaluatedForDate: today, metrics, @@ -332,7 +386,10 @@ export function conciseScorecardSummary(report: PlatformDataQualityScorecard): s const { publicRecords, programCoverage, scholarships, cities, sourceManifests } = report.metrics return [ `Data quality: ${publicRecords.universities} universities / ${publicRecords.programs} programs / ${publicRecords.scholarships} scholarships`, - `current cycles ${programCoverage.currentCycleCoveragePct}%`, + `identities ${programCoverage.identityCoveragePct}%`, + `fresh dispositions ${programCoverage.freshDispositionCoveragePct}%`, + `dated/rolling ${programCoverage.datedOrRollingCoveragePct}%`, + `active/upcoming ${programCoverage.activeUpcomingCoveragePct}%`, `scholarship schools ${scholarships.universitiesCovered}`, `city coordinates ${cities.withCoordinates}/${publicRecords.cities}`, `manifests ${sourceManifests.institutionsRegistered} (${sourceManifests.completedReconciliations} reconciled)`, diff --git a/scripts/source-manifest-contract.ts b/scripts/source-manifest-contract.ts new file mode 100644 index 0000000..6347f8a --- /dev/null +++ b/scripts/source-manifest-contract.ts @@ -0,0 +1,97 @@ +import { z } from 'zod' + +import { + SOURCE_CATEGORIES, + sourceManifestSchema, +} from '../workers/ingestion/src/manifest-schema' + +export const CATALOG_RECONCILIATION_STATUSES = [ + 'published', + 'individual_application_unavailable', + 'discontinued', + 'not_announced', + 'source_unavailable', + 'pending', +] as const + +export type CatalogReconciliationStatus = + (typeof CATALOG_RECONCILIATION_STATUSES)[number] + +const coverageStatusSchema = z.enum([ + 'registered', + 'parser_pending', + 'source_unavailable', + 'discovery_pending', + 'officially_not_provided', +]) + +const coverageSchema = z.object({ + sourceCategory: z.enum(SOURCE_CATEGORIES), + status: coverageStatusSchema, + sourceIds: z.array(z.string().min(1)).optional(), + note: z.string().min(1).optional(), +}).strict() + +const checkedAtSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine( + (value) => !Number.isNaN(Date.parse(`${value}T00:00:00Z`)), + { message: 'checkedAt must be a real ISO calendar date' }, +) + +export const pilotSourceManifestSchema = z.object({ + version: z.literal(1), + institutionId: z.string().min(1), + catalogStatus: z.enum(['existing', 'planned_addition']), + checkedAt: checkedAtSchema, + sources: z.array(sourceManifestSchema).min(1), + coverage: z.array(coverageSchema).length(SOURCE_CATEGORIES.length), +}).strict() + +export type PilotSourceManifest = z.infer + +const reconciliationEntrySchema = z.object({ + sourceId: z.string().min(1), + officialKey: z.string().min(1), + officialName: z.string().min(1), + entityType: z.enum(['program', 'scholarship']), + status: z.enum(CATALOG_RECONCILIATION_STATUSES), + recordId: z.string().min(1).optional(), + note: z.string().min(1).optional(), +}).strict() + +const catalogReconciliationSchema = z.object({ + scope: z.enum([ + 'full_official_catalog', + 'representative_international_programs', + 'limited_official_catalog', + ]), + status: z.enum(['complete', 'in_progress']), + entries: z.array(reconciliationEntrySchema).min(1), + note: z.string().min(1).optional(), +}).strict().superRefine((reconciliation, context) => { + if (reconciliation.scope === 'representative_international_programs' + && reconciliation.status === 'complete') { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['scope'], + message: + 'representative_international_programs cannot claim complete catalog reconciliation', + }) + } +}) + +export const sourceManifestV2Schema = z.object({ + version: z.literal(2), + institutionId: z.string().min(1), + catalogStatus: z.enum(['existing', 'planned_addition']), + manifestStatus: z.enum(['complete', 'in_progress']), + checkedAt: checkedAtSchema, + officialHosts: z.array(z.string().min(1)).min(1), + // Fetch workers still consume the strict V1 source contract. V2 is the + // institution-level trust envelope around those source manifests. + sources: z.array(sourceManifestSchema).min(1), + coverage: z.array(coverageSchema).length(SOURCE_CATEGORIES.length), + catalogReconciliation: catalogReconciliationSchema, +}).strict() + +export type SourceManifestV2 = z.infer +export type SourceManifestRecord = PilotSourceManifest | SourceManifestV2 diff --git a/scripts/source-manifest-registry.ts b/scripts/source-manifest-registry.ts index ba9fb74..874fa55 100644 --- a/scripts/source-manifest-registry.ts +++ b/scripts/source-manifest-registry.ts @@ -1,10 +1,8 @@ import { readdirSync, readFileSync } from 'node:fs' import { basename, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { z } from 'zod' import { SOURCE_CATEGORIES, - sourceManifestSchema, } from '../workers/ingestion/src/manifest-schema' import { normalizeAllowedHost, @@ -13,77 +11,25 @@ import { import type { SourceCategory, SourceManifestV1 } from '../workers/ingestion/src/types' import { INSTITUTION_HOST_ALLOWLISTS, - pilotSourceManifestSchema, - type PilotSourceManifest, } from './validate-source-manifests' +import { + CATALOG_RECONCILIATION_STATUSES, + pilotSourceManifestSchema, + sourceManifestV2Schema, + type CatalogReconciliationStatus, + type SourceManifestRecord, + type SourceManifestV2, +} from './source-manifest-contract' -export const CATALOG_RECONCILIATION_STATUSES = [ - 'published', - 'individual_application_unavailable', - 'discontinued', - 'not_announced', - 'source_unavailable', - 'pending', -] as const - -export type CatalogReconciliationStatus = - (typeof CATALOG_RECONCILIATION_STATUSES)[number] - -const coverageStatusSchema = z.enum([ - 'registered', - 'parser_pending', - 'source_unavailable', - 'discovery_pending', - 'officially_not_provided', -]) - -const coverageSchema = z.object({ - sourceCategory: z.enum(SOURCE_CATEGORIES), - status: coverageStatusSchema, - sourceIds: z.array(z.string().min(1)).optional(), - note: z.string().min(1).optional(), -}).strict() - -const checkedAtSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine( - (value) => !Number.isNaN(Date.parse(`${value}T00:00:00Z`)), - { message: 'checkedAt must be a real ISO calendar date' }, -) - -const reconciliationEntrySchema = z.object({ - sourceId: z.string().min(1), - officialKey: z.string().min(1), - officialName: z.string().min(1), - entityType: z.enum(['program', 'scholarship']), - status: z.enum(CATALOG_RECONCILIATION_STATUSES), - recordId: z.string().min(1).optional(), - note: z.string().min(1).optional(), -}).strict() - -export const sourceManifestV2Schema = z.object({ - version: z.literal(2), - institutionId: z.string().min(1), - catalogStatus: z.enum(['existing', 'planned_addition']), - manifestStatus: z.enum(['complete', 'in_progress']), - checkedAt: checkedAtSchema, - officialHosts: z.array(z.string().min(1)).min(1), - // Individual fetch manifests remain V1 because this is the format consumed - // by the ingestion Worker. V2 describes the institution-level contract. - sources: z.array(sourceManifestSchema).min(1), - coverage: z.array(coverageSchema).length(SOURCE_CATEGORIES.length), - catalogReconciliation: z.object({ - scope: z.enum([ - 'full_official_catalog', - 'representative_international_programs', - 'limited_official_catalog', - ]), - status: z.enum(['complete', 'in_progress']), - entries: z.array(reconciliationEntrySchema).min(1), - note: z.string().min(1).optional(), - }).strict(), -}).strict() - -export type SourceManifestV2 = z.infer -export type SourceManifestRecord = PilotSourceManifest | SourceManifestV2 +export { + CATALOG_RECONCILIATION_STATUSES, + sourceManifestV2Schema, +} +export type { + CatalogReconciliationStatus, + SourceManifestRecord, + SourceManifestV2, +} export type LoadedSourceManifest = { filePath: string @@ -240,7 +186,10 @@ export function isCatalogReconciliationComplete( record: SourceManifestRecord, ): boolean { if (record.version !== 2) return false - return record.manifestStatus === 'complete' + const terminalScope = record.catalogReconciliation.scope === 'full_official_catalog' + || record.catalogReconciliation.scope === 'limited_official_catalog' + return terminalScope + && record.manifestStatus === 'complete' && record.catalogReconciliation.status === 'complete' && !record.catalogReconciliation.entries.some((entry) => entry.status === 'pending') } diff --git a/scripts/validate-maintenance.mjs b/scripts/validate-maintenance.mjs index 8d3af93..bab8bc7 100644 --- a/scripts/validate-maintenance.mjs +++ b/scripts/validate-maintenance.mjs @@ -30,6 +30,7 @@ const health = workflows.get('.github/workflows/data-health.yml') const backup = workflows.get('.github/workflows/cloudflare-backup.yml') const restore = workflows.get('.github/workflows/cloudflare-restore-drill.yml') const alias = workflows.get('.github/workflows/vercel-production-alias.yml') +const p0Reliability = await text('scripts/operations/evaluate-p0-reliability.mjs') requirePattern( refresh, @@ -84,6 +85,39 @@ requirePattern( /Verify read access to both remote D1 databases/, 'The backup must verify both remote D1 resources before export.', ) +requirePattern( + backup, + /secrets\.CLOUDFLARE_D1_BACKUP_TOKEN/, + 'The backup must use the dedicated least-privilege backup token.', +) +requirePattern( + backup, + /studyinchina-backups\/backups\/daily\/\$day\/raw-v1\/catalog\.sql\.gz[\s\S]*studyinchina-backups\/backups\/monthly\/\$month\/raw-v1\/catalog\.sql\.gz/, + 'D1 backups must use the raw-v1 namespace in the private backup-only R2 bucket.', +) +requirePattern( + backup, + /--content-type="application\/gzip" --content-encoding="identity"/, + 'Compressed SQL objects must be stored as raw bytes without transparent content decoding.', +) +requirePattern( + restore, + /studyinchina-backups\/backups\/monthly\/\$BACKUP_MONTH\/raw-v1\/catalog\.sql\.gz[\s\S]*raw-v1\/pipeline\.sql\.gz[\s\S]*raw-v1\/sha256\.txt/, + 'Restore drills must read the versioned raw-v1 monthly backup set.', +) +requirePattern( + backup, + /Upload daily and monthly copies[\s\S]*Read back and cryptographically verify daily checkpoint[\s\S]*--phase artifacts/, + 'The backup must read back and cryptographically verify uploaded objects.', +) +requirePattern( + backup, + /Read back and cryptographically verify daily checkpoint[\s\S]*backups\/daily\/\$day\/raw-v1\/catalog\.sql\.gz[\s\S]*raw-v1\/pipeline\.sql\.gz[\s\S]*raw-v1\/sha256\.txt[\s\S]*--phase artifacts/, + 'The readback step must verify the same raw-v1 daily object set that was uploaded.', +) +if (/secrets\.CLOUDFLARE_API_TOKEN/u.test(backup)) { + throw new Error('The backup still reads the legacy generic Cloudflare token secret.') +} requirePattern( backup, /if:\s*\$\{\{ failure\(\) \}\}[\s\S]*does \*\*not\*\* satisfy/, @@ -94,11 +128,77 @@ requirePattern( /15 1,4,7,10 \*/, 'A quarterly restore drill is required.', ) +requirePattern( + restore, + /environment:\s*cloudflare-restore-drill/, + 'Restore access must be protected by the cloudflare-restore-drill environment.', +) +requirePattern( + restore, + /secrets\.CLOUDFLARE_D1_RESTORE_TOKEN/, + 'Restore must use a credential distinct from daily backup.', +) +if (/secrets\.CLOUDFLARE_(?:API|D1_BACKUP)_TOKEN/u.test(restore)) { + throw new Error('Restore must not read the generic or daily backup token secret.') +} requirePattern( alias, /vercel@58\.0\.0 alias set/, 'Successful main deployments must support stable Vercel alias promotion.', ) +requirePattern( + alias, + /Wait for successful CI on the exact deployment SHA[\s\S]*branch=main&event=push[\s\S]*\.head_sha == \$sha[\s\S]*\.conclusion == "success"/, + 'Vercel alias promotion must wait for successful CI on the exact deployment SHA.', +) +requirePattern( + alias, + /Reconfirm deployment SHA is still current main[\s\S]*\/git\/ref\/heads\/main[\s\S]*steps\.current\.outputs\.matches == 'true'/, + 'Vercel alias promotion must recheck current main after waiting for CI.', +) +requirePattern( + alias, + /concurrency:[\s\S]*group:\s*vercel-production-alias[\s\S]*cancel-in-progress:\s*false/, + 'Vercel alias promotion must serialize runs without cancelling an in-progress mutation.', +) +const aliasPromotionStart = alias.indexOf('- name: Promote stable production alias transaction and verify release API') +if (aliasPromotionStart < 0) { + throw new Error('The fail-closed Vercel alias transaction is missing.') +} +const aliasPromotionBlock = alias.slice(aliasPromotionStart) +requirePattern( + aliasPromotionBlock, + /alias list[\s\S]*rollback_on_failure\(\)[\s\S]*vercel@58\.0\.0 alias set[\s\S]*previous_target[\s\S]*trap rollback_on_failure EXIT[\s\S]*previous_target=[\s\S]*final_main_sha=[\s\S]*mutation_attempted=true[\s\S]*vercel@58\.0\.0 alias set[\s\S]*DEPLOYMENT_URL[\s\S]*post_promotion_main_sha=[\s\S]*studyinchina\.vercel\.app\/api\/v1\/releases\/current[\s\S]*transaction_committed=true/, + 'Alias mutation must capture the previous target, recheck main immediately, and retain a rollback path.', +) +requirePattern( + aliasPromotionBlock, + /\/git\/ref\/heads\/main/, + 'Alias mutation must re-read the current main SHA inside the transaction.', +) +requirePattern( + aliasPromotionBlock, + /Production promotion raced with main/, + 'Alias mutation must fail closed when main advances during promotion.', +) +requirePattern( + aliasPromotionBlock, + /Stable alias rollback failed[\s\S]*Stable alias rollback verification failed/, + 'Alias mutation must retain explicit rollback and rollback-verification failure paths.', +) +if ((aliasPromotionBlock.match(/vercel@58\.0\.0 alias set/gu) ?? []).length !== 2) { + throw new Error('The alias mutation step must contain one promotion and one rollback command.') +} +requirePattern( + alias, + /\.data\.deploymentSha == \$sha[\s\S]*Promote stable production alias[\s\S]*\.data\.deploymentSha == \$sha/, + 'Immutable and stable release smokes must prove the exact deployment SHA.', +) +requirePattern( + aliasPromotionBlock, + /\.data\.publicCounts\.programs \| type == "number" and \. > 0/, + 'The stable release smoke must reject an empty or non-numeric public program count.', +) requirePattern( alias, /api\/v1\/releases\/current/, @@ -109,6 +209,27 @@ requirePattern( /VERCEL_TOKEN is not configured[\s\S]*exit 1/, 'A missing Vercel token must fail the alias workflow instead of producing a false green result.', ) +requirePattern( + p0Reliability, + /studyinchina\.p0-reliability-observations/, + 'The P0 reliability audit must consume an explicit observation document.', +) +requirePattern( + p0Reliability, + /backupMaxAgeHours:\s*26[\s\S]*releaseMaxAgeHours:\s*48[\s\S]*schedulerMaxAgeMinutes:\s*90[\s\S]*dlqMaxBacklogCount:\s*0[\s\S]*outboxMaxAgeHours:\s*168/, + 'The P0 reliability audit thresholds must remain fail-closed and reviewable.', +) +requirePattern( + p0Reliability, + /status:\s*'unobserved'[\s\S]*summary\.fail === 0 && summary\.unobserved === 0/, + 'Missing reliability observations must fail the overall audit.', +) +if (/\bfetch\s*\(|node:https|node:http|https?:\/\//u.test(p0Reliability)) { + throw new Error('The P0 reliability evaluator must not contain a network access path.') +} +if (/process\.env/u.test(p0Reliability)) { + throw new Error('The P0 reliability evaluator must not read environment secrets.') +} const programs = JSON.parse(await text('content/data/programs.json')) const currentPrograms = programs.filter( @@ -133,6 +254,7 @@ console.log(JSON.stringify({ scheduledUrlLimit, freshnessAudit: 'daily', factRefresh: 'weekly', - backup: 'daily', - restoreDrill: 'quarterly', + backup: 'daily-with-readback', + restoreDrill: 'quarterly-protected', + productionPromotion: 'exact-sha-ci-gated', }, null, 2)) diff --git a/scripts/validate-source-manifests.ts b/scripts/validate-source-manifests.ts index 866acbc..6e48878 100644 --- a/scripts/validate-source-manifests.ts +++ b/scripts/validate-source-manifests.ts @@ -1,7 +1,6 @@ import { readdirSync, readFileSync } from 'node:fs' import { basename, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { z } from 'zod' import { SOURCE_CATEGORIES, sourceManifestSchema, @@ -11,8 +10,17 @@ import type { SourceCategory, SourceManifestV1, } from '../workers/ingestion/src/types' +import { + pilotSourceManifestSchema, + sourceManifestV2Schema, + type PilotSourceManifest, + type SourceManifestRecord, + type SourceManifestV2, +} from './source-manifest-contract' export { SOURCE_CATEGORIES, sourceManifestSchema } +export { pilotSourceManifestSchema } +export type { PilotSourceManifest, SourceManifestRecord, SourceManifestV2 } const EXPECTED_CATALOG_STATUS = { 'uni-tsinghua-university': 'existing', @@ -78,39 +86,6 @@ export const INSTITUTION_HOST_ALLOWLISTS: Record< ], } -const coverageSchema = z - .object({ - sourceCategory: z.enum(SOURCE_CATEGORIES), - status: z.enum([ - 'registered', - 'parser_pending', - 'source_unavailable', - 'discovery_pending', - 'officially_not_provided', - ]), - sourceIds: z.array(z.string().min(1)).optional(), - note: z.string().min(1).optional(), - }) - .strict() - -export const pilotSourceManifestSchema = z - .object({ - version: z.literal(1), - institutionId: z.string().min(1), - catalogStatus: z.enum(['existing', 'planned_addition']), - checkedAt: z - .string() - .regex(/^\d{4}-\d{2}-\d{2}$/) - .refine((value) => !Number.isNaN(Date.parse(`${value}T00:00:00Z`)), { - message: 'checkedAt must be a real ISO calendar date', - }), - sources: z.array(sourceManifestSchema).min(1), - coverage: z.array(coverageSchema).length(SOURCE_CATEGORIES.length), - }) - .strict() - -export type PilotSourceManifest = z.infer - export type LoadedPilotSourceManifest = { filePath: string value: unknown @@ -135,14 +110,78 @@ export function loadPilotSourceManifestFiles( }) } +function validatePilotReconciliation( + record: SourceManifestV2, + filePath: string, + errors: string[], +): void { + const sources = new Set(record.sources.map((source) => source.id)) + const officialKeys = new Set() + for (const entry of record.catalogReconciliation.entries) { + if (officialKeys.has(entry.officialKey)) { + errors.push(errorMessage( + filePath, + `catalog reconciliation repeats officialKey ${entry.officialKey}`, + )) + } + officialKeys.add(entry.officialKey) + if (!sources.has(entry.sourceId)) { + errors.push(errorMessage( + filePath, + `catalog reconciliation references unknown source ${entry.sourceId}`, + )) + } + if (entry.status === 'published' && !entry.recordId) { + errors.push(errorMessage( + filePath, + `${entry.officialKey} published reconciliation requires recordId`, + )) + } + if (entry.status !== 'published' && entry.status !== 'pending' && !entry.note) { + errors.push(errorMessage( + filePath, + `${entry.officialKey} ${entry.status} reconciliation requires a note`, + )) + } + } + if (record.catalogReconciliation.status === 'complete' + && record.catalogReconciliation.entries.some((entry) => entry.status === 'pending')) { + errors.push(errorMessage( + filePath, + 'complete catalog reconciliation cannot contain pending entries', + )) + } + if (record.manifestStatus === 'complete') { + if (record.coverage.some((coverage) => coverage.status === 'discovery_pending')) { + errors.push(errorMessage( + filePath, + 'complete manifest cannot contain discovery_pending coverage', + )) + } + if (record.catalogReconciliation.status !== 'complete') { + errors.push(errorMessage( + filePath, + 'complete manifest requires complete catalog reconciliation', + )) + } + } +} + export function validatePilotSourceManifests( inputs: LoadedPilotSourceManifest[], -): PilotSourceManifest[] { +): SourceManifestRecord[] { const errors: string[] = [] - const records: PilotSourceManifest[] = [] + const records: SourceManifestRecord[] = [] for (const input of inputs) { - const parsed = pilotSourceManifestSchema.safeParse(input.value) + const version = typeof input.value === 'object' + && input.value !== null + && 'version' in input.value + ? input.value.version + : undefined + const parsed = version === 2 + ? sourceManifestV2Schema.safeParse(input.value) + : pilotSourceManifestSchema.safeParse(input.value) if (!parsed.success) { for (const issue of parsed.error.issues) { errors.push( @@ -209,6 +248,14 @@ export function validatePilotSourceManifests( record.institutionId as keyof typeof EXPECTED_CATALOG_STATUS ], ) + if (record.version === 2) { + for (const host of record.officialHosts) { + if (!approvedHosts.has(host.toLowerCase())) { + errors.push(errorMessage(filePath, `declares unapproved official host ${host}`)) + } + } + validatePilotReconciliation(record, filePath, errors) + } const sourcesById = new Map(record.sources.map((source) => [source.id, source])) const coverageByCategory = new Map() @@ -435,7 +482,7 @@ export function validatePilotSourceManifests( export function validatePilotSourceManifestDirectory( directory?: string, -): PilotSourceManifest[] { +): SourceManifestRecord[] { return validatePilotSourceManifests(loadPilotSourceManifestFiles(directory)) } diff --git a/src/app/[locale]/cities/page.tsx b/src/app/[locale]/cities/page.tsx index f032e98..4381e4a 100644 --- a/src/app/[locale]/cities/page.tsx +++ b/src/app/[locale]/cities/page.tsx @@ -1,15 +1,29 @@ import { notFound } from 'next/navigation' import { CityExplorer } from '@/components/features/CityExplorer' import { PageHero, SectionHeading } from '@/components/ui' +import { formatStudentCityTitle } from '@/i18n/home-experience' import { getMessages } from '@/i18n/messages' import { parseCityExplorerSearchParams, type CityExplorerSearchParams, } from '@/lib/city-explorer' import { getCatalogData, getData } from '@/lib/data/load' -import { pageMetadata, requireLocale } from '@/lib/site' +import { hasSearchParameters, pageMetadata, requireLocale } from '@/lib/site' -export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const locale = requireLocale((await params).locale) || 'en'; const m = getMessages(locale); return pageMetadata(locale, m.cities.title, m.cities.intro, 'cities') } +export async function generateMetadata({ + params, + searchParams, +}: { + params: Promise<{ locale: string }> + searchParams?: Promise +}) { + const locale = requireLocale((await params).locale) || 'en' + const messages = getMessages(locale) + const parameterized = searchParams ? hasSearchParameters(await searchParams) : false + return pageMetadata(locale, messages.cities.title, messages.cities.intro, 'cities', { + indexable: !parameterized, + }) +} export default async function CitiesPage({ params, searchParams, @@ -53,7 +67,7 @@ export default async function CitiesPage({ description={messages.cities.intro} />
- + }) { @@ -14,4 +12,25 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s robots: { index: false, follow: true }, } } -export default async function FavoritesPage({ params }: { params: Promise<{ locale: string }> }) { const locale = requireLocale((await params).locale); if (!locale) notFound(); const messages = getMessages(locale); const data = await getCatalogData(); return <>
} + +export default async function FavoritesPage({ + params, +}: { + params: Promise<{ locale: string }> +}) { + const locale = requireLocale((await params).locale) + if (!locale) notFound() + const messages = getMessages(locale) + + return <> + +
+ +
+ +} diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index d234501..a307317 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -3,7 +3,7 @@ import { notFound } from 'next/navigation' import { Badge, Card, LinkButton, PageHero, SectionHeading } from '@/components/ui' import { CityConstellation } from '@/components/features/CityConstellation' import { UniversityCard } from '@/components/features/RecordCards' -import { getHomeExperienceCopy } from '@/i18n/home-experience' +import { formatStudentCityTitle, getHomeExperienceCopy } from '@/i18n/home-experience' import { getMessages } from '@/i18n/messages' import { localize } from '@/lib/data/format' import { classifyProgramField, programFieldTaxonomy } from '@/lib/data/fields' @@ -28,7 +28,12 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s const sourceCheckLabel = latestSourceCheck ? new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeZone: 'UTC' }).format(new Date(`${latestSourceCheck}T00:00:00Z`)) : messages.common.unknown - const pathwayHrefs = ['universities', 'programs', 'data-policy', 'guides'] as const + const pathwayHrefs = [ + 'universities', + 'programs', + 'guides/verify-admissions-data', + 'programs?applicationState=open', + ] as const return <>
- {messages.common.explore} →} /> + {messages.common.explore} →} />
diff --git a/src/app/[locale]/programs/[slug]/page.tsx b/src/app/[locale]/programs/[slug]/page.tsx index 078f79f..bc57455 100644 --- a/src/app/[locale]/programs/[slug]/page.tsx +++ b/src/app/[locale]/programs/[slug]/page.tsx @@ -31,10 +31,14 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s const data = await getCatalogData() const program = data.programs.find((item) => item.slug === slug) if (!program) return {} + const university = data.universities.find((item) => item.id === program.universityId) + const title = university + ? `${localize(program.name, locale)} — ${localize(university.name, locale)}` + : localize(program.name, locale) return pageMetadata( locale, - localize(program.name, locale), + title, `${degreeLabels(locale)[program.degreeLevel]} · ${disciplineLabels(locale)[program.discipline]}`, `programs/${slug}`, { indexable: isIndexableProgram(program, data.admissionCycles, getTodayDate()) }, diff --git a/src/app/[locale]/programs/page.tsx b/src/app/[locale]/programs/page.tsx index 6e6b2ff..ea642d6 100644 --- a/src/app/[locale]/programs/page.tsx +++ b/src/app/[locale]/programs/page.tsx @@ -9,9 +9,22 @@ import { queryProgramCatalogRepository, type ProgramCatalogSearchParams, } from '@/lib/program-catalog' -import { pageMetadata, requireLocale } from '@/lib/site' +import { hasSearchParameters, pageMetadata, requireLocale } from '@/lib/site' -export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const locale = requireLocale((await params).locale) || 'en'; const m = getMessages(locale); return pageMetadata(locale, m.programs.title, m.programs.intro, 'programs') } +export async function generateMetadata({ + params, + searchParams, +}: { + params: Promise<{ locale: string }> + searchParams?: Promise +}) { + const locale = requireLocale((await params).locale) || 'en' + const messages = getMessages(locale) + const parameterized = searchParams ? hasSearchParameters(await searchParams) : false + return pageMetadata(locale, messages.programs.title, messages.programs.intro, 'programs', { + indexable: !parameterized, + }) +} export default async function ProgramsPage({ params, searchParams, diff --git a/src/app/[locale]/scholarships/page.tsx b/src/app/[locale]/scholarships/page.tsx index e5de360..12cc6fe 100644 --- a/src/app/[locale]/scholarships/page.tsx +++ b/src/app/[locale]/scholarships/page.tsx @@ -9,9 +9,22 @@ import { queryScholarshipCatalogRepository, type ScholarshipCatalogSearchParams, } from '@/lib/scholarship-catalog' -import { pageMetadata, requireLocale } from '@/lib/site' +import { hasSearchParameters, pageMetadata, requireLocale } from '@/lib/site' -export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const locale = requireLocale((await params).locale) || 'en'; const m = getMessages(locale); return pageMetadata(locale, m.scholarships.title, m.scholarships.intro, 'scholarships') } +export async function generateMetadata({ + params, + searchParams, +}: { + params: Promise<{ locale: string }> + searchParams?: Promise +}) { + const locale = requireLocale((await params).locale) || 'en' + const messages = getMessages(locale) + const parameterized = searchParams ? hasSearchParameters(await searchParams) : false + return pageMetadata(locale, messages.scholarships.title, messages.scholarships.intro, 'scholarships', { + indexable: !parameterized, + }) +} export default async function ScholarshipsPage({ params, searchParams, diff --git a/src/app/[locale]/universities/page.tsx b/src/app/[locale]/universities/page.tsx index 7a6dcf1..7863608 100644 --- a/src/app/[locale]/universities/page.tsx +++ b/src/app/[locale]/universities/page.tsx @@ -8,16 +8,24 @@ import { queryUniversityCatalogRepository, type UniversityCatalogSearchParams, } from '@/lib/university-catalog' -import { pageMetadata, requireLocale } from '@/lib/site' +import { hasSearchParameters, pageMetadata, requireLocale } from '@/lib/site' -export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { +export async function generateMetadata({ + params, + searchParams, +}: { + params: Promise<{ locale: string }> + searchParams?: Promise +}) { const locale = requireLocale((await params).locale) || 'en' const messages = getMessages(locale) + const parameterized = searchParams ? hasSearchParameters(await searchParams) : false return pageMetadata( locale, messages.universities.title, messages.universities.intro, 'universities', + { indexable: !parameterized }, ) } diff --git a/src/app/api/v1/institutions/route.ts b/src/app/api/v1/institutions/route.ts index 3f69b73..4b1c3bc 100644 --- a/src/app/api/v1/institutions/route.ts +++ b/src/app/api/v1/institutions/route.ts @@ -14,6 +14,7 @@ import { type FieldMeta, type InstitutionRecord, } from '@/lib/catalog-api/types' +import { deploymentShaFromEnvironment } from '@/lib/catalog-api/runtime' import { getTodayDate } from '@/lib/data/freshness' export const runtime = 'nodejs' @@ -136,7 +137,11 @@ function envelope( return { data: page.items.map((item) => institutionRecord(item, today)), meta: { - release: page.release, + release: { + ...page.release, + catalogBackend: getCatalogRepository().mode, + deploymentSha: deploymentShaFromEnvironment(), + }, notice: AUTOMATED_COLLECTION_NOTICE, pageSize: page.items.length, nextCursor: page.nextCursor, diff --git a/src/app/api/v1/programs/compare/route.ts b/src/app/api/v1/programs/compare/route.ts new file mode 100644 index 0000000..a5097d5 --- /dev/null +++ b/src/app/api/v1/programs/compare/route.ts @@ -0,0 +1,35 @@ +import { compareCatalogPrograms } from '@/lib/catalog-api/runtime' +import { + handleCatalogRequest, + InvalidQueryError, + ok, + stringParam, +} from '@/lib/catalog-api/http' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +const PROGRAM_ID_PATTERN = /^[a-z0-9][a-z0-9:_-]{0,199}$/u +const MAX_COMPARE_PROGRAMS = 4 + +function programIds(params: URLSearchParams): string[] { + const raw = stringParam(params, 'ids', { maxLength: 804 }) + if (!raw) throw new InvalidQueryError('ids is required.') + const ids = [...new Set(raw.split(',').map((id) => id.trim()).filter(Boolean))] + if (ids.length === 0 || ids.length > MAX_COMPARE_PROGRAMS) { + throw new InvalidQueryError( + `ids must contain between 1 and ${MAX_COMPARE_PROGRAMS} unique program ids.`, + ) + } + if (ids.some((id) => !PROGRAM_ID_PATTERN.test(id))) { + throw new InvalidQueryError('ids contains an invalid program id.') + } + return ids +} + +export function GET(request: Request) { + return handleCatalogRequest(async () => { + const ids = programIds(new URL(request.url).searchParams) + return ok(await compareCatalogPrograms(ids)) + }) +} diff --git a/src/app/api/v1/releases/current/route.ts b/src/app/api/v1/releases/current/route.ts index 42d5bbc..9b8fd79 100644 --- a/src/app/api/v1/releases/current/route.ts +++ b/src/app/api/v1/releases/current/route.ts @@ -1,9 +1,9 @@ -import { getCatalogApiService } from '@/lib/catalog-api/runtime' +import { getCurrentCatalogRelease } from '@/lib/catalog-api/runtime' import { handleCatalogRequest, ok } from '@/lib/catalog-api/http' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' export function GET() { - return handleCatalogRequest(async () => ok((await getCatalogApiService()).getCurrentRelease())) + return handleCatalogRequest(async () => ok(await getCurrentCatalogRelease())) } diff --git a/src/components/features/FavoritesView.tsx b/src/components/features/FavoritesView.tsx index 80a50e7..580fb3d 100644 --- a/src/components/features/FavoritesView.tsx +++ b/src/components/features/FavoritesView.tsx @@ -1,18 +1,77 @@ 'use client' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { Button, Card, EmptyState, LinkButton } from '@/components/ui' import type { LaunchLocale } from '@/i18n/config' import type { Messages } from '@/i18n/messages' -import { selectAdmissionCycle } from '@/lib/data/admission' +import type { + AdmissionCycleRecord, + ApiEnvelope, + ProgramRecord, +} from '@/lib/catalog-api/types' import { formatCny, formatDate, localize } from '@/lib/data/format' import { degreeLabels, disciplineLabels, languageLabel } from '@/lib/data/labels' -import type { AdmissionCycle, Program, University } from '@/lib/data/types' import { MAX_COMPARE } from '@/lib/favorites' import { useFavorites } from './useFavorites' import { FavoriteButton } from './FavoriteButton' -function tuitionLabel(cycle: AdmissionCycle | undefined, locale: LaunchLocale, messages: Messages) { +type ComparisonItem = { + program: ProgramRecord + currentCycle: AdmissionCycleRecord | null + linkedScholarshipCount: number +} + +type ComparisonResponse = ApiEnvelope<{ + items: ComparisonItem[] + missingIds: string[] +}> + +type FavoritesExperience = { + loadError: string + retry: string + unavailable: string + linkedScholarships: string +} + +const experienceCopy: Record = { + en: { loadError: 'Saved program details could not be loaded.', retry: 'Try again', unavailable: 'No longer in the public catalogue', linkedScholarships: 'Related scholarships' }, + zh: { loadError: '暂时无法载入收藏项目详情。', retry: '重新加载', unavailable: '已不在公开目录中', linkedScholarships: '关联奖学金' }, + ru: { loadError: 'Не удалось загрузить данные сохранённых программ.', retry: 'Повторить', unavailable: 'Больше нет в открытом каталоге', linkedScholarships: 'Связанные стипендии' }, + de: { loadError: 'Details zu gespeicherten Studiengängen konnten nicht geladen werden.', retry: 'Erneut versuchen', unavailable: 'Nicht mehr im öffentlichen Katalog', linkedScholarships: 'Verknüpfte Stipendien' }, + fr: { loadError: 'Impossible de charger les programmes enregistrés.', retry: 'Réessayer', unavailable: 'N’est plus dans le catalogue public', linkedScholarships: 'Bourses associées' }, + es: { loadError: 'No se pudieron cargar los programas guardados.', retry: 'Reintentar', unavailable: 'Ya no está en el catálogo público', linkedScholarships: 'Becas relacionadas' }, +} + +const PROGRAM_ID_PATTERN = /^[a-z0-9][a-z0-9:_-]{0,199}$/u + +function batches(values: T[], size: number): T[][] { + const result: T[][] = [] + for (let index = 0; index < values.length; index += size) { + result.push(values.slice(index, index + size)) + } + return result +} + +function safeHttpsUrl(value: string | null | undefined): string | null { + if (!value) return null + try { + const url = new URL(value) + return url.protocol === 'https:' ? url.toString() : null + } catch { + return null + } +} + +async function fetchComparison(ids: string[], signal: AbortSignal): Promise { + const response = await fetch( + `/api/v1/programs/compare?ids=${encodeURIComponent(ids.join(','))}`, + { headers: { Accept: 'application/json' }, signal }, + ) + if (!response.ok) throw new Error(`Comparison request failed with ${response.status}`) + return await response.json() as ComparisonResponse +} + +function tuitionLabel(cycle: AdmissionCycleRecord | null, locale: LaunchLocale, messages: Messages) { if (cycle?.tuitionCny == null) return messages.common.unknown const periods = { program: messages.programs.tuitionProgram, @@ -24,23 +83,166 @@ function tuitionLabel(cycle: AdmissionCycle | undefined, locale: LaunchLocale, m return `${formatCny(cycle.tuitionCny, locale, messages.common.unknown)} / ${periods[cycle.tuitionPeriod || 'other']}${cycle.tuitionStatus === 'reference' ? ` · ${messages.programs.tuitionReference}` : ''}` } -function durationLabel(program: Program, messages: Messages) { +function durationLabel(program: ProgramRecord, messages: Messages) { if (!program.durationMonths) return messages.common.unknown return program.durationMonthsMax && program.durationMonthsMax !== program.durationMonths ? `${program.durationMonths}–${program.durationMonthsMax} ${messages.common.months}` : `${program.durationMonths} ${messages.common.months}` } -export function FavoritesView({ programs, universities, cycles, locale, messages, today }: { programs: Program[]; universities: University[]; cycles: AdmissionCycle[]; locale: LaunchLocale; messages: Messages; today: string }) { - const { favorites, ready } = useFavorites(); const [selected, setSelected] = useState([]) - const saved = useMemo(() => programs.filter((program) => favorites.includes(program.id)), [programs, favorites]) - const compared = saved.filter((program) => selected.includes(program.id)) +function applicationStateLabel(cycle: AdmissionCycleRecord | null, messages: Messages): string { + if (!cycle) return messages.programs.notAnnounced + const labels = { + open: messages.common.openNow, + upcoming: messages.programs.upcoming, + closed: messages.programs.applicationsClosed, + rolling: messages.programs.rolling, + 'dates-published': messages.programs.datePublished, + 'not-announced': messages.programs.notAnnounced, + 'previous-cycle': messages.programs.previousCycle, + } + return labels[cycle.applicationState] +} + +export function FavoritesView({ + locale, + messages, +}: { + locale: LaunchLocale + messages: Messages +}) { + const { favorites, ready } = useFavorites() + const [selected, setSelected] = useState([]) + const [items, setItems] = useState([]) + const [missingIds, setMissingIds] = useState([]) + const [loading, setLoading] = useState(false) + const [loadError, setLoadError] = useState(false) + const [retryKey, setRetryKey] = useState(0) + const saved = useMemo( + () => items.filter(({ program }) => favorites.includes(program.id)), + [items, favorites], + ) + const compared = saved.filter(({ program }) => selected.includes(program.id)) const copy = messages.favorites - const toggleCompare = (id: string) => setSelected((current) => current.includes(id) ? current.filter((item) => item !== id) : current.length < MAX_COMPARE ? [...current, id] : current) + const experience = experienceCopy[locale] + + useEffect(() => { + if (!ready || favorites.length === 0) return + const controller = new AbortController() + + const load = async () => { + setLoading(true) + setLoadError(false) + const requestableIds = favorites.filter((id) => PROGRAM_ID_PATTERN.test(id)) + const invalidIds = favorites.filter((id) => !PROGRAM_ID_PATTERN.test(id)) + try { + const responses = await Promise.all( + batches(requestableIds, MAX_COMPARE) + .map((ids) => fetchComparison(ids, controller.signal)), + ) + if (controller.signal.aborted) return + const returnedItems = responses.flatMap((response) => response.data.items) + const byId = new Map(returnedItems.map((item) => [item.program.id, item])) + setItems(favorites.flatMap((id) => byId.get(id) ? [byId.get(id)!] : [])) + setMissingIds([ + ...invalidIds, + ...favorites.filter((id) => !byId.has(id) && !invalidIds.includes(id)), + ]) + } catch { + if (!controller.signal.aborted) setLoadError(true) + } finally { + if (!controller.signal.aborted) setLoading(false) + } + } + + void load() + return () => controller.abort() + }, [favorites, ready, retryKey]) + + const toggleCompare = (id: string) => setSelected((current) => { + const available = current.filter((item) => favorites.includes(item)) + if (available.includes(id)) return available.filter((item) => item !== id) + return available.length < MAX_COMPARE ? [...available, id] : available + }) + if (!ready) return

{copy.loading}

- if (!saved.length) return {messages.home.explorePrograms}} /> + if (!favorites.length) return {messages.home.explorePrograms}} /> + if (loadError) return

{experience.loadError}

+ if (loading && saved.length === 0) return

{copy.loading}

+ return
-

{copy.limit} {messages.favorites.localOnly}

{saved.map((program) => { const university = universities.find((item) => item.id === program.universityId); return

{localize(program.name, locale)}

{university ? localize(university.name, locale) : '—'}

{degreeLabels(locale)[program.degreeLevel]}{disciplineLabels(locale)[program.discipline]}
{messages.common.viewDetails}
})}
- {compared.length ?

{copy.comparison}

{compared.map((program) => { const university = universities.find((item) => item.id === program.universityId); const cycle = selectAdmissionCycle(cycles, program.id, today); return

{localize(program.name, locale)}

{copy.university}
{university ? localize(university.name, locale) : '—'}
{messages.programs.degree}
{degreeLabels(locale)[program.degreeLevel]}
{messages.common.language}
{program.teachingLanguages.length ? program.teachingLanguages.map((item) => languageLabel(item, locale)).join(', ') : messages.common.unknown}
{messages.common.duration}
{durationLabel(program, messages)}
{messages.common.tuition}
{tuitionLabel(cycle, locale, messages)}
{messages.common.deadline}
{formatDate(cycle?.closesOn ?? null, locale, messages.common.unknown)}
})}
: null} +
+

{copy.limit} {messages.favorites.localOnly}

+
+ {saved.map(({ program }) => + +

{localize(program.name, locale)}

+

{localize(program.university.name, locale)}

+
+ {degreeLabels(locale)[program.degreeLevel]} + {program.discipline ? disciplineLabels(locale)[program.discipline] : messages.common.unknown} +
+
+ {messages.common.viewDetails} + +
+
)} + {missingIds.filter((id) => favorites.includes(id)).map((id) => +

{experience.unavailable}

+

{id}

+
+ +
+
)} +
+
+ {compared.length ?
+
+

{copy.comparison}

+ +
+
+ {compared.map(({ program, currentCycle, linkedScholarshipCount }) => { + const canApply = currentCycle?.applicationState === 'open' + || currentCycle?.applicationState === 'rolling' + const applyHref = canApply ? safeHttpsUrl(program.applyUrl) : null + const sourceHref = currentCycle?.officialSources + .map((source) => safeHttpsUrl(source.url)).find(Boolean) + ?? program.officialSources.map((source) => safeHttpsUrl(source.url)).find(Boolean) + ?? safeHttpsUrl(program.programUrl) + const checkedAt = currentCycle?.officialSources[0]?.checkedAt + ?? program.officialSources[0]?.checkedAt + ?? program.verifiedAt + + return +

{localize(program.name, locale)}

+
+
{copy.university}
{localize(program.university.name, locale)}
+
{messages.programs.degree}
{degreeLabels(locale)[program.degreeLevel]}
+
{messages.common.language}
{program.teachingLanguages?.length ? program.teachingLanguages.map((item) => languageLabel(item, locale)).join(', ') : messages.common.unknown}
+
{messages.common.duration}
{durationLabel(program, messages)}
+
{messages.programs.applicationStatus}
{applicationStateLabel(currentCycle, messages)}
+
{messages.common.tuition}
{tuitionLabel(currentCycle, locale, messages)}
+
{messages.programs.fee}
{currentCycle?.applicationFeeCny == null ? messages.common.unknown : formatCny(currentCycle.applicationFeeCny, locale, messages.common.unknown)}
+
{messages.common.deadline}
{formatDate(currentCycle?.closesOn ?? null, locale, messages.common.unknown)}
+
{experience.linkedScholarships}
{linkedScholarshipCount.toLocaleString(locale)}
+
{messages.common.lastVerified}
+
+
+ {sourceHref ? {messages.common.officialSource} ↗ : null} + {applyHref ? {messages.common.applyOfficial} ↗ : null} +
+
+ })} +
+
: null}
} diff --git a/src/components/features/ProgramExplorerV2.module.css b/src/components/features/ProgramExplorerV2.module.css index 3544d10..bd5d026 100644 --- a/src/components/features/ProgramExplorerV2.module.css +++ b/src/components/features/ProgramExplorerV2.module.css @@ -3,6 +3,45 @@ position: static; } +.quickFilters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: .55rem; + margin-bottom: 1rem; +} + +.quickFilters > span { + margin-inline-end: .15rem; + color: var(--ink-soft); + font-size: .76rem; + font-weight: 800; + letter-spacing: .06em; + text-transform: uppercase; +} + +.quickFilter { + padding: .48rem .78rem; + border: 1px solid var(--line-strong); + border-radius: 999px; + color: var(--jade-dark); + background: color-mix(in srgb, var(--paper-light) 84%, transparent); + font-size: .82rem; + font-weight: 800; + text-decoration: none; +} + +.quickFilter:hover, +.isActive { + border-color: var(--jade); + background: var(--atlas-jade-pale); +} + +.quickFilter:focus-visible { + outline: 3px solid color-mix(in srgb, var(--jade) 28%, transparent); + outline-offset: 3px; +} + .primaryGrid, .advancedGrid { display: grid; diff --git a/src/components/features/ProgramExplorerV2.tsx b/src/components/features/ProgramExplorerV2.tsx index 5b73245..bc21e89 100644 --- a/src/components/features/ProgramExplorerV2.tsx +++ b/src/components/features/ProgramExplorerV2.tsx @@ -1,3 +1,4 @@ +import Link from 'next/link' import { Button, LinkButton } from '@/components/ui' import type { LaunchLocale } from '@/i18n/config' import type { Messages } from '@/i18n/messages' @@ -25,13 +26,14 @@ const labels: Record = { - en: { apply: 'Apply filters', defaultOrder: 'Default order', next: 'Next', pagination: 'Program catalogue pages', previous: 'Previous', sortBy: 'Sort by', linkedScholarship: 'Linked scholarship' }, - zh: { apply: '应用筛选', defaultOrder: '默认顺序', next: '下一页', pagination: '项目目录分页', previous: '上一页', sortBy: '排序方式', linkedScholarship: '有关联奖学金' }, - ru: { apply: 'Применить фильтры', defaultOrder: 'По умолчанию', next: 'Далее', pagination: 'Страницы каталога программ', previous: 'Назад', sortBy: 'Сортировка', linkedScholarship: 'Есть связанная стипендия' }, - de: { apply: 'Filter anwenden', defaultOrder: 'Standardreihenfolge', next: 'Weiter', pagination: 'Studiengangseiten', previous: 'Zurück', sortBy: 'Sortieren nach', linkedScholarship: 'Verknüpftes Stipendium' }, - fr: { apply: 'Appliquer les filtres', defaultOrder: 'Ordre par défaut', next: 'Suivant', pagination: 'Pages du catalogue des programmes', previous: 'Précédent', sortBy: 'Trier par', linkedScholarship: 'Bourse associée' }, - es: { apply: 'Aplicar filtros', defaultOrder: 'Orden predeterminado', next: 'Siguiente', pagination: 'Páginas del catálogo de programas', previous: 'Anterior', sortBy: 'Ordenar por', linkedScholarship: 'Beca vinculada' }, + en: { apply: 'Apply filters', defaultOrder: 'Default order', next: 'Next', pagination: 'Program catalogue pages', previous: 'Previous', sortBy: 'Sort by', linkedScholarship: 'Linked scholarship', statusShortcuts: 'Application status shortcuts' }, + zh: { apply: '应用筛选', defaultOrder: '默认顺序', next: '下一页', pagination: '项目目录分页', previous: '上一页', sortBy: '排序方式', linkedScholarship: '有关联奖学金', statusShortcuts: '申请状态快捷筛选' }, + ru: { apply: 'Применить фильтры', defaultOrder: 'По умолчанию', next: 'Далее', pagination: 'Страницы каталога программ', previous: 'Назад', sortBy: 'Сортировка', linkedScholarship: 'Есть связанная стипендия', statusShortcuts: 'Быстрый выбор статуса заявки' }, + de: { apply: 'Filter anwenden', defaultOrder: 'Standardreihenfolge', next: 'Weiter', pagination: 'Studiengangseiten', previous: 'Zurück', sortBy: 'Sortieren nach', linkedScholarship: 'Verknüpftes Stipendium', statusShortcuts: 'Schnellfilter für Bewerbungsstatus' }, + fr: { apply: 'Appliquer les filtres', defaultOrder: 'Ordre par défaut', next: 'Suivant', pagination: 'Pages du catalogue des programmes', previous: 'Précédent', sortBy: 'Trier par', linkedScholarship: 'Bourse associée', statusShortcuts: 'Filtres rapides du statut de candidature' }, + es: { apply: 'Aplicar filtros', defaultOrder: 'Orden predeterminado', next: 'Siguiente', pagination: 'Páginas del catálogo de programas', previous: 'Anterior', sortBy: 'Ordenar por', linkedScholarship: 'Beca vinculada', statusShortcuts: 'Filtros rápidos del estado de solicitud' }, } type SelectOption = { value: string; label: string } @@ -134,7 +136,29 @@ export function ProgramExplorerV2({ filters.sort === 'default' ? '' : filters.sort, ].filter(Boolean).length + const statusHref = (applicationState: 'open' | 'upcoming') => programCatalogHref(locale, { + ...filters, + applicationState, + page: 1, + cursor: '', + cursorHistory: [], + nextCursor: '', + }, 1) + return <> +
{ - document.cookie = `studycn-locale=${locale}; Path=/; Max-Age=31536000; SameSite=Lax` - }, [locale]) - const nav = [ ['', messages.nav.home], ['universities', messages.nav.universities], @@ -44,27 +47,54 @@ export function AppHeader({ locale }: { locale: LaunchLocale }) { ] as const const favoritesHref = `/${locale}/favorites` const favoritesActive = pathname.startsWith(favoritesHref) + const routeSegment = pathname.split('/')[2] + const showsCatalogData = ['universities', 'programs', 'scholarships', 'cities'] + .includes(routeSegment || '') + + return <> + ({ + label, + href: segment ? `/${locale}/${segment}` : `/${locale}`, + active: segment ? pathname.startsWith(`/${locale}/${segment}`) : pathname === `/${locale}`, + }))} + languages={publicLocales.map((code) => ({ + code, + label: localeNames[code], + href: localizeNavigationHref(pathname, searchParams, code), + active: code === locale, + }))} + actions={} + /> + {isBetaLocale(locale) && showsCatalogData ? ( +
+

{betaContentFallbackNotice(locale)}

+
+ ) : null} + +} + +function QueryAwareHeader({ locale, pathname }: { locale: LaunchLocale; pathname: string }) { + const searchParams = useSearchParams() + return +} + +export function AppHeader({ locale }: { locale: LaunchLocale }) { + const pathname = usePathname() + + useEffect(() => { + document.cookie = `studycn-locale=${locale}; Path=/; Max-Age=31536000; SameSite=Lax` + }, [locale]) - return ({ - label, - href: segment ? `/${locale}/${segment}` : `/${locale}`, - active: segment ? pathname.startsWith(`/${locale}/${segment}`) : pathname === `/${locale}`, - }))} - languages={publicLocales.map((code) => ({ - code, - label: localeNames[code], - href: localizePathname(pathname, code), - active: code === locale, - }))} - actions={} - /> + return }> + + } diff --git a/src/i18n/config.ts b/src/i18n/config.ts index 2f05c7a..8307414 100644 --- a/src/i18n/config.ts +++ b/src/i18n/config.ts @@ -108,3 +108,30 @@ export function localizePathname(pathname: string, locale: PublicLocale): string return `/${locale}${remainder === '/' ? '' : remainder}` } + +const transientNavigationParameters = new Set([ + 'cursor', + 'cursorHistory', + 'nextCursor', + 'page', +]) + +/** + * Keep shareable catalogue filters when changing language while resetting + * pagination state, whose opaque cursors are bound to the previous request. + */ +export function localizeNavigationHref( + pathname: string, + searchParams: URLSearchParams, + locale: PublicLocale, +): string { + const localizedPath = localizePathname(pathname, locale) + const semanticParams = new URLSearchParams() + + searchParams.forEach((value, key) => { + if (!transientNavigationParameters.has(key)) semanticParams.append(key, value) + }) + + const query = semanticParams.toString() + return `${localizedPath}${query ? `?${query}` : ''}` +} diff --git a/src/i18n/home-experience.ts b/src/i18n/home-experience.ts index b4041f6..5996ed9 100644 --- a/src/i18n/home-experience.ts +++ b/src/i18n/home-experience.ts @@ -131,3 +131,16 @@ const copy = { export function getHomeExperienceCopy(locale: LaunchLocale): HomeExperienceCopy { return copy[locale] } + +export function formatStudentCityTitle(count: number, locale: LaunchLocale): string { + const formatted = count.toLocaleString(locale) + const templates: Record = { + en: `${formatted} student cities, one connected journey`, + zh: `${formatted} 座留学城市,一条贯通中国的旅程`, + ru: `${formatted} городов для учёбы — один маршрут по Китаю`, + de: `${formatted} Studienstädte, eine gemeinsame Reise`, + fr: `${formatted} villes étudiantes, un même parcours`, + es: `${formatted} ciudades estudiantiles, un viaje conectado`, + } + return templates[locale] +} diff --git a/src/i18n/messages.ts b/src/i18n/messages.ts index 8b0ff2f..7e009cc 100644 --- a/src/i18n/messages.ts +++ b/src/i18n/messages.ts @@ -11,7 +11,7 @@ const en = { shell: { brandTagline: 'Independent student atlas', navLabel: 'Primary navigation', mobileMenuLabel: 'Open menu', skipLinkLabel: 'Skip to main content', footerExplore: 'Explore', footerProject: 'Project', dataRelease: 'Data release', creatorPrefix: 'Created by' }, nav: { home: 'Home', universities: 'Universities', programs: 'Programs', scholarships: 'Scholarships', cities: 'Cities', guides: 'Guides', favorites: 'Saved', about: 'About', contact: 'Contact' }, common: { explore: 'Explore', viewDetails: 'View details', applyOfficial: 'Apply on official site', officialSource: 'Official source', verified: 'Verified', stale: 'Needs review', draft: 'Being verified', archived: 'Archived', lastVerified: 'Verified on', unknown: 'Not announced', all: 'All', search: 'Search', clear: 'Clear filters', save: 'Save', saved: 'Saved', compare: 'Compare', print: 'Print / Save PDF', back: 'Back', university: 'University', program: 'Program', scholarship: 'Scholarship', city: 'City', status: 'Status', openNow: 'Open now', deadline: 'Deadline', tuition: 'Tuition', duration: 'Duration', months: 'months', language: 'Teaching language', requirements: 'Language requirements', translationPending: 'Translation pending', authoritativeNotice: 'Admissions information changes. Always confirm on the linked official page.', automatedCollectionNotice: 'Information is collected by an automated system and updated regularly; application requirements, fees and deadlines are subject to the official websites of the university or scholarship provider.', sourcesLastChecked: 'Official sources last checked', reportInformationError: 'Report incorrect information' }, - home: { eyebrow: 'A clearer path to studying in China', title: 'Compare programs. Track deadlines. Choose with confidence.', intro: 'A source-backed guide for international students exploring Chinese universities, scholarships, application routes and student cities.', findUniversity: 'Find a university', explorePrograms: 'Explore programs', checkScholarships: 'Check scholarships', learnCities: 'Learn about cities', featured: 'Start with trusted essentials', featuredIntro: 'Browse a carefully structured first release, with visible sources and review dates.', deadlineTitle: 'Application windows to watch', disciplines: 'Explore by field', cityTitle: 'Twelve student cities, one connected journey', guideTitle: 'Your application, step by step', programVerificationNote: 'Program records are being verified one by one. Drafts without an official program page are not published or indexed.', sourceHeading: 'Every fact should lead back to a source' }, + home: { eyebrow: 'A clearer path to studying in China', title: 'Compare programs. Track deadlines. Choose with confidence.', intro: 'A source-backed guide for international students exploring Chinese universities, scholarships, application routes and student cities.', findUniversity: 'Find a university', explorePrograms: 'Explore programs', checkScholarships: 'Check scholarships', learnCities: 'Learn about cities', featured: 'Start with trusted essentials', featuredIntro: 'Browse a carefully structured first release, with visible sources and review dates.', deadlineTitle: 'Application windows to watch', disciplines: 'Explore by field', cityTitle: 'Student cities across China', guideTitle: 'Your application, step by step', programVerificationNote: 'Program records are being verified one by one. Drafts without an official program page are not published or indexed.', sourceHeading: 'Every fact should lead back to a source' }, universities: { title: 'Universities', intro: 'Filter a curated national selection by city, region and academic field.', searchPlaceholder: 'Search university, city or field', noResults: 'No universities match these filters.', cityFilter: 'City', regionFilter: 'Region', fieldFilter: 'Field', results: 'universities', programs: 'Programs to explore', funding: 'Related scholarships', facts: 'University at a glance', sources: 'Sources and review', review: 'Review due', official: 'University website', admission: 'International admissions' }, programs: { title: 'Programs', intro: 'Compare degree level, teaching language, duration and current admissions status.', searchPlaceholder: 'Search program or university', noResults: 'No programs match these filters.', degree: 'Degree level', discipline: 'Field', languageFilter: 'Teaching language', statusFilter: 'Application dates', tuitionFilter: 'Tuition data', published: 'Published', unannounced: 'Not announced', known: 'Available', results: 'programs', verificationNote: 'The public catalogue only accepts verified records with a program-level official source. Draft templates are excluded from production pages, the sitemap and structured data.', overview: 'Program facts', cycle: 'Admissions cycle', year: 'Academic year', intake: 'Intake', fee: 'Application fee', opens: 'Opens', sources: 'Official sources and review', related: 'Other programs at this university', university: 'University', faculty: 'Faculty', qualification: 'Qualification', studyMode: 'Study mode', languagePolicy: 'Language policy', curriculum: 'Curriculum highlights', eligibility: 'Eligibility', materials: 'Application materials', campus: 'Campus', fullTime: 'Full-time', partTime: 'Part-time', hybrid: 'Hybrid', applicationStatus: 'Application status', datePublished: 'Dates published', rolling: 'Rolling admissions', notAnnounced: 'Not announced', previousCycle: 'Previous-cycle reference', applicationsClosed: 'Applications closed', upcoming: 'Opening soon', springIntake: 'Spring intake', autumnIntake: 'Autumn intake', otherIntake: 'Other intake', tuitionProgram: 'per program', tuitionSemester: 'per semester', tuitionAcademicYear: 'per academic year', tuitionMonth: 'per month', tuitionOther: 'See official details' }, scholarships: { title: 'Scholarships', intro: 'Understand coverage, eligibility routes and official application links.', coverage: 'Coverage', stipend: 'Monthly stipend', catalogueNotice: 'The public catalogue only shows scholarships verified against official sources; a listing does not necessarily mean applications are open.', tuition: 'Tuition', accommodation: 'Accommodation', insurance: 'Medical insurance', scope: 'Participating universities', sources: 'Sources and review', included: 'Included', notIncluded: 'Not included' }, @@ -62,7 +62,7 @@ const zh: BaseMessages = { shell: { brandTagline: '独立留学资料地图', navLabel: '主导航', mobileMenuLabel: '打开菜单', skipLinkLabel: '跳到主要内容', footerExplore: '探索', footerProject: '项目', dataRelease: '数据版本', creatorPrefix: '创作者:' }, nav: { home: '首页', universities: '大学', programs: '项目', scholarships: '奖学金', cities: '城市', guides: '申请指南', favorites: '收藏', about: '关于', contact: '联系' }, common: { explore: '开始探索', viewDetails: '查看详情', applyOfficial: '前往官网申请', officialSource: '官方来源', verified: '已核验', stale: '需要复核', draft: '核验中', archived: '已归档', lastVerified: '核验日期', unknown: '尚未公布', all: '全部', search: '搜索', clear: '清除筛选', save: '收藏', saved: '已收藏', compare: '对比', print: '打印 / 另存为 PDF', back: '返回', university: '大学', program: '项目', scholarship: '奖学金', city: '城市', status: '状态', openNow: '开放申请', deadline: '截止日期', tuition: '学费', duration: '学制', months: '个月', language: '授课语言', requirements: '语言要求', translationPending: '翻译待补充', authoritativeNotice: '招生信息可能变化,请始终以所链接的学校官方公告为准。', automatedCollectionNotice: '信息由自动化系统收录并定期更新;申请条件、费用与截止日期以学校或奖学金官方网站实际情况为准。', sourcesLastChecked: '官方来源最近检查时间', reportInformationError: '报告信息错误' }, - home: { eyebrow: '更清晰的中国留学路径', title: '比较项目,掌握时间,安心选择。', intro: '为国际学生整理中国高校、奖学金、申请路径与留学城市信息,并为关键事实标注来源。', findUniversity: '寻找大学', explorePrograms: '浏览项目', checkScholarships: '查看奖学金', learnCities: '了解城市', featured: '从可信的核心信息开始', featuredIntro: '浏览结构化的首批资料,每条信息均显示来源与核验时间。', deadlineTitle: '值得关注的申请窗口', disciplines: '按学科探索', cityTitle: '十二座留学城市,一段中国旅程', guideTitle: '申请流程,一步一步完成', programVerificationNote: '项目资料正在逐项核验。未经官方项目页确认的草稿不会出现在公开目录或搜索引擎中。', sourceHeading: '每条事实都应能回到来源' }, + home: { eyebrow: '更清晰的中国留学路径', title: '比较项目,掌握时间,安心选择。', intro: '为国际学生整理中国高校、奖学金、申请路径与留学城市信息,并为关键事实标注来源。', findUniversity: '寻找大学', explorePrograms: '浏览项目', checkScholarships: '查看奖学金', learnCities: '了解城市', featured: '从可信的核心信息开始', featuredIntro: '浏览结构化的首批资料,每条信息均显示来源与核验时间。', deadlineTitle: '值得关注的申请窗口', disciplines: '按学科探索', cityTitle: '中国留学城市', guideTitle: '申请流程,一步一步完成', programVerificationNote: '项目资料正在逐项核验。未经官方项目页确认的草稿不会出现在公开目录或搜索引擎中。', sourceHeading: '每条事实都应能回到来源' }, universities: { title: '中国大学', intro: '按城市、地区和学科筛选精选高校。', searchPlaceholder: '搜索学校、城市或学科', noResults: '没有符合当前条件的大学。', cityFilter: '城市', regionFilter: '地区', fieldFilter: '学科', results: '所大学', programs: '可浏览项目', funding: '相关奖学金', facts: '学校概览', sources: '来源与复核', review: '下次复核', official: '大学官网', admission: '国际招生官网' }, programs: { title: '学习项目', intro: '比较学位、授课语言、学制和当前申请状态。', searchPlaceholder: '搜索项目或大学', noResults: '没有符合当前条件的项目。', degree: '学位类型', discipline: '学科领域', languageFilter: '授课语言', statusFilter: '申请日期', tuitionFilter: '学费信息', published: '已公布', unannounced: '尚未公布', known: '已有数据', results: '个项目', verificationNote: '公开目录目前只接受有项目级官方来源的核验记录。模板草稿已从生产页面、站点地图和结构化数据中隔离。', overview: '项目信息', cycle: '招生周期', year: '适用学年', intake: '入学季', fee: '申请费', opens: '开放日期', sources: '官方来源与核验', related: '同校其他项目', university: '开设学校', faculty: '院系', qualification: '授予资格', studyMode: '学习方式', languagePolicy: '语言政策', curriculum: '课程亮点', eligibility: '申请资格', materials: '申请材料', campus: '校区', fullTime: '全日制', partTime: '非全日制', hybrid: '混合制', applicationStatus: '申请状态', datePublished: '日期已公布', rolling: '滚动录取', notAnnounced: '尚未公布', previousCycle: '上一周期参考', applicationsClosed: '申请已截止', upcoming: '即将开放', springIntake: '春季入学', autumnIntake: '秋季入学', otherIntake: '其他入学季', tuitionProgram: '每个项目', tuitionSemester: '每学期', tuitionAcademicYear: '每学年', tuitionMonth: '每月', tuitionOther: '见官方说明' }, scholarships: { title: '奖学金', intro: '了解资助范围、申请渠道和官方申请链接。', coverage: '资助范围', stipend: '每月生活费', catalogueNotice: '公开目录只显示已经过官方来源核验的奖学金;列出名称并不代表当期一定开放。', tuition: '学费', accommodation: '住宿', insurance: '医疗保险', scope: '适用学校', sources: '来源与核验', included: '包含', notIncluded: '不包含' }, @@ -100,7 +100,7 @@ const ru: BaseMessages = { shell: { brandTagline: 'Независимый атлас', navLabel: 'Основная навигация', mobileMenuLabel: 'Открыть меню', skipLinkLabel: 'К основному содержанию', footerExplore: 'Навигация', footerProject: 'Проект', dataRelease: 'Версия данных', creatorPrefix: 'Автор:' }, nav: { home: 'Главная', universities: 'Вузы', programs: 'Программы', scholarships: 'Стипендии', cities: 'Города', guides: 'Поступление', favorites: 'Сохранённое', about: 'О проекте', contact: 'Связаться' }, common: { explore: 'Открыть', viewDetails: 'Подробнее', applyOfficial: 'Подать на официальном сайте', officialSource: 'Официальный источник', verified: 'Проверено', stale: 'Нужна проверка', draft: 'Проверяется', archived: 'Архив', lastVerified: 'Проверено', unknown: 'Не объявлено', all: 'Все', search: 'Поиск', clear: 'Сбросить', save: 'Сохранить', saved: 'Сохранено', compare: 'Сравнить', print: 'Печать / PDF', back: 'Назад', university: 'Университет', program: 'Программа', scholarship: 'Стипендия', city: 'Город', status: 'Статус', openNow: 'Приём открыт', deadline: 'Срок подачи', tuition: 'Стоимость', duration: 'Длительность', months: 'месяцев', language: 'Язык обучения', requirements: 'Языковые требования', translationPending: 'Перевод готовится', authoritativeNotice: 'Условия поступления меняются. Всегда сверяйтесь с официальной страницей по ссылке.', automatedCollectionNotice: 'Информация собирается автоматизированной системой и регулярно обновляется; требования к заявке, сборы и сроки уточняйте на официальных сайтах университета или стипендиальной программы.', sourcesLastChecked: 'Официальные источники последний раз проверены', reportInformationError: 'Сообщить об ошибке в информации' }, - home: { eyebrow: 'Понятный путь к учёбе в Китае', title: 'Сравнивайте программы. Следите за сроками. Выбирайте уверенно.', intro: 'Путеводитель по китайским вузам, стипендиям, поступлению и студенческим городам с указанием источников.', findUniversity: 'Найти вуз', explorePrograms: 'Смотреть программы', checkScholarships: 'Найти стипендию', learnCities: 'Узнать о городах', featured: 'Начните с проверенной основы', featuredIntro: 'Первый структурированный набор данных с источниками и датами проверки.', deadlineTitle: 'Сроки, за которыми стоит следить', disciplines: 'Поиск по направлению', cityTitle: 'Двенадцать студенческих городов — одно путешествие', guideTitle: 'Поступление шаг за шагом', programVerificationNote: 'Программы проходят проверку. Черновики без официальной страницы программы не публикуются и не индексируются.', sourceHeading: 'Каждый факт ведёт к источнику' }, + home: { eyebrow: 'Понятный путь к учёбе в Китае', title: 'Сравнивайте программы. Следите за сроками. Выбирайте уверенно.', intro: 'Путеводитель по китайским вузам, стипендиям, поступлению и студенческим городам с указанием источников.', findUniversity: 'Найти вуз', explorePrograms: 'Смотреть программы', checkScholarships: 'Найти стипендию', learnCities: 'Узнать о городах', featured: 'Начните с проверенной основы', featuredIntro: 'Первый структурированный набор данных с источниками и датами проверки.', deadlineTitle: 'Сроки, за которыми стоит следить', disciplines: 'Поиск по направлению', cityTitle: 'Студенческие города Китая', guideTitle: 'Поступление шаг за шагом', programVerificationNote: 'Программы проходят проверку. Черновики без официальной страницы программы не публикуются и не индексируются.', sourceHeading: 'Каждый факт ведёт к источнику' }, universities: { title: 'Университеты', intro: 'Фильтруйте подборку по городу, региону и направлению.', searchPlaceholder: 'Поиск вуза, города или направления', noResults: 'По этим фильтрам ничего не найдено.', cityFilter: 'Город', regionFilter: 'Регион', fieldFilter: 'Направление', results: 'вузов', programs: 'Программы', funding: 'Стипендии', facts: 'О вузе', sources: 'Источники и проверка', review: 'Следующая проверка', official: 'Сайт вуза', admission: 'Приём иностранных студентов' }, programs: { title: 'Программы', intro: 'Сравнивайте степень, язык, длительность и статус приёма.', searchPlaceholder: 'Поиск программы или вуза', noResults: 'Программы не найдены.', degree: 'Уровень', discipline: 'Направление', languageFilter: 'Язык', statusFilter: 'Сроки', tuitionFilter: 'Стоимость', published: 'Опубликованы', unannounced: 'Не объявлены', known: 'Есть данные', results: 'программ', verificationNote: 'В открытом каталоге публикуются только проверенные записи с официальным источником уровня программы. Черновики исключены из страниц, карты сайта и структурированных данных.', overview: 'О программе', cycle: 'Цикл приёма', year: 'Учебный год', intake: 'Набор', fee: 'Сбор', opens: 'Открытие', sources: 'Источники и проверка', related: 'Другие программы вуза', university: 'Университет', faculty: 'Факультет', qualification: 'Квалификация', studyMode: 'Форма обучения', languagePolicy: 'Языковая политика', curriculum: 'Основные дисциплины', eligibility: 'Требования к кандидатам', materials: 'Документы для подачи', campus: 'Кампус', fullTime: 'Очная форма', partTime: 'Неполная занятость', hybrid: 'Гибридная форма', applicationStatus: 'Статус подачи', datePublished: 'Даты опубликованы', rolling: 'Непрерывный приём', notAnnounced: 'Не объявлено', previousCycle: 'Данные прошлого цикла', applicationsClosed: 'Приём завершён', upcoming: 'Скоро откроется', springIntake: 'Весенний набор', autumnIntake: 'Осенний набор', otherIntake: 'Другой набор', tuitionProgram: 'за программу', tuitionSemester: 'за семестр', tuitionAcademicYear: 'за учебный год', tuitionMonth: 'в месяц', tuitionOther: 'См. официальные сведения' }, scholarships: { title: 'Стипендии', intro: 'Покрытие, маршруты подачи и официальные ссылки.', coverage: 'Покрытие', stipend: 'Ежемесячная выплата', catalogueNotice: 'В открытом каталоге показаны только стипендии, проверенные по официальным источникам; наличие записи не означает открытый набор.', tuition: 'Обучение', accommodation: 'Проживание', insurance: 'Страховка', scope: 'Вузы', sources: 'Источники и проверка', included: 'Включено', notIncluded: 'Не включено' }, @@ -138,7 +138,7 @@ const de: BaseMessages = { shell: { brandTagline: 'Unabhängiger Studienatlas', navLabel: 'Hauptnavigation', mobileMenuLabel: 'Menü öffnen', skipLinkLabel: 'Zum Hauptinhalt springen', footerExplore: 'Entdecken', footerProject: 'Projekt', dataRelease: 'Datenstand', creatorPrefix: 'Erstellt von' }, nav: { home: 'Start', universities: 'Universitäten', programs: 'Studiengänge', scholarships: 'Stipendien', cities: 'Städte', guides: 'Leitfäden', favorites: 'Merkliste', about: 'Über uns', contact: 'Kontakt' }, common: { explore: 'Entdecken', viewDetails: 'Details ansehen', applyOfficial: 'Offiziell bewerben', officialSource: 'Offizielle Quelle', verified: 'Geprüft', stale: 'Prüfung fällig', draft: 'In Prüfung', archived: 'Archiviert', lastVerified: 'Geprüft am', unknown: 'Nicht bekannt gegeben', all: 'Alle', search: 'Suchen', clear: 'Filter löschen', save: 'Speichern', saved: 'Gespeichert', compare: 'Vergleichen', print: 'Drucken / als PDF speichern', back: 'Zurück', university: 'Universität', program: 'Studiengang', scholarship: 'Stipendium', city: 'Stadt', status: 'Status', openNow: 'Jetzt geöffnet', deadline: 'Bewerbungsfrist', tuition: 'Studiengebühren', duration: 'Dauer', months: 'Monate', language: 'Unterrichtssprache', requirements: 'Sprachanforderungen', translationPending: 'Übersetzung ausstehend', authoritativeNotice: 'Zulassungsinformationen können sich ändern. Prüfen Sie stets die verlinkte offizielle Seite.', automatedCollectionNotice: 'Die Informationen werden automatisiert erfasst und regelmäßig aktualisiert; maßgeblich für Bewerbungsvoraussetzungen, Gebühren und Fristen sind die offiziellen Websites der Hochschule oder des Stipendiengebers.', sourcesLastChecked: 'Offizielle Quellen zuletzt geprüft', reportInformationError: 'Fehlerhafte Information melden' }, - home: { eyebrow: 'Ein klarerer Weg zum Studium in China', title: 'Studiengänge vergleichen. Fristen verfolgen. Sicher entscheiden.', intro: 'Ein quellenbasierter Wegweiser zu chinesischen Universitäten, Stipendien, Bewerbungswegen und Studienstädten.', findUniversity: 'Universität finden', explorePrograms: 'Studiengänge entdecken', checkScholarships: 'Stipendien prüfen', learnCities: 'Städte kennenlernen', featured: 'Mit verlässlichen Grundlagen starten', featuredIntro: 'Entdecken Sie strukturierte Daten mit sichtbaren Quellen und Prüfdaten.', deadlineTitle: 'Wichtige Bewerbungszeiträume', disciplines: 'Nach Fachgebiet suchen', cityTitle: 'Zwölf Studienstädte, eine gemeinsame Reise', guideTitle: 'Ihre Bewerbung Schritt für Schritt', programVerificationNote: 'Studiengangsdaten werden einzeln geprüft. Entwürfe ohne offizielle Programmseite werden weder veröffentlicht noch indexiert.', sourceHeading: 'Jede Angabe sollte zu ihrer Quelle führen' }, + home: { eyebrow: 'Ein klarerer Weg zum Studium in China', title: 'Studiengänge vergleichen. Fristen verfolgen. Sicher entscheiden.', intro: 'Ein quellenbasierter Wegweiser zu chinesischen Universitäten, Stipendien, Bewerbungswegen und Studienstädten.', findUniversity: 'Universität finden', explorePrograms: 'Studiengänge entdecken', checkScholarships: 'Stipendien prüfen', learnCities: 'Städte kennenlernen', featured: 'Mit verlässlichen Grundlagen starten', featuredIntro: 'Entdecken Sie strukturierte Daten mit sichtbaren Quellen und Prüfdaten.', deadlineTitle: 'Wichtige Bewerbungszeiträume', disciplines: 'Nach Fachgebiet suchen', cityTitle: 'Studienstädte in China', guideTitle: 'Ihre Bewerbung Schritt für Schritt', programVerificationNote: 'Studiengangsdaten werden einzeln geprüft. Entwürfe ohne offizielle Programmseite werden weder veröffentlicht noch indexiert.', sourceHeading: 'Jede Angabe sollte zu ihrer Quelle führen' }, universities: { title: 'Universitäten', intro: 'Filtern Sie eine kuratierte landesweite Auswahl nach Stadt, Region und Fachgebiet.', searchPlaceholder: 'Universität, Stadt oder Fach suchen', noResults: 'Keine Universität entspricht diesen Filtern.', cityFilter: 'Stadt', regionFilter: 'Region', fieldFilter: 'Fachgebiet', results: 'Universitäten', programs: 'Studiengänge', funding: 'Passende Stipendien', facts: 'Universität im Überblick', sources: 'Quellen und Prüfung', review: 'Nächste Prüfung', official: 'Universitätswebsite', admission: 'Internationale Zulassung' }, programs: { title: 'Studiengänge', intro: 'Vergleichen Sie Abschluss, Unterrichtssprache, Dauer und aktuellen Zulassungsstatus.', searchPlaceholder: 'Studiengang oder Universität suchen', noResults: 'Keine Studiengänge entsprechen diesen Filtern.', degree: 'Abschluss', discipline: 'Fachgebiet', languageFilter: 'Unterrichtssprache', statusFilter: 'Bewerbungsdaten', tuitionFilter: 'Gebührendaten', published: 'Veröffentlicht', unannounced: 'Nicht bekannt gegeben', known: 'Verfügbar', results: 'Studiengänge', verificationNote: 'Der öffentliche Katalog enthält nur geprüfte Einträge mit einer offiziellen Quelle auf Programmebene. Entwürfe sind von Produktionsseiten, Sitemap und strukturierten Daten ausgeschlossen.', overview: 'Programmdaten', cycle: 'Zulassungszeitraum', year: 'Studienjahr', intake: 'Studienbeginn', fee: 'Bewerbungsgebühr', opens: 'Öffnet am', sources: 'Offizielle Quellen und Prüfung', related: 'Weitere Studiengänge dieser Universität', university: 'Universität', faculty: 'Fakultät', qualification: 'Abschlussbezeichnung', studyMode: 'Studienform', languagePolicy: 'Sprachregelung', curriculum: 'Lehrplan-Schwerpunkte', eligibility: 'Zulassungsvoraussetzungen', materials: 'Bewerbungsunterlagen', campus: 'Campus', fullTime: 'Vollzeit', partTime: 'Teilzeit', hybrid: 'Hybrid', applicationStatus: 'Bewerbungsstatus', datePublished: 'Termine veröffentlicht', rolling: 'Laufende Zulassung', notAnnounced: 'Noch nicht bekannt gegeben', previousCycle: 'Referenz zum Vorjahr', applicationsClosed: 'Bewerbungsfrist abgelaufen', upcoming: 'Bald geöffnet', springIntake: 'Frühjahrsbeginn', autumnIntake: 'Herbstbeginn', otherIntake: 'Sonstiger Studienbeginn', tuitionProgram: 'pro Programm', tuitionSemester: 'pro Semester', tuitionAcademicYear: 'pro Studienjahr', tuitionMonth: 'pro Monat', tuitionOther: 'Offizielle Angaben beachten' }, scholarships: { title: 'Stipendien', intro: 'Informieren Sie sich über Leistungen, Bewerbungswege und offizielle Links.', coverage: 'Leistungsumfang', stipend: 'Monatlicher Zuschuss', catalogueNotice: 'Der öffentliche Katalog zeigt nur anhand offizieller Quellen geprüfte Stipendien; ein Eintrag bedeutet nicht zwingend, dass Bewerbungen geöffnet sind.', tuition: 'Studiengebühren', accommodation: 'Unterkunft', insurance: 'Krankenversicherung', scope: 'Teilnehmende Universitäten', sources: 'Quellen und Prüfung', included: 'Enthalten', notIncluded: 'Nicht enthalten' }, @@ -176,7 +176,7 @@ const fr: BaseMessages = { shell: { brandTagline: 'Atlas étudiant indépendant', navLabel: 'Navigation principale', mobileMenuLabel: 'Ouvrir le menu', skipLinkLabel: 'Aller au contenu principal', footerExplore: 'Explorer', footerProject: 'Projet', dataRelease: 'Version des données', creatorPrefix: 'Créé par' }, nav: { home: 'Accueil', universities: 'Universités', programs: 'Programmes', scholarships: 'Bourses', cities: 'Villes', guides: 'Guides', favorites: 'Favoris', about: 'À propos', contact: 'Contact' }, common: { explore: 'Explorer', viewDetails: 'Voir les détails', applyOfficial: 'Candidater sur le site officiel', officialSource: 'Source officielle', verified: 'Vérifié', stale: 'À vérifier', draft: 'En cours de vérification', archived: 'Archivé', lastVerified: 'Vérifié le', unknown: 'Non annoncé', all: 'Tous', search: 'Rechercher', clear: 'Effacer les filtres', save: 'Enregistrer', saved: 'Enregistré', compare: 'Comparer', print: 'Imprimer / enregistrer en PDF', back: 'Retour', university: 'Université', program: 'Programme', scholarship: 'Bourse', city: 'Ville', status: 'Statut', openNow: 'Ouvert maintenant', deadline: 'Date limite', tuition: 'Frais de scolarité', duration: 'Durée', months: 'mois', language: 'Langue d’enseignement', requirements: 'Exigences linguistiques', translationPending: 'Traduction en attente', authoritativeNotice: 'Les informations d’admission peuvent changer. Vérifiez toujours la page officielle liée.', automatedCollectionNotice: 'Les informations sont collectées par un système automatisé et mises à jour régulièrement ; les conditions de candidature, les frais et les dates limites sont ceux indiqués sur les sites officiels de l’université ou de l’organisme de bourse.', sourcesLastChecked: 'Dernière vérification des sources officielles', reportInformationError: 'Signaler une information erronée' }, - home: { eyebrow: 'Un parcours plus clair pour étudier en Chine', title: 'Comparez les programmes. Suivez les dates. Choisissez sereinement.', intro: 'Un guide sourcé des universités chinoises, bourses, voies de candidature et villes étudiantes.', findUniversity: 'Trouver une université', explorePrograms: 'Explorer les programmes', checkScholarships: 'Consulter les bourses', learnCities: 'Découvrir les villes', featured: 'Commencer par des informations fiables', featuredIntro: 'Consultez une première sélection structurée avec sources et dates de vérification visibles.', deadlineTitle: 'Périodes de candidature à suivre', disciplines: 'Explorer par domaine', cityTitle: 'Douze villes étudiantes, un même parcours', guideTitle: 'Votre candidature, étape par étape', programVerificationNote: 'Les programmes sont vérifiés un par un. Les brouillons sans page officielle ne sont ni publiés ni indexés.', sourceHeading: 'Chaque information doit mener à sa source' }, + home: { eyebrow: 'Un parcours plus clair pour étudier en Chine', title: 'Comparez les programmes. Suivez les dates. Choisissez sereinement.', intro: 'Un guide sourcé des universités chinoises, bourses, voies de candidature et villes étudiantes.', findUniversity: 'Trouver une université', explorePrograms: 'Explorer les programmes', checkScholarships: 'Consulter les bourses', learnCities: 'Découvrir les villes', featured: 'Commencer par des informations fiables', featuredIntro: 'Consultez une première sélection structurée avec sources et dates de vérification visibles.', deadlineTitle: 'Périodes de candidature à suivre', disciplines: 'Explorer par domaine', cityTitle: 'Villes étudiantes en Chine', guideTitle: 'Votre candidature, étape par étape', programVerificationNote: 'Les programmes sont vérifiés un par un. Les brouillons sans page officielle ne sont ni publiés ni indexés.', sourceHeading: 'Chaque information doit mener à sa source' }, universities: { title: 'Universités', intro: 'Filtrez une sélection nationale par ville, région et domaine d’études.', searchPlaceholder: 'Rechercher une université, une ville ou un domaine', noResults: 'Aucune université ne correspond à ces filtres.', cityFilter: 'Ville', regionFilter: 'Région', fieldFilter: 'Domaine', results: 'universités', programs: 'Programmes à explorer', funding: 'Bourses associées', facts: 'L’université en bref', sources: 'Sources et vérification', review: 'Révision prévue', official: 'Site de l’université', admission: 'Admissions internationales' }, programs: { title: 'Programmes', intro: 'Comparez le niveau, la langue d’enseignement, la durée et le statut des admissions.', searchPlaceholder: 'Rechercher un programme ou une université', noResults: 'Aucun programme ne correspond à ces filtres.', degree: 'Niveau de diplôme', discipline: 'Domaine', languageFilter: 'Langue d’enseignement', statusFilter: 'Dates de candidature', tuitionFilter: 'Données sur les frais', published: 'Publiées', unannounced: 'Non annoncées', known: 'Disponibles', results: 'programmes', verificationNote: 'Le catalogue public n’accepte que les fiches vérifiées avec une source officielle au niveau du programme. Les brouillons sont exclus des pages publiques, du plan du site et des données structurées.', overview: 'Informations sur le programme', cycle: 'Cycle d’admission', year: 'Année universitaire', intake: 'Rentrée', fee: 'Frais de dossier', opens: 'Ouverture', sources: 'Sources officielles et vérification', related: 'Autres programmes de cette université', university: 'Université', faculty: 'Faculté', qualification: 'Diplôme obtenu', studyMode: 'Mode d’études', languagePolicy: 'Politique linguistique', curriculum: 'Points forts du cursus', eligibility: 'Conditions d’admission', materials: 'Pièces de candidature', campus: 'Campus', fullTime: 'Temps plein', partTime: 'Temps partiel', hybrid: 'Hybride', applicationStatus: 'Statut de candidature', datePublished: 'Dates publiées', rolling: 'Admissions continues', notAnnounced: 'Non annoncé', previousCycle: 'Référence au cycle précédent', applicationsClosed: 'Candidatures closes', upcoming: 'Ouverture prochaine', springIntake: 'Rentrée de printemps', autumnIntake: 'Rentrée d’automne', otherIntake: 'Autre rentrée', tuitionProgram: 'par programme', tuitionSemester: 'par semestre', tuitionAcademicYear: 'par année universitaire', tuitionMonth: 'par mois', tuitionOther: 'Voir les informations officielles' }, scholarships: { title: 'Bourses', intro: 'Comprenez la couverture, les voies d’éligibilité et les liens de candidature officiels.', coverage: 'Couverture', stipend: 'Allocation mensuelle', catalogueNotice: 'Le catalogue public ne présente que les bourses vérifiées auprès de sources officielles ; une fiche ne signifie pas forcément que les candidatures sont ouvertes.', tuition: 'Frais de scolarité', accommodation: 'Hébergement', insurance: 'Assurance médicale', scope: 'Universités participantes', sources: 'Sources et vérification', included: 'Inclus', notIncluded: 'Non inclus' }, @@ -214,7 +214,7 @@ const es: BaseMessages = { shell: { brandTagline: 'Atlas estudiantil independiente', navLabel: 'Navegación principal', mobileMenuLabel: 'Abrir menú', skipLinkLabel: 'Saltar al contenido principal', footerExplore: 'Explorar', footerProject: 'Proyecto', dataRelease: 'Versión de datos', creatorPrefix: 'Creado por' }, nav: { home: 'Inicio', universities: 'Universidades', programs: 'Programas', scholarships: 'Becas', cities: 'Ciudades', guides: 'Guías', favorites: 'Guardados', about: 'Acerca de', contact: 'Contacto' }, common: { explore: 'Explorar', viewDetails: 'Ver detalles', applyOfficial: 'Solicitar en el sitio oficial', officialSource: 'Fuente oficial', verified: 'Verificado', stale: 'Revisión pendiente', draft: 'En verificación', archived: 'Archivado', lastVerified: 'Verificado el', unknown: 'No anunciado', all: 'Todos', search: 'Buscar', clear: 'Borrar filtros', save: 'Guardar', saved: 'Guardado', compare: 'Comparar', print: 'Imprimir / guardar PDF', back: 'Volver', university: 'Universidad', program: 'Programa', scholarship: 'Beca', city: 'Ciudad', status: 'Estado', openNow: 'Abierto ahora', deadline: 'Fecha límite', tuition: 'Matrícula', duration: 'Duración', months: 'meses', language: 'Idioma de enseñanza', requirements: 'Requisitos de idioma', translationPending: 'Traducción pendiente', authoritativeNotice: 'La información de admisión puede cambiar. Confírmala siempre en la página oficial enlazada.', automatedCollectionNotice: 'La información se recopila mediante un sistema automatizado y se actualiza periódicamente; los requisitos de solicitud, las tasas y los plazos están sujetos a los sitios web oficiales de la universidad o de la entidad de la beca.', sourcesLastChecked: 'Última comprobación de las fuentes oficiales', reportInformationError: 'Informar de información incorrecta' }, - home: { eyebrow: 'Un camino más claro para estudiar en China', title: 'Compara programas. Sigue los plazos. Elige con confianza.', intro: 'Una guía con fuentes sobre universidades chinas, becas, vías de solicitud y ciudades estudiantiles.', findUniversity: 'Buscar una universidad', explorePrograms: 'Explorar programas', checkScholarships: 'Consultar becas', learnCities: 'Conocer ciudades', featured: 'Empieza con información fiable', featuredIntro: 'Consulta una primera selección estructurada con fuentes y fechas de revisión visibles.', deadlineTitle: 'Periodos de solicitud importantes', disciplines: 'Explorar por área', cityTitle: 'Doce ciudades estudiantiles, un viaje conectado', guideTitle: 'Tu solicitud, paso a paso', programVerificationNote: 'Los programas se verifican uno por uno. Los borradores sin una página oficial no se publican ni se indexan.', sourceHeading: 'Cada dato debe conducir a su fuente' }, + home: { eyebrow: 'Un camino más claro para estudiar en China', title: 'Compara programas. Sigue los plazos. Elige con confianza.', intro: 'Una guía con fuentes sobre universidades chinas, becas, vías de solicitud y ciudades estudiantiles.', findUniversity: 'Buscar una universidad', explorePrograms: 'Explorar programas', checkScholarships: 'Consultar becas', learnCities: 'Conocer ciudades', featured: 'Empieza con información fiable', featuredIntro: 'Consulta una primera selección estructurada con fuentes y fechas de revisión visibles.', deadlineTitle: 'Periodos de solicitud importantes', disciplines: 'Explorar por área', cityTitle: 'Ciudades estudiantiles de China', guideTitle: 'Tu solicitud, paso a paso', programVerificationNote: 'Los programas se verifican uno por uno. Los borradores sin una página oficial no se publican ni se indexan.', sourceHeading: 'Cada dato debe conducir a su fuente' }, universities: { title: 'Universidades', intro: 'Filtra una selección nacional por ciudad, región y área académica.', searchPlaceholder: 'Buscar universidad, ciudad o área', noResults: 'Ninguna universidad coincide con estos filtros.', cityFilter: 'Ciudad', regionFilter: 'Región', fieldFilter: 'Área', results: 'universidades', programs: 'Programas disponibles', funding: 'Becas relacionadas', facts: 'La universidad de un vistazo', sources: 'Fuentes y revisión', review: 'Próxima revisión', official: 'Sitio de la universidad', admission: 'Admisiones internacionales' }, programs: { title: 'Programas', intro: 'Compara nivel, idioma de enseñanza, duración y estado actual de admisión.', searchPlaceholder: 'Buscar programa o universidad', noResults: 'Ningún programa coincide con estos filtros.', degree: 'Nivel académico', discipline: 'Área', languageFilter: 'Idioma de enseñanza', statusFilter: 'Fechas de solicitud', tuitionFilter: 'Datos de matrícula', published: 'Publicadas', unannounced: 'No anunciadas', known: 'Disponibles', results: 'programas', verificationNote: 'El catálogo público solo acepta registros verificados con una fuente oficial a nivel de programa. Los borradores se excluyen de las páginas públicas, el mapa del sitio y los datos estructurados.', overview: 'Datos del programa', cycle: 'Ciclo de admisión', year: 'Año académico', intake: 'Convocatoria', fee: 'Tasa de solicitud', opens: 'Apertura', sources: 'Fuentes oficiales y revisión', related: 'Otros programas de esta universidad', university: 'Universidad', faculty: 'Facultad', qualification: 'Titulación', studyMode: 'Modalidad de estudio', languagePolicy: 'Política lingüística', curriculum: 'Aspectos destacados del plan', eligibility: 'Requisitos de acceso', materials: 'Documentos de solicitud', campus: 'Campus', fullTime: 'Tiempo completo', partTime: 'Tiempo parcial', hybrid: 'Híbrido', applicationStatus: 'Estado de solicitud', datePublished: 'Fechas publicadas', rolling: 'Admisión continua', notAnnounced: 'No anunciado', previousCycle: 'Referencia del ciclo anterior', applicationsClosed: 'Solicitudes cerradas', upcoming: 'Próxima apertura', springIntake: 'Ingreso de primavera', autumnIntake: 'Ingreso de otoño', otherIntake: 'Otro ingreso', tuitionProgram: 'por programa', tuitionSemester: 'por semestre', tuitionAcademicYear: 'por año académico', tuitionMonth: 'por mes', tuitionOther: 'Consulta la información oficial' }, scholarships: { title: 'Becas', intro: 'Consulta la cobertura, las vías de elegibilidad y los enlaces oficiales de solicitud.', coverage: 'Cobertura', stipend: 'Estipendio mensual', catalogueNotice: 'El catálogo público solo muestra becas verificadas con fuentes oficiales; que aparezca una beca no significa necesariamente que la convocatoria esté abierta.', tuition: 'Matrícula', accommodation: 'Alojamiento', insurance: 'Seguro médico', scope: 'Universidades participantes', sources: 'Fuentes y revisión', included: 'Incluido', notIncluded: 'No incluido' }, diff --git a/src/i18n/navigation-experience.ts b/src/i18n/navigation-experience.ts new file mode 100644 index 0000000..1f88ba4 --- /dev/null +++ b/src/i18n/navigation-experience.ts @@ -0,0 +1,11 @@ +import type { BetaLocale } from './config' + +const betaContentFallbackNotices: Record = { + de: 'Hinweis: Die Oberfläche ist auf Deutsch verfügbar; einzelne geprüfte Datentexte können auf Englisch erscheinen, wenn noch keine geprüfte Übersetzung vorliegt.', + fr: 'Remarque : l’interface est disponible en français ; certains textes de données vérifiées peuvent apparaître en anglais lorsqu’aucune traduction révisée n’est encore disponible.', + es: 'Nota: la interfaz está disponible en español; algunos textos de datos verificados pueden aparecer en inglés cuando todavía no exista una traducción revisada.', +} + +export function betaContentFallbackNotice(locale: BetaLocale): string { + return betaContentFallbackNotices[locale] +} diff --git a/src/lib/catalog-api/runtime.ts b/src/lib/catalog-api/runtime.ts index 8aa0883..977e1bf 100644 --- a/src/lib/catalog-api/runtime.ts +++ b/src/lib/catalog-api/runtime.ts @@ -3,9 +3,27 @@ import { createCatalogRepository, getCatalogRecordCounts, type CatalogRepository import { getTodayDate } from '@/lib/data/freshness' import { CatalogApiService } from './service' import { selectCatalogApiData } from './projection' +import { AUTOMATED_COLLECTION_NOTICE, type ApiEnvelope, type ReleaseInfo } from './types' let repository: CatalogRepository | undefined +const DEPLOYMENT_SHA_PATTERN = /^[a-f0-9]{40}$/u + +export function deploymentShaFromEnvironment(value = process.env.VERCEL_GIT_COMMIT_SHA): string | null { + const candidate = value?.trim() + return candidate && DEPLOYMENT_SHA_PATTERN.test(candidate) ? candidate : null +} + +async function operationalRelease(activeRepository: CatalogRepository) { + return activeRepository.getOperationalRelease + ? activeRepository.getOperationalRelease() + : activeRepository.getRelease() +} + +function releaseInfo(release: Awaited>, mode: CatalogRepository['mode']): ReleaseInfo { + return { ...release, catalogBackend: mode, deploymentSha: deploymentShaFromEnvironment() } +} + function getRepository() { repository ??= createCatalogRepository() return repository @@ -15,17 +33,53 @@ export async function getCatalogApiService(): Promise { const activeRepository = getRepository() const [rawBundle, release] = await Promise.all([ activeRepository.getBundle(), - activeRepository.getRelease(), + operationalRelease(activeRepository), ]) const today = getTodayDate() const publicBundle = selectCatalogApiData(rawBundle, today) + const isJson = activeRepository.mode === 'json' + const rawCounts = isJson ? getCatalogRecordCounts(rawBundle) : release.rawCounts + const publicCounts = isJson ? getCatalogRecordCounts(publicBundle) : release.publicCounts + return new CatalogApiService(publicBundle, { - ...release, - recordCounts: getCatalogRecordCounts(publicBundle), + ...releaseInfo(release, activeRepository.mode), + recordCounts: publicCounts, + rawCounts, + publicCounts, }, today) } +export async function getCurrentCatalogRelease(): Promise> { + const activeRepository = getRepository() + const operational = await operationalRelease(activeRepository) + let release = releaseInfo(operational, activeRepository.mode) + if (activeRepository.mode === 'json') { + const evaluatedForDate = getTodayDate() + const rawBundle = await activeRepository.getBundle() + const publicBundle = selectCatalogApiData(rawBundle, evaluatedForDate) + const rawCounts = getCatalogRecordCounts(rawBundle) + const publicCounts = getCatalogRecordCounts(publicBundle) + release = { + ...release, + recordCounts: publicCounts, + rawCounts, + publicCounts, + evaluatedForDate, + } + } + return { + data: release, + meta: { release, notice: AUTOMATED_COLLECTION_NOTICE }, + } +} + +export async function compareCatalogPrograms(ids: string[]) { + const activeRepository = getRepository() + if (activeRepository.comparePrograms) return activeRepository.comparePrograms(ids) + return (await getCatalogApiService()).comparePrograms(ids) +} + export function resetCatalogApiRepositoryForTests() { repository = undefined } diff --git a/src/lib/catalog-api/service.ts b/src/lib/catalog-api/service.ts index 2b27406..86e60ba 100644 --- a/src/lib/catalog-api/service.ts +++ b/src/lib/catalog-api/service.ts @@ -1,4 +1,5 @@ -import { getApplicationState } from '@/lib/data/admission' +import { getApplicationState, selectAdmissionCycle } from '@/lib/data/admission' +import { getTodayDate } from '@/lib/data/freshness' import { classifyProgramField, isProgramField, programSearchKeywords } from '@/lib/data/fields' import { canonicalUniversitySlug } from '@/lib/data/slug-aliases' import type { @@ -173,7 +174,7 @@ export class CatalogApiService { constructor( private readonly bundle: DataBundle, private readonly release: ReleaseInfo, - private readonly today = new Date().toISOString().slice(0, 10), + private readonly today = getTodayDate(), ) {} private envelope( @@ -459,6 +460,43 @@ export class CatalogApiService { return this.envelope(cycles) } + comparePrograms(ids: string[]): ApiEnvelope<{ + items: Array<{ + program: ProgramRecord + currentCycle: AdmissionCycleRecord | null + linkedScholarshipCount: number + }> + missingIds: string[] + }> { + const uniqueIds = [...new Set(ids)] + const programsById = new Map(this.bundle.programs.map((program) => [program.id, program])) + const currentCycles = this.bundle.admissionCycles.filter( + (cycle) => hasCurrentFacts(cycle, this.today), + ) + const items = uniqueIds.flatMap((id) => { + const program = programsById.get(id) + if (!program) return [] + const record = this.programRecord(program) + if (!record) return [] + const currentCycle = selectAdmissionCycle(currentCycles, program.id, this.today) + const linkedScholarshipCount = this.bundle.scholarships.filter((scholarship) => ( + hasCurrentFacts(scholarship, this.today) + && (scholarship.programIds.includes(program.id) + || scholarship.universityIds.includes(program.universityId)) + )).length + return [{ + program: record, + currentCycle: currentCycle ? this.cycleRecord(currentCycle, program) : null, + linkedScholarshipCount, + }] + }) + const returnedIds = new Set(items.map((item) => item.program.id)) + return this.envelope({ + items, + missingIds: uniqueIds.filter((id) => !returnedIds.has(id)), + }) + } + listScholarships(query: ScholarshipQuery = {}): ApiEnvelope { const filtered = this.bundle.scholarships.filter((scholarship) => { const factsAreCurrent = hasCurrentFacts(scholarship, this.today) @@ -525,19 +563,45 @@ export class CatalogApiService { } } -export function releaseFromBundle(bundle: DataBundle, dataDate: string): ReleaseInfo { +type ReleaseFromBundleOptions = { + rawBundle?: DataBundle + dataCheckedThrough?: string + evaluatedForDate?: string + activatedAt?: string + catalogBackend?: ReleaseInfo['catalogBackend'] + deploymentSha?: string | null +} + +function releaseRecordCounts(bundle: DataBundle): ReleaseInfo['recordCounts'] { + return { + sources: bundle.sources.length, + cities: bundle.cities.length, + universities: bundle.universities.length, + programs: bundle.programs.length, + admissionCycles: bundle.admissionCycles.length, + scholarships: bundle.scholarships.length, + } +} + +export function releaseFromBundle( + bundle: DataBundle, + dataDate: string, + options: ReleaseFromBundleOptions = {}, +): ReleaseInfo { + const generatedAt = `${dataDate}T00:00:00.000Z` + const publicCounts = releaseRecordCounts(bundle) return { id: `json:${dataDate}`, dataDate, - generatedAt: `${dataDate}T00:00:00.000Z`, - recordCounts: { - sources: bundle.sources.length, - cities: bundle.cities.length, - universities: bundle.universities.length, - programs: bundle.programs.length, - admissionCycles: bundle.admissionCycles.length, - scholarships: bundle.scholarships.length, - }, + generatedAt, + recordCounts: publicCounts, + rawCounts: releaseRecordCounts(options.rawBundle ?? bundle), + publicCounts, + dataCheckedThrough: options.dataCheckedThrough ?? dataDate, + evaluatedForDate: options.evaluatedForDate ?? getTodayDate(), + activatedAt: options.activatedAt ?? generatedAt, + catalogBackend: options.catalogBackend ?? 'json', + deploymentSha: options.deploymentSha ?? null, } } diff --git a/src/lib/catalog-api/types.ts b/src/lib/catalog-api/types.ts index 8c74289..8f43465 100644 --- a/src/lib/catalog-api/types.ts +++ b/src/lib/catalog-api/types.ts @@ -27,18 +27,28 @@ export type OfficialSourceLink = { checkedAt: string } +export type ReleaseRecordCounts = { + sources: number + cities: number + universities: number + programs: number + admissionCycles: number + scholarships: number +} + export type ReleaseInfo = { id: string dataDate: string generatedAt: string - recordCounts: { - sources: number - cities: number - universities: number - programs: number - admissionCycles: number - scholarships: number - } + // Deprecated compatibility alias of publicCounts. + recordCounts: ReleaseRecordCounts + rawCounts: ReleaseRecordCounts + publicCounts: ReleaseRecordCounts + dataCheckedThrough: string + evaluatedForDate: string + activatedAt: string + catalogBackend: 'json' | 'shadow' | 'd1' + deploymentSha: string | null } export type ProgramType = diff --git a/src/lib/catalog/d1-compare.ts b/src/lib/catalog/d1-compare.ts new file mode 100644 index 0000000..fde154e --- /dev/null +++ b/src/lib/catalog/d1-compare.ts @@ -0,0 +1,385 @@ +import type { + AdmissionCycleRecord, + ApiEnvelope, + FactStatus, + FieldMeta, + OfficialSourceLink, + ProgramRecord, +} from '@/lib/catalog-api/types' +import type { DegreeLevel, Discipline, LocalizedText } from '@/lib/data/types' +import { CatalogRepositoryError } from './types' +import { parseCatalogReleaseInfo } from './release' + +type UnknownRecord = Record + +export type CatalogProgramComparison = ApiEnvelope<{ + items: Array<{ + program: ProgramRecord + currentCycle: AdmissionCycleRecord | null + linkedScholarshipCount: number + }> + missingIds: string[] +}> + +const FACT_STATUSES = new Set([ + 'known', + 'officially_not_announced', + 'not_applicable', + 'source_unavailable', + 'conflict', + 'stale', +]) +const APPLICATION_STATES = new Set([ + 'open', + 'upcoming', + 'closed', + 'rolling', + 'dates-published', + 'not-announced', + 'previous-cycle', +]) +const DISCIPLINES = new Set([ + 'engineering', + 'business', + 'medicine', + 'chinese-education', + 'humanities', + 'law-ir', + 'science', + 'art-design', + 'other', +]) + +function isObject(value: unknown): value is UnknownRecord { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function invalid(message: string): never { + throw new CatalogRepositoryError('INVALID_COMPARE_RESPONSE', message) +} + +function text(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +function dateOnly(value: unknown, fallback: string): string { + const candidate = text(value)?.slice(0, 10) + return candidate && /^\d{4}-\d{2}-\d{2}$/u.test(candidate) ? candidate : fallback +} + +function safeHttps(value: unknown): string | null { + const candidate = text(value) + if (!candidate) return null + try { + const url = new URL(candidate) + return url.protocol === 'https:' && !url.username && !url.password ? url.toString() : null + } catch { + return null + } +} + +function localized(value: unknown, fallback: string): LocalizedText { + if (!isObject(value)) return { en: fallback } + const entries = Object.entries(value).filter((entry): entry is [string, string] => ( + typeof entry[1] === 'string' && entry[1].length > 0 + )) + return entries.length > 0 ? Object.fromEntries(entries) : { en: fallback } +} + +function sources(value: unknown, fallbackUrl: string, today: string): OfficialSourceLink[] { + const parsed = Array.isArray(value) ? value.flatMap((source) => { + if (!isObject(source)) return [] + const url = safeHttps(source.url) + if (!url) return [] + return [{ + url, + title: text(source.title) ?? 'Official source', + checkedAt: dateOnly(source.checkedAt, today), + }] + }) : [] + return parsed.length > 0 + ? parsed + : [{ url: fallbackUrl, title: 'Official program source', checkedAt: today }] +} + +function sourceIds(value: unknown): string[] { + return Array.isArray(value) ? [...new Set(value.flatMap((source) => { + if (!isObject(source)) return [] + const id = text(source.id) + return id ? [id] : [] + }))].sort() : [] +} + +function fieldMeta( + value: unknown, + fallbackUrl: string, + fallbackTitle: string, + today: string, +): FieldMeta { + const record = isObject(value) ? value : {} + const status = FACT_STATUSES.has(record.status as FactStatus) + ? record.status as FactStatus + : 'source_unavailable' + return { + status, + officialUrl: safeHttps(record.officialUrl) ?? fallbackUrl, + sourceTitle: text(record.sourceTitle) ?? fallbackTitle, + checkedAt: dateOnly(record.checkedAt, today), + } +} + +function fields( + value: unknown, + fallbackUrl: string, + fallbackTitle: string, + today: string, +): Record { + if (!isObject(value)) return {} + return Object.fromEntries(Object.entries(value).map(([key, item]) => [ + key, + fieldMeta(item, fallbackUrl, fallbackTitle, today), + ])) +} + +function aliasFields( + source: Record, + aliases: Readonly>, +): Record { + const result = { ...source } + for (const [target, candidates] of Object.entries(aliases)) { + const match = candidates.map((candidate) => source[candidate]).find(Boolean) + if (match) result[target] = match + } + return result +} + +function audit( + rawMeta: unknown, + officialSources: OfficialSourceLink[], + today: string, +) { + const meta = isObject(rawMeta) ? rawMeta : {} + const identity = isObject(meta.name) ? meta.name : {} + const verifiedAt = dateOnly(identity.verifiedAt ?? identity.checkedAt ?? officialSources[0]?.checkedAt, today) + const reviewAfter = dateOnly(identity.reviewAfter, today) + return { + verifiedAt, + reviewAfter, + status: reviewAfter < today ? 'stale' as const : 'verified' as const, + } +} + +function durationMonths(value: unknown, unit: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null + const factor = unit === 'academic_years' ? 12 : unit === 'semesters' ? 6 : unit === 'months' ? 1 : null + return factor === null ? null : Math.min(120, Math.max(1, Math.round(value * factor))) +} + +function degreeLevel(value: unknown, programType: unknown): DegreeLevel { + if (value === 'bachelor' || value === 'master' || value === 'doctorate') return value + if (programType === 'language') return 'language' + if (programType === 'foundation') return 'foundation' + return 'other' +} + +function teachingLanguage(value: string): string { + const normalized = value.toLocaleLowerCase() + if (normalized === 'zh' || normalized === 'zho' || normalized === 'chinese') return 'Chinese' + if (normalized === 'en' || normalized === 'eng' || normalized === 'english') return 'English' + if (normalized === 'bilingual') return 'Bilingual' + return value +} + +function moneyCny(value: unknown): number | null { + if (!isObject(value) || value.currencyCode !== 'CNY') return null + const amount = typeof value.amountMinimumMinor === 'number' + ? value.amountMinimumMinor + : typeof value.amountMaximumMinor === 'number' + ? value.amountMaximumMinor + : null + const exponent = Number.isInteger(value.currencyExponent) ? Number(value.currencyExponent) : 2 + return amount === null || exponent < 0 || exponent > 6 ? null : amount / (10 ** exponent) +} + +function tuitionPeriod(value: unknown): AdmissionCycleRecord['tuitionPeriod'] { + if (value === 'program' || value === 'semester' || value === 'month' || value === 'other') return value + if (value === 'academic_year') return 'academic-year' + return null +} + +function cycleRecord( + value: unknown, + programId: string, + programUrl: string, + today: string, +): { record: AdmissionCycleRecord | null; applyUrl: string | null } { + if (value === null || value === undefined) return { record: null, applyUrl: null } + if (!isObject(value)) invalid('Catalog compare cycle must be an object or null.') + const attributes = isObject(value.attributes) ? value.attributes : {} + const application = isObject(attributes.application) ? attributes.application : {} + const id = text(value.id) + const academicYear = text(attributes.academicYear) + if (!id || !academicYear || !/^\d{4}-\d{4}$/u.test(academicYear)) { + invalid('Catalog compare cycle identity is invalid.') + } + const state = APPLICATION_STATES.has(application.state as AdmissionCycleRecord['applicationState']) + ? application.state as AdmissionCycleRecord['applicationState'] + : 'not-announced' + const intake = attributes.intake === 'spring' || attributes.intake === 'autumn' + ? attributes.intake + : 'other' + const officialSources = sources(value.sources, programUrl, today) + const rawFields = fields(value.fieldMeta, programUrl, officialSources[0]!.title, today) + const mappedFields = aliasFields(rawFields, { + opensOn: ['application.opensOn'], + closesOn: ['application.closesOn'], + dateStatus: ['application.state', 'application.rolling'], + tuitionCny: ['tuition'], + tuitionPeriod: ['tuition'], + tuitionStatus: ['tuition'], + applicationFeeCny: ['applicationFee'], + }) + const tuitionCny = moneyCny(attributes.tuition) + const applicationFeeCny = moneyCny(attributes.applicationFee) + const record: AdmissionCycleRecord = { + ...audit(value.fieldMeta, officialSources, today), + id, + programId, + academicYear, + intake, + opensOn: text(application.opensOn), + closesOn: text(application.closesOn), + dateStatus: state === 'rolling' + ? 'rolling' + : state === 'not-announced' + ? 'not-announced' + : state === 'previous-cycle' + ? 'previous-cycle-reference' + : 'published', + tuitionCny, + tuitionPeriod: tuitionPeriod(isObject(attributes.tuition) ? attributes.tuition.period : null), + tuitionStatus: tuitionCny === null ? null : 'confirmed', + evidenceBasis: null, + applicationFeeCny, + applicationState: state, + sourceIds: sourceIds(value.sources), + officialSources, + fieldMeta: mappedFields, + } + return { record, applyUrl: safeHttps(application.applyUrl) } +} + +function programItem( + value: unknown, + today: string, +): CatalogProgramComparison['data']['items'][number] | null { + if (!isObject(value) || !isObject(value.program)) invalid('Catalog compare item is invalid.') + const rawProgram = value.program + const attributes = isObject(rawProgram.attributes) ? rawProgram.attributes : {} + const relationships = isObject(rawProgram.relationships) ? rawProgram.relationships : {} + const institution = isObject(relationships.institution) ? relationships.institution : null + const id = text(rawProgram.id) + const slug = text(rawProgram.slug) + const institutionId = institution ? text(institution.id) : null + const institutionSlug = institution ? text(institution.slug) : null + const programUrl = safeHttps(attributes.officialUrl) + if (!id || !slug || !institutionId || !institutionSlug || !programUrl) return null + + const officialSources = sources(rawProgram.sources, programUrl, today) + const rawFields = fields(rawProgram.fieldMeta, programUrl, officialSources[0]!.title, today) + const mappedFields = aliasFields(rawFields, { + universityId: ['institution'], + programUrl: ['officialUrl'], + discipline: ['disciplineCodes'], + teachingLanguages: ['teachingLanguageCodes'], + durationMonths: ['duration.minimum'], + durationMonthsMax: ['duration.maximum'], + }) + const duration = isObject(attributes.duration) ? attributes.duration : {} + const codes = Array.isArray(attributes.disciplineCodes) + ? attributes.disciplineCodes.filter((item): item is string => typeof item === 'string') + : [] + const discipline = codes.find((code): code is Discipline => DISCIPLINES.has(code as Discipline)) ?? null + const programType = attributes.programType === 'degree' + || attributes.programType === 'language' + || attributes.programType === 'foundation' + || attributes.programType === 'exchange' + || attributes.programType === 'visiting' + || attributes.programType === 'short_term' + || attributes.programType === 'other' + ? attributes.programType + : 'other' + const current = cycleRecord(value.currentCycle, id, programUrl, today) + const applyUrl = safeHttps(attributes.applyUrl) ?? current.applyUrl + const languages = Array.isArray(attributes.teachingLanguageCodes) + ? attributes.teachingLanguageCodes + .filter((item): item is string => typeof item === 'string' && item.length > 0) + .map(teachingLanguage) + : null + const minimumDuration = durationMonths(duration.minimum, duration.unit) + const maximumDuration = durationMonths(duration.maximum, duration.unit) + const programSourceIds = sourceIds(rawProgram.sources) + + const program: ProgramRecord = { + ...audit(rawProgram.fieldMeta, officialSources, today), + id, + slug, + universityId: institutionId, + name: localized(attributes.name, slug), + degreeLevel: degreeLevel(attributes.degreeLevel, programType), + discipline, + teachingLanguages: languages, + durationMonths: minimumDuration, + durationMonthsMax: maximumDuration, + programUrl, + applyUrl, + languageRequirements: null, + verificationScope: 'identity', + details: null, + sourceIds: programSourceIds, + programType, + university: { + id: institutionId, + slug: institutionSlug, + name: localized(institution?.name, institutionSlug), + }, + officialSources, + fieldMeta: mappedFields, + } + const linkedScholarshipCount = value.linkedScholarshipCount + if (!Number.isInteger(linkedScholarshipCount) || Number(linkedScholarshipCount) < 0) { + invalid('Catalog compare scholarship count is invalid.') + } + return { program, currentCycle: current.record, linkedScholarshipCount: Number(linkedScholarshipCount) } +} + +export function parseD1ProgramComparison( + value: unknown, + requestedIds: readonly string[], + today: string, +): CatalogProgramComparison { + if (!isObject(value) || !isObject(value.data) || !isObject(value.meta)) { + invalid('Catalog compare response must be an API envelope.') + } + if (!Array.isArray(value.data.items)) invalid('Catalog compare items are missing.') + const release = parseCatalogReleaseInfo(value.meta.release) + const requested = [...new Set(requestedIds)] + const requestedSet = new Set(requested) + const parsed = value.data.items.flatMap((item) => { + const result = programItem(item, today) + return result && requestedSet.has(result.program.id) ? [result] : [] + }) + const byId = new Map(parsed.map((item) => [item.program.id, item])) + const items = requested.flatMap((id) => byId.get(id) ? [byId.get(id)!] : []) + return { + data: { + items, + missingIds: requested.filter((id) => !byId.has(id)), + }, + meta: { + release, + notice: text(value.meta.notice) + ?? '信息由自动化系统收录并定期更新;申请条件、费用与截止日期以学校或奖学金官方网站实际情况为准。', + }, + } +} diff --git a/src/lib/catalog/d1.ts b/src/lib/catalog/d1.ts index 29d948e..1dff260 100644 --- a/src/lib/catalog/d1.ts +++ b/src/lib/catalog/d1.ts @@ -1,8 +1,9 @@ import { bundleSchema } from '@/lib/data/schema' import type { DataBundle } from '@/lib/data/types' import { getTodayDate } from '@/lib/data/freshness' +import { parseD1ProgramComparison } from './d1-compare' import { parseD1InstitutionList, parseD1ProgramList, parseD1ScholarshipList } from './d1-list' -import { deriveCatalogRelease, parseCatalogRelease } from './release' +import { deriveCatalogRelease, parseCatalogRelease, parseCatalogReleaseInfo } from './release' import { CATALOG_LIST_DEFAULT_LIMIT, CATALOG_LIST_MAX_LIMIT, @@ -138,6 +139,8 @@ export class D1CatalogRepository implements CatalogRepository { private readonly now: () => number private cached: { snapshot: CatalogSnapshot; expiresAt: number } | undefined private inFlight: Promise | undefined + private operationalReleaseCached: { release: CatalogRelease; expiresAt: number } | undefined + private operationalReleaseInFlight: Promise | undefined constructor(options: D1CatalogRepositoryOptions) { this.apiUrl = options.apiUrl.trim() @@ -195,6 +198,47 @@ export class D1CatalogRepository implements CatalogRepository { return (await this.getSnapshot()).release } + getOperationalRelease(): Promise { + const now = this.now() + if (this.operationalReleaseCached && this.operationalReleaseCached.expiresAt >= now) { + return Promise.resolve(this.operationalReleaseCached.release) + } + if (this.operationalReleaseInFlight) return this.operationalReleaseInFlight + this.operationalReleaseInFlight = this.fetchOperationalRelease() + .then((release) => { + this.operationalReleaseCached = { + release, + expiresAt: this.now() + this.cacheTtlMs, + } + return release + }) + .finally(() => { + this.operationalReleaseInFlight = undefined + }) + return this.operationalReleaseInFlight + } + + async comparePrograms(ids: string[]): Promise { + const uniqueIds = [...new Set(ids)] + if ( + uniqueIds.length < 1 + || uniqueIds.length > 4 + || uniqueIds.some((id) => !/^[a-z0-9][a-z0-9:_-]{0,199}$/u.test(id)) + ) { + throw new CatalogRepositoryError( + 'INVALID_COMPARE_IDS', + 'Program comparison requires between one and four valid program ids.', + ) + } + const url = this.publicEndpoint('programs/compare') + url.searchParams.set('ids', uniqueIds.join(',')) + return parseD1ProgramComparison( + await this.fetchListPayload(url), + uniqueIds, + getTodayDate(), + ) + } + async listInstitutions( query: CatalogInstitutionListQuery = {}, ): Promise { @@ -261,7 +305,14 @@ export class D1CatalogRepository implements CatalogRepository { ) } - private publicEndpoint(resource: 'institutions' | 'programs' | 'scholarships'): URL { + private publicEndpoint( + resource: + | 'institutions' + | 'programs' + | 'programs/compare' + | 'scholarships' + | 'releases/current', + ): URL { const url = new URL(this.parsedApiUrl) url.search = '' url.hash = '' @@ -323,6 +374,18 @@ export class D1CatalogRepository implements CatalogRepository { } } + private async fetchOperationalRelease(): Promise { + const payload = await this.fetchListPayload(this.publicEndpoint('releases/current')) + if (!isObject(payload) || !Object.hasOwn(payload, 'data')) { + throw new CatalogRepositoryError( + 'INVALID_RELEASE', + 'Catalog release endpoint did not return an API envelope.', + ) + } + const release = parseCatalogReleaseInfo(payload.data) + return { ...release, catalogBackend: 'd1' } + } + private getSnapshot(): Promise { const now = this.now() if (this.cached && this.cached.expiresAt >= now) return Promise.resolve(this.cached.snapshot) diff --git a/src/lib/catalog/json.ts b/src/lib/catalog/json.ts index 176c6ac..57ce4cc 100644 --- a/src/lib/catalog/json.ts +++ b/src/lib/catalog/json.ts @@ -5,6 +5,7 @@ import type { DataBundle } from '@/lib/data/types' import { getTodayDate, isCurrentVerifiedRecord } from '@/lib/data/freshness' import { classifyProgramField } from '@/lib/data/fields' import { selectCatalogApiData } from '@/lib/catalog-api/projection' +import { CatalogApiService } from '@/lib/catalog-api/service' import { selectPublishedData } from '@/lib/data/publication' import { parseProgramCatalogFilters, @@ -18,7 +19,7 @@ import { decodeJsonListCursor, encodeJsonListCursor, } from './list-cursor' -import { deriveCatalogRelease } from './release' +import { deriveCatalogRelease, getCatalogRecordCounts } from './release' import { CATALOG_LIST_DEFAULT_LIMIT, CATALOG_LIST_MAX_LIMIT, @@ -118,6 +119,32 @@ export class JsonCatalogRepository implements CatalogRepository { async getRelease(): Promise { return deriveCatalogRelease(await this.getBundle()) } + + async comparePrograms(ids: string[]): Promise { + const uniqueIds = [...new Set(ids)] + if ( + uniqueIds.length < 1 + || uniqueIds.length > 4 + || uniqueIds.some((id) => !/^[a-z0-9][a-z0-9:_-]{0,199}$/u.test(id)) + ) { + throw new CatalogRepositoryError( + 'INVALID_COMPARE_IDS', + 'Program comparison requires between one and four valid program ids.', + ) + } + const today = getTodayDate() + const rawBundle = await this.getBundle() + const publicBundle = selectCatalogApiData(rawBundle, today) + const release = deriveCatalogRelease(rawBundle) + const publicCounts = getCatalogRecordCounts(publicBundle) + return new CatalogApiService(publicBundle, { + ...release, + recordCounts: publicCounts, + rawCounts: getCatalogRecordCounts(rawBundle), + publicCounts, + }, today).comparePrograms(uniqueIds) + } + async listInstitutions( query: CatalogInstitutionListQuery = {}, ): Promise { diff --git a/src/lib/catalog/release.ts b/src/lib/catalog/release.ts index 823943e..50ca27b 100644 --- a/src/lib/catalog/release.ts +++ b/src/lib/catalog/release.ts @@ -8,6 +8,8 @@ import { } from './types' const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/ +const DEPLOYMENT_SHA_PATTERN = /^[a-f0-9]{40}$/u +const BACKENDS = new Set(['json', 'shadow', 'd1']) export function getCatalogRecordCounts(bundle: DataBundle): CatalogRecordCounts { return Object.fromEntries( @@ -17,12 +19,21 @@ export function getCatalogRecordCounts(bundle: DataBundle): CatalogRecordCounts export function deriveCatalogRelease(bundle: DataBundle, idPrefix = 'json'): CatalogRelease { const dataDate = getDataReleaseDate(bundle) + const generatedAt = `${dataDate}T00:00:00.000Z` + const counts = getCatalogRecordCounts(bundle) return { id: `${idPrefix}:${dataDate}`, dataDate, - generatedAt: `${dataDate}T00:00:00.000Z`, - recordCounts: getCatalogRecordCounts(bundle), + generatedAt, + recordCounts: counts, + rawCounts: counts, + publicCounts: counts, + dataCheckedThrough: dataDate, + evaluatedForDate: dataDate, + activatedAt: generatedAt, + catalogBackend: idPrefix === 'd1' ? 'd1' : 'json', + deploymentSha: null, } } @@ -30,6 +41,40 @@ function isObject(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value) } +function parseCounts(value: unknown, field: string): CatalogRecordCounts { + if (!isObject(value)) { + throw new CatalogRepositoryError('INVALID_RELEASE', `Catalog release ${field} is missing.`) + } + const parsed = {} as CatalogRecordCounts + for (const collection of CATALOG_COLLECTIONS) { + const count = value[collection] + if (!Number.isInteger(count) || (count as number) < 0) { + throw new CatalogRepositoryError( + 'INVALID_RELEASE', + `Catalog release ${field} count for ${collection} must be a non-negative integer.`, + ) + } + parsed[collection] = count as number + } + return parsed +} + +function releaseDate(value: unknown, fallback: string, field: string): string { + if (value === undefined) return fallback + if (typeof value !== 'string' || !DATE_PATTERN.test(value)) { + throw new CatalogRepositoryError('INVALID_RELEASE', `Catalog release ${field} must use YYYY-MM-DD.`) + } + return value +} + +function releaseTimestamp(value: unknown, fallback: string, field: string): string { + if (value === undefined) return fallback + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new CatalogRepositoryError('INVALID_RELEASE', `Catalog release ${field} must be an ISO timestamp.`) + } + return value +} + export function parseCatalogReleaseInfo(value: unknown): CatalogRelease { if (!isObject(value)) { throw new CatalogRepositoryError('INVALID_RELEASE', 'Catalog API release metadata is missing.') @@ -45,30 +90,52 @@ export function parseCatalogReleaseInfo(value: unknown): CatalogRelease { if (typeof generatedAt !== 'string' || Number.isNaN(Date.parse(generatedAt))) { throw new CatalogRepositoryError('INVALID_RELEASE', 'Catalog release generatedAt must be an ISO timestamp.') } - if (!isObject(recordCounts)) { - throw new CatalogRepositoryError('INVALID_RELEASE', 'Catalog release recordCounts is missing.') - } - const parsedCounts = {} as CatalogRecordCounts + const parsedRecordCounts = parseCounts(recordCounts, 'recordCounts') + const parsedPublicCounts = value.publicCounts === undefined + ? parsedRecordCounts + : parseCounts(value.publicCounts, 'publicCounts') + const parsedRawCounts = value.rawCounts === undefined + ? parsedRecordCounts + : parseCounts(value.rawCounts, 'rawCounts') for (const collection of CATALOG_COLLECTIONS) { - const count = recordCounts[collection] - if (!Number.isInteger(count) || (count as number) < 0) { + if (parsedRecordCounts[collection] !== parsedPublicCounts[collection]) { throw new CatalogRepositoryError( 'INVALID_RELEASE', - `Catalog release count for ${collection} must be a non-negative integer.`, + `Catalog release recordCounts must remain an alias of publicCounts for ${collection}.`, ) } - parsedCounts[collection] = count as number } - return { id, dataDate, generatedAt, recordCounts: parsedCounts } + const catalogBackend = value.catalogBackend ?? 'd1' + if (typeof catalogBackend !== 'string' || !BACKENDS.has(catalogBackend)) { + throw new CatalogRepositoryError('INVALID_RELEASE', 'Catalog release catalogBackend is invalid.') + } + const deploymentSha = value.deploymentSha ?? null + if (deploymentSha !== null && (typeof deploymentSha !== 'string' || !DEPLOYMENT_SHA_PATTERN.test(deploymentSha))) { + throw new CatalogRepositoryError('INVALID_RELEASE', 'Catalog release deploymentSha must be null or a 40-character lowercase hexadecimal SHA.') + } + + return { + id, + dataDate, + generatedAt, + recordCounts: parsedPublicCounts, + rawCounts: parsedRawCounts, + publicCounts: parsedPublicCounts, + dataCheckedThrough: releaseDate(value.dataCheckedThrough, dataDate, 'dataCheckedThrough'), + evaluatedForDate: releaseDate(value.evaluatedForDate, dataDate, 'evaluatedForDate'), + activatedAt: releaseTimestamp(value.activatedAt, generatedAt, 'activatedAt'), + catalogBackend: catalogBackend as CatalogRelease['catalogBackend'], + deploymentSha, + } } export function parseCatalogRelease(value: unknown, bundle: DataBundle): CatalogRelease { const release = parseCatalogReleaseInfo(value) const actualCounts = getCatalogRecordCounts(bundle) for (const collection of CATALOG_COLLECTIONS) { - const count = release.recordCounts[collection] + const count = release.publicCounts[collection] if (count !== actualCounts[collection]) { throw new CatalogRepositoryError( 'RELEASE_COUNT_MISMATCH', diff --git a/src/lib/catalog/shadow.ts b/src/lib/catalog/shadow.ts index 903ec3b..dca0d63 100644 --- a/src/lib/catalog/shadow.ts +++ b/src/lib/catalog/shadow.ts @@ -1,6 +1,7 @@ import type { DataBundle } from '@/lib/data/types' import { CATALOG_COLLECTIONS, + CatalogRepositoryError, type CatalogBackendMode, type CatalogCollection, type CatalogInstitutionListPage, @@ -20,6 +21,7 @@ import { export type CatalogShadowOperation = | 'getBundle' | 'getRelease' + | 'comparePrograms' | 'listInstitutions' | 'listPrograms' | 'listScholarships' @@ -287,6 +289,27 @@ function compareListPage( return collector } +function comparableProjection(value: unknown): unknown { + return isObject(value) && Object.hasOwn(value, 'data') ? value.data : value +} + +function compareProgramProjection( + primary: unknown, + shadow: unknown, + maxDifferences: number, +): DifferenceCollector { + const collector = new DifferenceCollector(maxDifferences) + compareValue( + collector, + 'programs', + 'compare', + '', + comparableProjection(primary), + comparableProjection(shadow), + ) + return collector +} + function cursorInputs( cursor: string | undefined, resource: 'institutions' | 'programs' | 'scholarships', @@ -376,6 +399,51 @@ export class ShadowCatalogRepository implements CatalogRepository { return primaryResult.value } + async getOperationalRelease(): Promise { + const [primaryResult, shadowResult] = await Promise.allSettled([ + this.primary.getRelease(), + this.shadow.getOperationalRelease + ? this.shadow.getOperationalRelease() + : this.shadow.getRelease(), + ]) + if (primaryResult.status === 'fulfilled' && shadowResult.status === 'fulfilled') { + await this.recordComparison( + 'getRelease', + compareRelease(primaryResult.value, shadowResult.value, this.maxDifferences), + ) + } else if (shadowResult.status === 'rejected') { + await this.recordShadowError('getRelease', shadowResult.reason) + if (primaryResult.status === 'rejected') throw primaryResult.reason + return { ...primaryResult.value, catalogBackend: 'shadow' } + } + return { ...shadowResult.value, catalogBackend: 'shadow' } + } + + async comparePrograms(ids: string[]): Promise { + const compare = (repository: CatalogRepository, role: 'primary' | 'shadow') => ( + repository.comparePrograms + ? Promise.resolve().then(() => repository.comparePrograms!(ids)) + : Promise.reject(new CatalogRepositoryError( + 'COMPARE_UNAVAILABLE', + `The ${role} catalog does not support lightweight program comparison.`, + )) + ) + const [primaryResult, shadowResult] = await Promise.allSettled([ + compare(this.primary, 'primary'), + compare(this.shadow, 'shadow'), + ]) + if (primaryResult.status === 'rejected') throw primaryResult.reason + if (shadowResult.status === 'rejected') { + await this.recordShadowError('comparePrograms', shadowResult.reason) + return primaryResult.value + } + await this.recordComparison( + 'comparePrograms', + compareProgramProjection(primaryResult.value, shadowResult.value, this.maxDifferences), + ) + return primaryResult.value + } + async listInstitutions( query: CatalogInstitutionListQuery = {}, ): Promise { diff --git a/src/lib/catalog/types.ts b/src/lib/catalog/types.ts index 1fe28fa..e3c2620 100644 --- a/src/lib/catalog/types.ts +++ b/src/lib/catalog/types.ts @@ -162,12 +162,23 @@ export type CatalogRelease = { dataDate: string generatedAt: string recordCounts: CatalogRecordCounts + rawCounts: CatalogRecordCounts + publicCounts: CatalogRecordCounts + dataCheckedThrough: string + evaluatedForDate: string + activatedAt: string + catalogBackend: CatalogBackendMode + deploymentSha: string | null } export interface CatalogRepository { readonly mode: CatalogBackendMode getBundle(): Promise getRelease(): Promise + /** Runtime release truth; remote backends may read the live Worker endpoint. */ + getOperationalRelease?(): Promise + /** Optional lightweight API projection that must not load the compatibility bundle. */ + comparePrograms?(ids: string[]): Promise listInstitutions(query?: CatalogInstitutionListQuery): Promise listPrograms(query?: CatalogProgramListQuery): Promise listScholarships(query?: CatalogScholarshipListQuery): Promise diff --git a/src/lib/site.ts b/src/lib/site.ts index fa5d7f1..b83b8d1 100644 --- a/src/lib/site.ts +++ b/src/lib/site.ts @@ -23,6 +23,12 @@ export function requireLocale(value: string): LaunchLocale | null { return isPublicLocale(value) ? value : null } +export type PageSearchParams = Record + +export function hasSearchParameters(params: PageSearchParams): boolean { + return Object.keys(params).length > 0 +} + type PageMetadataOptions = { indexable?: boolean } diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts index 2279294..a46a6a2 100644 --- a/tests/e2e/smoke.spec.ts +++ b/tests/e2e/smoke.spec.ts @@ -11,7 +11,9 @@ for (const locale of locales) { expect(response?.ok(), `${locale}/${route || 'home'} should respond successfully`).toBe(true) await expect(page.locator('html')).toHaveAttribute('lang', locale) await expect(page.locator('html')).toHaveAttribute('dir', 'ltr') - await expect(page.locator('header.atlas-site-header')).toBeVisible() + const header = page.locator('header.atlas-site-header') + await expect(header).toHaveCount(1) + await expect(header).toBeVisible() await expect(page.locator('main#main-content')).toBeVisible() } }) @@ -33,8 +35,9 @@ test('the root route redirects using the accepted launch language', async ({ bro test('the skip link moves keyboard focus into the main content', async ({ page }) => { await page.goto('/en', { waitUntil: 'domcontentloaded' }) - await page.keyboard.press('Tab') const skipLink = page.locator('.atlas-skip-link') + await expect(skipLink).toHaveCount(1) + await page.keyboard.press('Tab') await expect(skipLink).toBeFocused() await page.keyboard.press('Enter') diff --git a/tests/unit/app-header.test.tsx b/tests/unit/app-header.test.tsx index 58379d9..dabe557 100644 --- a/tests/unit/app-header.test.tsx +++ b/tests/unit/app-header.test.tsx @@ -5,6 +5,7 @@ import { AppHeader } from '@/components/layout/AppHeader' vi.mock('next/navigation', () => ({ usePathname: () => '/en/programs/software-engineering', + useSearchParams: () => new URLSearchParams('degree=master&applicationState=open&page=3&cursor=opaque&cursorHistory=%7E%2Cprevious'), })) describe('AppHeader locale navigation', () => { @@ -14,12 +15,12 @@ describe('AppHeader locale navigation', () => { .map((link) => link.getAttribute('href')) expect(new Set(hrefs)).toEqual(new Set([ - '/en/programs/software-engineering', - '/zh/programs/software-engineering', - '/ru/programs/software-engineering', - '/de/programs/software-engineering', - '/fr/programs/software-engineering', - '/es/programs/software-engineering', + '/en/programs/software-engineering?degree=master&applicationState=open', + '/zh/programs/software-engineering?degree=master&applicationState=open', + '/ru/programs/software-engineering?degree=master&applicationState=open', + '/de/programs/software-engineering?degree=master&applicationState=open', + '/fr/programs/software-engineering?degree=master&applicationState=open', + '/es/programs/software-engineering?degree=master&applicationState=open', ])) expect(hrefs.some((href) => href?.startsWith('/pt'))).toBe(false) expect(hrefs.some((href) => href?.startsWith('/ar'))).toBe(false) @@ -45,4 +46,10 @@ describe('AppHeader locale navigation', () => { expect(savedLinks).toHaveLength(2) expect(savedLinks.every((link) => link.getAttribute('href') === '/en/favorites')).toBe(true) }) + + it('discloses possible English record fallbacks on beta-language data routes', () => { + render() + + expect(screen.getByRole('note')).toHaveTextContent(/Englisch/) + }) }) diff --git a/tests/unit/catalog-api-route.test.ts b/tests/unit/catalog-api-route.test.ts index 518bd95..e359d14 100644 --- a/tests/unit/catalog-api-route.test.ts +++ b/tests/unit/catalog-api-route.test.ts @@ -55,6 +55,29 @@ describe('catalog API routes', () => { expect(response.status).toBe(200) expect(body.data.recordCounts.universities).toBeGreaterThan(0) expect(body.data.recordCounts.programs).toBe(publicBundle.programs.length) + expect(body.data.recordCounts).toEqual(body.data.publicCounts) + expect(body.data.rawCounts.programs).toBeGreaterThanOrEqual(body.data.publicCounts.programs) + expect(body.data.dataCheckedThrough).toMatch(/^\d{4}-\d{2}-\d{2}$/u) + expect(body.data.evaluatedForDate).toBe(getTodayDate()) + expect(body.data.activatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/u) + expect(body.data.catalogBackend).toBe('json') expect(body.data.recordCounts.programs).toBeGreaterThan(0) }) + + it('reports the immutable Vercel deployment SHA separately from release identity', async () => { + const previous = process.env.VERCEL_GIT_COMMIT_SHA + const sha = 'b'.repeat(40) + process.env.VERCEL_GIT_COMMIT_SHA = sha + try { + const response = await getCurrentRelease() + const body = await response.json() + expect(response.status).toBe(200) + expect(body.data.deploymentSha).toBe(sha) + expect(body.meta.release.deploymentSha).toBe(sha) + expect(body.data.id).not.toBe(sha) + expect(body.data.dataDate).toMatch(/^\d{4}-\d{2}-\d{2}$/u) + } finally { + process.env.VERCEL_GIT_COMMIT_SHA = previous + } + }) }) diff --git a/tests/unit/catalog-d1-p0-contract.test.ts b/tests/unit/catalog-d1-p0-contract.test.ts new file mode 100644 index 0000000..d6c6d15 --- /dev/null +++ b/tests/unit/catalog-d1-p0-contract.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, it, vi } from 'vitest' +import { + CatalogRepositoryError, + createD1CatalogRepository, + createShadowCatalogRepository, + parseCatalogReleaseInfo, + type CatalogRepository, +} from '@/lib/catalog' +import { deploymentShaFromEnvironment } from '@/lib/catalog-api/runtime' +import type { CatalogProgramComparison } from '@/lib/catalog/d1-compare' + +const publicCounts = { + sources: 1, + cities: 1, + universities: 1, + programs: 1, + admissionCycles: 1, + scholarships: 1, +} +const rawCounts = { ...publicCounts, programs: 2 } +const release = { + id: 'release-2026-08-10', + dataDate: '2026-08-10', + generatedAt: '2026-08-10T01:00:00.000Z', + recordCounts: publicCounts, + rawCounts, + publicCounts, + dataCheckedThrough: '2026-08-09', + evaluatedForDate: '2026-08-10', + activatedAt: '2026-08-10T01:05:00.000Z', + catalogBackend: 'd1', + deploymentSha: null, +} as const + +function meta(status: 'known' | 'officially_not_announced' = 'known') { + return { + status, + officialUrl: 'https://admissions.example.edu.cn/program', + sourceTitle: 'Official admissions page', + checkedAt: '2026-08-09', + verifiedAt: '2026-08-09', + reviewAfter: '2026-09-08', + sourceIds: ['src-program'], + } +} + +const normalizedComparison = { + data: { + items: [{ + program: { + type: 'program', + id: 'prog-safe-1', + slug: 'safe-program', + attributes: { + name: { en: 'Safe Program', zh: '安全项目' }, + programType: 'degree', + degreeLevel: 'master', + credentialType: 'masters_degree', + attendanceMode: 'full_time', + deliveryMode: 'on_campus', + duration: { minimum: 2, maximum: 2, unit: 'academic_years' }, + disciplineCodes: ['engineering'], + teachingLanguageCodes: ['eng'], + officialUrl: 'https://admissions.example.edu.cn/program', + applyUrl: 'http://unsafe.example/apply', + }, + relationships: { + institution: { id: 'uni-safe', slug: 'safe-university', name: { en: 'Safe University' } }, + }, + sources: [{ + id: 'src-program', + url: 'https://admissions.example.edu.cn/program', + title: 'Official admissions page', + publisher: 'Safe University', + languageCode: 'en', + authorityLevel: 'primary_official', + checkedAt: '2026-08-09', + }], + fieldMeta: { + name: meta(), + officialUrl: meta(), + applyUrl: meta(), + institution: meta(), + disciplineCodes: meta(), + teachingLanguageCodes: meta(), + 'duration.minimum': meta(), + 'duration.maximum': meta(), + }, + }, + currentCycle: { + type: 'program_cycle', + id: 'cycle-safe-1', + slug: null, + attributes: { + academicYear: '2026-2027', + intake: 'autumn', + sequence: 1, + cycleStatus: 'announced', + startsOn: null, + endsOn: null, + application: { + routeType: 'direct', + accessMode: 'individual', + applyUrl: 'http://unsafe.example/cycle-apply', + opensOn: '2026-08-01', + closesOn: '2026-10-01', + rolling: false, + state: 'open', + }, + tuition: { + amountMinimumMinor: 30_000_00, + amountMaximumMinor: 30_000_00, + currencyCode: 'CNY', + currencyExponent: 2, + period: 'academic_year', + }, + applicationFee: { + amountMinimumMinor: 800_00, + amountMaximumMinor: 800_00, + currencyCode: 'CNY', + currencyExponent: 2, + period: 'one_time', + }, + }, + relationships: { program: { id: 'prog-safe-1', slug: 'safe-program' } }, + sources: [{ + id: 'src-cycle', + url: 'https://admissions.example.edu.cn/cycle', + title: 'Official cycle page', + publisher: 'Safe University', + languageCode: 'en', + authorityLevel: 'primary_official', + checkedAt: '2026-08-09', + }], + fieldMeta: { + name: meta(), + academicYear: meta(), + intake: meta(), + 'application.opensOn': meta(), + 'application.closesOn': meta(), + 'application.state': meta(), + tuition: meta(), + applicationFee: meta(), + }, + }, + linkedScholarshipCount: 2, + }], + missingIds: ['prog-missing-record'], + }, + meta: { + release, + notice: 'Official-source automated catalog.', + }, +} + +describe('D1 P0 release and compare contracts', () => { + it('preserves extended Worker release truth while accepting legacy payloads', () => { + expect(parseCatalogReleaseInfo(release)).toEqual(release) + const legacy = parseCatalogReleaseInfo({ + id: release.id, + dataDate: release.dataDate, + generatedAt: release.generatedAt, + recordCounts: publicCounts, + }) + expect(legacy).toMatchObject({ + recordCounts: publicCounts, + rawCounts: publicCounts, + publicCounts, + dataCheckedThrough: release.dataDate, + evaluatedForDate: release.dataDate, + activatedAt: release.generatedAt, + catalogBackend: 'd1', + deploymentSha: null, + }) + expect(() => parseCatalogReleaseInfo({ ...release, deploymentSha: 'main' })) + .toThrow(CatalogRepositoryError) + }) + + it('reads operational release metadata and comparisons from public Worker endpoints only', async () => { + const calls: string[] = [] + const fetcher = vi.fn(async (input: string | URL) => { + const url = new URL(input.toString()) + calls.push(`${url.pathname}${url.search}`) + if (url.pathname === '/api/v1/releases/current') { + return Response.json({ data: release, meta: { release, notice: 'notice' } }) + } + if (url.pathname === '/api/v1/programs/compare') { + return Response.json(normalizedComparison) + } + throw new Error(`Unexpected endpoint: ${url.pathname}`) + }) + const repository = createD1CatalogRepository({ + apiUrl: 'https://catalog.example.test/internal/v1/catalog-bundle', + apiToken: 'private-token', + apiTokenHost: 'catalog.example.test', + fetch: fetcher, + }) + + await expect(repository.getOperationalRelease?.()).resolves.toEqual(release) + const comparison = await repository.comparePrograms?.([ + 'prog-safe-1', + 'prog-missing-record', + ]) as CatalogProgramComparison + + expect(calls).toEqual([ + '/api/v1/releases/current', + '/api/v1/programs/compare?ids=prog-safe-1%2Cprog-missing-record', + ]) + expect(calls.every((call) => !call.startsWith('/internal/'))).toBe(true) + expect(comparison.data.items.map((item) => item.program.id)).toEqual(['prog-safe-1']) + expect(comparison.data.missingIds).toEqual(['prog-missing-record']) + expect(comparison.data.items[0]!.program.applyUrl).toBeNull() + expect(comparison.data.items[0]!.currentCycle?.applicationState).toBe('open') + expect(comparison.data.items[0]!.currentCycle?.tuitionCny).toBe(30_000) + expect(comparison.data.items[0]!.linkedScholarshipCount).toBe(2) + expect(comparison.meta.release.rawCounts.programs).toBe(2) + }) + + it('returns the primary lightweight projection in Shadow mode and records D1 parity only', async () => { + const primaryGetBundle = vi.fn(async () => { throw new Error('primary bundle must not load') }) + const shadowGetBundle = vi.fn(async () => { throw new Error('shadow bundle must not load') }) + const primaryComparison = structuredClone(normalizedComparison) + const shadowComparison = structuredClone(normalizedComparison) + shadowComparison.data.items[0]!.linkedScholarshipCount = 3 + const primaryComparePrograms = vi.fn(async () => primaryComparison) + const shadowComparePrograms = vi.fn(async () => shadowComparison) + const onReport = vi.fn() + const base = { + getRelease: async () => release, + listInstitutions: async () => { throw new Error('not used') }, + listPrograms: async () => { throw new Error('not used') }, + listScholarships: async () => { throw new Error('not used') }, + } + const primary: CatalogRepository = { + ...base, + mode: 'json', + getBundle: primaryGetBundle, + comparePrograms: primaryComparePrograms, + } + const shadow: CatalogRepository = { + ...base, + mode: 'd1', + getBundle: shadowGetBundle, + comparePrograms: shadowComparePrograms, + } + const repository = createShadowCatalogRepository({ + primary, + shadow, + onReport, + now: () => new Date('2026-08-10T08:00:00.000Z'), + }) + + await expect(repository.comparePrograms(['prog-safe-1'])).resolves.toBe(primaryComparison) + expect(primaryComparePrograms).toHaveBeenCalledWith(['prog-safe-1']) + expect(shadowComparePrograms).toHaveBeenCalledWith(['prog-safe-1']) + expect(primaryGetBundle).not.toHaveBeenCalled() + expect(shadowGetBundle).not.toHaveBeenCalled() + expect(onReport).toHaveBeenCalledWith(expect.objectContaining({ + operation: 'comparePrograms', + checkedAt: '2026-08-10T08:00:00.000Z', + status: 'different', + matches: false, + primaryMode: 'json', + shadowMode: 'd1', + summary: expect.objectContaining({ differenceCount: 1 }), + })) + }) + + it('keeps the primary comparison available when the D1 shadow fails', async () => { + const primaryComparison = structuredClone(normalizedComparison) + const primary = { + mode: 'json' as const, + getBundle: async () => { throw new Error('not used') }, + getRelease: async () => release, + comparePrograms: async () => primaryComparison, + listInstitutions: async () => { throw new Error('not used') }, + listPrograms: async () => { throw new Error('not used') }, + listScholarships: async () => { throw new Error('not used') }, + } + const shadow = { + ...primary, + mode: 'd1' as const, + comparePrograms: async () => { throw new Error('D1 compare unavailable') }, + } + const onReport = vi.fn() + const repository = createShadowCatalogRepository({ primary, shadow, onReport }) + + await expect(repository.comparePrograms(['prog-safe-1'])).resolves.toBe(primaryComparison) + expect(onReport).toHaveBeenCalledWith(expect.objectContaining({ + operation: 'comparePrograms', + status: 'shadow-error', + shadowError: { name: 'Error', message: 'D1 compare unavailable' }, + })) + }) + + it('accepts only an immutable 40-hex Vercel deployment SHA', () => { + expect(deploymentShaFromEnvironment('a'.repeat(40))).toBe('a'.repeat(40)) + expect(deploymentShaFromEnvironment('A'.repeat(40))).toBeNull() + expect(deploymentShaFromEnvironment('main')).toBeNull() + expect(deploymentShaFromEnvironment(undefined)).toBeNull() + }) +}) diff --git a/tests/unit/catalog-explorer-controls.test.tsx b/tests/unit/catalog-explorer-controls.test.tsx index 255307c..057b85c 100644 --- a/tests/unit/catalog-explorer-controls.test.tsx +++ b/tests/unit/catalog-explorer-controls.test.tsx @@ -61,7 +61,11 @@ describe('catalogue explorer controls', () => { expect(summary.closest('details')).toHaveAttribute('open') expect(screen.getAllByLabelText(new RegExp(`^${text.removeFilter}:`))).toHaveLength(3) expect(screen.getByRole('combobox', { name: messages.nav.scholarships })).toHaveValue('linked') - expect(screen.getAllByRole('navigation')).toHaveLength(2) + expect(screen.getAllByRole('navigation')).toHaveLength(3) + const openNow = screen.getByRole('link', { name: messages.common.openNow }) + expect(openNow).toHaveAttribute('href', expect.stringContaining('applicationState=open')) + expect(openNow).not.toHaveAttribute('tabindex', '-1') + expect(screen.getByRole('link', { name: messages.programs.upcoming })).toHaveAttribute('href', expect.stringContaining('applicationState=upcoming')) expect(screen.getByRole('navigation', { name: new RegExp(text.topPagination) })).toBeVisible() expect(screen.getByRole('navigation', { name: new RegExp(text.bottomPagination) })).toBeVisible() }) diff --git a/tests/unit/catalog-metadata-indexability.test.ts b/tests/unit/catalog-metadata-indexability.test.ts new file mode 100644 index 0000000..2ce2ff1 --- /dev/null +++ b/tests/unit/catalog-metadata-indexability.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' + +import { generateMetadata as generateCitiesMetadata } from '@/app/[locale]/cities/page' +import { generateMetadata as generateProgramDetailMetadata } from '@/app/[locale]/programs/[slug]/page' +import { generateMetadata as generateProgramsMetadata } from '@/app/[locale]/programs/page' +import { generateMetadata as generateScholarshipsMetadata } from '@/app/[locale]/scholarships/page' +import { generateMetadata as generateUniversitiesMetadata } from '@/app/[locale]/universities/page' + +const localeParams = Promise.resolve({ locale: 'en' }) + +describe('catalogue metadata indexability', () => { + it('indexes base catalogues and marks every parameterized catalogue noindex,follow', async () => { + const pages = [ + generateProgramsMetadata, + generateUniversitiesMetadata, + generateScholarshipsMetadata, + generateCitiesMetadata, + ] + + for (const generateMetadata of pages) { + const base = await generateMetadata({ params: localeParams }) + const parameterized = await generateMetadata({ + params: localeParams, + searchParams: Promise.resolve({ q: 'engineering' }), + }) + + expect(base.robots).toBeUndefined() + expect(parameterized.robots).toEqual({ index: false, follow: true }) + } + }) + + it('adds the university name to a program title to disambiguate repeated names', async () => { + const metadata = await generateProgramDetailMetadata({ + params: Promise.resolve({ + locale: 'en', + slug: 'tsinghua-university-computer-science-bachelor', + }), + }) + + expect(metadata.title).toContain('Computer Science and Technology') + expect(metadata.title).toContain('Tsinghua University') + }) +}) diff --git a/tests/unit/catalog-repository.test.ts b/tests/unit/catalog-repository.test.ts index 251dced..8861ae2 100644 --- a/tests/unit/catalog-repository.test.ts +++ b/tests/unit/catalog-repository.test.ts @@ -12,11 +12,9 @@ import { createJsonCatalogRepository, createShadowCatalogRepository, deriveCatalogRelease, - getCatalogRecordCounts, type CatalogFetch, type CatalogRepository, } from '@/lib/catalog' -import { getDataReleaseDate } from '@/lib/data/release' import { selectPublishedData } from '@/lib/data/publication' import { bundleSchema } from '@/lib/data/schema' import type { DataBundle } from '@/lib/data/types' @@ -67,13 +65,7 @@ describe('CatalogRepository', () => { it('derives release metadata and all six record counts for JSON compatibility', async () => { const repository = createJsonCatalogRepository(() => copyBundle()) - const expectedDataDate = getDataReleaseDate(allData) - await expect(repository.getRelease()).resolves.toEqual({ - id: `json:${expectedDataDate}`, - dataDate: expectedDataDate, - generatedAt: `${expectedDataDate}T00:00:00.000Z`, - recordCounts: getCatalogRecordCounts(allData), - }) + await expect(repository.getRelease()).resolves.toEqual(deriveCatalogRelease(allData)) }) it('filters JSON programs by linked or specifically selected published scholarship scopes', async () => { diff --git a/tests/unit/catalog-sql-api.test.ts b/tests/unit/catalog-sql-api.test.ts index 8d4b70f..f99dfe8 100644 --- a/tests/unit/catalog-sql-api.test.ts +++ b/tests/unit/catalog-sql-api.test.ts @@ -163,6 +163,47 @@ describe('Catalog D1 normalized v1 API', () => { expect(queries.some(({ sql }) => sql.includes('FROM current_programs AS program'))).toBe(true) }, 30_000) + it('compares at most four programs from normalized SQL without reading the R2 bundle', async () => { + const listResponse = await worker.fetch( + new Request('https://catalog.test/api/v1/programs?limit=2'), + environment, + ) + const list = await listResponse.json() as ApiEnvelopeDto + const ids = list.data.map((program) => program.id) + expect(ids).toHaveLength(2) + + queries.length = 0 + const readsBefore = r2Reads + const response = await worker.fetch( + new Request( + `https://catalog.test/api/v1/programs/compare?ids=${encodeURIComponent(`${ids.join(',')},prog-missing-record`)}`, + ), + environment, + ) + const payload = await response.json() as ApiEnvelopeDto<{ + items: Array<{ + program: ProgramDto + currentCycle: ProgramCycleDto | null + linkedScholarshipCount: number + }> + missingIds: string[] + }> + + expect(response.status, JSON.stringify(payload)).toBe(200) + expect(payload.data.items.map((item) => item.program.id)).toEqual(ids) + expect(payload.data.missingIds).toEqual(['prog-missing-record']) + expect(payload.data.items.every((item) => ( + Number.isInteger(item.linkedScholarshipCount) + && item.linkedScholarshipCount >= 0 + && item.program.attributes.officialUrl.startsWith('https://') + && (item.program.attributes.applyUrl === null + || item.program.attributes.applyUrl.startsWith('https://')) + ))).toBe(true) + expect(JSON.stringify(payload).length).toBeLessThan(150_000) + expect(r2Reads).toBe(readsBefore) + expect(queries.some(({ sql }) => sql.includes('target_programs AS MATERIALIZED'))).toBe(true) + }, 30_000) + it('filters programs to any explicitly linked scholarship without treating linked as a slug', async () => { queries.length = 0 const response = await worker.fetch( diff --git a/tests/unit/catalog-worker.test.ts b/tests/unit/catalog-worker.test.ts index a1e6112..aad4483 100644 --- a/tests/unit/catalog-worker.test.ts +++ b/tests/unit/catalog-worker.test.ts @@ -12,6 +12,9 @@ const release = { release_id: 'release-2026-07-20', data_date: '2026-07-20', generated_at: '2026-07-20T12:00:00.000Z', + raw_counts_json: JSON.stringify({ sources: 2, cities: 1, universities: 1, programs: 2, admissionCycles: 1, scholarships: 1 }), + data_checked_through: '2026-07-19', + activated_at: '2026-07-20T12:05:00.000Z', counts_json: JSON.stringify({ sources: 1, cities: 1, universities: 1, programs: 1, admissionCycles: 1, scholarships: 1 }), content_sha256: 'a'.repeat(64), } @@ -64,6 +67,14 @@ describe('catalog API worker', () => { expect(response.status).toBe(200) expect(body.data.id).toBe(release.release_id) expect(body.data.recordCounts.programs).toBe(1) + expect(body.data.publicCounts.programs).toBe(1) + expect(body.data.rawCounts.programs).toBe(2) + expect(body.data.recordCounts).toEqual(body.data.publicCounts) + expect(body.data.dataCheckedThrough).toBe('2026-07-19') + expect(body.data.evaluatedForDate).toMatch(/^\d{4}-\d{2}-\d{2}$/u) + expect(body.data.activatedAt).toBe(release.activated_at) + expect(body.data.catalogBackend).toBe('d1') + expect(body.data.deploymentSha).toBeNull() expect(response.headers.get('etag')).toMatch(/^"[a-f0-9]{64}:\d{4}-\d{2}-\d{2}"$/u) }) @@ -117,6 +128,21 @@ describe('catalog API worker', () => { expect(response.status).toBe(400) }) + it('validates lightweight comparison ids before returning a public projection', async () => { + for (const query of [ + '', + '?ids=bad%20id', + '?ids=prog-1,prog-2,prog-3,prog-4,prog-5', + ]) { + const response = await worker.fetch( + new Request(`https://catalog.test/api/v1/programs/compare${query}`), + environment(), + ) + expect(response.status).toBe(400) + expect(response.headers.get('cache-control')).toBe('no-store') + } + }) + it('supports cacheable read-only CORS without opening the internal endpoint', async () => { const preflight = await worker.fetch( new Request('https://catalog.test/api/v1/programs', { method: 'OPTIONS' }), diff --git a/tests/unit/cloudflare-backup-preflight.test.ts b/tests/unit/cloudflare-backup-preflight.test.ts index 9bd60e9..8b8c692 100644 --- a/tests/unit/cloudflare-backup-preflight.test.ts +++ b/tests/unit/cloudflare-backup-preflight.test.ts @@ -9,6 +9,7 @@ import { formatBackupPreflightError, inspectBackupArtifacts, validateBackupCredentials, + validateRestoreCredentials, } from '../../scripts/cloudflare/backup-preflight' function digest(value: Buffer): string { @@ -32,26 +33,37 @@ describe('Cloudflare backup preflight', () => { it('validates credential presence without returning secret values', () => { const token = 'secret-token-that-must-not-be-printed' const result = validateBackupCredentials({ - CLOUDFLARE_API_TOKEN: token, + CLOUDFLARE_D1_BACKUP_TOKEN: token, CLOUDFLARE_ACCOUNT_ID: '78969c65bfdd892bb12c116869ea91cf', }) - expect(result).toEqual({ databases: 2, bucket: 'studyinchina-releases' }) + expect(result).toEqual({ databases: 2, bucket: 'studyinchina-backups' }) expect(JSON.stringify(result)).not.toContain(token) expect(() => validateBackupCredentials({})).toThrow( - /CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID/u, + /CLOUDFLARE_D1_BACKUP_TOKEN, CLOUDFLARE_ACCOUNT_ID/u, ) expect(() => validateBackupCredentials({})).toThrow(BACKUP_CONFIGURATION_DOC) expect(() => validateBackupCredentials({ - CLOUDFLARE_API_TOKEN: token, + CLOUDFLARE_D1_BACKUP_TOKEN: token, CLOUDFLARE_ACCOUNT_ID: 'invalid', })).toThrow(/32-character hexadecimal/u) }) + it('requires a separate protected restore credential without exposing it', () => { + const token = 'restore-token-that-must-not-be-printed' + expect(validateRestoreCredentials({ + CLOUDFLARE_D1_RESTORE_TOKEN: token, + CLOUDFLARE_ACCOUNT_ID: '78969c65bfdd892bb12c116869ea91cf', + })).toEqual({ bucket: 'studyinchina-backups' }) + expect(() => validateRestoreCredentials({})).toThrow( + /CLOUDFLARE_D1_RESTORE_TOKEN, CLOUDFLARE_ACCOUNT_ID/u, + ) + }) + it('formats an actionable GitHub error without exposing credential values', () => { const credentialValue = 'credential-value-that-must-stay-private' let failure: unknown try { - validateBackupCredentials({ CLOUDFLARE_API_TOKEN: credentialValue }) + validateBackupCredentials({ CLOUDFLARE_D1_BACKUP_TOKEN: credentialValue }) } catch (error) { failure = error } @@ -92,13 +104,63 @@ describe('Cloudflare backup preflight', () => { const exportStep = workflow.indexOf('Export catalog and pipeline databases') const artifactPreflight = workflow.indexOf('--phase artifacts') const uploadStep = workflow.indexOf('Upload daily and monthly copies') + const readbackStep = workflow.indexOf('Read back and cryptographically verify daily checkpoint') expect(credentialPreflight).toBeGreaterThan(0) expect(dependencyInstall).toBeGreaterThan(credentialPreflight) expect(remoteAccess).toBeGreaterThan(dependencyInstall) expect(exportStep).toBeGreaterThan(remoteAccess) expect(artifactPreflight).toBeGreaterThan(exportStep) expect(uploadStep).toBeGreaterThan(artifactPreflight) + expect(readbackStep).toBeGreaterThan(uploadStep) + const uploadBlock = workflow.slice(uploadStep, readbackStep) + const readbackBlock = workflow.slice(readbackStep) + expect(readbackBlock).toContain('--phase artifacts') + expect(uploadBlock).toContain('studyinchina-backups/backups/daily/$day/raw-v1/catalog.sql.gz') + expect(uploadBlock).toContain('studyinchina-backups/backups/daily/$day/raw-v1/pipeline.sql.gz') + expect(uploadBlock).toContain('studyinchina-backups/backups/daily/$day/raw-v1/sha256.txt') + expect(uploadBlock).toContain('studyinchina-backups/backups/monthly/$month/raw-v1/catalog.sql.gz') + expect(uploadBlock).toContain('studyinchina-backups/backups/monthly/$month/raw-v1/pipeline.sql.gz') + expect(uploadBlock).toContain('studyinchina-backups/backups/monthly/$month/raw-v1/sha256.txt') + expect(readbackBlock).toContain('backups/daily/$day/raw-v1/catalog.sql.gz') + expect(readbackBlock).toContain('backups/daily/$day/raw-v1/pipeline.sql.gz') + expect(readbackBlock).toContain('backups/daily/$day/raw-v1/sha256.txt') + expect(workflow.match(/--content-encoding="identity"/gu)).toHaveLength(4) + expect(workflow).not.toContain('--content-encoding="gzip"') + const retention = readFileSync( + join(process.cwd(), 'scripts', 'cloudflare', 'configure-retention.ps1'), + 'utf8', + ) + expect(retention).toContain("[string]$Bucket = 'studyinchina-backups'") + expect(workflow).toContain('secrets.CLOUDFLARE_D1_BACKUP_TOKEN') + expect(workflow).not.toContain('secrets.CLOUDFLARE_API_TOKEN') expect(workflow).toContain('if: ${{ failure() }}') expect(workflow).toContain('does **not** satisfy the 24-hour RPO') }) + + it('protects restore access with a distinct environment credential', () => { + const workflow = readFileSync( + join(process.cwd(), '.github', 'workflows', 'cloudflare-restore-drill.yml'), + 'utf8', + ) + expect(workflow).toContain('environment: cloudflare-restore-drill') + expect(workflow).toContain('secrets.CLOUDFLARE_D1_RESTORE_TOKEN') + expect(workflow).not.toContain('secrets.CLOUDFLARE_D1_BACKUP_TOKEN') + expect(workflow).not.toContain('secrets.CLOUDFLARE_API_TOKEN') + expect(workflow).toContain( + 'studyinchina-backups/backups/monthly/$BACKUP_MONTH/raw-v1/catalog.sql.gz', + ) + expect(workflow).toContain('monthly/$BACKUP_MONTH/raw-v1/pipeline.sql.gz') + expect(workflow).toContain('monthly/$BACKUP_MONTH/raw-v1/sha256.txt') + const credentialGate = workflow.indexOf('--phase restore-credentials') + const dependencyInstall = workflow.indexOf('Install dependencies') + const download = workflow.indexOf('Download private monthly backup') + const isolatedRestore = workflow.indexOf('Run local isolated restore drill') + expect(credentialGate).toBeGreaterThan(-1) + expect(dependencyInstall).toBeGreaterThan(credentialGate) + expect(download).toBeGreaterThan(dependencyInstall) + expect(isolatedRestore).toBeGreaterThan(download) + expect(workflow.slice(isolatedRestore)).not.toContain( + 'CLOUDFLARE_API_TOKEN: ${{ secrets.', + ) + }) }) diff --git a/tests/unit/d1-bulk-restore-importer.test.ts b/tests/unit/d1-bulk-restore-importer.test.ts new file mode 100644 index 0000000..ce51f0c --- /dev/null +++ b/tests/unit/d1-bulk-restore-importer.test.ts @@ -0,0 +1,110 @@ +import { spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { afterEach, describe, expect, it } from 'vitest' + +const importer = join(process.cwd(), 'scripts', 'cloudflare', 'import-restored-d1.mjs') +const temporaryDirectories: string[] = [] + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'studyinchina-local-d1-import-')) + temporaryDirectories.push(root) + const stateDirectory = join(root, 'state', 'nested') + mkdirSync(stateDirectory, { recursive: true }) + const databasePath = join(stateDirectory, 'pipeline.sqlite') + const sqlPath = join(root, 'pipeline.sql') + const database = new DatabaseSync(databasePath) + database.exec(` + PRAGMA foreign_keys = ON; + CREATE TABLE records (id TEXT PRIMARY KEY); + CREATE TABLE restored_rows ( + id TEXT PRIMARY KEY, + record_id TEXT NOT NULL REFERENCES records(id) + ); + CREATE TRIGGER validate_restored_row + BEFORE INSERT ON restored_rows + WHEN NEW.id = '' + BEGIN + SELECT RAISE(ABORT, 'id is required'); + END; + `) + database.close() + return { root, stateDirectory, databasePath, sqlPath } +} + +function runImporter(stateDirectory: string, sqlPath: string) { + const env = { ...process.env } + delete env.CLOUDFLARE_API_TOKEN + delete env.CLOUDFLARE_ACCOUNT_ID + delete env.CLOUDFLARE_D1_BACKUP_TOKEN + delete env.CLOUDFLARE_D1_RESTORE_TOKEN + return spawnSync( + process.execPath, + ['--no-warnings', importer, stateDirectory, 'pipeline', sqlPath], + { encoding: 'utf8', env }, + ) +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('isolated D1 bulk restore importer', () => { + it('imports a data-only dump atomically without Cloudflare credentials', () => { + const { stateDirectory, databasePath, sqlPath } = fixture() + writeFileSync( + sqlPath, + [ + 'PRAGMA defer_foreign_keys=TRUE;', + `INSERT INTO restored_rows (id, record_id) VALUES ('row-1', 'record-1');`, + `INSERT INTO records (id) VALUES ('record-1');`, + ].join('\n'), + ) + + const result = runImporter(stateDirectory, sqlPath) + expect(result.status, result.stderr).toBe(0) + const report = JSON.parse(result.stdout) + expect(report).toMatchObject({ + engine: 'node:sqlite', + transactionMode: 'wrapper', + sqlBytes: expect.any(Number), + elapsedMs: expect.any(Number), + }) + + const restored = new DatabaseSync(databasePath, { readOnly: true }) + try { + expect(restored.prepare('SELECT * FROM restored_rows').all()).toEqual([ + { id: 'row-1', record_id: 'record-1' }, + ]) + expect(restored.prepare('PRAGMA foreign_key_check').all()).toEqual([]) + } finally { + restored.close() + } + }) + + it('rolls back the complete dump when any statement fails', () => { + const { stateDirectory, databasePath, sqlPath } = fixture() + writeFileSync( + sqlPath, + [ + `INSERT INTO records (id) VALUES ('must-roll-back');`, + `INSERT INTO table_that_does_not_exist (id) VALUES ('failure');`, + ].join('\n'), + ) + + const result = runImporter(stateDirectory, sqlPath) + expect(result.status).toBe(1) + expect(result.stderr).toContain('Local D1 bulk import failed') + + const restored = new DatabaseSync(databasePath, { readOnly: true }) + try { + expect(restored.prepare('SELECT COUNT(*) AS count FROM records').get()).toEqual({ count: 0 }) + } finally { + restored.close() + } + }) +}) diff --git a/tests/unit/d1-restore-verifier.test.ts b/tests/unit/d1-restore-verifier.test.ts new file mode 100644 index 0000000..6344bca --- /dev/null +++ b/tests/unit/d1-restore-verifier.test.ts @@ -0,0 +1,97 @@ +import { spawnSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { afterEach, describe, expect, it } from 'vitest' + +const verifier = join(process.cwd(), 'scripts', 'cloudflare', 'verify-restored-d1.mjs') +const temporaryDirectories: string[] = [] + +function catalogFixture(declaredCycles: number) { + const root = mkdtempSync(join(tmpdir(), 'studyinchina-local-d1-verify-')) + temporaryDirectories.push(root) + const stateDirectory = join(root, 'state', 'nested') + mkdirSync(stateDirectory, { recursive: true }) + const databasePath = join(stateDirectory, 'catalog.sqlite') + const database = new DatabaseSync(databasePath) + database.exec(` + CREATE TABLE catalog_releases ( + release_id TEXT PRIMARY KEY, + release_status TEXT NOT NULL, + data_date TEXT NOT NULL, + generated_at TEXT NOT NULL, + counts_json TEXT NOT NULL + ); + CREATE TABLE release_pointer (singleton_id INTEGER PRIMARY KEY, current_release_id TEXT); + CREATE TABLE catalog_records (id TEXT PRIMARY KEY); + CREATE TABLE institutions (release_id TEXT NOT NULL); + CREATE TABLE programs (release_id TEXT NOT NULL); + CREATE TABLE program_cycles (release_id TEXT NOT NULL); + CREATE TABLE scholarships (release_id TEXT NOT NULL); + CREATE TABLE search_documents (id INTEGER PRIMARY KEY); + CREATE TABLE search_fts (id INTEGER PRIMARY KEY); + CREATE TRIGGER validate_catalog_record + BEFORE INSERT ON catalog_records + WHEN NEW.id = '' + BEGIN + SELECT RAISE(ABORT, 'id is required'); + END; + INSERT INTO catalog_releases VALUES ( + 'release-1', + 'active', + '2026-08-10', + '2026-08-10T00:00:00.000Z', + '{"universities":1,"programs":1,"admissionCycles":${declaredCycles},"scholarships":1}' + ); + INSERT INTO release_pointer VALUES (1, 'release-1'); + INSERT INTO institutions VALUES ('release-1'); + INSERT INTO programs VALUES ('release-1'); + INSERT INTO scholarships VALUES ('release-1'); + INSERT INTO search_documents VALUES (1); + INSERT INTO search_fts VALUES (1); + `) + database.close() + return stateDirectory +} + +function runVerifier(stateDirectory: string) { + const env = { ...process.env } + delete env.CLOUDFLARE_API_TOKEN + delete env.CLOUDFLARE_ACCOUNT_ID + delete env.CLOUDFLARE_D1_BACKUP_TOKEN + delete env.CLOUDFLARE_D1_RESTORE_TOKEN + return spawnSync( + process.execPath, + ['--no-warnings', verifier, stateDirectory, 'catalog'], + { encoding: 'utf8', env }, + ) +} + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('isolated D1 restore verifier', () => { + it('accepts a zero-cycle release when the versioned count contract declares zero', () => { + const result = runVerifier(catalogFixture(0)) + expect(result.status, result.stderr).toBe(0) + expect(JSON.parse(result.stdout).currentRelease).toMatchObject({ + id: 'release-1', + institutions: 1, + programs: 1, + programCycles: 0, + scholarships: 1, + }) + }) + + it('fails closed when restored rows disagree with the release count contract', () => { + const result = runVerifier(catalogFixture(1)) + expect(result.status).toBe(1) + expect(result.stderr).toContain( + 'Catalog current release program_cycles count mismatch: expected 1, restored 0', + ) + }) +}) diff --git a/tests/unit/favorites-view.test.tsx b/tests/unit/favorites-view.test.tsx new file mode 100644 index 0000000..9e355db --- /dev/null +++ b/tests/unit/favorites-view.test.tsx @@ -0,0 +1,165 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { FavoritesView } from '@/components/features/FavoritesView' +import { getMessages } from '@/i18n/messages' +import { FAVORITES_KEY } from '@/lib/favorites' + +const ids = Array.from({ length: 6 }, (_, index) => `program-${index + 1}`) + +function comparisonItem(id: string) { + return { + program: { + id, + slug: id, + universityId: `university-${id}`, + name: { en: `Program ${id}` }, + degreeLevel: 'master', + discipline: 'engineering', + teachingLanguages: ['English'], + durationMonths: 24, + durationMonthsMax: null, + programUrl: `https://example.edu/${id}`, + applyUrl: `https://apply.example.edu/${id}`, + languageRequirements: [], + verificationScope: 'facts', + details: null, + sourceIds: [`source-${id}`], + verifiedAt: '2026-08-01', + reviewAfter: '2026-09-01', + status: 'verified', + programType: 'degree', + university: { + id: `university-${id}`, + slug: `university-${id}`, + name: { en: `University ${id}` }, + }, + officialSources: [{ + url: `https://example.edu/${id}`, + title: 'Official program page', + checkedAt: '2026-08-01', + }], + fieldMeta: {}, + }, + currentCycle: { + id: `cycle-${id}`, + programId: id, + academicYear: '2026-2027', + intake: 'autumn', + opensOn: '2026-08-01', + closesOn: '2026-12-01', + dateStatus: 'published', + tuitionCny: 30_000, + tuitionPeriod: 'academic-year', + tuitionStatus: 'confirmed', + evidenceBasis: 'cycle-specific', + applicationFeeCny: 600, + sourceIds: [`source-${id}`], + verifiedAt: '2026-08-01', + reviewAfter: '2026-09-01', + status: 'verified', + applicationState: 'open', + officialSources: [{ + url: `https://example.edu/${id}/admissions`, + title: 'Official admissions notice', + checkedAt: '2026-08-02', + }], + fieldMeta: {}, + }, + linkedScholarshipCount: 2, + } +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('favorites comparison workspace', () => { + it('keeps the server page from serializing the complete catalogue', () => { + const source = readFileSync( + join(process.cwd(), 'src', 'app', '[locale]', 'favorites', 'page.tsx'), + 'utf8', + ) + + expect(source).not.toContain('getCatalogData') + expect(source).not.toContain('programs={') + expect(source).not.toContain('admissionCycles') + }) + + it('loads any number of saved ids in batches of four and limits comparison to four', async () => { + window.localStorage.setItem(FAVORITES_KEY, JSON.stringify(ids)) + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = new URL(String(input), 'https://example.test') + const requestedIds = (url.searchParams.get('ids') || '').split(',').filter(Boolean) + return { + ok: true, + status: 200, + json: async () => ({ + data: { + items: requestedIds.map(comparisonItem), + missingIds: [], + }, + meta: { + releaseId: 'test-release', + generatedAt: '2026-08-10T00:00:00.000Z', + notice: 'Official sources remain authoritative.', + }, + }), + } as Response + }) + vi.stubGlobal('fetch', fetchMock) + const user = userEvent.setup() + + render() + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)) + const batchSizes = fetchMock.mock.calls.map(([input]) => { + const url = new URL(String(input), 'https://example.test') + return (url.searchParams.get('ids') || '').split(',').filter(Boolean).length + }) + expect(batchSizes).toEqual([4, 2]) + expect(await screen.findAllByRole('checkbox')).toHaveLength(6) + + const checkboxes = screen.getAllByRole('checkbox') + for (const checkbox of checkboxes.slice(0, 4)) await user.click(checkbox) + expect(checkboxes[4]).toBeDisabled() + + expect(screen.getAllByText('Application status')).toHaveLength(4) + expect(screen.getAllByText('Application fee')).toHaveLength(4) + expect(screen.getAllByText('Related scholarships')).toHaveLength(4) + expect(screen.getAllByText('Verified on')).toHaveLength(4) + expect(screen.getAllByRole('link', { name: /Official source/ })).toHaveLength(4) + expect(screen.getAllByRole('link', { name: /Apply on official site/ })).toHaveLength(4) + expect(screen.getAllByRole('link', { name: /Apply on official site/ })[0]) + .toHaveAttribute('href', 'https://apply.example.edu/program-1') + }) + + it('does not show an application action when the official cycle is not open', async () => { + window.localStorage.setItem(FAVORITES_KEY, JSON.stringify([ids[0]])) + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + data: { + items: [{ + ...comparisonItem(ids[0]), + currentCycle: { ...comparisonItem(ids[0]).currentCycle, applicationState: 'upcoming' }, + }], + missingIds: [], + }, + meta: {}, + }), + }) as Response)) + const user = userEvent.setup() + + render() + const checkbox = await screen.findByRole('checkbox') + await user.click(checkbox) + + expect(screen.getByRole('link', { name: /Official source/ })).toBeVisible() + expect(screen.queryByRole('link', { name: /Apply on official site/ })).not.toBeInTheDocument() + }) +}) diff --git a/tests/unit/freshness-reverification-2026-08-10-wave-2.test.ts b/tests/unit/freshness-reverification-2026-08-10-wave-2.test.ts new file mode 100644 index 0000000..70d6c6f --- /dev/null +++ b/tests/unit/freshness-reverification-2026-08-10-wave-2.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest' + +import admissionCycles from '../../content/data/admission-cycles.json' +import cities from '../../content/data/cities.json' +import programs from '../../content/data/programs.json' +import scholarships from '../../content/data/scholarships.json' +import sources from '../../content/data/sources.json' +import universities from '../../content/data/universities.json' +import { bundleSchema } from '../../src/lib/data/schema' + +const TODAY = '2026-08-10' +const data = bundleSchema.parse({ admissionCycles, cities, programs, scholarships, sources, universities }) +const cycleById = new Map(data.admissionCycles.map((record) => [record.id, record])) +const scholarshipById = new Map(data.scholarships.map((record) => [record.id, record])) +const sourceById = new Map(data.sources.map((record) => [record.id, record])) + +describe('2026-08-10 freshness reverification wave 2', () => { + it('records a live check for every accepted official HTTPS source', () => { + const sourceIds = [ + 'src-wku-international-admissions-2027', + 'src-shnu-iclt-2026', + 'src-gap-scholarship-pku-depth-international-chinese-language-teachers-scholarship', + 'src-gap-scholarship-pku-depth-international-chinese-language-teachers-scholarship-support-1', + 'src-gap-scholarship-sch-sisu-iclt-2026', + 'src-gap-scholarship-sch-mew-nss-synu-iclts', + 'src-gap-scholarship-mew-scws-hainnu-iclt-scholarship-2026', + ] + + for (const sourceId of sourceIds) { + const source = sourceById.get(sourceId) + expect(source, sourceId).toBeDefined() + expect(source?.official, sourceId).toBe(true) + expect(source?.url.startsWith('https://'), sourceId).toBe(true) + expect(source?.accessedAt, sourceId).toBe(TODAY) + } + }) + + it('reverifies five WKU spring-transfer cycles without widening their scope', () => { + const cycleIds = [ + 'cycle-2027-wenzhou-kean-university-finance-bs-spring-transfer', + 'cycle-2027-wenzhou-kean-university-global-business-bs-spring-transfer', + 'cycle-2027-wenzhou-kean-university-computer-science-bs-spring-transfer', + 'cycle-2027-wenzhou-kean-university-biology-cell-molecular-bs-spring-transfer', + 'cycle-2027-wenzhou-kean-university-architecture-bfa-spring-transfer', + ] + + for (const cycleId of cycleIds) { + expect(cycleById.get(cycleId), cycleId).toMatchObject({ + academicYear: '2026-2027', + intake: 'spring', + closesOn: '2026-11-01', + tuitionCny: 68000, + tuitionPeriod: 'academic-year', + applicationFeeCny: 400, + factScope: 'complete', + verifiedAt: TODAY, + reviewAfter: '2026-08-17', + status: 'verified', + }) + expect(cycleById.get(cycleId)?.notes?.en).toContain('transfer students only') + } + }) + + it('reverifies the SHNU spring scholarship route as dates-only', () => { + expect(cycleById.get('cycle-2027-shnu-iclt-one-semester-spring')).toMatchObject({ + academicYear: '2026-2027', + intake: 'spring', + closesOn: '2026-10-31', + factScope: 'dates-only', + verifiedAt: TODAY, + reviewAfter: '2026-08-17', + status: 'verified', + }) + }) + + it('reverifies four scholarship deadlines while preserving unsupported unknowns', () => { + expect(scholarshipById.get('sch-gap-pku-depth-international-chinese-language-teachers-scholarship')).toMatchObject({ + deadline: '2026-10-31', + coverage: { tuition: 'full', accommodation: 'full', insurance: true }, + verifiedAt: TODAY, + reviewAfter: '2026-08-17', + status: 'verified', + }) + expect(scholarshipById.get('sch-gap-sch-sisu-iclt-2026')).toMatchObject({ + deadline: '2026-10-31', + coverage: { tuition: 'full', accommodation: 'full', insurance: true }, + verifiedAt: TODAY, + reviewAfter: '2026-08-17', + status: 'verified', + }) + expect(scholarshipById.get('sch-gap-sch-mew-nss-synu-iclts')).toMatchObject({ + deadline: '2026-09-15', + coverage: { tuition: 'full', accommodation: 'full', insurance: true }, + verifiedAt: TODAY, + reviewAfter: '2026-08-13', + status: 'stale', + }) + expect(scholarshipById.get('sch-gap-mew-scws-hainnu-iclt-scholarship-2026')).toMatchObject({ + deadline: '2026-10-31', + coverage: { tuition: 'unknown', accommodation: 'unknown', insurance: 'unknown' }, + verifiedAt: TODAY, + reviewAfter: '2026-08-17', + status: 'verified', + }) + }) +}) diff --git a/tests/unit/freshness-reverification-2026-08-10.test.ts b/tests/unit/freshness-reverification-2026-08-10.test.ts new file mode 100644 index 0000000..8b455e2 --- /dev/null +++ b/tests/unit/freshness-reverification-2026-08-10.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' + +import admissionCycles from '../../content/data/admission-cycles.json' +import cities from '../../content/data/cities.json' +import programs from '../../content/data/programs.json' +import scholarships from '../../content/data/scholarships.json' +import sources from '../../content/data/sources.json' +import universities from '../../content/data/universities.json' +import { bundleSchema } from '../../src/lib/data/schema' + +const TODAY = '2026-08-10' +const data = bundleSchema.parse({ admissionCycles, cities, programs, scholarships, sources, universities }) +const programById = new Map(data.programs.map((record) => [record.id, record])) +const cycleById = new Map(data.admissionCycles.map((record) => [record.id, record])) +const scholarshipById = new Map(data.scholarships.map((record) => [record.id, record])) +const sourceById = new Map(data.sources.map((record) => [record.id, record])) + +describe('2026-08-10 freshness reverification wave', () => { + it('records a live check for every accepted official source', () => { + const sourceIds = [ + 'src-gov-clec', + 'src-program-review-88495cf206e1', + 'src-thu-graduate-programs-in-english-current', + 'src-schwarzman-program-current', + 'src-schwarzman-application-2027', + 'src-gap-program-mve-jzh-suda-long-chinese-year', + 'src-gap-program-mve-jzh-suda-long-chinese-semester', + 'src-gap-program-mve-jzh-zust-iclt-semester', + 'src-gap-program-mve-jzh-zust-iclt-master', + 'src-gap-scholarship-mve-jzh-suda-iclt-scholarship', + 'src-gap-scholarship-mew-csw-scau-guangdong-government-scholarship-2026', + 'src-gap-scholarship-mve-jzh-zust-iclt-scholarship', + ] + + for (const sourceId of sourceIds) { + const source = sourceById.get(sourceId) + expect(source, sourceId).toBeDefined() + expect(source?.official, sourceId).toBe(true) + expect(source?.url.startsWith('https://'), sourceId).toBe(true) + expect(source?.accessedAt, sourceId).toBe(TODAY) + } + }) + + it('keeps Schwarzman dates and requirements exact without making the video mandatory', () => { + const program = programById.get('program-tsinghua-university-schwarzman-scholars-master-of-global-affairs-master') + const cycle = cycleById.get('cycle-2027-schwarzman-scholars-global') + const scholarship = scholarshipById.get('scholarship-schwarzman-scholars-2027') + + expect(cycle).toMatchObject({ + opensOn: '2026-04-08', + closesOn: '2026-09-09', + verifiedAt: TODAY, + reviewAfter: '2026-08-13', + status: 'stale', + }) + expect(scholarship).toMatchObject({ deadline: '2026-09-09', reviewAfter: '2026-08-13' }) + expect(program?.languageRequirements[0]?.minimum).toContain('IELTS 7') + expect(program?.details?.applicationMaterials[1]?.en).toContain('not required') + expect(program?.details?.applicationMaterials[1]?.en).not.toContain('required video') + }) + + it('publishes Soochow fees and restores the current spring scholarship route', () => { + for (const id of [ + 'cycle-gap-mve-jzh-suda-long-chinese-year-2026-2027-autumn', + 'cycle-gap-mve-jzh-suda-long-chinese-semester-2026-2027-autumn', + ]) { + expect(cycleById.get(id)).toMatchObject({ + applicationFeeCny: 500, + factScope: 'complete', + verifiedAt: TODAY, + reviewAfter: '2026-08-17', + }) + } + + expect(cycleById.get('cycle-2026-a6e5661b86ff')).toMatchObject({ + closesOn: '2026-10-31', + status: 'verified', + verifiedAt: TODAY, + reviewAfter: '2026-08-17', + }) + + const scholarship = scholarshipById.get('sch-gap-mve-jzh-suda-iclt-scholarship') + expect(scholarship?.programIds).toContain('program-soochow-university-international-chinese-language-teachers-scholarship-o') + expect(scholarship?.sourceIds).toContain('src-gov-clec') + expect(scholarship?.coverage).toMatchObject({ tuition: 'full', accommodation: 'full', insurance: true }) + }) + + it('publishes only directly supported SCAU and ZUST facts', () => { + const scau = scholarshipById.get('sch-gap-mew-csw-scau-guangdong-government-scholarship-2026') + expect(scau).toMatchObject({ + deadline: '2026-09-01', + verifiedAt: TODAY, + reviewAfter: '2026-08-13', + }) + expect(scau?.summary?.en).toContain('CNY 30,000') + + const zustScholarship = scholarshipById.get('sch-gap-mve-jzh-zust-iclt-scholarship') + expect(zustScholarship).toMatchObject({ + deadline: '2026-10-31', + verifiedAt: TODAY, + reviewAfter: '2026-08-17', + }) + expect(zustScholarship?.coverage).toMatchObject({ tuition: 'full', accommodation: 'full', insurance: true }) + expect(programById.get('prog-gap-mve-jzh-zust-iclt-master')?.languageRequirements[0]?.minimum).toContain('HSKK Intermediate: 60') + expect(programById.get('prog-gap-mve-jzh-zust-iclt-semester')?.languageRequirements[0]?.minimum).toContain('HSK Level 3: 180') + }) +}) diff --git a/tests/unit/home-experience.test.ts b/tests/unit/home-experience.test.ts index 242d9b9..0104cd5 100644 --- a/tests/unit/home-experience.test.ts +++ b/tests/unit/home-experience.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { publicLocales } from '@/i18n/config' -import { getHomeExperienceCopy } from '@/i18n/home-experience' +import { formatStudentCityTitle, getHomeExperienceCopy } from '@/i18n/home-experience' describe('homepage experience copy', () => { it('keeps the four-step decision path complete in every public locale', () => { @@ -17,4 +17,10 @@ describe('homepage experience copy', () => { } } }) + + it('derives the student-city heading from the current catalogue count in every locale', () => { + for (const locale of publicLocales) { + expect(formatStudentCityTitle(62, locale)).toContain('62') + } + }) }) diff --git a/tests/unit/i18n-config.test.ts b/tests/unit/i18n-config.test.ts index b0a9940..283bbd4 100644 --- a/tests/unit/i18n-config.test.ts +++ b/tests/unit/i18n-config.test.ts @@ -10,6 +10,7 @@ import { isPublicLocale, launchLocales, localeDirection, + localizeNavigationHref, localizePathname, pathnameLocale, previewLocales, @@ -47,6 +48,17 @@ describe('locale registry', () => { expect(localizePathname('/', 'en')).toBe('/en') }) + it('keeps semantic filters and resets opaque pagination when changing language', () => { + const searchParams = new URLSearchParams( + 'degree=master&applicationState=open&page=3&cursor=opaque&cursorHistory=%7E%2Cprevious', + ) + + expect(localizeNavigationHref('/en/programs', searchParams, 'zh')).toBe( + '/zh/programs?degree=master&applicationState=open', + ) + expect(searchParams.get('page')).toBe('3') + }) + it('publishes alternates and Open Graph metadata for public locales only', () => { const metadata = pageMetadata('zh', '项目', '项目介绍', 'programs') diff --git a/tests/unit/p0-reliability-evaluator.test.ts b/tests/unit/p0-reliability-evaluator.test.ts new file mode 100644 index 0000000..4173e0b --- /dev/null +++ b/tests/unit/p0-reliability-evaluator.test.ts @@ -0,0 +1,194 @@ +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { describe, expect, it } from 'vitest' + +import { + P0_AUDIT_FORMAT, + P0_OBSERVATION_FORMAT, + P0_RELIABILITY_THRESHOLDS, + evaluateP0Reliability, + parseArguments, +} from '../../scripts/operations/evaluate-p0-reliability.mjs' + +const NOW = '2026-08-10T12:00:00.000Z' + +function healthyObservation() { + return { + format: P0_OBSERVATION_FORMAT, + formatVersion: 1, + observedAt: '2026-08-10T11:55:00.000Z', + backup: { + lastVerifiedAt: '2026-08-09T12:30:00.000Z', + source: 'github-actions:cloudflare-backup/readback', + }, + release: { + lastActivatedAt: '2026-08-09T00:30:00.000Z', + source: 'catalog-d1:catalog-releases/active', + }, + scheduler: { + lastHeartbeatAt: '2026-08-10T11:00:00.000Z', + source: 'pipeline-d1:ingestion-jobs/scheduled', + }, + dlq: { + backlogCount: 0, + oldestMessageAt: null as string | null, + source: 'cloudflare-queues:realtime/dlq', + }, + outbox: { + backlogCount: 2, + oldestPendingAt: '2026-08-04T13:00:00.000Z', + source: 'pipeline-d1:outbox-events/pending', + }, + } +} + +function check(report: ReturnType, id: string) { + const result = report.checks.find((item) => item.id === id) + if (!result) throw new Error(`Missing check: ${id}`) + return result +} + +describe('P0 reliability evaluator', () => { + it('passes only an explicit, fresh and fully sourced observation document', () => { + const report = evaluateP0Reliability(healthyObservation(), NOW) + + expect(report.format).toBe(P0_AUDIT_FORMAT) + expect(report.status).toBe('pass') + expect(report.summary).toEqual({ pass: 7, fail: 0, unobserved: 0 }) + expect(report.thresholds).toEqual({ + observationMaxAgeMinutes: 15, + backupMaxAgeHours: 26, + releaseMaxAgeHours: 48, + schedulerMaxAgeMinutes: 90, + dlqMaxBacklogCount: 0, + outboxMaxAgeHours: 168, + }) + }) + + it('fails closed when observations are absent without echoing unrelated input', () => { + const report = evaluateP0Reliability({ + format: P0_OBSERVATION_FORMAT, + formatVersion: 1, + observedAt: '2026-08-10T11:55:00.000Z', + secret: 'must-never-appear-in-the-report', + }, NOW) + + expect(report.status).toBe('fail') + expect(report.summary.unobserved).toBe(5) + expect(check(report, 'backup_age').status).toBe('unobserved') + expect(check(report, 'dlq_backlog').status).toBe('unobserved') + expect(JSON.stringify(report)).not.toContain('must-never-appear-in-the-report') + }) + + it('accepts inclusive age boundaries and enforces a strict outbox boundary', () => { + const observation = healthyObservation() + observation.backup.lastVerifiedAt = '2026-08-09T10:00:00.000Z' + observation.release.lastActivatedAt = '2026-08-08T12:00:00.000Z' + observation.scheduler.lastHeartbeatAt = '2026-08-10T10:30:00.000Z' + observation.outbox.oldestPendingAt = '2026-08-03T12:00:01.000Z' + + const inside = evaluateP0Reliability(observation, NOW) + expect(inside.status).toBe('pass') + expect(check(inside, 'backup_age').value).toBe(26) + expect(check(inside, 'release_age').value).toBe(48) + expect(check(inside, 'scheduler_heartbeat_age').value).toBe(90) + + observation.outbox.oldestPendingAt = '2026-08-03T12:00:00.000Z' + const boundary = evaluateP0Reliability(observation, NOW) + expect(boundary.status).toBe('fail') + expect(check(boundary, 'outbox_backlog_age')).toMatchObject({ + status: 'fail', + value: { backlogCount: 2, oldestAgeHours: 168 }, + }) + }) + + it.each([ + ['backup_age', (value: ReturnType) => { + value.backup.lastVerifiedAt = '2026-08-09T09:59:59.000Z' + }], + ['release_age', (value: ReturnType) => { + value.release.lastActivatedAt = '2026-08-08T11:59:59.000Z' + }], + ['scheduler_heartbeat_age', (value: ReturnType) => { + value.scheduler.lastHeartbeatAt = '2026-08-10T10:29:59.000Z' + }], + ['dlq_backlog', (value: ReturnType) => { + value.dlq.backlogCount = 1 + value.dlq.oldestMessageAt = '2026-08-10T11:50:00.000Z' + }], + ])('reports %s as failed beyond its exact threshold', (id, mutate) => { + const observation = healthyObservation() + mutate(observation) + const report = evaluateP0Reliability(observation, NOW) + expect(report.status).toBe('fail') + expect(check(report, id).status).toBe('fail') + }) + + it('treats stale documents, future timestamps and inconsistent empty backlogs as untrusted', () => { + const stale = healthyObservation() + stale.observedAt = '2026-08-10T11:44:59.000Z' + expect(check(evaluateP0Reliability(stale, NOW), 'observation_freshness').status).toBe('fail') + + const future = healthyObservation() + future.scheduler.lastHeartbeatAt = '2026-08-10T12:00:01.000Z' + expect(check(evaluateP0Reliability(future, NOW), 'scheduler_heartbeat_age').status) + .toBe('unobserved') + + const inconsistent = healthyObservation() + inconsistent.outbox.backlogCount = 0 + expect(check(evaluateP0Reliability(inconsistent, NOW), 'outbox_backlog_age').status) + .toBe('unobserved') + }) + + it('parses only the bounded local-file CLI contract', () => { + expect(parseArguments(['--input', 'observations.json'])).toEqual({ + inputPath: 'observations.json', + outputPath: null, + }) + expect(() => parseArguments([])).toThrow('--input is required') + expect(() => parseArguments(['--input', 'same.json', '--output', 'same.json'])) + .toThrow('--output must not overwrite') + expect(() => parseArguments(['--url', 'https://example.com'])).toThrow('Unknown argument') + }) + + it('emits machine-readable CLI output and exits non-zero for invalid input', () => { + const directory = mkdtempSync(join(tmpdir(), 'studyinchina-p0-reliability-')) + const script = resolve('scripts/operations/evaluate-p0-reliability.mjs') + const validPath = join(directory, 'valid.json') + writeFileSync(validPath, JSON.stringify({ + ...healthyObservation(), + observedAt: new Date().toISOString(), + backup: { ...healthyObservation().backup, lastVerifiedAt: new Date().toISOString() }, + release: { ...healthyObservation().release, lastActivatedAt: new Date().toISOString() }, + scheduler: { ...healthyObservation().scheduler, lastHeartbeatAt: new Date().toISOString() }, + outbox: { ...healthyObservation().outbox, backlogCount: 0, oldestPendingAt: null }, + })) + + const valid = spawnSync(process.execPath, [script, '--input', validPath], { + encoding: 'utf8', + }) + expect(valid.status).toBe(0) + expect(JSON.parse(valid.stdout)).toMatchObject({ format: P0_AUDIT_FORMAT, status: 'pass' }) + + const invalidPath = join(directory, 'invalid.json') + writeFileSync(invalidPath, '{invalid-json') + const invalid = spawnSync(process.execPath, [script, '--input', invalidPath], { + encoding: 'utf8', + }) + expect(invalid.status).toBe(1) + expect(JSON.parse(invalid.stdout)).toMatchObject({ format: P0_AUDIT_FORMAT, status: 'fail' }) + }) + + it('contains no network or environment-secret access path', () => { + const source = readFileSync( + resolve('scripts/operations/evaluate-p0-reliability.mjs'), + 'utf8', + ) + expect(source).not.toMatch(/\bfetch\s*\(|node:https|node:http|https?:\/\//u) + expect(source).not.toContain('process.env') + expect(P0_RELIABILITY_THRESHOLDS.dlqMaxBacklogCount).toBe(0) + }) +}) diff --git a/tests/unit/platform-data-quality.test.ts b/tests/unit/platform-data-quality.test.ts index a753d6d..faf8e8b 100644 --- a/tests/unit/platform-data-quality.test.ts +++ b/tests/unit/platform-data-quality.test.ts @@ -50,8 +50,17 @@ describe('platform data-quality scorecard', () => { expect(report.metrics.publicRecords).toMatchObject({ universities: 2, programs: 3, scholarships: 1 }) expect(report.metrics.programCoverage).toMatchObject({ schoolsBelowThreePrograms: 2, + programsWithVerifiedIdentity: 3, + identityCoveragePct: 100, + programsWithFreshDisposition: 1, + freshDispositionCoveragePct: 33.33, + programsWithDatedOrRollingCycle: 1, + datedOrRollingCoveragePct: 33.33, + programsActiveOrUpcoming: 1, + activeUpcomingCoveragePct: 33.33, programsWithCurrentCycle: 1, currentCycleCoveragePct: 33.33, + currentCycleCoverageSemantics: 'deprecated_alias_of_dated_or_rolling', durationCoveragePct: 33.33, applicationUrlCoveragePct: 33.33, teachingLanguageCoveragePct: 66.67, @@ -63,6 +72,31 @@ describe('platform data-quality scorecard', () => { expect(JSON.stringify(data)).toBe(snapshot) }) + it('does not let a date-free fee reference raise actionable cycle coverage', () => { + const data = fixture() + data.admissionCycles.push({ + ...data.admissionCycles[0], + id: 'cycle-program-2-fee-reference', + programId: 'program-2', + opensOn: null, + closesOn: null, + dateStatus: 'not-announced', + tuitionCny: 24_000, + tuitionStatus: 'reference', + evidenceBasis: 'recurring-official-rule', + }) + + const report = buildPlatformDataQualityScorecard(data, [], { today: '2026-08-06' }) + + expect(report.metrics.publicRecords.admissionCycles).toBe(2) + expect(report.metrics.programCoverage).toMatchObject({ + programsWithFreshDisposition: 1, + programsWithDatedOrRollingCycle: 1, + programsActiveOrUpcoming: 1, + programsWithCurrentCycle: 1, + }) + }) + it('reports overdue verified data and published cycles with no dates', () => { const data = fixture() data.universities[0].reviewAfter = '2026-08-05' diff --git a/tests/unit/program-compare-api.test.ts b/tests/unit/program-compare-api.test.ts new file mode 100644 index 0000000..abe65a2 --- /dev/null +++ b/tests/unit/program-compare-api.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { GET } from '@/app/api/v1/programs/compare/route' +import { getData } from '@/lib/data/load' + +describe('program comparison API', () => { + it('returns at most four lightweight records in requested order', async () => { + const ids = getData().programs.slice(0, 4).map((program) => program.id) + const response = await GET(new Request( + `https://example.test/api/v1/programs/compare?ids=${encodeURIComponent(ids.join(','))}`, + )) + const payload = await response.json() as { + data: { + items: Array<{ + program: { id: string; university: { id: string }; fieldMeta: Record } + currentCycle: unknown + linkedScholarshipCount: number + }> + missingIds: string[] + } + } + + expect(response.status).toBe(200) + expect(payload.data.items.map((item) => item.program.id)).toEqual(ids) + expect(payload.data.missingIds).toEqual([]) + expect(payload.data.items.every((item) => ( + item.program.university.id.length > 0 + && typeof item.linkedScholarshipCount === 'number' + && Object.keys(item.program.fieldMeta).length > 0 + ))).toBe(true) + expect(JSON.stringify(payload).length).toBeLessThan(150_000) + }) + + it('deduplicates ids and reports records that are no longer public', async () => { + const id = getData().programs[0]!.id + const response = await GET(new Request( + `https://example.test/api/v1/programs/compare?ids=${id},${id},prog-missing-record`, + )) + const payload = await response.json() as { + data: { items: Array<{ program: { id: string } }>; missingIds: string[] } + } + + expect(response.status).toBe(200) + expect(payload.data.items.map((item) => item.program.id)).toEqual([id]) + expect(payload.data.missingIds).toEqual(['prog-missing-record']) + }) + + it('rejects empty, malformed, or oversized comparisons without caching', async () => { + for (const query of [ + '', + '?ids=bad%20id', + '?ids=prog-1,prog-2,prog-3,prog-4,prog-5', + ]) { + const response = await GET(new Request( + `https://example.test/api/v1/programs/compare${query}`, + )) + expect(response.status).toBe(400) + expect(response.headers.get('cache-control')).toBe('no-store') + } + }) +}) diff --git a/tests/unit/program-explorer.test.tsx b/tests/unit/program-explorer.test.tsx index df4c62e..9635a70 100644 --- a/tests/unit/program-explorer.test.tsx +++ b/tests/unit/program-explorer.test.tsx @@ -15,11 +15,17 @@ const admissionCycles = admissionCyclesJson as AdmissionCycle[] describe('ProgramExplorer', () => { it('normalizes a legacy discipline deep link into the complete field taxonomy', () => { - const expectedCount = programs.filter((program) => classifyProgramField(program) === 'engineering-technology').length + const engineeringPrograms = programs + .filter((program) => classifyProgramField(program) === 'engineering-technology') + .slice(0, 8) + const comparisonPrograms = programs + .filter((program) => classifyProgramField(program) !== 'engineering-technology') + .slice(0, 2) + const samplePrograms = [...engineeringPrograms, ...comparisonPrograms] render( { ) expect(screen.getByLabelText('Field')).toHaveValue('engineering-technology') - expect(screen.getByText(`${expectedCount} programs`)).toBeVisible() + expect(screen.getByText(`${engineeringPrograms.length} programs`)).toBeVisible() }) it('surfaces verified Chinese degree programs in the Chinese language and culture field', () => { diff --git a/tests/unit/regional-breadth-wave-2026-08-05.test.ts b/tests/unit/regional-breadth-wave-2026-08-05.test.ts index 101b075..3e9c5e8 100644 --- a/tests/unit/regional-breadth-wave-2026-08-05.test.ts +++ b/tests/unit/regional-breadth-wave-2026-08-05.test.ts @@ -286,7 +286,9 @@ describe('verified regional breadth expansion on 2026-08-05', () => { it('raises the public breadth floor while preserving Tibet University as an explicit limited case', () => { expect(published.universities.length).toBeGreaterThanOrEqual(266) expect(published.programs.length).toBeGreaterThanOrEqual(1_211) - expect(published.scholarships.length).toBeGreaterThanOrEqual(355) + expect(data.scholarships.filter( + (scholarship) => scholarship.status === 'verified' || scholarship.status === 'stale', + ).length).toBeGreaterThanOrEqual(355) const counts = new Map() for (const program of published.programs) { diff --git a/tests/unit/release-workflow-safety.test.ts b/tests/unit/release-workflow-safety.test.ts index 186d7b2..6098e40 100644 --- a/tests/unit/release-workflow-safety.test.ts +++ b/tests/unit/release-workflow-safety.test.ts @@ -12,23 +12,81 @@ describe('production release workflow safety', () => { const workflow = readWorkflow('vercel-production-alias.yml') expect(workflow).toContain("github.event.deployment_status.state == 'success'") + expect(workflow).toMatch(/permissions:\s+actions: read\s+contents: read/u) expect(workflow).toContain("github.event.deployment.environment == 'Production'") expect(workflow).toContain('DEPLOYMENT_SHA: ${{ github.event.deployment.sha }}') expect(workflow).toContain('main_sha="$(git rev-parse HEAD)"') expect(workflow).toContain("steps.main.outputs.matches == 'true'") + expect(workflow).toContain('cancel-in-progress: false') + expect(workflow).not.toContain('cancel-in-progress: true') expect(workflow).not.toContain("github.event.deployment.ref == 'main'") const comparison = workflow.indexOf('Verify deployment commit is current main') - const promotion = workflow.indexOf('Promote stable production alias') + const ciGate = workflow.indexOf('Wait for successful CI on the exact deployment SHA') + const currentMainRecheck = workflow.indexOf('Reconfirm deployment SHA is still current main') + const promotion = workflow.indexOf('Promote stable production alias transaction') expect(comparison).toBeGreaterThan(-1) - expect(promotion).toBeGreaterThan(comparison) + expect(ciGate).toBeGreaterThan(comparison) + expect(currentMainRecheck).toBeGreaterThan(ciGate) + expect(promotion).toBeGreaterThan(currentMainRecheck) + expect(workflow).toContain('branch=main&event=push') + expect(workflow).toContain('.head_sha == $sha') + expect(workflow).toContain('.conclusion == "success"') + expect(workflow).toContain("steps.ci.outputs.passed == 'true'") + expect(workflow).toContain('/git/ref/heads/main') + expect(workflow).toContain("steps.current.outputs.matches == 'true'") + }) + + it('keeps alias mutation and stable smoke in one fail-closed rollback transaction', () => { + const workflow = readWorkflow('vercel-production-alias.yml') + const transactionStart = workflow.indexOf('Promote stable production alias transaction') + const transaction = workflow.slice(transactionStart) + const rollbackFunction = transaction.indexOf('rollback_on_failure()') + const rollbackTrap = transaction.indexOf('trap rollback_on_failure EXIT') + const previousTarget = transaction.indexOf('previous_target="$(current_stable_target)"') + const finalMainCheck = transaction.indexOf('final_main_sha=') + const mutationArmed = transaction.indexOf('mutation_attempted=true') + const candidateAliasSet = transaction.indexOf( + 'npx --yes vercel@58.0.0 alias set', + mutationArmed, + ) + const postPromotionCheck = transaction.indexOf('post_promotion_main_sha=') + const stableSmoke = transaction.indexOf( + 'https://studyinchina.vercel.app/api/v1/releases/current', + ) + const transactionCommit = transaction.indexOf('transaction_committed=true') + const rollbackAliasSet = transaction.indexOf( + 'npx --yes vercel@58.0.0 alias set', + rollbackFunction, + ) + + expect(transactionStart).toBeGreaterThan(-1) + expect(rollbackFunction).toBeGreaterThan(-1) + expect(rollbackTrap).toBeGreaterThan(rollbackFunction) + expect(previousTarget).toBeGreaterThan(rollbackTrap) + expect(finalMainCheck).toBeGreaterThan(previousTarget) + expect(mutationArmed).toBeGreaterThan(finalMainCheck) + expect(candidateAliasSet).toBeGreaterThan(mutationArmed) + expect(postPromotionCheck).toBeGreaterThan(candidateAliasSet) + expect(stableSmoke).toBeGreaterThan(postPromotionCheck) + expect(transactionCommit).toBeGreaterThan(stableSmoke) + expect(rollbackAliasSet).toBeGreaterThan(rollbackFunction) + expect(rollbackAliasSet).toBeLessThan(rollbackTrap) + expect(transaction).toContain('${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/git/ref/heads/main') + expect(transaction).toContain('"${previous_target}"') + expect(transaction).toContain('Production promotion raced with main') + expect(transaction).toContain('Stable alias rollback failed') + expect(transaction).toContain('Stable alias rollback verification failed') + expect(transaction).toContain('restored_target="$(current_stable_target || true)"') + expect(transaction).toContain('"${restored_target}" == "${previous_target}"') + expect(transaction.match(/vercel@58\.0\.0 alias set/gu)).toHaveLength(2) }) it('fails closed when the stable-alias credential is unavailable', () => { const workflow = readWorkflow('vercel-production-alias.yml') const credentialGate = workflow.indexOf('Require stable-alias credential') const missingCredential = workflow.indexOf('VERCEL_TOKEN is not configured') - const promotion = workflow.indexOf('Promote stable production alias') + const promotion = workflow.indexOf('Promote stable production alias transaction') const credentialBlock = workflow.slice(credentialGate, promotion) expect(credentialGate).toBeGreaterThan(-1) @@ -38,34 +96,42 @@ describe('production release workflow safety', () => { expect(credentialBlock).not.toContain('::warning::') }) - it('smoke-tests the immutable deployment before promotion and the stable alias after it', () => { + it('smoke-tests the immutable deployment before the fail-closed stable-alias transaction', () => { const workflow = readWorkflow('vercel-production-alias.yml') const urlValidation = workflow.indexOf('Validate deployment URL') + const ciGate = workflow.indexOf('Wait for successful CI on the exact deployment SHA') const immutableSmoke = workflow.indexOf('Verify immutable deployment release API') - const promotion = workflow.indexOf('Promote stable production alias') - const stableSmoke = workflow.indexOf('Verify stable alias release API') + const transaction = workflow.indexOf('Promote stable production alias transaction') + const stableSmoke = workflow.indexOf( + 'https://studyinchina.vercel.app/api/v1/releases/current', + transaction, + ) expect(urlValidation).toBeGreaterThan(-1) + expect(ciGate).toBeGreaterThan(-1) + expect(immutableSmoke).toBeGreaterThan(ciGate) expect(immutableSmoke).toBeGreaterThan(urlValidation) - expect(promotion).toBeGreaterThan(immutableSmoke) - expect(stableSmoke).toBeGreaterThan(promotion) - expect(workflow.slice(immutableSmoke, promotion)).toContain( + expect(transaction).toBeGreaterThan(immutableSmoke) + expect(stableSmoke).toBeGreaterThan(transaction) + expect(workflow.slice(immutableSmoke, transaction)).toContain( '${DEPLOYMENT_URL%/}/api/v1/releases/current', ) - expect(workflow.slice(stableSmoke)).toContain( - 'https://studyinchina.vercel.app/api/v1/releases/current', + expect(workflow.slice(immutableSmoke, transaction)).toContain('.data.deploymentSha == $sha') + expect(workflow.slice(transaction)).toContain('.data.deploymentSha == $sha') + expect(workflow.slice(transaction)).toContain( + '.data.publicCounts.programs | type == "number" and . > 0', ) + expect(workflow.slice(stableSmoke)).toContain('transaction_committed=true') }) - it('exposes the Vercel token only to the credential gate and alias command', () => { + it('exposes the Vercel token only to the credential gate and alias transaction', () => { const workflow = readWorkflow('vercel-production-alias.yml') const bindings = workflow.match( /^\s+VERCEL_TOKEN:\s+\$\{\{ secrets\.VERCEL_TOKEN \}\}$/gmu, ) ?? [] const credentialGate = workflow.indexOf('Require stable-alias credential') const immutableSmoke = workflow.indexOf('Verify immutable deployment release API') - const promotion = workflow.indexOf('Promote stable production alias') - const stableSmoke = workflow.indexOf('Verify stable alias release API') + const transaction = workflow.indexOf('Promote stable production alias transaction') expect(bindings).toHaveLength(2) expect(workflow.slice(0, credentialGate)).not.toContain( @@ -74,13 +140,13 @@ describe('production release workflow safety', () => { expect(workflow.slice(credentialGate, immutableSmoke)).toContain( 'VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}', ) - expect(workflow.slice(immutableSmoke, promotion)).not.toContain( + expect(workflow.slice(immutableSmoke, transaction)).not.toContain( 'VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}', ) - expect(workflow.slice(promotion, stableSmoke)).toContain( - 'VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}', - ) - expect(workflow.slice(stableSmoke)).not.toContain( + expect(workflow.slice(transaction).match( + /^\s+VERCEL_TOKEN:\s+\$\{\{ secrets\.VERCEL_TOKEN \}\}$/gmu, + )).toHaveLength(1) + expect(workflow.slice(transaction)).toContain( 'VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}', ) }) diff --git a/tests/unit/source-manifest-candidate-promotion.test.ts b/tests/unit/source-manifest-candidate-promotion.test.ts index 5670437..6f7e2af 100644 --- a/tests/unit/source-manifest-candidate-promotion.test.ts +++ b/tests/unit/source-manifest-candidate-promotion.test.ts @@ -144,6 +144,7 @@ function completeManifest(candidate: SourceManifestV2): SourceManifestV2 { ), catalogReconciliation: { ...structuredClone(candidate.catalogReconciliation), + scope: "full_official_catalog", status: "complete", entries: candidate.catalogReconciliation.entries.map((entry, index) => ({ ...entry, @@ -331,6 +332,27 @@ describe("SourceManifestV2 candidate promotion gate", () => { ).toThrow(/requires a complete manifest/); }); + it("rejects representative discovery mislabeled as complete reconciliation", () => { + const representative = setupPromotion(); + representative.review.manifest.catalogReconciliation.scope = + "representative_international_programs"; + writeFileSync( + representative.reviewDecisionPath, + JSON.stringify(representative.review, null, 2) + "\n", + "utf8", + ); + + expect(() => + promoteSourceManifestCandidate({ + artifactDirectory: representative.artifactDirectory, + reviewDecisionPath: representative.reviewDecisionPath, + repositoryRoot: representative.repositoryRoot, + }), + ).toThrow( + /representative_international_programs cannot claim complete catalog reconciliation/, + ); + }); + it("rejects a second formal manifest for the same institution", () => { const setup = setupPromotion(); const existingDirectory = join( diff --git a/tests/unit/source-manifest-import.test.ts b/tests/unit/source-manifest-import.test.ts index eacfd38..97fe07a 100644 --- a/tests/unit/source-manifest-import.test.ts +++ b/tests/unit/source-manifest-import.test.ts @@ -2,8 +2,16 @@ import { DatabaseSync } from 'node:sqlite' import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { buildPilotSourceImport } from '../../scripts/ingestion/build-source-import' -import { validatePilotSourceManifestDirectory } from '../../scripts/validate-source-manifests' +import { + buildPilotSourceImport, + normalizeSourceManifestsForImport, +} from '../../scripts/ingestion/build-source-import' +import type { SourceManifestV2 } from '../../scripts/source-manifest-contract' +import { + loadPilotSourceManifestFiles, + validatePilotSourceManifestDirectory, + validatePilotSourceManifests, +} from '../../scripts/validate-source-manifests' function databaseWithPipelineSchema() { const database = new DatabaseSync(':memory:') @@ -26,6 +34,18 @@ describe('pilot Source Manifest import', () => { it('is idempotent, preserves fetch state, and disables removed pilot sources', () => { const records = validatePilotSourceManifestDirectory() const generatedAt = '2026-07-23T08:00:00.000Z' + const normalized = normalizeSourceManifestsForImport(records) + expect(normalized).toHaveLength(10) + expect(normalized.every((record) => record.manifestVersion === 2)).toBe(true) + expect(normalized.every( + (record) => record.catalogReconciliationComplete === false, + )).toBe(true) + const expectedPkuSource = records + .find((record) => record.institutionId === 'uni-peking-university')! + .sources.find((source) => source.id === 'pku-intl-admissions-home')! + expect(normalized + .find((record) => record.institutionId === 'uni-peking-university')! + .sources).toContainEqual(expectedPkuSource) const artifacts = buildPilotSourceImport(records, generatedAt) expect(artifacts.institutions).toBe(10) expect(artifacts.sources).toBe(100) @@ -53,6 +73,16 @@ describe('pilot Source Manifest import', () => { next_fetch_at: generatedAt, }) + const storedPkuSource = database.prepare(` + SELECT manifest_json FROM ingestion_sources + WHERE source_id = 'pku-intl-admissions-home' + `).get() as { manifest_json: string } + expect(JSON.parse(storedPkuSource.manifest_json)).toEqual(expectedPkuSource) + expect(JSON.parse(storedPkuSource.manifest_json)).toMatchObject({ + version: 1, enabled: true, schedule: expectedPkuSource.schedule, + robots: expectedPkuSource.robots, officialUrl: expectedPkuSource.officialUrl, + }) + const changed = structuredClone(records) const pku = changed.find((record) => record.institutionId === 'uni-peking-university')! pku.sources = pku.sources.filter((source) => source.id !== 'pku-intl-admissions-home') @@ -63,4 +93,26 @@ describe('pilot Source Manifest import', () => { `).get()).toEqual({ enabled: 0, next_fetch_at: null }) database.close() }) + + it('accepts an in-progress V2 envelope without treating it as reconciled', () => { + const inputs = loadPilotSourceManifestFiles().map((input) => ({ + filePath: input.filePath, + value: structuredClone(input.value), + })) + const records = validatePilotSourceManifests(inputs) + expect(records.every((record) => ( + record.version === 2 + && record.manifestStatus === 'in_progress' + && !normalizeSourceManifestsForImport([record])[0]! + .catalogReconciliationComplete + ))).toBe(true) + + const invalidComplete = inputs[0]!.value as SourceManifestV2 + invalidComplete.manifestStatus = 'complete' + invalidComplete.catalogReconciliation.scope = 'full_official_catalog' + invalidComplete.catalogReconciliation.status = 'complete' + expect(() => validatePilotSourceManifests(inputs)).toThrow( + /complete catalog reconciliation cannot contain pending entries/, + ) + }) }) diff --git a/tests/unit/source-manifest-registry.test.ts b/tests/unit/source-manifest-registry.test.ts index 6d350c2..cd7f225 100644 --- a/tests/unit/source-manifest-registry.test.ts +++ b/tests/unit/source-manifest-registry.test.ts @@ -14,10 +14,6 @@ import { type LoadedSourceManifest, type SourceManifestV2, } from '../../scripts/source-manifest-registry' -import { - loadPilotSourceManifestFiles, - type PilotSourceManifest, -} from '../../scripts/validate-source-manifests' const temporaryDirectories: string[] = [] @@ -27,33 +23,28 @@ afterEach(() => { } }) -function legacyInputs(): LoadedSourceManifest[] { - return loadPilotSourceManifestFiles().map((input) => ({ +function formalInputs(): LoadedSourceManifest[] { + return loadSourceManifestFiles(join( + process.cwd(), + 'content', + 'source-manifests', + 'pilot', + )).map((input) => ({ filePath: input.filePath, value: structuredClone(input.value), })) } function v2Fixture(): SourceManifestV2 { - const legacy = structuredClone(legacyInputs()[0]!.value) as PilotSourceManifest - const officialHosts = [ - ...new Set( - legacy.sources.flatMap((source) => [ - ...source.allowedHosts, - ...(source.allowedRedirectHosts ?? []), - ]), - ), - ] + const formal = structuredClone(formalInputs()[0]!.value) as SourceManifestV2 return { - ...legacy, - version: 2, + ...formal, manifestStatus: 'in_progress', - officialHosts, catalogReconciliation: { scope: 'full_official_catalog', status: 'in_progress', entries: [{ - sourceId: legacy.sources[0]!.id, + sourceId: formal.sources[0]!.id, officialKey: 'official-program-001', officialName: 'Official international programme', entityType: 'program', @@ -75,7 +66,7 @@ describe('recursive source manifest registry', () => { ) writeFileSync( join(nested, 'school.json'), - JSON.stringify(legacyInputs()[0]!.value), + JSON.stringify(formalInputs()[0]!.value), ) const files = loadSourceManifestFiles(directory) @@ -85,17 +76,19 @@ describe('recursive source manifest registry', () => { }) it('does not require the old exact ten-school pilot set', () => { - const records = validateSourceManifests(legacyInputs().slice(0, 3)) + const records = validateSourceManifests(formalInputs().slice(0, 3)) expect(records).toHaveLength(3) - expect(records.every((record) => record.version === 1)).toBe(true) + expect(records.every((record) => ( + record.version === 2 && record.manifestStatus === 'in_progress' + ))).toBe(true) expect(records.every((record) => !isCatalogReconciliationComplete(record))).toBe(true) }) it('still rejects institution and source identities reused across manifests', () => { - const inputs = legacyInputs().slice(0, 2) - const first = inputs[0]!.value as PilotSourceManifest - const second = inputs[1]!.value as PilotSourceManifest + const inputs = formalInputs().slice(0, 2) + const first = inputs[0]!.value as SourceManifestV2 + const second = inputs[1]!.value as SourceManifestV2 second.institutionId = first.institutionId for (const source of second.sources) source.institutionId = first.institutionId @@ -133,5 +126,24 @@ describe('recursive source manifest registry', () => { expect(validated).toBeDefined() expect(isCatalogReconciliationComplete(validated!)).toBe(true) + + const limited = structuredClone(complete) + limited.catalogReconciliation.scope = 'limited_official_catalog' + const [validatedLimited] = validateSourceManifests([{ + filePath: 'v2-limited-complete.json', + value: limited, + }]) + expect(isCatalogReconciliationComplete(validatedLimited!)).toBe(true) + + const representative = structuredClone(complete) + representative.catalogReconciliation.scope = 'representative_international_programs' + + expect(isCatalogReconciliationComplete(representative)).toBe(false) + expect(() => validateSourceManifests([{ + filePath: 'v2-representative-complete.json', + value: representative, + }])).toThrow( + /representative_international_programs cannot claim complete catalog reconciliation/, + ) }) }) diff --git a/tests/unit/source-manifest-trust-ledger.test.ts b/tests/unit/source-manifest-trust-ledger.test.ts new file mode 100644 index 0000000..37da13e --- /dev/null +++ b/tests/unit/source-manifest-trust-ledger.test.ts @@ -0,0 +1,149 @@ +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + buildCurrentSourceManifestTrustLedger, + buildSourceManifestTrustLedger, + parseSourceManifestTrustLedgerCli, +} from '../../scripts/ingestion/build-source-manifest-trust-ledger' +import { buildCurrentSourceManifestCohort } from '../../scripts/ingestion/build-source-manifest-cohort' + +describe('SourceManifestV2 public trust ledger', () => { + it('covers every current public university while separating candidates from reconciliation', () => { + const report = buildCurrentSourceManifestTrustLedger('2026-08-10', resolve('.')) + + expect(report).toMatchObject({ + format: 'studyinchina.source-manifest-trust-ledger', + formatVersion: 1, + checkedAt: '2026-08-10', + scope: 'public_catalog', + disposition: 'audit_only', + summary: { + publicUniversities: 266, + ledgerEntries: 266, + formalManifestRecords: 10, + formalRecordsOutsidePublicCatalog: 0, + legacyV1UpgradePaths: 0, + formalV2InProgress: 10, + completeFormalReconciliations: 0, + candidateCoverage: 140, + candidateRecordsOutsidePublicCatalog: 4, + candidateOnlyRecords: 130, + formalCandidateOverlap: 10, + officialSourceDiscoveryRequired: 126, + candidateCohort: { + officialTargets: 147, + militaryExcluded: 3, + eligibleTargets: 144, + candidateManifests: 144, + catalogLinkedCandidates: 140, + limitedOfficialCatalogCandidates: 4, + }, + }, + }) + expect( + Object.values(report.summary.statusCounts) + .reduce((total, count) => total + count, 0), + ).toBe(report.summary.publicUniversities) + expect(report.summary.statusCounts).toEqual({ + complete: 0, + in_progress: 266, + limited_official_catalog: 0, + }) + expect(new Set(report.entries.map((entry) => entry.institutionId)).size) + .toBe(report.entries.length) + + const candidates = report.entries.filter((entry) => entry.candidate.available) + expect(candidates).toHaveLength(140) + expect(candidates.every((entry) => ( + entry.candidate.safelyDisabled + && entry.gates.candidateEvidenceOnly + && !entry.gates.publicationEligible + && entry.reconciliation.state !== 'complete' + ))).toBe(true) + + const formalV2Pilots = report.entries.filter( + (entry) => entry.formalManifest.version === 2 + && entry.formalManifest.state === 'in_progress', + ) + expect(formalV2Pilots).toHaveLength(10) + expect(formalV2Pilots.every((entry) => ( + entry.upgradeStage === 'formal_v2_reconciliation_required' + && entry.reconciliation.state === 'in_progress' + && !entry.gates.publicationEligible + ))).toBe(true) + }) + + it('fails closed if a cohort candidate is enabled or no longer pending', () => { + const { build } = buildCurrentSourceManifestCohort('2026-08-10', resolve('.')) + const candidateBuild = structuredClone(build) + candidateBuild.candidates[0]!.manifest.sources[0]!.enabled = true + + expect(() => buildSourceManifestTrustLedger({ + checkedAt: '2026-08-10', + publicUniversities: [{ + id: candidateBuild.candidates[0]!.manifest.institutionId, + slug: 'test-university', + name: { en: 'Test University', zh: '测试大学' }, + }], + formalManifests: [], + candidateBuild, + })).toThrow(/not safely disabled and pending/) + }) + + it('never makes representative discovery publication eligible', () => { + const { build } = buildCurrentSourceManifestCohort('2026-08-10', resolve('.')) + const candidateBuild = structuredClone(build) + const candidate = candidateBuild.candidates[0]! + const representative = structuredClone(candidate.manifest) + representative.manifestStatus = 'complete' + representative.catalogReconciliation.status = 'complete' + representative.catalogReconciliation.scope = 'representative_international_programs' + representative.catalogReconciliation.entries = representative.catalogReconciliation.entries + .map((entry, index) => ({ + ...entry, + status: 'published' as const, + recordId: `program-review-${index + 1}`, + })) + + const report = buildSourceManifestTrustLedger({ + checkedAt: '2026-08-10', + publicUniversities: [{ + id: representative.institutionId, + slug: 'representative-test', + name: { en: 'Representative Test', zh: '代表性发现测试' }, + }], + formalManifests: [representative], + candidateBuild, + }) + + expect(report.entries).toHaveLength(1) + expect(report.entries[0]).toMatchObject({ + status: 'in_progress', + reconciliation: { state: 'in_progress', basis: 'formal_v2' }, + gates: { + publicationEligible: false, + candidateEvidenceOnly: true, + requiresHumanReview: true, + }, + }) + expect(report.summary.completeFormalReconciliations).toBe(0) + }) + + it('requires an explicit real date and an explicit output option', () => { + expect(parseSourceManifestTrustLedgerCli([ + '--checked-at', '2026-08-10', + ])).toEqual({ checkedAt: '2026-08-10' }) + expect(parseSourceManifestTrustLedgerCli([ + '--checked-at', '2026-08-10', '--output', 'ledger.json', + ])).toEqual({ + checkedAt: '2026-08-10', + outputPath: 'ledger.json', + }) + expect(() => parseSourceManifestTrustLedgerCli([ + '--checked-at', '2026-02-30', + ])).toThrow(/real YYYY-MM-DD/) + expect(() => parseSourceManifestTrustLedgerCli([ + '--checked-at', '2026-08-10', '--write-formal', + ])).toThrow(/Unknown CLI option/) + }) +}) diff --git a/tests/unit/source-manifests.test.ts b/tests/unit/source-manifests.test.ts index 687e4de..adce05a 100644 --- a/tests/unit/source-manifests.test.ts +++ b/tests/unit/source-manifests.test.ts @@ -1,34 +1,42 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' +import { + isCatalogReconciliationComplete, + loadSourceManifestFiles, + validateSourceManifests, + type LoadedSourceManifest, + type SourceManifestV2, +} from '../../scripts/source-manifest-registry' import { EXPECTED_PILOT_INSTITUTION_IDS, INSTITUTION_HOST_ALLOWLISTS, SOURCE_CATEGORIES, - loadPilotSourceManifestFiles, - validatePilotSourceManifests, - type LoadedPilotSourceManifest, - type PilotSourceManifest, } from '../../scripts/validate-source-manifests' const RESERVED_USTC_ID = 'uni-university-of-science-and-technology-of-china' -function clonedInputs(): LoadedPilotSourceManifest[] { - return loadPilotSourceManifestFiles().map((input) => ({ +function clonedInputs(): LoadedSourceManifest[] { + return loadSourceManifestFiles(join( + process.cwd(), + 'content', + 'source-manifests', + 'pilot', + )).map((input) => ({ filePath: input.filePath, value: structuredClone(input.value), })) } -function recordOf(input: LoadedPilotSourceManifest): PilotSourceManifest { - return input.value as PilotSourceManifest +function recordOf(input: LoadedSourceManifest): SourceManifestV2 { + return input.value as SourceManifestV2 } function findRecord( - inputs: LoadedPilotSourceManifest[], + inputs: LoadedSourceManifest[], institutionId: string, -): PilotSourceManifest { +): SourceManifestV2 { const record = inputs .map(recordOf) .find((candidate) => candidate.institutionId === institutionId) @@ -37,14 +45,23 @@ function findRecord( } describe('pilot source manifests', () => { - it('validates the exact ten-school pilot and all sixteen coverage categories', () => { - const records = validatePilotSourceManifests(clonedInputs()) + it('validates ten fail-closed V2 pilot manifests and all source categories', () => { + const records = validateSourceManifests(clonedInputs()) expect(records).toHaveLength(10) expect(records.map((record) => record.institutionId).sort()).toEqual( [...EXPECTED_PILOT_INSTITUTION_IDS].sort(), ) for (const record of records) { + expect(record.version).toBe(2) + if (record.version !== 2) throw new Error('Expected a V2 pilot manifest') + expect(record.manifestStatus).toBe('in_progress') + expect(record.catalogReconciliation.status).toBe('in_progress') + expect(record.catalogReconciliation.entries.length).toBeGreaterThan(0) + expect(record.catalogReconciliation.entries.every( + (entry) => entry.status === 'pending', + )).toBe(true) + expect(isCatalogReconciliationComplete(record)).toBe(false) expect(record.coverage.map((entry) => entry.sourceCategory).sort()).toEqual( [...SOURCE_CATEGORIES].sort(), ) @@ -52,9 +69,11 @@ describe('pilot source manifests', () => { }) it('uses only HTTPS URLs and institution-scoped official host allowlists', () => { - const records = validatePilotSourceManifests(clonedInputs()) + const records = validateSourceManifests(clonedInputs()) for (const record of records) { + expect(record.version).toBe(2) + if (record.version !== 2) throw new Error('Expected a V2 pilot manifest') const approvedHosts = new Set( INSTITUTION_HOST_ALLOWLISTS[ record.institutionId as keyof typeof INSTITUTION_HOST_ALLOWLISTS @@ -69,17 +88,19 @@ describe('pilot source manifests', () => { ...(source.allowedRedirectHosts ?? []), ]) { expect(approvedHosts.has(host)).toBe(true) + expect(record.officialHosts).toContain(host) } } } }) it('keeps source ids globally unique', () => { - const records = validatePilotSourceManifests(clonedInputs()) + const records = validateSourceManifests(clonedInputs()) const ids = records.flatMap((record) => record.sources.map((source) => source.id), ) + expect(ids).toHaveLength(100) expect(new Set(ids).size).toBe(ids.length) }) @@ -89,8 +110,8 @@ describe('pilot source manifests', () => { record.sources[0]!.officialUrl = 'https://attacker.example/admissions' record.sources[0]!.allowedHosts = ['attacker.example'] - expect(() => validatePilotSourceManifests(inputs)).toThrow( - /uses unapproved host attacker\.example/, + expect(() => validateSourceManifests(inputs)).toThrow( + /uses undeclared official host attacker\.example/, ) }) @@ -100,7 +121,7 @@ describe('pilot source manifests', () => { const second = recordOf(inputs[1]!) second.sources[0]!.id = first.sources[0]!.id - expect(() => validatePilotSourceManifests(inputs)).toThrow( + expect(() => validateSourceManifests(inputs)).toThrow( /duplicate source id/, ) }) @@ -109,8 +130,8 @@ describe('pilot source manifests', () => { const inputs = clonedInputs() recordOf(inputs[0]!).coverage.pop() - expect(() => validatePilotSourceManifests(inputs)).toThrow( - /coverage: Too small|coverage: Array must contain exactly/, + expect(() => validateSourceManifests(inputs)).toThrow( + /coverage/, ) }) @@ -123,13 +144,13 @@ describe('pilot source manifests', () => { if (!unavailableSource) throw new Error('Missing Peking unavailable source fixture') unavailableSource.enabled = true - expect(() => validatePilotSourceManifests(inputs)).toThrow( + expect(() => validateSourceManifests(inputs)).toThrow( /must be disabled while coverage is source_unavailable/, ) }) it('requires a confirmed official admissions home and application entrance', () => { - const records = validatePilotSourceManifests(clonedInputs()) + const records = validateSourceManifests(clonedInputs()) const knownStatuses = new Set([ 'registered', 'parser_pending', @@ -152,7 +173,7 @@ describe('pilot source manifests', () => { }) it('keeps every pilot institution in the expanded catalog', () => { - const records = validatePilotSourceManifests(clonedInputs()) + const records = validateSourceManifests(clonedInputs()) const planned = records.filter( (record) => record.catalogStatus === 'planned_addition', ) @@ -171,8 +192,8 @@ describe('pilot source manifests', () => { const inputs = clonedInputs() findRecord(inputs, RESERVED_USTC_ID).catalogStatus = 'planned_addition' - expect(() => validatePilotSourceManifests(inputs)).toThrow( - /catalogStatus must be existing/, + expect(() => validateSourceManifests(inputs)).toThrow( + /planned institution already exists/, ) }) }) diff --git a/tests/unit/sparse-depth-wave-2026-08-08.test.ts b/tests/unit/sparse-depth-wave-2026-08-08.test.ts index 19399c0..37a642f 100644 --- a/tests/unit/sparse-depth-wave-2026-08-08.test.ts +++ b/tests/unit/sparse-depth-wave-2026-08-08.test.ts @@ -239,7 +239,9 @@ describe('evidence-first sparse-school depth wave on 2026-08-08', () => { expect(published.universities.filter((item) => (publicCounts.get(item.id) ?? 0) < 3).length) .toBeLessThanOrEqual(8) expect(published.universities.length).toBeGreaterThanOrEqual(266) - expect(published.programs.length).toBeGreaterThanOrEqual(1_234) + expect(data.programs.filter( + (program) => program.status === 'verified' || program.status === 'stale', + ).length).toBeGreaterThanOrEqual(1_234) expect(data.scholarships.filter( (scholarship) => scholarship.status === 'verified' || scholarship.status === 'stale', ).length).toBeGreaterThanOrEqual(358) @@ -258,7 +260,7 @@ describe('evidence-first sparse-school depth wave on 2026-08-08', () => { expect(cycle.closesOn, cycle.id).toBeNull() expect(cycle.dateStatus, cycle.id).toBe('not-announced') expect(cycle.tuitionStatus, cycle.id).toBe('reference') - expect(cycle.status, cycle.id).toBe('verified') + expect(cycle.status, cycle.id).toBe('stale') } expect(JSON.stringify({ admissionCycles, programs, scholarships })).not.toMatch(/2026-06-31|June 31/iu) }) diff --git a/tests/unit/verified-broad-expansion-2026-07-29.test.ts b/tests/unit/verified-broad-expansion-2026-07-29.test.ts index 8468373..6733380 100644 --- a/tests/unit/verified-broad-expansion-2026-07-29.test.ts +++ b/tests/unit/verified-broad-expansion-2026-07-29.test.ts @@ -17,6 +17,9 @@ const data = bundleSchema.parse({ scholarships, }) const published = selectPublishedData(data, '2026-07-29') +const auditedPrograms = data.programs.filter( + (program) => program.status === 'verified' || program.status === 'stale', +) const priorityCoverage = new Map([ ['uni-tsinghua-university', 42], @@ -32,7 +35,7 @@ const priorityCoverage = new Map([ describe('verified broad university expansion on 2026-07-29', () => { it('restores Tsinghua with official program-level evidence', () => { - const tsinghuaPrograms = published.programs.filter( + const tsinghuaPrograms = auditedPrograms.filter( (program) => program.universityId === 'uni-tsinghua-university', ) @@ -71,7 +74,7 @@ describe('verified broad university expansion on 2026-07-29', () => { (cycle) => cycle.id === 'cycle-2027-tsinghua-university-chinese-language-program-language', )).toBe(false) - const schwarzmanCycle = published.admissionCycles.find( + const schwarzmanCycle = data.admissionCycles.find( (cycle) => cycle.id === 'cycle-2027-schwarzman-scholars-global', ) expect(schwarzmanCycle).toMatchObject({ @@ -79,13 +82,18 @@ describe('verified broad university expansion on 2026-07-29', () => { opensOn: '2026-04-08', closesOn: '2026-09-09', dateStatus: 'published', + status: 'stale', }) + expect(published.admissionCycles.some( + (cycle) => cycle.id === 'cycle-2027-schwarzman-scholars-global', + )).toBe(false) - const schwarzmanScholarship = published.scholarships.find( + const schwarzmanScholarship = data.scholarships.find( (scholarship) => scholarship.id === 'scholarship-schwarzman-scholars-2027', ) expect(schwarzmanScholarship).toMatchObject({ deadline: '2026-09-09', + status: 'stale', coverage: { tuition: 'full', accommodation: 'full', @@ -95,11 +103,14 @@ describe('verified broad university expansion on 2026-07-29', () => { }) it('broadens verified coverage across Double First-Class and local strong universities', () => { + expect(published.scholarships.some( + (scholarship) => scholarship.id === 'scholarship-schwarzman-scholars-2027', + )).toBe(false) expect(published.universities.length).toBeGreaterThanOrEqual(200) expect(published.programs.length).toBeGreaterThanOrEqual(400) for (const [universityId, minimum] of priorityCoverage) { - const count = published.programs.filter( + const count = auditedPrograms.filter( (program) => program.universityId === universityId, ).length expect(count, universityId).toBeGreaterThanOrEqual(minimum) @@ -107,7 +118,7 @@ describe('verified broad university expansion on 2026-07-29', () => { const counts = new Map(published.universities.map((university) => [ university.id, - published.programs.filter((program) => program.universityId === university.id).length, + auditedPrograms.filter((program) => program.universityId === university.id).length, ])) const zeroProgramUniversities = [...counts.values()].filter((count) => count === 0).length const underThreeUniversities = [...counts.values()].filter((count) => count < 3).length @@ -118,7 +129,7 @@ describe('verified broad university expansion on 2026-07-29', () => { it('keeps every new priority record multilingual and tied to an exact official source', () => { const priorityIds = new Set(priorityCoverage.keys()) - const priorityPrograms = published.programs.filter( + const priorityPrograms = auditedPrograms.filter( (program) => priorityIds.has(program.universityId), ) diff --git a/workers/catalog-api/src/index.ts b/workers/catalog-api/src/index.ts index 5e22390..2245150 100644 --- a/workers/catalog-api/src/index.ts +++ b/workers/catalog-api/src/index.ts @@ -47,6 +47,9 @@ async function getActiveRelease(env: CatalogApiEnv): Promise id.trim()).filter(Boolean))] + if (ids.length < 1 || ids.length > 4) { + throw new InvalidRequestError('ids must contain between 1 and 4 unique program ids.') + } + if (ids.some((id) => !/^[a-z0-9][a-z0-9:_-]{0,199}$/u.test(id))) { + throw new InvalidRequestError('ids contains an invalid program id.') + } + return ids +} + function publicResponse(request: Request, payload: unknown, etag: string) { if (request.method === 'HEAD') { return new Response(null, { @@ -249,6 +265,9 @@ async function publicCatalogResponse(request: Request, environment: CatalogApiEn limit: integerParam(url.searchParams, 'limit', 1, 100), }), etag) } + if (resource === 'programs' && parts.length === 4 && parts[3] === 'compare') { + return publicResponse(request, await api.comparePrograms(programIdsParam(url.searchParams)), etag) + } if (resource === 'programs' && parts.length === 4) { const result = await api.getProgram(safeSlug(parts[3]!)) return result diff --git a/workers/catalog-api/src/sql-api.ts b/workers/catalog-api/src/sql-api.ts index 42d4475..4cfc0dc 100644 --- a/workers/catalog-api/src/sql-api.ts +++ b/workers/catalog-api/src/sql-api.ts @@ -100,6 +100,11 @@ type InstitutionCodeRow = { code: string } +type ProgramScholarshipCountRow = { + program_id: string + scholarship_count: number +} + type ProgramCycleRow = RecordAuditRow & { slug: string | null program_cycle_id: string @@ -474,8 +479,20 @@ function identityMeta( return decorations.meta(row, [key], true) } -export function releaseInfo(release: ActiveReleaseRow): ReleaseInfoDto { - const parsed: unknown = JSON.parse(release.counts_json) +function strictHttpsUrl(value: string | null): string | null { + if (!value) return null + try { + const url = new URL(value) + return url.protocol === 'https:' && !url.username && !url.password + ? url.toString() + : null + } catch { + return null + } +} + +function parseReleaseCounts(value: string): ReleaseInfoDto['recordCounts'] { + const parsed: unknown = JSON.parse(value) if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error('Invalid release counts.') } @@ -493,18 +510,34 @@ export function releaseInfo(release: ActiveReleaseRow): ReleaseInfoDto { throw new Error('Invalid release counts.') } } + return { + sources: Number(counts.sources), + cities: Number(counts.cities), + universities: Number(counts.universities), + programs: Number(counts.programs), + admissionCycles: Number(counts.admissionCycles), + scholarships: Number(counts.scholarships), + } +} + +export function releaseInfo( + release: ActiveReleaseRow, + today = chinaCalendarDate(), +): ReleaseInfoDto { + const publicCounts = parseReleaseCounts(release.counts_json) + const rawCounts = parseReleaseCounts(release.raw_counts_json) return { id: release.release_id, dataDate: release.data_date, generatedAt: release.generated_at, - recordCounts: { - sources: Number(counts.sources), - cities: Number(counts.cities), - universities: Number(counts.universities), - programs: Number(counts.programs), - admissionCycles: Number(counts.admissionCycles), - scholarships: Number(counts.scholarships), - }, + recordCounts: publicCounts, + rawCounts, + publicCounts, + dataCheckedThrough: release.data_checked_through ?? release.data_date, + evaluatedForDate: today, + activatedAt: release.activated_at, + catalogBackend: 'd1', + deploymentSha: null, } } @@ -516,7 +549,7 @@ export class CatalogSqlApi { activeRelease: ActiveReleaseRow, private readonly today = chinaCalendarDate(), ) { - this.release = releaseInfo(activeRelease) + this.release = releaseInfo(activeRelease, today) } private envelope( @@ -789,10 +822,17 @@ export class CatalogSqlApi { if (!row) return null return this.envelope((await this.mapInstitutions([row]))[0]!) } - private async selectPrograms(query: ProgramQuery, exactSlug?: string) { + private async selectPrograms( + query: ProgramQuery, + exactSlug?: string, + exactIds?: readonly string[], + ) { const conditions = ['record.release_id = ?'] const values: unknown[] = [this.release.id] if (exactSlug !== undefined) addCondition(conditions, values, 'record.slug = ?', exactSlug) + if (exactIds && exactIds.length > 0) { + addCondition(conditions, values, `record.record_id IN (${placeholders(exactIds.length)})`, ...exactIds) + } if (query.q) { addCondition(conditions, values, `EXISTS ( SELECT 1 @@ -1159,7 +1199,9 @@ export class CatalogSqlApi { cursor.id, ) } - const limit = exactSlug === undefined ? pageLimit(query.limit) + 1 : 1 + const limit = exactIds && exactIds.length > 0 + ? exactIds.length + : exactSlug === undefined ? pageLimit(query.limit) + 1 : 1 values.push(limit) const rows = await queryAll(this.database, ` SELECT @@ -1287,6 +1329,117 @@ export class CatalogSqlApi { }) } + private async linkedScholarshipCounts(programIds: readonly string[]) { + if (programIds.length === 0) return new Map() + const slots = placeholders(programIds.length) + const rows = await queryAll(this.database, ` + WITH target_programs AS MATERIALIZED ( + SELECT program_id, institution_id, release_id + FROM current_programs + WHERE release_id = ? AND program_id IN (${slots}) + ), + linked AS ( + SELECT target.program_id, scholarship.scholarship_id + FROM target_programs AS target + JOIN current_scholarships AS scholarship + ON scholarship.release_id = target.release_id + JOIN current_record_fields AS scope + ON scope.release_id = scholarship.release_id + AND scope.record_id = scholarship.scholarship_id + AND scope.field_path IN ('programIds', 'program_ids') + JOIN json_each(scope.value_json) AS scoped_program ON 1 = 1 + WHERE CAST(scoped_program.value AS TEXT) = target.program_id + AND NOT EXISTS ( + SELECT 1 FROM current_scholarship_cycles AS normalized_cycle + WHERE normalized_cycle.release_id = scholarship.release_id + AND normalized_cycle.scholarship_id = scholarship.scholarship_id + ) + + UNION + + SELECT target.program_id, scholarship.scholarship_id + FROM target_programs AS target + JOIN current_scholarships AS scholarship + ON scholarship.release_id = target.release_id + JOIN current_record_fields AS scope + ON scope.release_id = scholarship.release_id + AND scope.record_id = scholarship.scholarship_id + AND scope.field_path IN ('universityIds', 'institution_ids') + JOIN json_each(scope.value_json) AS scoped_institution ON 1 = 1 + WHERE CAST(scoped_institution.value AS TEXT) = target.institution_id + AND NOT EXISTS ( + SELECT 1 FROM current_scholarship_cycles AS normalized_cycle + WHERE normalized_cycle.release_id = scholarship.release_id + AND normalized_cycle.scholarship_id = scholarship.scholarship_id + ) + + UNION + + SELECT target.program_id, cycle.scholarship_id + FROM target_programs AS target + JOIN current_scholarship_cycles AS cycle + ON cycle.release_id = target.release_id + JOIN scholarship_cycle_programs AS included_program + ON included_program.release_id = cycle.release_id + AND included_program.scholarship_cycle_id = cycle.scholarship_cycle_id + AND included_program.program_id = target.program_id + AND included_program.inclusion = 'include' + WHERE cycle.program_scope = 'listed' + AND ( + (cycle.institution_scope = 'all' AND NOT EXISTS ( + SELECT 1 FROM scholarship_cycle_institutions AS excluded_institution + WHERE excluded_institution.release_id = cycle.release_id + AND excluded_institution.scholarship_cycle_id = cycle.scholarship_cycle_id + AND excluded_institution.institution_id = target.institution_id + AND excluded_institution.inclusion = 'exclude' + )) + OR (cycle.institution_scope = 'listed' AND EXISTS ( + SELECT 1 FROM scholarship_cycle_institutions AS included_institution + WHERE included_institution.release_id = cycle.release_id + AND included_institution.scholarship_cycle_id = cycle.scholarship_cycle_id + AND included_institution.institution_id = target.institution_id + AND included_institution.inclusion = 'include' + )) + ) + + UNION + + SELECT target.program_id, cycle.scholarship_id + FROM target_programs AS target + JOIN current_scholarship_cycles AS cycle + ON cycle.release_id = target.release_id + WHERE cycle.program_scope = 'all' + AND NOT EXISTS ( + SELECT 1 FROM scholarship_cycle_programs AS excluded_program + WHERE excluded_program.release_id = cycle.release_id + AND excluded_program.scholarship_cycle_id = cycle.scholarship_cycle_id + AND excluded_program.program_id = target.program_id + AND excluded_program.inclusion = 'exclude' + ) + AND ( + (cycle.institution_scope = 'all' AND NOT EXISTS ( + SELECT 1 FROM scholarship_cycle_institutions AS excluded_institution + WHERE excluded_institution.release_id = cycle.release_id + AND excluded_institution.scholarship_cycle_id = cycle.scholarship_cycle_id + AND excluded_institution.institution_id = target.institution_id + AND excluded_institution.inclusion = 'exclude' + )) + OR (cycle.institution_scope = 'listed' AND EXISTS ( + SELECT 1 FROM scholarship_cycle_institutions AS included_institution + WHERE included_institution.release_id = cycle.release_id + AND included_institution.scholarship_cycle_id = cycle.scholarship_cycle_id + AND included_institution.institution_id = target.institution_id + AND included_institution.inclusion = 'include' + )) + ) + ) + SELECT program_id, COUNT(DISTINCT scholarship_id) AS scholarship_count + FROM linked + GROUP BY program_id + `, [this.release.id, ...programIds]) + return new Map(rows.map((row) => [row.program_id, Number(row.scholarship_count)])) + } + private async programListMetadata( conditions: readonly string[], values: readonly unknown[], @@ -1382,6 +1535,54 @@ export class CatalogSqlApi { return this.envelope((await this.mapPrograms([row]))[0]!) } + async comparePrograms(ids: readonly string[]) { + const uniqueIds = [...new Set(ids)] + const rows = (await this.selectPrograms({}, undefined, uniqueIds)).rows + const [mapped, scholarshipCounts] = await Promise.all([ + this.mapPrograms(rows), + this.linkedScholarshipCounts(uniqueIds), + ]) + const mappedById = new Map(mapped.flatMap((item) => { + const officialUrl = strictHttpsUrl(item.attributes.officialUrl) + if (!officialUrl) return [] + const { currentCycle, ...program } = item + const safeCycle = currentCycle ? { + ...currentCycle, + attributes: { + ...currentCycle.attributes, + application: { + ...currentCycle.attributes.application, + applyUrl: strictHttpsUrl(currentCycle.attributes.application.applyUrl), + }, + }, + sources: currentCycle.sources.filter((source) => strictHttpsUrl(source.url) !== null), + } : null + return [[item.id, { + program: { + ...program, + attributes: { + ...program.attributes, + officialUrl, + applyUrl: strictHttpsUrl(program.attributes.applyUrl) + ?? safeCycle?.attributes.application.applyUrl + ?? null, + }, + sources: program.sources.filter((source) => strictHttpsUrl(source.url) !== null), + }, + currentCycle: safeCycle, + linkedScholarshipCount: scholarshipCounts.get(item.id) ?? 0, + }] as const] + })) + const items = uniqueIds.flatMap((id) => { + const item = mappedById.get(id) + return item ? [item] : [] + }) + return this.envelope({ + items, + missingIds: uniqueIds.filter((id) => !mappedById.has(id)), + }) + } + private async selectScholarships(query: ScholarshipQuery, exactSlug?: string) { const conditions = ['record.release_id = ?'] const values: unknown[] = [this.release.id] diff --git a/workers/catalog-api/src/sql-types.ts b/workers/catalog-api/src/sql-types.ts index 5ac346b..eeb7b97 100644 --- a/workers/catalog-api/src/sql-types.ts +++ b/workers/catalog-api/src/sql-types.ts @@ -28,18 +28,28 @@ export type FieldMetaDto = { sourceIds: string[] } +export type ReleaseRecordCountsDto = { + sources: number + cities: number + universities: number + programs: number + admissionCycles: number + scholarships: number +} + export type ReleaseInfoDto = { id: string dataDate: string generatedAt: string - recordCounts: { - sources: number - cities: number - universities: number - programs: number - admissionCycles: number - scholarships: number - } + // Deprecated compatibility alias of publicCounts. + recordCounts: ReleaseRecordCountsDto + rawCounts: ReleaseRecordCountsDto + publicCounts: ReleaseRecordCountsDto + dataCheckedThrough: string + evaluatedForDate: string + activatedAt: string + catalogBackend: 'd1' + deploymentSha: null } export type ApiMetaDto = { diff --git a/workers/catalog-api/src/types.ts b/workers/catalog-api/src/types.ts index 962a031..7d16742 100644 --- a/workers/catalog-api/src/types.ts +++ b/workers/catalog-api/src/types.ts @@ -34,6 +34,9 @@ export type ActiveReleaseRow = { release_id: string data_date: string generated_at: string + raw_counts_json: string + data_checked_through: string | null + activated_at: string counts_json: string content_sha256: string compatibility_artifact_key: string | null