Lordkiki commited on
Commit
74bf532
·
verified ·
1 Parent(s): 529c8f2

Deploy URAAS — African Research Archival & Analytics System

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +3 -0
  2. Dockerfile +40 -40
  3. api/index.py +17 -17
  4. gunicorn_config.py +64 -64
  5. netlify/functions/app.py +49 -49
  6. scratch/apply_remaining_ror_fixes.py +56 -56
  7. scratch/check_db.py +18 -18
  8. scratch/fix_rors.py +91 -91
  9. scratch/fix_rors_manual.py +56 -56
  10. scratch/generate_all_configs.py +145 -145
  11. scratch/generate_creds.py +18 -18
  12. scratch/inspect_citations.py +30 -30
  13. scratch/inspect_db.py +73 -73
  14. scratch/replica_smoke_test.sh +80 -80
  15. scratch/verify_rors.py +39 -39
  16. scripts/backfill_alignment.py +99 -99
  17. scripts/backfill_citation_velocity.py +81 -81
  18. scripts/backfill_collaboration_data.py +227 -227
  19. scripts/backfill_pids.py +70 -70
  20. scripts/backfill_special_collections.py +69 -69
  21. scripts/build_app.py +26 -26
  22. scripts/check_staff.py +9 -9
  23. scripts/clean_database.py +156 -156
  24. scripts/crawl_multi_institution.py +242 -242
  25. scripts/deploy.sh +110 -110
  26. scripts/fix_rors.py +26 -26
  27. scripts/generate_registry.py +444 -444
  28. scripts/harvest_staff_openalex.py +315 -315
  29. scripts/init_db.py +78 -78
  30. scripts/migrate.py +44 -44
  31. scripts/migrate_2026_upgrade.py +86 -86
  32. scripts/migrate_add_ror.py +69 -69
  33. scripts/migrate_add_sc_columns.py +72 -72
  34. scripts/migrate_sqlite_to_postgres.py +202 -202
  35. scripts/migrate_unilag_ror.py +55 -55
  36. scripts/patch_html.py +74 -74
  37. scripts/push_to_hf.py +195 -195
  38. scripts/reclassify_and_prune_sc.py +199 -199
  39. scripts/scrape_nigerian_universities.py +618 -618
  40. scripts/seed_demo_db.py +252 -252
  41. scripts/start_hf.sh +26 -26
  42. scripts/test_harvest_50.py +846 -846
  43. start_dashboard.py +32 -32
  44. tests/test_all_spiders.py +202 -202
  45. tests/test_api.py +473 -473
  46. tests/test_multi_institution.py +175 -175
  47. tests/test_multi_institution_crawl.py +207 -207
  48. tests/test_new_features.py +208 -208
  49. tests/test_production_ready.py +375 -375
  50. tests/test_staff_res.py +12 -12
.gitignore CHANGED
@@ -10,6 +10,9 @@ env/
10
  .env.*
11
  !.env.example
12
 
 
 
 
13
  # Databases
14
  *.db
15
  *.db-journal
 
10
  .env.*
11
  !.env.example
12
 
13
+ # Claude Code local settings (may contain tokens/keys from shell history)
14
+ .claude/settings.local.json
15
+
16
  # Databases
17
  *.db
18
  *.db-journal
Dockerfile CHANGED
@@ -1,40 +1,40 @@
1
- # ── URAAS — Hugging Face Spaces Dockerfile ────────────────────────────────────
2
- # Single container: SQLite on /data (persistent bucket), gunicorn on port 7860.
3
- # No PostgreSQL or Redis needed — SQLite stored in HF persistent storage.
4
- # ──────────────────────────────────────────────────────────────────────────────
5
- FROM python:3.11-slim
6
-
7
- ENV PYTHONUNBUFFERED=1 \
8
- PYTHONDONTWRITEBYTECODE=1 \
9
- URAAS_ENV=production \
10
- PORT=7860
11
-
12
- # System deps
13
- RUN apt-get update && apt-get install -y \
14
- gcc g++ curl \
15
- && rm -rf /var/lib/apt/lists/*
16
-
17
- # App user — HF Spaces runs as root but we keep the same uid as prod
18
- RUN useradd -m -u 1000 uraas && \
19
- mkdir -p /app /app/storage/pdfs /app/data /app/logs && \
20
- chown -R uraas:uraas /app
21
-
22
- WORKDIR /app
23
-
24
- # Install Python dependencies (no libpq — SQLite only)
25
- COPY requirements.txt .
26
- RUN pip install --no-cache-dir -r requirements.txt && \
27
- python -m spacy download en_core_web_sm
28
-
29
- # Copy application code
30
- COPY --chown=uraas:uraas . .
31
-
32
- # Make startup script executable
33
- RUN chmod +x scripts/start_hf.sh
34
-
35
- USER uraas
36
-
37
- EXPOSE 7860
38
-
39
- # Startup: init DB in /data then start gunicorn
40
- CMD ["bash", "scripts/start_hf.sh"]
 
1
+ # ── URAAS — Hugging Face Spaces Dockerfile ────────────────────────────────────
2
+ # Single container: SQLite on /data (persistent bucket), gunicorn on port 7860.
3
+ # No PostgreSQL or Redis needed — SQLite stored in HF persistent storage.
4
+ # ──────────────────────────────────────────────────────────────────────────────
5
+ FROM python:3.11-slim
6
+
7
+ ENV PYTHONUNBUFFERED=1 \
8
+ PYTHONDONTWRITEBYTECODE=1 \
9
+ URAAS_ENV=production \
10
+ PORT=7860
11
+
12
+ # System deps
13
+ RUN apt-get update && apt-get install -y \
14
+ gcc g++ curl \
15
+ && rm -rf /var/lib/apt/lists/*
16
+
17
+ # App user — HF Spaces runs as root but we keep the same uid as prod
18
+ RUN useradd -m -u 1000 uraas && \
19
+ mkdir -p /app /app/storage/pdfs /app/data /app/logs && \
20
+ chown -R uraas:uraas /app
21
+
22
+ WORKDIR /app
23
+
24
+ # Install Python dependencies (no libpq — SQLite only)
25
+ COPY requirements.txt .
26
+ RUN pip install --no-cache-dir -r requirements.txt && \
27
+ python -m spacy download en_core_web_sm
28
+
29
+ # Copy application code
30
+ COPY --chown=uraas:uraas . .
31
+
32
+ # Make startup script executable
33
+ RUN chmod +x scripts/start_hf.sh
34
+
35
+ USER uraas
36
+
37
+ EXPOSE 7860
38
+
39
+ # Startup: init DB in /data then start gunicorn
40
+ CMD ["bash", "scripts/start_hf.sh"]
api/index.py CHANGED
@@ -1,17 +1,17 @@
1
- import os
2
- import sys
3
-
4
- # Add root directory to sys.path so 'uraas' package is findable
5
- sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
6
-
7
- # Set environment variables for Vercel
8
- # Vercel filesystem is read-only, so we point SQLite to a temp dir if we want to write,
9
- # or just keep it in the project root if it's read-only.
10
- os.environ["DATABASE_URL"] = "sqlite:///uraas.db"
11
-
12
- from uraas.dashboard.app import app
13
-
14
- # For Vercel, the variable must be named 'app'
15
- # but since we imported 'app' from uraas.dashboard.app, it's already there.
16
- # We just need to make sure it's exported at the module level.
17
- handler = app
 
1
+ import os
2
+ import sys
3
+
4
+ # Add root directory to sys.path so 'uraas' package is findable
5
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
6
+
7
+ # Set environment variables for Vercel
8
+ # Vercel filesystem is read-only, so we point SQLite to a temp dir if we want to write,
9
+ # or just keep it in the project root if it's read-only.
10
+ os.environ["DATABASE_URL"] = "sqlite:///uraas.db"
11
+
12
+ from uraas.dashboard.app import app
13
+
14
+ # For Vercel, the variable must be named 'app'
15
+ # but since we imported 'app' from uraas.dashboard.app, it's already there.
16
+ # We just need to make sure it's exported at the module level.
17
+ handler = app
gunicorn_config.py CHANGED
@@ -1,64 +1,64 @@
1
- """
2
- Gunicorn configuration for production deployment on Render.
3
- Optimized for Flask-SocketIO with WebSocket support.
4
- """
5
-
6
- import multiprocessing
7
- import os
8
-
9
- # Server socket — default 8080 matches Dockerfile EXPOSE and health checks.
10
- # HF Spaces overrides this with PORT=7860 via Space config.
11
- port = os.getenv("PORT", "8080")
12
- bind = f"0.0.0.0:{port}"
13
-
14
- # Worker processes
15
- # Free tier: 2 workers, Starter tier: 4 workers
16
- workers = int(os.getenv("GUNICORN_WORKERS", "2"))
17
-
18
- # Worker class — must match Flask-SocketIO async_mode.
19
- # app.py uses async_mode="threading", so we use gthread (synchronous + threads).
20
- # Do NOT use eventlet or gevent here without also changing async_mode in SocketIO.
21
- worker_class = "gthread"
22
-
23
- # Threads per worker (for gthread worker_class)
24
- threads = 4
25
-
26
- # Worker connections
27
- worker_connections = 1000
28
-
29
- # Restart workers after handling this many requests (prevents memory leaks)
30
- max_requests = 1000
31
- max_requests_jitter = 50
32
-
33
- # Timeout for requests (120 seconds for long-running crawler operations)
34
- timeout = 120
35
-
36
- # Keep-alive connections
37
- keepalive = 5
38
-
39
- # Logging
40
- accesslog = "-" # Log to stdout (Render captures this)
41
- errorlog = "-" # Log to stderr (Render captures this)
42
- loglevel = "info"
43
- access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"'
44
-
45
- # Process naming
46
- proc_name = "uraas-dashboard"
47
-
48
- # Graceful shutdown timeout
49
- graceful_timeout = 30
50
-
51
- # Preload app for faster worker spawning
52
- preload_app = True
53
-
54
- # Server mechanics
55
- daemon = False
56
- pidfile = None
57
- umask = 0
58
- user = None
59
- group = None
60
- tmp_upload_dir = None
61
-
62
- # SSL (handled by Render's load balancer)
63
- keyfile = None
64
- certfile = None
 
1
+ """
2
+ Gunicorn configuration for production deployment on Render.
3
+ Optimized for Flask-SocketIO with WebSocket support.
4
+ """
5
+
6
+ import multiprocessing
7
+ import os
8
+
9
+ # Server socket — default 8080 matches Dockerfile EXPOSE and health checks.
10
+ # HF Spaces overrides this with PORT=7860 via Space config.
11
+ port = os.getenv("PORT", "8080")
12
+ bind = f"0.0.0.0:{port}"
13
+
14
+ # Worker processes
15
+ # Free tier: 2 workers, Starter tier: 4 workers
16
+ workers = int(os.getenv("GUNICORN_WORKERS", "2"))
17
+
18
+ # Worker class — must match Flask-SocketIO async_mode.
19
+ # app.py uses async_mode="threading", so we use gthread (synchronous + threads).
20
+ # Do NOT use eventlet or gevent here without also changing async_mode in SocketIO.
21
+ worker_class = "gthread"
22
+
23
+ # Threads per worker (for gthread worker_class)
24
+ threads = 4
25
+
26
+ # Worker connections
27
+ worker_connections = 1000
28
+
29
+ # Restart workers after handling this many requests (prevents memory leaks)
30
+ max_requests = 1000
31
+ max_requests_jitter = 50
32
+
33
+ # Timeout for requests (120 seconds for long-running crawler operations)
34
+ timeout = 120
35
+
36
+ # Keep-alive connections
37
+ keepalive = 5
38
+
39
+ # Logging
40
+ accesslog = "-" # Log to stdout (Render captures this)
41
+ errorlog = "-" # Log to stderr (Render captures this)
42
+ loglevel = "info"
43
+ access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"'
44
+
45
+ # Process naming
46
+ proc_name = "uraas-dashboard"
47
+
48
+ # Graceful shutdown timeout
49
+ graceful_timeout = 30
50
+
51
+ # Preload app for faster worker spawning
52
+ preload_app = True
53
+
54
+ # Server mechanics
55
+ daemon = False
56
+ pidfile = None
57
+ umask = 0
58
+ user = None
59
+ group = None
60
+ tmp_upload_dir = None
61
+
62
+ # SSL (handled by Render's load balancer)
63
+ keyfile = None
64
+ certfile = None
netlify/functions/app.py CHANGED
@@ -1,49 +1,49 @@
1
- import os
2
- import sys
3
-
4
- # Add project root to path
5
- sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../.."))
6
-
7
- from uraas.dashboard.app import app
8
-
9
-
10
- def handler(event, context):
11
- """Netlify serverless function handler"""
12
- from io import BytesIO
13
-
14
- from werkzeug.wrappers import Request, Response
15
-
16
- # Convert Netlify event to WSGI environ
17
- environ = {
18
- "REQUEST_METHOD": event["httpMethod"],
19
- "SCRIPT_NAME": "",
20
- "PATH_INFO": event["path"],
21
- "QUERY_STRING": event.get("rawQuery", ""),
22
- "CONTENT_TYPE": event["headers"].get("content-type", ""),
23
- "CONTENT_LENGTH": str(len(event.get("body", ""))),
24
- "SERVER_NAME": event["headers"].get("host", "localhost"),
25
- "SERVER_PORT": "443",
26
- "SERVER_PROTOCOL": "HTTP/1.1",
27
- "wsgi.version": (1, 0),
28
- "wsgi.url_scheme": "https",
29
- "wsgi.input": BytesIO(event.get("body", "").encode()),
30
- "wsgi.errors": sys.stderr,
31
- "wsgi.multithread": False,
32
- "wsgi.multiprocess": True,
33
- "wsgi.run_once": False,
34
- }
35
-
36
- # Add headers
37
- for key, value in event.get("headers", {}).items():
38
- key = key.upper().replace("-", "_")
39
- if key not in ("CONTENT_TYPE", "CONTENT_LENGTH"):
40
- environ[f"HTTP_{key}"] = value
41
-
42
- # Call Flask app
43
- response = Response.from_app(app, environ)
44
-
45
- return {
46
- "statusCode": response.status_code,
47
- "headers": dict(response.headers),
48
- "body": response.get_data(as_text=True),
49
- }
 
1
+ import os
2
+ import sys
3
+
4
+ # Add project root to path
5
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../.."))
6
+
7
+ from uraas.dashboard.app import app
8
+
9
+
10
+ def handler(event, context):
11
+ """Netlify serverless function handler"""
12
+ from io import BytesIO
13
+
14
+ from werkzeug.wrappers import Request, Response
15
+
16
+ # Convert Netlify event to WSGI environ
17
+ environ = {
18
+ "REQUEST_METHOD": event["httpMethod"],
19
+ "SCRIPT_NAME": "",
20
+ "PATH_INFO": event["path"],
21
+ "QUERY_STRING": event.get("rawQuery", ""),
22
+ "CONTENT_TYPE": event["headers"].get("content-type", ""),
23
+ "CONTENT_LENGTH": str(len(event.get("body", ""))),
24
+ "SERVER_NAME": event["headers"].get("host", "localhost"),
25
+ "SERVER_PORT": "443",
26
+ "SERVER_PROTOCOL": "HTTP/1.1",
27
+ "wsgi.version": (1, 0),
28
+ "wsgi.url_scheme": "https",
29
+ "wsgi.input": BytesIO(event.get("body", "").encode()),
30
+ "wsgi.errors": sys.stderr,
31
+ "wsgi.multithread": False,
32
+ "wsgi.multiprocess": True,
33
+ "wsgi.run_once": False,
34
+ }
35
+
36
+ # Add headers
37
+ for key, value in event.get("headers", {}).items():
38
+ key = key.upper().replace("-", "_")
39
+ if key not in ("CONTENT_TYPE", "CONTENT_LENGTH"):
40
+ environ[f"HTTP_{key}"] = value
41
+
42
+ # Call Flask app
43
+ response = Response.from_app(app, environ)
44
+
45
+ return {
46
+ "statusCode": response.status_code,
47
+ "headers": dict(response.headers),
48
+ "body": response.get_data(as_text=True),
49
+ }
scratch/apply_remaining_ror_fixes.py CHANGED
@@ -1,56 +1,56 @@
1
- """
2
- Apply the manually-found ROR corrections for the 7 remaining institutions.
3
- """
4
- import json
5
- from pathlib import Path
6
-
7
- config_dir = Path(__file__).parent.parent / "config" / "institutions"
8
-
9
- CORRECTIONS = {
10
- "agostinhoneto.json": "https://ror.org/0057ag334", # Agostinho Neto University (5726 works)
11
- "kinshasa.json": "https://ror.org/05rrz2q74", # University of Kinshasa (10374 works)
12
- "marienngouabi.json": "https://ror.org/00tt5kf04", # Marien Ngouabi University (4236 works)
13
- "masuku.json": "https://ror.org/03f0njg03", # Univ. Sciences et Techniques de Masuku (1522)
14
- "mohammedv.json": "https://ror.org/00r8w8f84", # Mohammed V University (49646 works)
15
- "tunis.json": "https://ror.org/029cgt552", # Tunis El Manar University (37992 works)
16
- "yaoundei.json": None, # Need to search for Université de Yaoundé I specifically
17
- }
18
-
19
- # For Yaoundé I, search for the proper institution (not the hospital)
20
- import urllib.request, urllib.parse
21
- q = urllib.parse.quote("Universite de Yaounde")
22
- url = f"https://api.openalex.org/institutions?search={q}&per-page=5&mailto=cokiki@unilag.edu.ng"
23
- req = urllib.request.urlopen(url, timeout=15)
24
- resp = json.loads(req.read())
25
- print("=== Yaoundé I search results ===")
26
- for r in resp.get("results", []):
27
- print(f" [{r['works_count']:6d}] {r['display_name']} | ROR: {r['ror']}")
28
-
29
- # The actual Université de Yaoundé I
30
- # Will manually set from search result
31
- CORRECTIONS["yaoundei.json"] = "https://ror.org/01ktt0j77" # Université de Yaoundé I (verified below)
32
-
33
- # Re-verify with direct lookup
34
- import urllib.request as ur
35
- try:
36
- check_url = "https://api.openalex.org/works?filter=institutions.ror:01ktt0j77&select=id&per-page=1&mailto=cokiki@unilag.edu.ng"
37
- r2 = json.loads(ur.urlopen(check_url, timeout=10).read())
38
- print(f"\nYaoundé I (01ktt0j77): count={r2['meta']['count']}")
39
- except Exception as e:
40
- print(f"Check failed: {e}")
41
-
42
- print("\n--- APPLYING REMAINING PATCHES ---")
43
- for fname, new_ror in CORRECTIONS.items():
44
- if new_ror is None:
45
- print(f"[SKIP] {fname}")
46
- continue
47
- jf = config_dir / fname
48
- with open(jf) as f:
49
- data = json.load(f)
50
- old_ror = data["ror"]
51
- data["ror"] = new_ror
52
- with open(jf, "w", encoding="utf-8") as f:
53
- json.dump(data, f, indent=2, ensure_ascii=False)
54
- print(f"[PATCHED] {fname}: {old_ror} -> {new_ror}")
55
-
56
- print("\nAll done!")
 
1
+ """
2
+ Apply the manually-found ROR corrections for the 7 remaining institutions.
3
+ """
4
+ import json
5
+ from pathlib import Path
6
+
7
+ config_dir = Path(__file__).parent.parent / "config" / "institutions"
8
+
9
+ CORRECTIONS = {
10
+ "agostinhoneto.json": "https://ror.org/0057ag334", # Agostinho Neto University (5726 works)
11
+ "kinshasa.json": "https://ror.org/05rrz2q74", # University of Kinshasa (10374 works)
12
+ "marienngouabi.json": "https://ror.org/00tt5kf04", # Marien Ngouabi University (4236 works)
13
+ "masuku.json": "https://ror.org/03f0njg03", # Univ. Sciences et Techniques de Masuku (1522)
14
+ "mohammedv.json": "https://ror.org/00r8w8f84", # Mohammed V University (49646 works)
15
+ "tunis.json": "https://ror.org/029cgt552", # Tunis El Manar University (37992 works)
16
+ "yaoundei.json": None, # Need to search for Université de Yaoundé I specifically
17
+ }
18
+
19
+ # For Yaoundé I, search for the proper institution (not the hospital)
20
+ import urllib.request, urllib.parse
21
+ q = urllib.parse.quote("Universite de Yaounde")
22
+ url = f"https://api.openalex.org/institutions?search={q}&per-page=5&mailto=cokiki@unilag.edu.ng"
23
+ req = urllib.request.urlopen(url, timeout=15)
24
+ resp = json.loads(req.read())
25
+ print("=== Yaoundé I search results ===")
26
+ for r in resp.get("results", []):
27
+ print(f" [{r['works_count']:6d}] {r['display_name']} | ROR: {r['ror']}")
28
+
29
+ # The actual Université de Yaoundé I
30
+ # Will manually set from search result
31
+ CORRECTIONS["yaoundei.json"] = "https://ror.org/01ktt0j77" # Université de Yaoundé I (verified below)
32
+
33
+ # Re-verify with direct lookup
34
+ import urllib.request as ur
35
+ try:
36
+ check_url = "https://api.openalex.org/works?filter=institutions.ror:01ktt0j77&select=id&per-page=1&mailto=cokiki@unilag.edu.ng"
37
+ r2 = json.loads(ur.urlopen(check_url, timeout=10).read())
38
+ print(f"\nYaoundé I (01ktt0j77): count={r2['meta']['count']}")
39
+ except Exception as e:
40
+ print(f"Check failed: {e}")
41
+
42
+ print("\n--- APPLYING REMAINING PATCHES ---")
43
+ for fname, new_ror in CORRECTIONS.items():
44
+ if new_ror is None:
45
+ print(f"[SKIP] {fname}")
46
+ continue
47
+ jf = config_dir / fname
48
+ with open(jf) as f:
49
+ data = json.load(f)
50
+ old_ror = data["ror"]
51
+ data["ror"] = new_ror
52
+ with open(jf, "w", encoding="utf-8") as f:
53
+ json.dump(data, f, indent=2, ensure_ascii=False)
54
+ print(f"[PATCHED] {fname}: {old_ror} -> {new_ror}")
55
+
56
+ print("\nAll done!")
scratch/check_db.py CHANGED
@@ -1,18 +1,18 @@
1
- import sqlite3
2
-
3
- conn = sqlite3.connect("uraas.db")
4
- cursor = conn.cursor()
5
-
6
- cursor.execute(
7
- "SELECT title, institution, created_at FROM items WHERE institution = 'Addis Ababa University' ORDER BY created_at DESC;"
8
- )
9
- rows = cursor.fetchall()
10
-
11
- if not rows:
12
- print("No papers found for Addis Ababa University.")
13
- else:
14
- print(f"Found {len(rows)} papers total:")
15
- for row in rows:
16
- print(f"- {row[0][:50]}... | {row[1]} | {row[2]}")
17
-
18
- conn.close()
 
1
+ import sqlite3
2
+
3
+ conn = sqlite3.connect("uraas.db")
4
+ cursor = conn.cursor()
5
+
6
+ cursor.execute(
7
+ "SELECT title, institution, created_at FROM items WHERE institution = 'Addis Ababa University' ORDER BY created_at DESC;"
8
+ )
9
+ rows = cursor.fetchall()
10
+
11
+ if not rows:
12
+ print("No papers found for Addis Ababa University.")
13
+ else:
14
+ print(f"Found {len(rows)} papers total:")
15
+ for row in rows:
16
+ print(f"- {row[0][:50]}... | {row[1]} | {row[2]}")
17
+
18
+ conn.close()
scratch/fix_rors.py CHANGED
@@ -1,91 +1,91 @@
1
- """
2
- Lookup correct RORs from OpenAlex for all broken institutions,
3
- then patch the JSON files automatically.
4
- """
5
- import json
6
- import time
7
- import urllib.request
8
- import urllib.parse
9
- from pathlib import Path
10
-
11
- BASE = "https://api.openalex.org"
12
- MAILTO = "cokiki@unilag.edu.ng"
13
-
14
- # Institutions we know are broken (from verify_rors.py output)
15
- BROKEN = [
16
- "agostinhoneto.json",
17
- "ainshams.json",
18
- "alexandria.json",
19
- "cairo.json",
20
- "daressalaam.json",
21
- "kinshasa.json",
22
- "marienngouabi.json",
23
- "masuku.json",
24
- "mohammedv.json",
25
- "pretoria.json",
26
- "rwanda.json",
27
- "tunis.json",
28
- "wits.json",
29
- "yaoundei.json",
30
- "zimbabwe.json",
31
- ]
32
-
33
- config_dir = Path(__file__).parent.parent / "config" / "institutions"
34
- fixes = {}
35
-
36
- for fname in BROKEN:
37
- jf = config_dir / fname
38
- with open(jf) as f:
39
- data = json.load(f)
40
-
41
- name = data["name"]
42
- q = urllib.parse.quote(name)
43
- url = f"{BASE}/institutions?search={q}&per-page=3&mailto={MAILTO}"
44
-
45
- try:
46
- req = urllib.request.urlopen(url, timeout=15)
47
- resp = json.loads(req.read())
48
- results = resp.get("results", [])
49
- except Exception as e:
50
- print(f"[ERROR] {name}: {e}")
51
- fixes[fname] = None
52
- time.sleep(1)
53
- continue
54
-
55
- if not results:
56
- print(f"[NOT FOUND] {name}")
57
- fixes[fname] = None
58
- else:
59
- best = results[0]
60
- ror = best["ror"] # e.g. "https://ror.org/00cb9w016"
61
- ror_short = ror.split("/")[-1]
62
- count = best.get("works_count", "?")
63
- display = best["display_name"]
64
- print(f"[FOUND] {name!r}")
65
- print(f" OpenAlex: {display!r}")
66
- print(f" ROR: {ror} (works: {count})")
67
- if count == 0:
68
- print(f" WARNING: works_count=0 — double check!")
69
- fixes[fname] = ror
70
-
71
- time.sleep(0.4)
72
-
73
- # Now apply the patches
74
- print("\n--- APPLYING PATCHES ---")
75
- for fname, new_ror in fixes.items():
76
- if new_ror is None:
77
- print(f"[SKIP] {fname} — no ROR found")
78
- continue
79
- jf = config_dir / fname
80
- with open(jf) as f:
81
- data = json.load(f)
82
- old_ror = data["ror"]
83
- if old_ror == new_ror:
84
- print(f"[SAME] {fname} — already correct")
85
- continue
86
- data["ror"] = new_ror
87
- with open(jf, "w", encoding="utf-8") as f:
88
- json.dump(data, f, indent=2, ensure_ascii=False)
89
- print(f"[PATCHED] {fname}: {old_ror} -> {new_ror}")
90
-
91
- print("\nDone. Run verify_rors.py again to confirm all are fixed.")
 
1
+ """
2
+ Lookup correct RORs from OpenAlex for all broken institutions,
3
+ then patch the JSON files automatically.
4
+ """
5
+ import json
6
+ import time
7
+ import urllib.request
8
+ import urllib.parse
9
+ from pathlib import Path
10
+
11
+ BASE = "https://api.openalex.org"
12
+ MAILTO = "cokiki@unilag.edu.ng"
13
+
14
+ # Institutions we know are broken (from verify_rors.py output)
15
+ BROKEN = [
16
+ "agostinhoneto.json",
17
+ "ainshams.json",
18
+ "alexandria.json",
19
+ "cairo.json",
20
+ "daressalaam.json",
21
+ "kinshasa.json",
22
+ "marienngouabi.json",
23
+ "masuku.json",
24
+ "mohammedv.json",
25
+ "pretoria.json",
26
+ "rwanda.json",
27
+ "tunis.json",
28
+ "wits.json",
29
+ "yaoundei.json",
30
+ "zimbabwe.json",
31
+ ]
32
+
33
+ config_dir = Path(__file__).parent.parent / "config" / "institutions"
34
+ fixes = {}
35
+
36
+ for fname in BROKEN:
37
+ jf = config_dir / fname
38
+ with open(jf) as f:
39
+ data = json.load(f)
40
+
41
+ name = data["name"]
42
+ q = urllib.parse.quote(name)
43
+ url = f"{BASE}/institutions?search={q}&per-page=3&mailto={MAILTO}"
44
+
45
+ try:
46
+ req = urllib.request.urlopen(url, timeout=15)
47
+ resp = json.loads(req.read())
48
+ results = resp.get("results", [])
49
+ except Exception as e:
50
+ print(f"[ERROR] {name}: {e}")
51
+ fixes[fname] = None
52
+ time.sleep(1)
53
+ continue
54
+
55
+ if not results:
56
+ print(f"[NOT FOUND] {name}")
57
+ fixes[fname] = None
58
+ else:
59
+ best = results[0]
60
+ ror = best["ror"] # e.g. "https://ror.org/00cb9w016"
61
+ ror_short = ror.split("/")[-1]
62
+ count = best.get("works_count", "?")
63
+ display = best["display_name"]
64
+ print(f"[FOUND] {name!r}")
65
+ print(f" OpenAlex: {display!r}")
66
+ print(f" ROR: {ror} (works: {count})")
67
+ if count == 0:
68
+ print(f" WARNING: works_count=0 — double check!")
69
+ fixes[fname] = ror
70
+
71
+ time.sleep(0.4)
72
+
73
+ # Now apply the patches
74
+ print("\n--- APPLYING PATCHES ---")
75
+ for fname, new_ror in fixes.items():
76
+ if new_ror is None:
77
+ print(f"[SKIP] {fname} — no ROR found")
78
+ continue
79
+ jf = config_dir / fname
80
+ with open(jf) as f:
81
+ data = json.load(f)
82
+ old_ror = data["ror"]
83
+ if old_ror == new_ror:
84
+ print(f"[SAME] {fname} — already correct")
85
+ continue
86
+ data["ror"] = new_ror
87
+ with open(jf, "w", encoding="utf-8") as f:
88
+ json.dump(data, f, indent=2, ensure_ascii=False)
89
+ print(f"[PATCHED] {fname}: {old_ror} -> {new_ror}")
90
+
91
+ print("\nDone. Run verify_rors.py again to confirm all are fixed.")
scratch/fix_rors_manual.py CHANGED
@@ -1,56 +1,56 @@
1
- """
2
- Manual ROR lookup for institutions not found by name search.
3
- Uses OpenAlex institution search with alternate spellings/names.
4
- """
5
- import json
6
- import time
7
- import urllib.request
8
- import urllib.parse
9
- from pathlib import Path
10
-
11
- BASE = "https://api.openalex.org"
12
- MAILTO = "cokiki@unilag.edu.ng"
13
-
14
- # Alternate search terms for institutions not found by direct name
15
- ALTERNATES = {
16
- "agostinhoneto.json": ["Agostinho Neto", "UAN Angola", "Luanda university"],
17
- "kinshasa.json": ["Kinshasa university", "UNIKIN", "Congo kinshasa"],
18
- "marienngouabi.json": ["Marien Ngouabi", "Brazzaville university", "Congo Brazzaville"],
19
- "masuku.json": ["Masuku", "Franceville", "Gabon university science"],
20
- "mohammedv.json": ["Mohammed V", "Rabat university", "Mohammed 5"],
21
- "tunis.json": ["Tunis El Manar", "Tunis university", "UTM Tunisia"],
22
- "yaoundei.json": ["Yaounde", "Cameroon university", "Yaounde 1"],
23
- }
24
-
25
- config_dir = Path(__file__).parent.parent / "config" / "institutions"
26
-
27
- for fname, search_terms in ALTERNATES.items():
28
- jf = config_dir / fname
29
- with open(jf) as f:
30
- data = json.load(f)
31
- name = data["name"]
32
- print(f"\n=== {name} ===")
33
-
34
- found = False
35
- for term in search_terms:
36
- q = urllib.parse.quote(term)
37
- url = f"{BASE}/institutions?search={q}&per-page=5&mailto={MAILTO}"
38
- try:
39
- req = urllib.request.urlopen(url, timeout=15)
40
- resp = json.loads(req.read())
41
- results = resp.get("results", [])
42
- except Exception as e:
43
- print(f" ERROR searching {term!r}: {e}")
44
- time.sleep(1)
45
- continue
46
-
47
- if results:
48
- print(f" Search '{term}' -> {len(results)} results:")
49
- for r in results[:3]:
50
- print(f" [{r['works_count']:6d} works] {r['display_name']} | ROR: {r['ror']}")
51
- found = True
52
- else:
53
- print(f" Search '{term}' -> no results")
54
- time.sleep(0.4)
55
- if found:
56
- break
 
1
+ """
2
+ Manual ROR lookup for institutions not found by name search.
3
+ Uses OpenAlex institution search with alternate spellings/names.
4
+ """
5
+ import json
6
+ import time
7
+ import urllib.request
8
+ import urllib.parse
9
+ from pathlib import Path
10
+
11
+ BASE = "https://api.openalex.org"
12
+ MAILTO = "cokiki@unilag.edu.ng"
13
+
14
+ # Alternate search terms for institutions not found by direct name
15
+ ALTERNATES = {
16
+ "agostinhoneto.json": ["Agostinho Neto", "UAN Angola", "Luanda university"],
17
+ "kinshasa.json": ["Kinshasa university", "UNIKIN", "Congo kinshasa"],
18
+ "marienngouabi.json": ["Marien Ngouabi", "Brazzaville university", "Congo Brazzaville"],
19
+ "masuku.json": ["Masuku", "Franceville", "Gabon university science"],
20
+ "mohammedv.json": ["Mohammed V", "Rabat university", "Mohammed 5"],
21
+ "tunis.json": ["Tunis El Manar", "Tunis university", "UTM Tunisia"],
22
+ "yaoundei.json": ["Yaounde", "Cameroon university", "Yaounde 1"],
23
+ }
24
+
25
+ config_dir = Path(__file__).parent.parent / "config" / "institutions"
26
+
27
+ for fname, search_terms in ALTERNATES.items():
28
+ jf = config_dir / fname
29
+ with open(jf) as f:
30
+ data = json.load(f)
31
+ name = data["name"]
32
+ print(f"\n=== {name} ===")
33
+
34
+ found = False
35
+ for term in search_terms:
36
+ q = urllib.parse.quote(term)
37
+ url = f"{BASE}/institutions?search={q}&per-page=5&mailto={MAILTO}"
38
+ try:
39
+ req = urllib.request.urlopen(url, timeout=15)
40
+ resp = json.loads(req.read())
41
+ results = resp.get("results", [])
42
+ except Exception as e:
43
+ print(f" ERROR searching {term!r}: {e}")
44
+ time.sleep(1)
45
+ continue
46
+
47
+ if results:
48
+ print(f" Search '{term}' -> {len(results)} results:")
49
+ for r in results[:3]:
50
+ print(f" [{r['works_count']:6d} works] {r['display_name']} | ROR: {r['ror']}")
51
+ found = True
52
+ else:
53
+ print(f" Search '{term}' -> no results")
54
+ time.sleep(0.4)
55
+ if found:
56
+ break
scratch/generate_all_configs.py CHANGED
@@ -1,145 +1,145 @@
1
- import json
2
- import os
3
-
4
- # Sub-regions and countries
5
- subregions = {
6
- "North Africa": ["Egypt", "Morocco", "Algeria", "Tunisia", "Libya", "Sudan"],
7
- "West Africa": ["Nigeria", "Ghana", "Senegal", "Cote d'Ivoire", "Benin", "Burkina Faso", "Cape Verde", "Gambia", "Guinea", "Guinea-Bissau", "Liberia", "Mali", "Mauritania", "Niger", "Sierra Leone", "Togo"],
8
- "East Africa": ["Kenya", "Uganda", "Tanzania", "Ethiopia", "Rwanda", "Burundi", "Djibouti", "Eritrea", "Somalia", "South Sudan", "Madagascar", "Mauritius", "Seychelles", "Comoros"],
9
- "Southern Africa": ["South Africa", "Zimbabwe", "Zambia", "Namibia", "Botswana", "Lesotho", "Eswatini", "Malawi", "Mozambique"],
10
- "Central Africa": ["Cameroon", "DR Congo", "Angola", "Gabon", "Republic of the Congo", "Central African Republic", "Chad", "Equatorial Guinea", "Sao Tome and Principe"],
11
- }
12
-
13
- curated_universities = {
14
- "Egypt": [
15
- {"name": "Cairo University", "ror": "https://ror.org/03c4mpy73"},
16
- {"name": "Ain Shams University", "ror": "https://ror.org/034x7p097"},
17
- {"name": "Alexandria University", "ror": "https://ror.org/02078r490"},
18
- {"name": "Mansoura University", "ror": "https://ror.org/032p18087"},
19
- {"name": "Assiut University", "ror": "https://ror.org/047fpp722"},
20
- ],
21
- "Morocco": [
22
- {"name": "Université Mohammed V de Rabat", "ror": "https://ror.org/03vpy3v17"},
23
- {"name": "Université Cadi Ayyad", "ror": "https://ror.org/0154pcf71"},
24
- {"name": "Université Hassan II de Casablanca", "ror": "https://ror.org/013y27r38"},
25
- ],
26
- "Tunisia": [
27
- {"name": "Université de Tunis El Manar", "ror": "https://ror.org/050j3a172"},
28
- {"name": "Université de Sfax", "ror": "https://ror.org/02157p641"},
29
- {"name": "Université de Carthage", "ror": "https://ror.org/011yvpy71"},
30
- ],
31
- "Cameroon": [
32
- {"name": "Université de Yaoundé I", "ror": "https://ror.org/04h7g6177"},
33
- {"name": "Université de Dschang", "ror": "https://ror.org/012y7p041"},
34
- {"name": "Université de Douala", "ror": "https://ror.org/041y27r28"},
35
- ],
36
- "DR Congo": [
37
- {"name": "Université de Kinshasa", "ror": "https://ror.org/05vzwad88"},
38
- {"name": "Université de Lubumbashi", "ror": "https://ror.org/02rry3m21"},
39
- ],
40
- "Angola": [
41
- {"name": "Université Agostinho Neto", "ror": "https://ror.org/00z2bpt98"}
42
- ],
43
- "Gabon": [
44
- {"name": "Université des Sciences et Techniques de Masuku", "ror": "https://ror.org/059gqse72"},
45
- {"name": "Université Omar Bongo", "ror": "https://ror.org/041yp1812"},
46
- ],
47
- "Republic of the Congo": [
48
- {"name": "Université Marien Ngouabi", "ror": "https://ror.org/02y1sra05"}
49
- ],
50
- "Nigeria": [
51
- {"name": "University of Lagos", "ror": "https://ror.org/05rk03822"},
52
- {"name": "University of Ibadan", "ror": "https://ror.org/01es5me90"},
53
- {"name": "Covenant University", "ror": "https://ror.org/02n05rk12"},
54
- {"name": "Obafemi Awolowo University", "ror": "https://ror.org/013pcr241"},
55
- {"name": "University of Nigeria Nsukka", "ror": "https://ror.org/02kpy5732"},
56
- ],
57
- "Ghana": [
58
- {"name": "University of Ghana", "ror": "https://ror.org/00zpy3v12"},
59
- {"name": "Kwame Nkrumah University of Science and Technology", "ror": "https://ror.org/00x4mpy73"},
60
- {"name": "University of Cape Coast", "ror": "https://ror.org/01es3v123"},
61
- ],
62
- "South Africa": [
63
- {"name": "University of Cape Town", "ror": "https://ror.org/017620319"},
64
- {"name": "Stellenbosch University", "ror": "https://ror.org/05777p686"},
65
- {"name": "University of the Witwatersrand", "ror": "https://ror.org/039482g93"},
66
- {"name": "University of Pretoria", "ror": "https://ror.org/047fpp722"},
67
- {"name": "University of KwaZulu-Natal", "ror": "https://ror.org/01267r312"},
68
- ],
69
- "Zimbabwe": [
70
- {"name": "University of Zimbabwe", "ror": "https://ror.org/03w489125"},
71
- {"name": "National University of Science and Technology", "ror": "https://ror.org/01y6mpy73"},
72
- ],
73
- "Kenya": [
74
- {"name": "University of Nairobi", "ror": "https://ror.org/01078r490"},
75
- {"name": "Kenyatta University", "ror": "https://ror.org/01py3v171"},
76
- {"name": "Jomo Kenyatta University of Agriculture and Technology", "ror": "https://ror.org/03pyvpy71"},
77
- ],
78
- "Uganda": [
79
- {"name": "Makerere University", "ror": "https://ror.org/05vzwad88"},
80
- {"name": "Mbarara University of Science and Technology", "ror": "https://ror.org/0155pcf71"},
81
- ],
82
- "Tanzania": [
83
- {"name": "University of Dar es Salaam", "ror": "https://ror.org/0199e1957"},
84
- {"name": "Sokoine University of Agriculture", "ror": "https://ror.org/011y27r38"},
85
- ],
86
- "Ethiopia": [
87
- {"name": "Addis Ababa University", "ror": "https://ror.org/01py3v171"}
88
- ],
89
- "Rwanda": [
90
- {"name": "University of Rwanda", "ror": "https://ror.org/02yr01r27"}
91
- ],
92
- }
93
-
94
- os.makedirs("config/institutions", exist_ok=True)
95
- count = 0
96
-
97
- for country, unis in curated_universities.items():
98
- region = next(r for r, c in subregions.items() if country in c)
99
- for u in unis:
100
- # Create a safe shortname / filename
101
- short_name = u["name"].replace("University of ", "").replace("Université de ", "").replace("Université ", "")
102
- if len(short_name.split()) > 3:
103
- short_name = "".join([word[0] for word in short_name.split() if word.istitle()])
104
- if not short_name:
105
- short_name = u["name"].split()[0]
106
-
107
- # Overrides for some known ones
108
- if "Lagos" in u["name"]: short_name = "UNILAG"
109
- elif "Ibadan" in u["name"]: short_name = "UI"
110
- elif "Cape Town" in u["name"]: short_name = "UCT"
111
- elif "Witwatersrand" in u["name"]: short_name = "Wits"
112
- elif "Kwame Nkrumah" in u["name"]: short_name = "KNUST"
113
- elif "Yaoundé" in u["name"]: short_name = "Yaounde I"
114
-
115
- file_name = "".join(x for x in short_name.lower() if x.isalnum()) + ".json"
116
-
117
- cfg = {
118
- "ror": u["ror"],
119
- "name": u["name"],
120
- "short_name": short_name,
121
- "country": country,
122
- "sub_region": region,
123
- "staff_file": f"data/{short_name.lower().replace(' ', '_')}_staff.json",
124
- "affiliation_patterns": [u["name"], short_name, f"{u['name']} Department"],
125
- "faculties": [
126
- "Science",
127
- "Humanities",
128
- "Engineering",
129
- "Medicine",
130
- "Social Sciences",
131
- "Arts",
132
- "Law",
133
- ],
134
- "crawler_settings": {
135
- "rate_limit": 2.0,
136
- "concurrent_requests": 8,
137
- "retry_times": 3,
138
- "download_delay": 2.0,
139
- },
140
- }
141
- with open(f"config/institutions/{file_name}", "w", encoding="utf-8") as f:
142
- json.dump(cfg, f, indent=2, ensure_ascii=False)
143
- count += 1
144
-
145
- print(f"Generated {count} university configurations.")
 
1
+ import json
2
+ import os
3
+
4
+ # Sub-regions and countries
5
+ subregions = {
6
+ "North Africa": ["Egypt", "Morocco", "Algeria", "Tunisia", "Libya", "Sudan"],
7
+ "West Africa": ["Nigeria", "Ghana", "Senegal", "Cote d'Ivoire", "Benin", "Burkina Faso", "Cape Verde", "Gambia", "Guinea", "Guinea-Bissau", "Liberia", "Mali", "Mauritania", "Niger", "Sierra Leone", "Togo"],
8
+ "East Africa": ["Kenya", "Uganda", "Tanzania", "Ethiopia", "Rwanda", "Burundi", "Djibouti", "Eritrea", "Somalia", "South Sudan", "Madagascar", "Mauritius", "Seychelles", "Comoros"],
9
+ "Southern Africa": ["South Africa", "Zimbabwe", "Zambia", "Namibia", "Botswana", "Lesotho", "Eswatini", "Malawi", "Mozambique"],
10
+ "Central Africa": ["Cameroon", "DR Congo", "Angola", "Gabon", "Republic of the Congo", "Central African Republic", "Chad", "Equatorial Guinea", "Sao Tome and Principe"],
11
+ }
12
+
13
+ curated_universities = {
14
+ "Egypt": [
15
+ {"name": "Cairo University", "ror": "https://ror.org/03c4mpy73"},
16
+ {"name": "Ain Shams University", "ror": "https://ror.org/034x7p097"},
17
+ {"name": "Alexandria University", "ror": "https://ror.org/02078r490"},
18
+ {"name": "Mansoura University", "ror": "https://ror.org/032p18087"},
19
+ {"name": "Assiut University", "ror": "https://ror.org/047fpp722"},
20
+ ],
21
+ "Morocco": [
22
+ {"name": "Université Mohammed V de Rabat", "ror": "https://ror.org/03vpy3v17"},
23
+ {"name": "Université Cadi Ayyad", "ror": "https://ror.org/0154pcf71"},
24
+ {"name": "Université Hassan II de Casablanca", "ror": "https://ror.org/013y27r38"},
25
+ ],
26
+ "Tunisia": [
27
+ {"name": "Université de Tunis El Manar", "ror": "https://ror.org/050j3a172"},
28
+ {"name": "Université de Sfax", "ror": "https://ror.org/02157p641"},
29
+ {"name": "Université de Carthage", "ror": "https://ror.org/011yvpy71"},
30
+ ],
31
+ "Cameroon": [
32
+ {"name": "Université de Yaoundé I", "ror": "https://ror.org/04h7g6177"},
33
+ {"name": "Université de Dschang", "ror": "https://ror.org/012y7p041"},
34
+ {"name": "Université de Douala", "ror": "https://ror.org/041y27r28"},
35
+ ],
36
+ "DR Congo": [
37
+ {"name": "Université de Kinshasa", "ror": "https://ror.org/05vzwad88"},
38
+ {"name": "Université de Lubumbashi", "ror": "https://ror.org/02rry3m21"},
39
+ ],
40
+ "Angola": [
41
+ {"name": "Université Agostinho Neto", "ror": "https://ror.org/00z2bpt98"}
42
+ ],
43
+ "Gabon": [
44
+ {"name": "Université des Sciences et Techniques de Masuku", "ror": "https://ror.org/059gqse72"},
45
+ {"name": "Université Omar Bongo", "ror": "https://ror.org/041yp1812"},
46
+ ],
47
+ "Republic of the Congo": [
48
+ {"name": "Université Marien Ngouabi", "ror": "https://ror.org/02y1sra05"}
49
+ ],
50
+ "Nigeria": [
51
+ {"name": "University of Lagos", "ror": "https://ror.org/05rk03822"},
52
+ {"name": "University of Ibadan", "ror": "https://ror.org/01es5me90"},
53
+ {"name": "Covenant University", "ror": "https://ror.org/02n05rk12"},
54
+ {"name": "Obafemi Awolowo University", "ror": "https://ror.org/013pcr241"},
55
+ {"name": "University of Nigeria Nsukka", "ror": "https://ror.org/02kpy5732"},
56
+ ],
57
+ "Ghana": [
58
+ {"name": "University of Ghana", "ror": "https://ror.org/00zpy3v12"},
59
+ {"name": "Kwame Nkrumah University of Science and Technology", "ror": "https://ror.org/00x4mpy73"},
60
+ {"name": "University of Cape Coast", "ror": "https://ror.org/01es3v123"},
61
+ ],
62
+ "South Africa": [
63
+ {"name": "University of Cape Town", "ror": "https://ror.org/017620319"},
64
+ {"name": "Stellenbosch University", "ror": "https://ror.org/05777p686"},
65
+ {"name": "University of the Witwatersrand", "ror": "https://ror.org/039482g93"},
66
+ {"name": "University of Pretoria", "ror": "https://ror.org/047fpp722"},
67
+ {"name": "University of KwaZulu-Natal", "ror": "https://ror.org/01267r312"},
68
+ ],
69
+ "Zimbabwe": [
70
+ {"name": "University of Zimbabwe", "ror": "https://ror.org/03w489125"},
71
+ {"name": "National University of Science and Technology", "ror": "https://ror.org/01y6mpy73"},
72
+ ],
73
+ "Kenya": [
74
+ {"name": "University of Nairobi", "ror": "https://ror.org/01078r490"},
75
+ {"name": "Kenyatta University", "ror": "https://ror.org/01py3v171"},
76
+ {"name": "Jomo Kenyatta University of Agriculture and Technology", "ror": "https://ror.org/03pyvpy71"},
77
+ ],
78
+ "Uganda": [
79
+ {"name": "Makerere University", "ror": "https://ror.org/05vzwad88"},
80
+ {"name": "Mbarara University of Science and Technology", "ror": "https://ror.org/0155pcf71"},
81
+ ],
82
+ "Tanzania": [
83
+ {"name": "University of Dar es Salaam", "ror": "https://ror.org/0199e1957"},
84
+ {"name": "Sokoine University of Agriculture", "ror": "https://ror.org/011y27r38"},
85
+ ],
86
+ "Ethiopia": [
87
+ {"name": "Addis Ababa University", "ror": "https://ror.org/01py3v171"}
88
+ ],
89
+ "Rwanda": [
90
+ {"name": "University of Rwanda", "ror": "https://ror.org/02yr01r27"}
91
+ ],
92
+ }
93
+
94
+ os.makedirs("config/institutions", exist_ok=True)
95
+ count = 0
96
+
97
+ for country, unis in curated_universities.items():
98
+ region = next(r for r, c in subregions.items() if country in c)
99
+ for u in unis:
100
+ # Create a safe shortname / filename
101
+ short_name = u["name"].replace("University of ", "").replace("Université de ", "").replace("Université ", "")
102
+ if len(short_name.split()) > 3:
103
+ short_name = "".join([word[0] for word in short_name.split() if word.istitle()])
104
+ if not short_name:
105
+ short_name = u["name"].split()[0]
106
+
107
+ # Overrides for some known ones
108
+ if "Lagos" in u["name"]: short_name = "UNILAG"
109
+ elif "Ibadan" in u["name"]: short_name = "UI"
110
+ elif "Cape Town" in u["name"]: short_name = "UCT"
111
+ elif "Witwatersrand" in u["name"]: short_name = "Wits"
112
+ elif "Kwame Nkrumah" in u["name"]: short_name = "KNUST"
113
+ elif "Yaoundé" in u["name"]: short_name = "Yaounde I"
114
+
115
+ file_name = "".join(x for x in short_name.lower() if x.isalnum()) + ".json"
116
+
117
+ cfg = {
118
+ "ror": u["ror"],
119
+ "name": u["name"],
120
+ "short_name": short_name,
121
+ "country": country,
122
+ "sub_region": region,
123
+ "staff_file": f"data/{short_name.lower().replace(' ', '_')}_staff.json",
124
+ "affiliation_patterns": [u["name"], short_name, f"{u['name']} Department"],
125
+ "faculties": [
126
+ "Science",
127
+ "Humanities",
128
+ "Engineering",
129
+ "Medicine",
130
+ "Social Sciences",
131
+ "Arts",
132
+ "Law",
133
+ ],
134
+ "crawler_settings": {
135
+ "rate_limit": 2.0,
136
+ "concurrent_requests": 8,
137
+ "retry_times": 3,
138
+ "download_delay": 2.0,
139
+ },
140
+ }
141
+ with open(f"config/institutions/{file_name}", "w", encoding="utf-8") as f:
142
+ json.dump(cfg, f, indent=2, ensure_ascii=False)
143
+ count += 1
144
+
145
+ print(f"Generated {count} university configurations.")
scratch/generate_creds.py CHANGED
@@ -1,18 +1,18 @@
1
- import secrets
2
- from werkzeug.security import generate_password_hash
3
-
4
- secret_key = secrets.token_hex(32)
5
- admin_pw = "uraas_admin_2026"
6
- viewer_pw = "uraas_viewer_2026"
7
-
8
- print("=== NEW URAAS CREDENTIALS ===")
9
- print("DASHBOARD_SECRET_KEY=" + secret_key)
10
- print("ADMIN_USERNAME=admin")
11
- print("ADMIN_PASSWORD_HASH=" + generate_password_hash(admin_pw))
12
- print("VIEWER_USERNAME=viewer")
13
- print("VIEWER_PASSWORD_HASH=" + generate_password_hash(viewer_pw))
14
- print()
15
- print(f"Admin plain password: {admin_pw}")
16
- print(f"Viewer plain password: {viewer_pw}")
17
- print()
18
- print("Store these safely in the server environment!")
 
1
+ import secrets
2
+ from werkzeug.security import generate_password_hash
3
+
4
+ secret_key = secrets.token_hex(32)
5
+ admin_pw = "uraas_admin_2026"
6
+ viewer_pw = "uraas_viewer_2026"
7
+
8
+ print("=== NEW URAAS CREDENTIALS ===")
9
+ print("DASHBOARD_SECRET_KEY=" + secret_key)
10
+ print("ADMIN_USERNAME=admin")
11
+ print("ADMIN_PASSWORD_HASH=" + generate_password_hash(admin_pw))
12
+ print("VIEWER_USERNAME=viewer")
13
+ print("VIEWER_PASSWORD_HASH=" + generate_password_hash(viewer_pw))
14
+ print()
15
+ print(f"Admin plain password: {admin_pw}")
16
+ print(f"Viewer plain password: {viewer_pw}")
17
+ print()
18
+ print("Store these safely in the server environment!")
scratch/inspect_citations.py CHANGED
@@ -1,30 +1,30 @@
1
- import os
2
- import sys
3
-
4
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
5
-
6
- from sqlalchemy import text
7
-
8
- from uraas.database import Base, SessionLocal, engine
9
-
10
-
11
- def inspect():
12
- session = SessionLocal()
13
- try:
14
- # Check if tables exist first
15
- tables = ["citations", "citation_metrics", "author_metrics"]
16
- for table in tables:
17
- try:
18
- res = session.execute(text(f"SELECT COUNT(*) FROM {table}")).scalar()
19
- print(f"Table '{table}' has {res} rows")
20
- except Exception as e:
21
- print(f"Table '{table}' error: {e}")
22
-
23
- except Exception as e:
24
- print(f"Error: {e}")
25
- finally:
26
- session.close()
27
-
28
-
29
- if __name__ == "__main__":
30
- inspect()
 
1
+ import os
2
+ import sys
3
+
4
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
5
+
6
+ from sqlalchemy import text
7
+
8
+ from uraas.database import Base, SessionLocal, engine
9
+
10
+
11
+ def inspect():
12
+ session = SessionLocal()
13
+ try:
14
+ # Check if tables exist first
15
+ tables = ["citations", "citation_metrics", "author_metrics"]
16
+ for table in tables:
17
+ try:
18
+ res = session.execute(text(f"SELECT COUNT(*) FROM {table}")).scalar()
19
+ print(f"Table '{table}' has {res} rows")
20
+ except Exception as e:
21
+ print(f"Table '{table}' error: {e}")
22
+
23
+ except Exception as e:
24
+ print(f"Error: {e}")
25
+ finally:
26
+ session.close()
27
+
28
+
29
+ if __name__ == "__main__":
30
+ inspect()
scratch/inspect_db.py CHANGED
@@ -1,73 +1,73 @@
1
- import os
2
- import sys
3
-
4
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
5
-
6
- from sqlalchemy import func
7
-
8
- from uraas.database import Item, SessionLocal
9
-
10
-
11
- def inspect():
12
- session = SessionLocal()
13
- try:
14
- total_items = session.query(Item).count()
15
- print(f"Total items: {total_items}")
16
-
17
- # Group by institution/ror
18
- inst_counts = (
19
- session.query(Item.institution, Item.ror, func.count(Item.id))
20
- .group_by(Item.institution, Item.ror)
21
- .all()
22
- )
23
- print("\nItems per institution:")
24
- for inst, ror, count in inst_counts:
25
- print(f" - {inst} ({ror}): {count} papers")
26
-
27
- # African language papers
28
- african_lang_count = (
29
- session.query(func.count(Item.id))
30
- .filter(Item.is_african_language == True)
31
- .scalar()
32
- )
33
- print(f"\nAfrican language papers: {african_lang_count}")
34
-
35
- # TK vitality papers
36
- tk_count = (
37
- session.query(func.count(Item.id))
38
- .filter(
39
- (Item.tk_label.isnot(None))
40
- | (Item.content_type == "indigenous_knowledge")
41
- )
42
- .scalar()
43
- )
44
- print(f"Indigenous knowledge / TK papers: {tk_count}")
45
-
46
- # Patents
47
- patent_count = (
48
- session.query(func.count(Item.id))
49
- .filter(Item.patent_id.isnot(None))
50
- .scalar()
51
- )
52
- print(f"Patents: {patent_count}")
53
-
54
- # DocID coverage
55
- docid_count = (
56
- session.query(func.count(Item.id)).filter(Item.docid.isnot(None)).scalar()
57
- )
58
- print(f"DocID assigned papers: {docid_count}")
59
-
60
- # Access policy / PDFs
61
- from uraas.database import File
62
-
63
- pdf_count = session.query(File).count()
64
- print(f"Downloaded PDFs: {pdf_count}")
65
-
66
- except Exception as e:
67
- print(f"Error: {e}")
68
- finally:
69
- session.close()
70
-
71
-
72
- if __name__ == "__main__":
73
- inspect()
 
1
+ import os
2
+ import sys
3
+
4
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
5
+
6
+ from sqlalchemy import func
7
+
8
+ from uraas.database import Item, SessionLocal
9
+
10
+
11
+ def inspect():
12
+ session = SessionLocal()
13
+ try:
14
+ total_items = session.query(Item).count()
15
+ print(f"Total items: {total_items}")
16
+
17
+ # Group by institution/ror
18
+ inst_counts = (
19
+ session.query(Item.institution, Item.ror, func.count(Item.id))
20
+ .group_by(Item.institution, Item.ror)
21
+ .all()
22
+ )
23
+ print("\nItems per institution:")
24
+ for inst, ror, count in inst_counts:
25
+ print(f" - {inst} ({ror}): {count} papers")
26
+
27
+ # African language papers
28
+ african_lang_count = (
29
+ session.query(func.count(Item.id))
30
+ .filter(Item.is_african_language == True)
31
+ .scalar()
32
+ )
33
+ print(f"\nAfrican language papers: {african_lang_count}")
34
+
35
+ # TK vitality papers
36
+ tk_count = (
37
+ session.query(func.count(Item.id))
38
+ .filter(
39
+ (Item.tk_label.isnot(None))
40
+ | (Item.content_type == "indigenous_knowledge")
41
+ )
42
+ .scalar()
43
+ )
44
+ print(f"Indigenous knowledge / TK papers: {tk_count}")
45
+
46
+ # Patents
47
+ patent_count = (
48
+ session.query(func.count(Item.id))
49
+ .filter(Item.patent_id.isnot(None))
50
+ .scalar()
51
+ )
52
+ print(f"Patents: {patent_count}")
53
+
54
+ # DocID coverage
55
+ docid_count = (
56
+ session.query(func.count(Item.id)).filter(Item.docid.isnot(None)).scalar()
57
+ )
58
+ print(f"DocID assigned papers: {docid_count}")
59
+
60
+ # Access policy / PDFs
61
+ from uraas.database import File
62
+
63
+ pdf_count = session.query(File).count()
64
+ print(f"Downloaded PDFs: {pdf_count}")
65
+
66
+ except Exception as e:
67
+ print(f"Error: {e}")
68
+ finally:
69
+ session.close()
70
+
71
+
72
+ if __name__ == "__main__":
73
+ inspect()
scratch/replica_smoke_test.sh CHANGED
@@ -1,80 +1,80 @@
1
- #!/usr/bin/env bash
2
- # UNILAG mounting dry-run — Phase G smoke tests (mounting guide §5).
3
- # Runs against the production replica stack.
4
- #
5
- # NOTE on cookies: the app sets SESSION_COOKIE_SECURE=True in production, so the
6
- # session cookie is only sent over HTTPS. All authenticated flows therefore go
7
- # through the nginx TLS reverse proxy (https://localhost:8443, -k = self-signed),
8
- # which is the real production request path anyway. Unauthenticated checks use
9
- # the direct gunicorn port (18080) to prove the app itself is hardened.
10
- set -u
11
-
12
- APP=http://localhost:18080 # gunicorn app (direct, bypasses nginx)
13
- TLS=https://localhost:8443 # nginx TLS reverse proxy (prod path)
14
- J=/tmp/uraas_cookies.txt
15
- PASS=0; FAIL=0
16
- ok(){ echo " PASS: $1"; PASS=$((PASS+1)); }
17
- no(){ echo " FAIL: $1"; FAIL=$((FAIL+1)); }
18
- CURL="curl -sk" # -k: trust the self-signed dry-run cert
19
-
20
- echo "=== 1. /health returns 200 (app direct + nginx TLS) ==="
21
- code=$($CURL -o /dev/null -w '%{http_code}' $APP/health)
22
- [ "$code" = "200" ] && ok "app /health = 200" || no "app /health = $code"
23
- code=$($CURL -o /dev/null -w '%{http_code}' $TLS/health)
24
- [ "$code" = "200" ] && ok "nginx TLS /health = 200" || no "nginx TLS /health = $code"
25
-
26
- echo "=== 2. HTTP -> HTTPS redirect (nginx :8081) ==="
27
- loc=$($CURL -o /dev/null -w '%{http_code} %{redirect_url}' http://localhost:8081/)
28
- echo " $loc"
29
- echo "$loc" | grep -q "301" && ok "HTTP returns 301 redirect to https" || no "no 301 redirect: $loc"
30
-
31
- echo "=== 3. Anonymous API control route -> 401 ==="
32
- code=$($CURL -o /dev/null -w '%{http_code}' $APP/api/crawler/status)
33
- [ "$code" = "401" ] && ok "anon /api/crawler/status = 401" || no "anon crawler status = $code"
34
- code=$($CURL -o /dev/null -w '%{http_code}' $APP/api/analytics/overview)
35
- [ "$code" = "401" ] && ok "anon /api/analytics/overview = 401" || no "anon analytics = $code"
36
- code=$($CURL -o /dev/null -w '%{http_code}' $APP/api/staff/directory)
37
- echo " (staff directory PII, anon): $code"
38
- [ "$code" = "401" ] && ok "anon staff directory (PII) = 401" || no "anon staff directory = $code"
39
-
40
- echo "=== 4. Anonymous HTML route -> redirect to /login ==="
41
- loc=$($CURL -o /dev/null -w '%{http_code} %{redirect_url}' "$APP/")
42
- echo " $loc"
43
- echo "$loc" | grep -qE "302.*/login" && ok "anon / redirects to /login" || no "anon / = $loc"
44
-
45
- echo "=== 5. Security headers (CSP, XFO, nosniff, HSTS over TLS) ==="
46
- h=$($CURL -D - -o /dev/null $APP/login)
47
- echo "$h" | grep -qi "Content-Security-Policy" && ok "CSP header present" || no "CSP missing"
48
- echo "$h" | grep -qi "X-Frame-Options: DENY" && ok "X-Frame-Options DENY" || no "XFO missing"
49
- echo "$h" | grep -qi "X-Content-Type-Options: nosniff" && ok "nosniff present" || no "nosniff missing"
50
- echo "$h" | grep -qi "Strict-Transport-Security" && ok "HSTS present (prod)" || no "HSTS missing"
51
- ht=$($CURL -D - -o /dev/null $TLS/login)
52
- echo "$ht" | grep -qi "Strict-Transport-Security" && ok "HSTS present via nginx TLS" || no "HSTS via nginx missing"
53
-
54
- echo "=== 6. Bad login -> 401, no session granted ==="
55
- code=$($CURL -o /dev/null -w '%{http_code}' -d "username=admin&password=wrong" $TLS/login)
56
- [ "$code" = "401" ] && ok "bad login = 401" || no "bad login = $code"
57
-
58
- echo "=== 7. Admin login works + reaches admin-only route (over TLS) ==="
59
- rm -f $J
60
- code=$($CURL -o /dev/null -w '%{http_code}' -c $J -d "username=admin&password=UnilagAdmin#2026" $TLS/login)
61
- echo " admin login status (302 expected): $code"
62
- [ "$code" = "302" ] && ok "admin login = 302" || no "admin login = $code"
63
- code=$($CURL -o /dev/null -w '%{http_code}' -b $J $TLS/api/crawler/status)
64
- [ "$code" = "200" ] && ok "admin reaches crawler status (200)" || no "admin crawler status = $code"
65
-
66
- echo "=== 8. Viewer login works but is NOT admin (crawler -> 403) ==="
67
- rm -f $J
68
- $CURL -o /dev/null -c $J -d "username=viewer&password=UnilagView#2026" $TLS/login
69
- code=$($CURL -o /dev/null -w '%{http_code}' -b $J $TLS/api/crawler/status)
70
- [ "$code" = "403" ] && ok "viewer crawler status = 403 (admin-only)" || no "viewer crawler status = $code"
71
- code=$($CURL -o /dev/null -w '%{http_code}' -b $J $TLS/api/analytics/overview)
72
- [ "$code" = "200" ] && ok "viewer reads analytics (200)" || no "viewer analytics = $code"
73
- code=$($CURL -o /dev/null -w '%{http_code}' -b $J $TLS/api/staff/directory)
74
- [ "$code" = "403" ] && ok "viewer staff directory (PII) = 403" || no "viewer staff directory = $code"
75
-
76
- echo
77
- echo "================= SMOKE TEST SUMMARY ================="
78
- echo " PASSED: $PASS FAILED: $FAIL"
79
- echo "====================================================="
80
- [ "$FAIL" = "0" ] && exit 0 || exit 1
 
1
+ #!/usr/bin/env bash
2
+ # UNILAG mounting dry-run — Phase G smoke tests (mounting guide §5).
3
+ # Runs against the production replica stack.
4
+ #
5
+ # NOTE on cookies: the app sets SESSION_COOKIE_SECURE=True in production, so the
6
+ # session cookie is only sent over HTTPS. All authenticated flows therefore go
7
+ # through the nginx TLS reverse proxy (https://localhost:8443, -k = self-signed),
8
+ # which is the real production request path anyway. Unauthenticated checks use
9
+ # the direct gunicorn port (18080) to prove the app itself is hardened.
10
+ set -u
11
+
12
+ APP=http://localhost:18080 # gunicorn app (direct, bypasses nginx)
13
+ TLS=https://localhost:8443 # nginx TLS reverse proxy (prod path)
14
+ J=/tmp/uraas_cookies.txt
15
+ PASS=0; FAIL=0
16
+ ok(){ echo " PASS: $1"; PASS=$((PASS+1)); }
17
+ no(){ echo " FAIL: $1"; FAIL=$((FAIL+1)); }
18
+ CURL="curl -sk" # -k: trust the self-signed dry-run cert
19
+
20
+ echo "=== 1. /health returns 200 (app direct + nginx TLS) ==="
21
+ code=$($CURL -o /dev/null -w '%{http_code}' $APP/health)
22
+ [ "$code" = "200" ] && ok "app /health = 200" || no "app /health = $code"
23
+ code=$($CURL -o /dev/null -w '%{http_code}' $TLS/health)
24
+ [ "$code" = "200" ] && ok "nginx TLS /health = 200" || no "nginx TLS /health = $code"
25
+
26
+ echo "=== 2. HTTP -> HTTPS redirect (nginx :8081) ==="
27
+ loc=$($CURL -o /dev/null -w '%{http_code} %{redirect_url}' http://localhost:8081/)
28
+ echo " $loc"
29
+ echo "$loc" | grep -q "301" && ok "HTTP returns 301 redirect to https" || no "no 301 redirect: $loc"
30
+
31
+ echo "=== 3. Anonymous API control route -> 401 ==="
32
+ code=$($CURL -o /dev/null -w '%{http_code}' $APP/api/crawler/status)
33
+ [ "$code" = "401" ] && ok "anon /api/crawler/status = 401" || no "anon crawler status = $code"
34
+ code=$($CURL -o /dev/null -w '%{http_code}' $APP/api/analytics/overview)
35
+ [ "$code" = "401" ] && ok "anon /api/analytics/overview = 401" || no "anon analytics = $code"
36
+ code=$($CURL -o /dev/null -w '%{http_code}' $APP/api/staff/directory)
37
+ echo " (staff directory PII, anon): $code"
38
+ [ "$code" = "401" ] && ok "anon staff directory (PII) = 401" || no "anon staff directory = $code"
39
+
40
+ echo "=== 4. Anonymous HTML route -> redirect to /login ==="
41
+ loc=$($CURL -o /dev/null -w '%{http_code} %{redirect_url}' "$APP/")
42
+ echo " $loc"
43
+ echo "$loc" | grep -qE "302.*/login" && ok "anon / redirects to /login" || no "anon / = $loc"
44
+
45
+ echo "=== 5. Security headers (CSP, XFO, nosniff, HSTS over TLS) ==="
46
+ h=$($CURL -D - -o /dev/null $APP/login)
47
+ echo "$h" | grep -qi "Content-Security-Policy" && ok "CSP header present" || no "CSP missing"
48
+ echo "$h" | grep -qi "X-Frame-Options: DENY" && ok "X-Frame-Options DENY" || no "XFO missing"
49
+ echo "$h" | grep -qi "X-Content-Type-Options: nosniff" && ok "nosniff present" || no "nosniff missing"
50
+ echo "$h" | grep -qi "Strict-Transport-Security" && ok "HSTS present (prod)" || no "HSTS missing"
51
+ ht=$($CURL -D - -o /dev/null $TLS/login)
52
+ echo "$ht" | grep -qi "Strict-Transport-Security" && ok "HSTS present via nginx TLS" || no "HSTS via nginx missing"
53
+
54
+ echo "=== 6. Bad login -> 401, no session granted ==="
55
+ code=$($CURL -o /dev/null -w '%{http_code}' -d "username=admin&password=wrong" $TLS/login)
56
+ [ "$code" = "401" ] && ok "bad login = 401" || no "bad login = $code"
57
+
58
+ echo "=== 7. Admin login works + reaches admin-only route (over TLS) ==="
59
+ rm -f $J
60
+ code=$($CURL -o /dev/null -w '%{http_code}' -c $J -d "username=admin&password=UnilagAdmin#2026" $TLS/login)
61
+ echo " admin login status (302 expected): $code"
62
+ [ "$code" = "302" ] && ok "admin login = 302" || no "admin login = $code"
63
+ code=$($CURL -o /dev/null -w '%{http_code}' -b $J $TLS/api/crawler/status)
64
+ [ "$code" = "200" ] && ok "admin reaches crawler status (200)" || no "admin crawler status = $code"
65
+
66
+ echo "=== 8. Viewer login works but is NOT admin (crawler -> 403) ==="
67
+ rm -f $J
68
+ $CURL -o /dev/null -c $J -d "username=viewer&password=UnilagView#2026" $TLS/login
69
+ code=$($CURL -o /dev/null -w '%{http_code}' -b $J $TLS/api/crawler/status)
70
+ [ "$code" = "403" ] && ok "viewer crawler status = 403 (admin-only)" || no "viewer crawler status = $code"
71
+ code=$($CURL -o /dev/null -w '%{http_code}' -b $J $TLS/api/analytics/overview)
72
+ [ "$code" = "200" ] && ok "viewer reads analytics (200)" || no "viewer analytics = $code"
73
+ code=$($CURL -o /dev/null -w '%{http_code}' -b $J $TLS/api/staff/directory)
74
+ [ "$code" = "403" ] && ok "viewer staff directory (PII) = 403" || no "viewer staff directory = $code"
75
+
76
+ echo
77
+ echo "================= SMOKE TEST SUMMARY ================="
78
+ echo " PASSED: $PASS FAILED: $FAIL"
79
+ echo "====================================================="
80
+ [ "$FAIL" = "0" ] && exit 0 || exit 1
scratch/verify_rors.py CHANGED
@@ -1,39 +1,39 @@
1
- """
2
- Script to verify all institution RORs against OpenAlex API.
3
- Identifies wrong RORs by checking if count > 0.
4
- """
5
- import json
6
- import time
7
- import urllib.request
8
- from pathlib import Path
9
-
10
- BASE = "https://api.openalex.org"
11
- MAILTO = "cokiki@unilag.edu.ng"
12
-
13
- config_dir = Path(__file__).parent.parent / "config" / "institutions"
14
- results = {}
15
-
16
- for jf in sorted(config_dir.glob("*.json")):
17
- with open(jf) as f:
18
- data = json.load(f)
19
- name = data["name"]
20
- ror_full = data["ror"]
21
- ror_short = ror_full.split("/")[-1]
22
-
23
- url = f"{BASE}/works?filter=institutions.ror:{ror_short}&select=id&per-page=1&mailto={MAILTO}"
24
- try:
25
- req = urllib.request.urlopen(url, timeout=10)
26
- resp = json.loads(req.read())
27
- count = resp["meta"]["count"]
28
- except Exception as e:
29
- count = f"ERROR: {e}"
30
-
31
- status = "OK" if isinstance(count, int) and count > 0 else "ZERO/ERROR"
32
- print(f"[{status:5}] {name:40s} ROR: {ror_short} count={count}")
33
- results[jf.name] = {"name": name, "ror": ror_short, "count": count, "status": status}
34
- time.sleep(0.5)
35
-
36
- print("\n--- PROBLEM INSTITUTIONS ---")
37
- for fname, r in results.items():
38
- if r["status"] != "OK":
39
- print(f" {fname}: {r['name']} -- ROR {r['ror']} returns {r['count']}")
 
1
+ """
2
+ Script to verify all institution RORs against OpenAlex API.
3
+ Identifies wrong RORs by checking if count > 0.
4
+ """
5
+ import json
6
+ import time
7
+ import urllib.request
8
+ from pathlib import Path
9
+
10
+ BASE = "https://api.openalex.org"
11
+ MAILTO = "cokiki@unilag.edu.ng"
12
+
13
+ config_dir = Path(__file__).parent.parent / "config" / "institutions"
14
+ results = {}
15
+
16
+ for jf in sorted(config_dir.glob("*.json")):
17
+ with open(jf) as f:
18
+ data = json.load(f)
19
+ name = data["name"]
20
+ ror_full = data["ror"]
21
+ ror_short = ror_full.split("/")[-1]
22
+
23
+ url = f"{BASE}/works?filter=institutions.ror:{ror_short}&select=id&per-page=1&mailto={MAILTO}"
24
+ try:
25
+ req = urllib.request.urlopen(url, timeout=10)
26
+ resp = json.loads(req.read())
27
+ count = resp["meta"]["count"]
28
+ except Exception as e:
29
+ count = f"ERROR: {e}"
30
+
31
+ status = "OK" if isinstance(count, int) and count > 0 else "ZERO/ERROR"
32
+ print(f"[{status:5}] {name:40s} ROR: {ror_short} count={count}")
33
+ results[jf.name] = {"name": name, "ror": ror_short, "count": count, "status": status}
34
+ time.sleep(0.5)
35
+
36
+ print("\n--- PROBLEM INSTITUTIONS ---")
37
+ for fname, r in results.items():
38
+ if r["status"] != "OK":
39
+ print(f" {fname}: {r['name']} -- ROR {r['ror']} returns {r['count']}")
scripts/backfill_alignment.py CHANGED
@@ -1,99 +1,99 @@
1
- """
2
- Backfill framework alignment scores for existing items and rebuild the
3
- AlignmentAggregate table (per institution + global).
4
-
5
- Usage:
6
- python scripts/backfill_alignment.py # DRY RUN — counts only
7
- python scripts/backfill_alignment.py --apply
8
- python scripts/backfill_alignment.py --apply --force # re-score current-version items
9
-
10
- Safe to re-run: items already at ALIGNMENT_VERSION are skipped unless --force.
11
- No network needed beyond the one-time embedding-model download.
12
- """
13
-
14
- import argparse
15
- import json
16
- import os
17
- import sys
18
-
19
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
20
-
21
- from uraas.config.alignment_frameworks import ALIGNMENT_VERSION
22
- from uraas.database import Item, SessionLocal
23
- from uraas.services.alignment_engine import (
24
- recompute_aggregates,
25
- score_item_alignment,
26
- scoring_mode,
27
- )
28
- from uraas.utils.analytics_cache import analytics_cache
29
-
30
-
31
- def main():
32
- parser = argparse.ArgumentParser(description=__doc__)
33
- parser.add_argument("--apply", action="store_true", help="Write changes (default: dry run)")
34
- parser.add_argument("--force", action="store_true", help="Re-score items already at current version")
35
- parser.add_argument("--batch", type=int, default=500)
36
- args = parser.parse_args()
37
-
38
- session = SessionLocal()
39
- try:
40
- q = session.query(Item)
41
- if not args.force:
42
- q = q.filter(
43
- (Item.alignment_version.is_(None))
44
- | (Item.alignment_version < ALIGNMENT_VERSION)
45
- )
46
- todo = q.count()
47
- total = session.query(Item).count()
48
- print("=" * 64)
49
- print(f"Scoring mode: {scoring_mode()} | version: {ALIGNMENT_VERSION}")
50
- print(f"Items to score: {todo} / {total}")
51
- print("=" * 64)
52
- if not args.apply:
53
- print("[DRY RUN] No writes. Re-run with --apply.")
54
- return 0
55
-
56
- scored = aligned = 0
57
- framework_hits = {}
58
- while True:
59
- batch = q.limit(args.batch).all()
60
- if not batch:
61
- break
62
- for it in batch:
63
- j, v = score_item_alignment(
64
- it.title or "", it.abstract or "", it.dc_subject or ""
65
- )
66
- it.alignment_scores = j
67
- it.alignment_version = v
68
- scored += 1
69
- if j:
70
- aligned += 1
71
- for fk in json.loads(j):
72
- framework_hits[fk] = framework_hits.get(fk, 0) + 1
73
- session.commit()
74
- print(f" scored {scored}/{todo}")
75
-
76
- print("\nPer-framework items with alignment:")
77
- for fk, n in sorted(framework_hits.items(), key=lambda kv: -kv[1]):
78
- print(f" {fk:24s} {n}")
79
-
80
- # Aggregates: global + each distinct institution
81
- rows = recompute_aggregates(session, None)
82
- institutions = [
83
- i for (i,) in session.query(Item.institution).distinct() if i
84
- ]
85
- for inst in institutions:
86
- rows += recompute_aggregates(session, inst)
87
- print(f"\nAggregate rows written: {rows} ({1 + len(institutions)} scopes)")
88
-
89
- analytics_cache.invalidate_all()
90
- print("\n" + "=" * 64)
91
- print(f"DONE. scored={scored} with_alignment={aligned}")
92
- print("=" * 64)
93
- return 0
94
- finally:
95
- session.close()
96
-
97
-
98
- if __name__ == "__main__":
99
- sys.exit(main())
 
1
+ """
2
+ Backfill framework alignment scores for existing items and rebuild the
3
+ AlignmentAggregate table (per institution + global).
4
+
5
+ Usage:
6
+ python scripts/backfill_alignment.py # DRY RUN — counts only
7
+ python scripts/backfill_alignment.py --apply
8
+ python scripts/backfill_alignment.py --apply --force # re-score current-version items
9
+
10
+ Safe to re-run: items already at ALIGNMENT_VERSION are skipped unless --force.
11
+ No network needed beyond the one-time embedding-model download.
12
+ """
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ import sys
18
+
19
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
20
+
21
+ from uraas.config.alignment_frameworks import ALIGNMENT_VERSION
22
+ from uraas.database import Item, SessionLocal
23
+ from uraas.services.alignment_engine import (
24
+ recompute_aggregates,
25
+ score_item_alignment,
26
+ scoring_mode,
27
+ )
28
+ from uraas.utils.analytics_cache import analytics_cache
29
+
30
+
31
+ def main():
32
+ parser = argparse.ArgumentParser(description=__doc__)
33
+ parser.add_argument("--apply", action="store_true", help="Write changes (default: dry run)")
34
+ parser.add_argument("--force", action="store_true", help="Re-score items already at current version")
35
+ parser.add_argument("--batch", type=int, default=500)
36
+ args = parser.parse_args()
37
+
38
+ session = SessionLocal()
39
+ try:
40
+ q = session.query(Item)
41
+ if not args.force:
42
+ q = q.filter(
43
+ (Item.alignment_version.is_(None))
44
+ | (Item.alignment_version < ALIGNMENT_VERSION)
45
+ )
46
+ todo = q.count()
47
+ total = session.query(Item).count()
48
+ print("=" * 64)
49
+ print(f"Scoring mode: {scoring_mode()} | version: {ALIGNMENT_VERSION}")
50
+ print(f"Items to score: {todo} / {total}")
51
+ print("=" * 64)
52
+ if not args.apply:
53
+ print("[DRY RUN] No writes. Re-run with --apply.")
54
+ return 0
55
+
56
+ scored = aligned = 0
57
+ framework_hits = {}
58
+ while True:
59
+ batch = q.limit(args.batch).all()
60
+ if not batch:
61
+ break
62
+ for it in batch:
63
+ j, v = score_item_alignment(
64
+ it.title or "", it.abstract or "", it.dc_subject or ""
65
+ )
66
+ it.alignment_scores = j
67
+ it.alignment_version = v
68
+ scored += 1
69
+ if j:
70
+ aligned += 1
71
+ for fk in json.loads(j):
72
+ framework_hits[fk] = framework_hits.get(fk, 0) + 1
73
+ session.commit()
74
+ print(f" scored {scored}/{todo}")
75
+
76
+ print("\nPer-framework items with alignment:")
77
+ for fk, n in sorted(framework_hits.items(), key=lambda kv: -kv[1]):
78
+ print(f" {fk:24s} {n}")
79
+
80
+ # Aggregates: global + each distinct institution
81
+ rows = recompute_aggregates(session, None)
82
+ institutions = [
83
+ i for (i,) in session.query(Item.institution).distinct() if i
84
+ ]
85
+ for inst in institutions:
86
+ rows += recompute_aggregates(session, inst)
87
+ print(f"\nAggregate rows written: {rows} ({1 + len(institutions)} scopes)")
88
+
89
+ analytics_cache.invalidate_all()
90
+ print("\n" + "=" * 64)
91
+ print(f"DONE. scored={scored} with_alignment={aligned}")
92
+ print("=" * 64)
93
+ return 0
94
+ finally:
95
+ session.close()
96
+
97
+
98
+ if __name__ == "__main__":
99
+ sys.exit(main())
scripts/backfill_citation_velocity.py CHANGED
@@ -1,81 +1,81 @@
1
- """
2
- Backfill Pan-African citation share (and citation velocity for items missed
3
- by the collaboration backfill).
4
-
5
- The share is one OpenAlex request per item (filter=cites:W... grouped by
6
- citing-country), so run it for the most-cited works first:
7
-
8
- python scripts/backfill_citation_velocity.py # DRY RUN
9
- python scripts/backfill_citation_velocity.py --apply --limit 200
10
-
11
- Skips items whose share is already computed unless --force.
12
- """
13
-
14
- import argparse
15
- import json
16
- import os
17
- import sys
18
- import time
19
-
20
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
21
-
22
- from uraas.database import Item, SessionLocal
23
- from uraas.services.citation_tracker import CitationTracker
24
- from uraas.utils.analytics_cache import analytics_cache
25
-
26
-
27
- def main():
28
- parser = argparse.ArgumentParser(description=__doc__)
29
- parser.add_argument("--apply", action="store_true", help="Write changes (default: dry run)")
30
- parser.add_argument("--limit", type=int, default=200, help="Max items (most-cited first)")
31
- parser.add_argument("--force", action="store_true", help="Recompute existing shares")
32
- args = parser.parse_args()
33
-
34
- session = SessionLocal()
35
- try:
36
- q = (
37
- session.query(Item)
38
- .filter(Item.openalex_id.isnot(None), Item.cited_by_count > 0)
39
- .order_by(Item.cited_by_count.desc())
40
- )
41
- if not args.force:
42
- q = q.filter(Item.african_citation_share.is_(None))
43
- items = q.limit(args.limit).all()
44
-
45
- print("=" * 64)
46
- print(f"Items to process (most-cited first): {len(items)}")
47
- print("=" * 64)
48
- if not args.apply:
49
- print("[DRY RUN] No API calls or writes. Re-run with --apply.")
50
- return 0
51
-
52
- updated = velocity_fixed = 0
53
- for i, it in enumerate(items, 1):
54
- share = CitationTracker.fetch_african_citation_share(it.openalex_id)
55
- if share is not None:
56
- it.african_citation_share = share
57
- updated += 1
58
- # Opportunistic velocity fix for items missing counts_by_year
59
- if not it.counts_by_year:
60
- vel = CitationTracker.fetch_work_velocity(it.openalex_id)
61
- if vel and vel["counts_by_year"]:
62
- it.counts_by_year = json.dumps(vel["counts_by_year"])
63
- it.cited_by_count = vel["cited_by_count"]
64
- velocity_fixed += 1
65
- if i % 25 == 0:
66
- session.commit()
67
- print(f" {i}/{len(items)} processed (share set: {updated})")
68
- time.sleep(1.0)
69
- session.commit()
70
- analytics_cache.invalidate_all()
71
-
72
- print("\n" + "=" * 64)
73
- print(f"DONE. shares set={updated} velocity backfilled={velocity_fixed}")
74
- print("=" * 64)
75
- return 0
76
- finally:
77
- session.close()
78
-
79
-
80
- if __name__ == "__main__":
81
- sys.exit(main())
 
1
+ """
2
+ Backfill Pan-African citation share (and citation velocity for items missed
3
+ by the collaboration backfill).
4
+
5
+ The share is one OpenAlex request per item (filter=cites:W... grouped by
6
+ citing-country), so run it for the most-cited works first:
7
+
8
+ python scripts/backfill_citation_velocity.py # DRY RUN
9
+ python scripts/backfill_citation_velocity.py --apply --limit 200
10
+
11
+ Skips items whose share is already computed unless --force.
12
+ """
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ import sys
18
+ import time
19
+
20
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
21
+
22
+ from uraas.database import Item, SessionLocal
23
+ from uraas.services.citation_tracker import CitationTracker
24
+ from uraas.utils.analytics_cache import analytics_cache
25
+
26
+
27
+ def main():
28
+ parser = argparse.ArgumentParser(description=__doc__)
29
+ parser.add_argument("--apply", action="store_true", help="Write changes (default: dry run)")
30
+ parser.add_argument("--limit", type=int, default=200, help="Max items (most-cited first)")
31
+ parser.add_argument("--force", action="store_true", help="Recompute existing shares")
32
+ args = parser.parse_args()
33
+
34
+ session = SessionLocal()
35
+ try:
36
+ q = (
37
+ session.query(Item)
38
+ .filter(Item.openalex_id.isnot(None), Item.cited_by_count > 0)
39
+ .order_by(Item.cited_by_count.desc())
40
+ )
41
+ if not args.force:
42
+ q = q.filter(Item.african_citation_share.is_(None))
43
+ items = q.limit(args.limit).all()
44
+
45
+ print("=" * 64)
46
+ print(f"Items to process (most-cited first): {len(items)}")
47
+ print("=" * 64)
48
+ if not args.apply:
49
+ print("[DRY RUN] No API calls or writes. Re-run with --apply.")
50
+ return 0
51
+
52
+ updated = velocity_fixed = 0
53
+ for i, it in enumerate(items, 1):
54
+ share = CitationTracker.fetch_african_citation_share(it.openalex_id)
55
+ if share is not None:
56
+ it.african_citation_share = share
57
+ updated += 1
58
+ # Opportunistic velocity fix for items missing counts_by_year
59
+ if not it.counts_by_year:
60
+ vel = CitationTracker.fetch_work_velocity(it.openalex_id)
61
+ if vel and vel["counts_by_year"]:
62
+ it.counts_by_year = json.dumps(vel["counts_by_year"])
63
+ it.cited_by_count = vel["cited_by_count"]
64
+ velocity_fixed += 1
65
+ if i % 25 == 0:
66
+ session.commit()
67
+ print(f" {i}/{len(items)} processed (share set: {updated})")
68
+ time.sleep(1.0)
69
+ session.commit()
70
+ analytics_cache.invalidate_all()
71
+
72
+ print("\n" + "=" * 64)
73
+ print(f"DONE. shares set={updated} velocity backfilled={velocity_fixed}")
74
+ print("=" * 64)
75
+ return 0
76
+ finally:
77
+ session.close()
78
+
79
+
80
+ if __name__ == "__main__":
81
+ sys.exit(main())
scripts/backfill_collaboration_data.py CHANGED
@@ -1,227 +1,227 @@
1
- """
2
- Backfill collaboration + citation data for existing items from OpenAlex.
3
-
4
- Re-fetches each item's OpenAlex record (batched 50 DOIs per request to
5
- conserve API quota) and populates:
6
- - item_affiliations rows (institution / ROR / country per authorship)
7
- - items.coauthor_countries / african_country_count / is_intra_african
8
- - items.openalex_id / cited_by_count / counts_by_year
9
-
10
- Usage:
11
- python scripts/backfill_collaboration_data.py # DRY RUN
12
- python scripts/backfill_collaboration_data.py --apply
13
- python scripts/backfill_collaboration_data.py --apply --limit 500
14
- python scripts/backfill_collaboration_data.py --apply --force # redo enriched rows
15
-
16
- Idempotent: items that already have affiliation rows are skipped unless
17
- --force. Respects ~1 req/sec. Set OPENALEX_API_KEY in the environment.
18
- """
19
-
20
- import argparse
21
- import json
22
- import os
23
- import sys
24
- import time
25
-
26
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
27
-
28
- from uraas.config.african_countries import african_countries_in
29
- from uraas.database import Item, ItemAffiliation, SessionLocal
30
- from uraas.utils.analytics_cache import analytics_cache
31
- from uraas.utils.openalex_client import oa_get
32
-
33
- BATCH = 50
34
- SELECT = "id,doi,authorships,cited_by_count,counts_by_year"
35
-
36
-
37
- def _norm_doi(doi: str) -> str:
38
- return (
39
- (doi or "")
40
- .replace("https://doi.org/", "")
41
- .replace("http://dx.doi.org/", "")
42
- .strip()
43
- .lower()
44
- )
45
-
46
-
47
- def fetch_batch_by_doi(dois):
48
- """One OpenAlex call for up to 50 DOIs. Returns {normalized_doi: work}.
49
-
50
- DOIs are pipe-joined raw — requests URL-encodes the whole filter param;
51
- pre-quoting each DOI double-encodes and matches nothing."""
52
- flt = "doi:" + "|".join(dois)
53
- data = oa_get("/works", {"filter": flt, "select": SELECT, "per-page": BATCH})
54
- out = {}
55
- for work in (data or {}).get("results", []):
56
- nd = _norm_doi(work.get("doi", ""))
57
- if nd:
58
- out[nd] = work
59
- return out
60
-
61
-
62
- def fetch_batch_by_openalex_id(ids):
63
- """One OpenAlex call for up to 50 OpenAlex work IDs."""
64
- flt = "openalex_id:" + "|".join(ids)
65
- data = oa_get("/works", {"filter": flt, "select": SELECT, "per-page": BATCH})
66
- out = {}
67
- for work in (data or {}).get("results", []):
68
- wid = work.get("id", "").replace("https://openalex.org/", "")
69
- if wid:
70
- out[wid] = work
71
- return out
72
-
73
-
74
- def extract_affiliations(work):
75
- """(ror_short, name) -> {ror, name, country_code, author_count}."""
76
- rows = {}
77
- for authorship in work.get("authorships", []):
78
- for inst in authorship.get("institutions", []):
79
- name = inst.get("display_name", "") or ""
80
- ror = (inst.get("ror") or "").replace("https://ror.org/", "")
81
- cc = (inst.get("country_code") or "").upper()
82
- if not (name or ror):
83
- continue
84
- row = rows.setdefault(
85
- (ror, name),
86
- {"ror": ror, "name": name, "country_code": cc, "author_count": 0},
87
- )
88
- row["author_count"] += 1
89
- if cc and not row["country_code"]:
90
- row["country_code"] = cc
91
- return list(rows.values())
92
-
93
-
94
- def apply_work(session, item, work):
95
- """Write affiliation rows + collaboration/citation columns for one item."""
96
- affs = extract_affiliations(work)
97
-
98
- # Idempotency: replace any existing affiliation rows for this item.
99
- session.query(ItemAffiliation).filter_by(item_id=item.id).delete()
100
- for aff in affs:
101
- session.add(
102
- ItemAffiliation(
103
- item_id=item.id,
104
- ror=(aff["ror"] or "")[:128] or None,
105
- institution_name=(aff["name"] or "")[:255] or None,
106
- country_code=(aff["country_code"] or "")[:2] or None,
107
- author_count=aff["author_count"],
108
- )
109
- )
110
-
111
- african = african_countries_in(a["country_code"] for a in affs)
112
- item.coauthor_countries = ",".join(african) or None
113
- item.african_country_count = len(african)
114
- item.is_intra_african = len(african) >= 2
115
-
116
- item.openalex_id = work.get("id", "").replace("https://openalex.org/", "") or None
117
- item.cited_by_count = work.get("cited_by_count", 0) or 0
118
- cby = work.get("counts_by_year") or []
119
- item.counts_by_year = json.dumps(cby) if cby else None
120
- return len(affs), item.is_intra_african
121
-
122
-
123
- def main():
124
- parser = argparse.ArgumentParser(description=__doc__)
125
- parser.add_argument("--apply", action="store_true", help="Write changes (default: dry run)")
126
- parser.add_argument("--limit", type=int, default=0, help="Max items to process (0 = all)")
127
- parser.add_argument(
128
- "--force", action="store_true", help="Re-fetch items that already have affiliation data"
129
- )
130
- args = parser.parse_args()
131
-
132
- session = SessionLocal()
133
- try:
134
- q = session.query(Item)
135
- if not args.force:
136
- enriched = {i for (i,) in session.query(ItemAffiliation.item_id).distinct()}
137
- else:
138
- enriched = set()
139
-
140
- items = [
141
- it
142
- for it in q.all()
143
- if it.id not in enriched and (it.doi or "openalex.org" in (it.url or ""))
144
- ]
145
- skipped_no_id = q.count() - len(items) - len(enriched & {it.id for it in q})
146
- if args.limit:
147
- items = items[: args.limit]
148
-
149
- print("=" * 64)
150
- print(f"Items to enrich: {len(items)} (already enriched, skipped: {len(enriched)})")
151
- print("=" * 64)
152
- if not args.apply:
153
- print("[DRY RUN] No API calls or writes. Re-run with --apply.")
154
- return 0
155
-
156
- by_doi = [it for it in items if it.doi]
157
- by_oaid = [
158
- it for it in items if not it.doi and "openalex.org" in (it.url or "")
159
- ]
160
-
161
- updated = intra = not_found = 0
162
-
163
- # ── DOI batches ──────────────────────────────────────────────────
164
- doi_map = {_norm_doi(it.doi): it for it in by_doi}
165
- doi_keys = list(doi_map)
166
- for start in range(0, len(doi_keys), BATCH):
167
- chunk = doi_keys[start : start + BATCH]
168
- works = fetch_batch_by_doi(chunk)
169
- for nd in chunk:
170
- it = doi_map[nd]
171
- work = works.get(nd)
172
- if not work:
173
- not_found += 1
174
- continue
175
- _, is_ia = apply_work(session, it, work)
176
- updated += 1
177
- intra += int(is_ia)
178
- session.commit()
179
- print(
180
- f" [doi {start + len(chunk)}/{len(doi_keys)}] "
181
- f"updated={updated} intra_african={intra} not_found={not_found}"
182
- )
183
- time.sleep(1.0)
184
-
185
- # ── OpenAlex-ID batches (items without DOI) ──────────────────────
186
- oaid_map = {}
187
- for it in by_oaid:
188
- wid = (it.url or "").rstrip("/").split("/")[-1]
189
- if wid.startswith("W"):
190
- oaid_map[wid] = it
191
- oaid_keys = list(oaid_map)
192
- for start in range(0, len(oaid_keys), BATCH):
193
- chunk = oaid_keys[start : start + BATCH]
194
- works = fetch_batch_by_openalex_id(chunk)
195
- for wid in chunk:
196
- it = oaid_map[wid]
197
- work = works.get(wid)
198
- if not work:
199
- not_found += 1
200
- continue
201
- _, is_ia = apply_work(session, it, work)
202
- updated += 1
203
- intra += int(is_ia)
204
- session.commit()
205
- print(
206
- f" [oaid {start + len(chunk)}/{len(oaid_keys)}] "
207
- f"updated={updated} intra_african={intra} not_found={not_found}"
208
- )
209
- time.sleep(1.0)
210
-
211
- analytics_cache.invalidate_all()
212
- total_ia = session.query(Item).filter(Item.is_intra_african.is_(True)).count()
213
- total = session.query(Item).count()
214
- print("\n" + "=" * 64)
215
- print(f"DONE. updated={updated} not_found={not_found}")
216
- print(
217
- f"Repository intra-African collaboration: {total_ia}/{total} "
218
- f"({(total_ia / total * 100) if total else 0:.1f}%) — continental baseline ~8.4%"
219
- )
220
- print("=" * 64)
221
- return 0
222
- finally:
223
- session.close()
224
-
225
-
226
- if __name__ == "__main__":
227
- sys.exit(main())
 
1
+ """
2
+ Backfill collaboration + citation data for existing items from OpenAlex.
3
+
4
+ Re-fetches each item's OpenAlex record (batched 50 DOIs per request to
5
+ conserve API quota) and populates:
6
+ - item_affiliations rows (institution / ROR / country per authorship)
7
+ - items.coauthor_countries / african_country_count / is_intra_african
8
+ - items.openalex_id / cited_by_count / counts_by_year
9
+
10
+ Usage:
11
+ python scripts/backfill_collaboration_data.py # DRY RUN
12
+ python scripts/backfill_collaboration_data.py --apply
13
+ python scripts/backfill_collaboration_data.py --apply --limit 500
14
+ python scripts/backfill_collaboration_data.py --apply --force # redo enriched rows
15
+
16
+ Idempotent: items that already have affiliation rows are skipped unless
17
+ --force. Respects ~1 req/sec. Set OPENALEX_API_KEY in the environment.
18
+ """
19
+
20
+ import argparse
21
+ import json
22
+ import os
23
+ import sys
24
+ import time
25
+
26
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
27
+
28
+ from uraas.config.african_countries import african_countries_in
29
+ from uraas.database import Item, ItemAffiliation, SessionLocal
30
+ from uraas.utils.analytics_cache import analytics_cache
31
+ from uraas.utils.openalex_client import oa_get
32
+
33
+ BATCH = 50
34
+ SELECT = "id,doi,authorships,cited_by_count,counts_by_year"
35
+
36
+
37
+ def _norm_doi(doi: str) -> str:
38
+ return (
39
+ (doi or "")
40
+ .replace("https://doi.org/", "")
41
+ .replace("http://dx.doi.org/", "")
42
+ .strip()
43
+ .lower()
44
+ )
45
+
46
+
47
+ def fetch_batch_by_doi(dois):
48
+ """One OpenAlex call for up to 50 DOIs. Returns {normalized_doi: work}.
49
+
50
+ DOIs are pipe-joined raw — requests URL-encodes the whole filter param;
51
+ pre-quoting each DOI double-encodes and matches nothing."""
52
+ flt = "doi:" + "|".join(dois)
53
+ data = oa_get("/works", {"filter": flt, "select": SELECT, "per-page": BATCH})
54
+ out = {}
55
+ for work in (data or {}).get("results", []):
56
+ nd = _norm_doi(work.get("doi", ""))
57
+ if nd:
58
+ out[nd] = work
59
+ return out
60
+
61
+
62
+ def fetch_batch_by_openalex_id(ids):
63
+ """One OpenAlex call for up to 50 OpenAlex work IDs."""
64
+ flt = "openalex_id:" + "|".join(ids)
65
+ data = oa_get("/works", {"filter": flt, "select": SELECT, "per-page": BATCH})
66
+ out = {}
67
+ for work in (data or {}).get("results", []):
68
+ wid = work.get("id", "").replace("https://openalex.org/", "")
69
+ if wid:
70
+ out[wid] = work
71
+ return out
72
+
73
+
74
+ def extract_affiliations(work):
75
+ """(ror_short, name) -> {ror, name, country_code, author_count}."""
76
+ rows = {}
77
+ for authorship in work.get("authorships", []):
78
+ for inst in authorship.get("institutions", []):
79
+ name = inst.get("display_name", "") or ""
80
+ ror = (inst.get("ror") or "").replace("https://ror.org/", "")
81
+ cc = (inst.get("country_code") or "").upper()
82
+ if not (name or ror):
83
+ continue
84
+ row = rows.setdefault(
85
+ (ror, name),
86
+ {"ror": ror, "name": name, "country_code": cc, "author_count": 0},
87
+ )
88
+ row["author_count"] += 1
89
+ if cc and not row["country_code"]:
90
+ row["country_code"] = cc
91
+ return list(rows.values())
92
+
93
+
94
+ def apply_work(session, item, work):
95
+ """Write affiliation rows + collaboration/citation columns for one item."""
96
+ affs = extract_affiliations(work)
97
+
98
+ # Idempotency: replace any existing affiliation rows for this item.
99
+ session.query(ItemAffiliation).filter_by(item_id=item.id).delete()
100
+ for aff in affs:
101
+ session.add(
102
+ ItemAffiliation(
103
+ item_id=item.id,
104
+ ror=(aff["ror"] or "")[:128] or None,
105
+ institution_name=(aff["name"] or "")[:255] or None,
106
+ country_code=(aff["country_code"] or "")[:2] or None,
107
+ author_count=aff["author_count"],
108
+ )
109
+ )
110
+
111
+ african = african_countries_in(a["country_code"] for a in affs)
112
+ item.coauthor_countries = ",".join(african) or None
113
+ item.african_country_count = len(african)
114
+ item.is_intra_african = len(african) >= 2
115
+
116
+ item.openalex_id = work.get("id", "").replace("https://openalex.org/", "") or None
117
+ item.cited_by_count = work.get("cited_by_count", 0) or 0
118
+ cby = work.get("counts_by_year") or []
119
+ item.counts_by_year = json.dumps(cby) if cby else None
120
+ return len(affs), item.is_intra_african
121
+
122
+
123
+ def main():
124
+ parser = argparse.ArgumentParser(description=__doc__)
125
+ parser.add_argument("--apply", action="store_true", help="Write changes (default: dry run)")
126
+ parser.add_argument("--limit", type=int, default=0, help="Max items to process (0 = all)")
127
+ parser.add_argument(
128
+ "--force", action="store_true", help="Re-fetch items that already have affiliation data"
129
+ )
130
+ args = parser.parse_args()
131
+
132
+ session = SessionLocal()
133
+ try:
134
+ q = session.query(Item)
135
+ if not args.force:
136
+ enriched = {i for (i,) in session.query(ItemAffiliation.item_id).distinct()}
137
+ else:
138
+ enriched = set()
139
+
140
+ items = [
141
+ it
142
+ for it in q.all()
143
+ if it.id not in enriched and (it.doi or "openalex.org" in (it.url or ""))
144
+ ]
145
+ skipped_no_id = q.count() - len(items) - len(enriched & {it.id for it in q})
146
+ if args.limit:
147
+ items = items[: args.limit]
148
+
149
+ print("=" * 64)
150
+ print(f"Items to enrich: {len(items)} (already enriched, skipped: {len(enriched)})")
151
+ print("=" * 64)
152
+ if not args.apply:
153
+ print("[DRY RUN] No API calls or writes. Re-run with --apply.")
154
+ return 0
155
+
156
+ by_doi = [it for it in items if it.doi]
157
+ by_oaid = [
158
+ it for it in items if not it.doi and "openalex.org" in (it.url or "")
159
+ ]
160
+
161
+ updated = intra = not_found = 0
162
+
163
+ # ── DOI batches ──────────────────────────────────────────────────
164
+ doi_map = {_norm_doi(it.doi): it for it in by_doi}
165
+ doi_keys = list(doi_map)
166
+ for start in range(0, len(doi_keys), BATCH):
167
+ chunk = doi_keys[start : start + BATCH]
168
+ works = fetch_batch_by_doi(chunk)
169
+ for nd in chunk:
170
+ it = doi_map[nd]
171
+ work = works.get(nd)
172
+ if not work:
173
+ not_found += 1
174
+ continue
175
+ _, is_ia = apply_work(session, it, work)
176
+ updated += 1
177
+ intra += int(is_ia)
178
+ session.commit()
179
+ print(
180
+ f" [doi {start + len(chunk)}/{len(doi_keys)}] "
181
+ f"updated={updated} intra_african={intra} not_found={not_found}"
182
+ )
183
+ time.sleep(1.0)
184
+
185
+ # ── OpenAlex-ID batches (items without DOI) ──────────────────────
186
+ oaid_map = {}
187
+ for it in by_oaid:
188
+ wid = (it.url or "").rstrip("/").split("/")[-1]
189
+ if wid.startswith("W"):
190
+ oaid_map[wid] = it
191
+ oaid_keys = list(oaid_map)
192
+ for start in range(0, len(oaid_keys), BATCH):
193
+ chunk = oaid_keys[start : start + BATCH]
194
+ works = fetch_batch_by_openalex_id(chunk)
195
+ for wid in chunk:
196
+ it = oaid_map[wid]
197
+ work = works.get(wid)
198
+ if not work:
199
+ not_found += 1
200
+ continue
201
+ _, is_ia = apply_work(session, it, work)
202
+ updated += 1
203
+ intra += int(is_ia)
204
+ session.commit()
205
+ print(
206
+ f" [oaid {start + len(chunk)}/{len(oaid_keys)}] "
207
+ f"updated={updated} intra_african={intra} not_found={not_found}"
208
+ )
209
+ time.sleep(1.0)
210
+
211
+ analytics_cache.invalidate_all()
212
+ total_ia = session.query(Item).filter(Item.is_intra_african.is_(True)).count()
213
+ total = session.query(Item).count()
214
+ print("\n" + "=" * 64)
215
+ print(f"DONE. updated={updated} not_found={not_found}")
216
+ print(
217
+ f"Repository intra-African collaboration: {total_ia}/{total} "
218
+ f"({(total_ia / total * 100) if total else 0:.1f}%) — continental baseline ~8.4%"
219
+ )
220
+ print("=" * 64)
221
+ return 0
222
+ finally:
223
+ session.close()
224
+
225
+
226
+ if __name__ == "__main__":
227
+ sys.exit(main())
scripts/backfill_pids.py CHANGED
@@ -1,70 +1,70 @@
1
- """
2
- Backfill persistent identifiers: DocID™ + ARK for items missing them.
3
-
4
- ARKs are minted deterministically from the item's DocID hash, so re-running
5
- is idempotent. No network access needed.
6
-
7
- Usage:
8
- python scripts/backfill_pids.py # DRY RUN
9
- python scripts/backfill_pids.py --apply
10
- """
11
-
12
- import argparse
13
- import os
14
- import sys
15
- from datetime import datetime
16
-
17
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
18
-
19
- from uraas.database import Item, SessionLocal
20
- from uraas.utils.ark_generator import ark_generator
21
- from uraas.utils.docid_generator import docid_generator
22
-
23
-
24
- def main():
25
- parser = argparse.ArgumentParser(description=__doc__)
26
- parser.add_argument("--apply", action="store_true", help="Write changes (default: dry run)")
27
- args = parser.parse_args()
28
-
29
- session = SessionLocal()
30
- try:
31
- need_docid = session.query(Item).filter(Item.docid.is_(None)).count()
32
- need_ark = session.query(Item).filter(Item.ark.is_(None)).count()
33
- print("=" * 64)
34
- print(f"Items missing DocID: {need_docid} missing ARK: {need_ark}")
35
- print("=" * 64)
36
- if not args.apply:
37
- print("[DRY RUN] No writes. Re-run with --apply.")
38
- return 0
39
-
40
- minted_docid = minted_ark = 0
41
- now = datetime.utcnow()
42
- for it in session.query(Item).filter(
43
- (Item.docid.is_(None)) | (Item.ark.is_(None))
44
- ):
45
- if not it.docid:
46
- it.docid = docid_generator.generate_docid(
47
- title=it.title or "",
48
- doi=it.doi,
49
- institution=it.institution or "Unknown",
50
- timestamp=it.publication_date,
51
- )
52
- it.docid_assigned_at = now
53
- minted_docid += 1
54
- if not it.ark:
55
- it.ark = ark_generator.mint(it.docid)
56
- it.ark_assigned_at = now
57
- minted_ark += 1
58
- session.commit()
59
-
60
- print(f"DONE. DocIDs minted: {minted_docid} ARKs minted: {minted_ark}")
61
- sample = session.query(Item.ark).filter(Item.ark.isnot(None)).first()
62
- if sample:
63
- print(f"Sample ARK: {sample[0]} (valid: {ark_generator.validate(sample[0])})")
64
- return 0
65
- finally:
66
- session.close()
67
-
68
-
69
- if __name__ == "__main__":
70
- sys.exit(main())
 
1
+ """
2
+ Backfill persistent identifiers: DocID™ + ARK for items missing them.
3
+
4
+ ARKs are minted deterministically from the item's DocID hash, so re-running
5
+ is idempotent. No network access needed.
6
+
7
+ Usage:
8
+ python scripts/backfill_pids.py # DRY RUN
9
+ python scripts/backfill_pids.py --apply
10
+ """
11
+
12
+ import argparse
13
+ import os
14
+ import sys
15
+ from datetime import datetime
16
+
17
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
18
+
19
+ from uraas.database import Item, SessionLocal
20
+ from uraas.utils.ark_generator import ark_generator
21
+ from uraas.utils.docid_generator import docid_generator
22
+
23
+
24
+ def main():
25
+ parser = argparse.ArgumentParser(description=__doc__)
26
+ parser.add_argument("--apply", action="store_true", help="Write changes (default: dry run)")
27
+ args = parser.parse_args()
28
+
29
+ session = SessionLocal()
30
+ try:
31
+ need_docid = session.query(Item).filter(Item.docid.is_(None)).count()
32
+ need_ark = session.query(Item).filter(Item.ark.is_(None)).count()
33
+ print("=" * 64)
34
+ print(f"Items missing DocID: {need_docid} missing ARK: {need_ark}")
35
+ print("=" * 64)
36
+ if not args.apply:
37
+ print("[DRY RUN] No writes. Re-run with --apply.")
38
+ return 0
39
+
40
+ minted_docid = minted_ark = 0
41
+ now = datetime.utcnow()
42
+ for it in session.query(Item).filter(
43
+ (Item.docid.is_(None)) | (Item.ark.is_(None))
44
+ ):
45
+ if not it.docid:
46
+ it.docid = docid_generator.generate_docid(
47
+ title=it.title or "",
48
+ doi=it.doi,
49
+ institution=it.institution or "Unknown",
50
+ timestamp=it.publication_date,
51
+ )
52
+ it.docid_assigned_at = now
53
+ minted_docid += 1
54
+ if not it.ark:
55
+ it.ark = ark_generator.mint(it.docid)
56
+ it.ark_assigned_at = now
57
+ minted_ark += 1
58
+ session.commit()
59
+
60
+ print(f"DONE. DocIDs minted: {minted_docid} ARKs minted: {minted_ark}")
61
+ sample = session.query(Item.ark).filter(Item.ark.isnot(None)).first()
62
+ if sample:
63
+ print(f"Sample ARK: {sample[0]} (valid: {ark_generator.validate(sample[0])})")
64
+ return 0
65
+ finally:
66
+ session.close()
67
+
68
+
69
+ if __name__ == "__main__":
70
+ sys.exit(main())
scripts/backfill_special_collections.py CHANGED
@@ -1,69 +1,69 @@
1
- """
2
- Backfill special_collection_score + special_collection_categories on existing items.
3
-
4
- Runs classify_special_collections() over every Item (title + abstract + dc_subject)
5
- and writes the score/categories. Idempotent — re-running on already-scored rows
6
- produces the same values.
7
- """
8
-
9
- import os
10
- import sys
11
-
12
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
13
-
14
- from uraas.database import Item, SessionLocal
15
- from uraas.utils.ai_classifier import classify_special_collections
16
-
17
- BATCH_SIZE = 500
18
-
19
-
20
- def main() -> int:
21
- session = SessionLocal()
22
- try:
23
- total = session.query(Item).count()
24
- print(f"Backfilling SC score for {total} items...")
25
-
26
- scored = 0
27
- hits = 0
28
- offset = 0
29
- while offset < total:
30
- batch = (
31
- session.query(Item)
32
- .order_by(Item.id)
33
- .offset(offset)
34
- .limit(BATCH_SIZE)
35
- .all()
36
- )
37
- if not batch:
38
- break
39
-
40
- for item in batch:
41
- sc = classify_special_collections(
42
- item.title or "",
43
- item.abstract or "",
44
- item.dc_subject or "",
45
- )
46
- if sc:
47
- item.special_collection_score = float(sum(h["score"] for h in sc))
48
- item.special_collection_categories = ",".join(
49
- h["category"] for h in sc
50
- )
51
- hits += 1
52
- else:
53
- item.special_collection_score = 0.0
54
- item.special_collection_categories = ""
55
- scored += 1
56
-
57
- session.commit()
58
- offset += len(batch)
59
- print(f" {scored}/{total} scored ({hits} SC hits so far)")
60
-
61
- print()
62
- print(f"Done. {scored} items scored, {hits} matched a special collection.")
63
- return 0
64
- finally:
65
- session.close()
66
-
67
-
68
- if __name__ == "__main__":
69
- sys.exit(main())
 
1
+ """
2
+ Backfill special_collection_score + special_collection_categories on existing items.
3
+
4
+ Runs classify_special_collections() over every Item (title + abstract + dc_subject)
5
+ and writes the score/categories. Idempotent — re-running on already-scored rows
6
+ produces the same values.
7
+ """
8
+
9
+ import os
10
+ import sys
11
+
12
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
13
+
14
+ from uraas.database import Item, SessionLocal
15
+ from uraas.utils.ai_classifier import classify_special_collections
16
+
17
+ BATCH_SIZE = 500
18
+
19
+
20
+ def main() -> int:
21
+ session = SessionLocal()
22
+ try:
23
+ total = session.query(Item).count()
24
+ print(f"Backfilling SC score for {total} items...")
25
+
26
+ scored = 0
27
+ hits = 0
28
+ offset = 0
29
+ while offset < total:
30
+ batch = (
31
+ session.query(Item)
32
+ .order_by(Item.id)
33
+ .offset(offset)
34
+ .limit(BATCH_SIZE)
35
+ .all()
36
+ )
37
+ if not batch:
38
+ break
39
+
40
+ for item in batch:
41
+ sc = classify_special_collections(
42
+ item.title or "",
43
+ item.abstract or "",
44
+ item.dc_subject or "",
45
+ )
46
+ if sc:
47
+ item.special_collection_score = float(sum(h["score"] for h in sc))
48
+ item.special_collection_categories = ",".join(
49
+ h["category"] for h in sc
50
+ )
51
+ hits += 1
52
+ else:
53
+ item.special_collection_score = 0.0
54
+ item.special_collection_categories = ""
55
+ scored += 1
56
+
57
+ session.commit()
58
+ offset += len(batch)
59
+ print(f" {scored}/{total} scored ({hits} SC hits so far)")
60
+
61
+ print()
62
+ print(f"Done. {scored} items scored, {hits} matched a special collection.")
63
+ return 0
64
+ finally:
65
+ session.close()
66
+
67
+
68
+ if __name__ == "__main__":
69
+ sys.exit(main())
scripts/build_app.py CHANGED
@@ -1,26 +1,26 @@
1
- import os
2
- import sys
3
-
4
- sys.path.insert(0, ".")
5
-
6
- APP = """import os,csv,io,subprocess,threading,re,logging
7
- from flask import Flask,render_template,jsonify,send_file,request,Response
8
- from flask_socketio import SocketIO
9
- from uraas.config import config
10
- from uraas.analytics.engine import analytics
11
- from uraas.database import SessionLocal,Item,File,Author,Community,Collection
12
- from uraas.utils.docid_generator import docid_generator
13
- from sqlalchemy import func,extract,desc,or_
14
-
15
- app = Flask(__name__)
16
- app.config["SECRET_KEY"] = config.DASHBOARD_SECRET_KEY
17
- socketio = SocketIO(app, cors_allowed_origins="*")
18
- logger = logging.getLogger(__name__)
19
- crawler_process = None
20
- crawler_lock = threading.Lock()
21
- docid_crawler_process = None
22
- docid_crawler_lock = threading.Lock()
23
- """
24
-
25
- open("uraas/dashboard/app.py", "w", encoding="utf-8").write(APP)
26
- print("wrote", len(APP), "chars")
 
1
+ import os
2
+ import sys
3
+
4
+ sys.path.insert(0, ".")
5
+
6
+ APP = """import os,csv,io,subprocess,threading,re,logging
7
+ from flask import Flask,render_template,jsonify,send_file,request,Response
8
+ from flask_socketio import SocketIO
9
+ from uraas.config import config
10
+ from uraas.analytics.engine import analytics
11
+ from uraas.database import SessionLocal,Item,File,Author,Community,Collection
12
+ from uraas.utils.docid_generator import docid_generator
13
+ from sqlalchemy import func,extract,desc,or_
14
+
15
+ app = Flask(__name__)
16
+ app.config["SECRET_KEY"] = config.DASHBOARD_SECRET_KEY
17
+ socketio = SocketIO(app, cors_allowed_origins="*")
18
+ logger = logging.getLogger(__name__)
19
+ crawler_process = None
20
+ crawler_lock = threading.Lock()
21
+ docid_crawler_process = None
22
+ docid_crawler_lock = threading.Lock()
23
+ """
24
+
25
+ open("uraas/dashboard/app.py", "w", encoding="utf-8").write(APP)
26
+ print("wrote", len(APP), "chars")
scripts/check_staff.py CHANGED
@@ -1,9 +1,9 @@
1
- import os
2
- import sys
3
-
4
- sys.path.insert(0, os.getcwd())
5
- from uraas.config.institutions import get_registry
6
-
7
- registry = get_registry()
8
- for inst in registry.list_all():
9
- print(f"{inst.short_name}: {len(inst.staff_names)} staff (File: {inst.staff_file})")
 
1
+ import os
2
+ import sys
3
+
4
+ sys.path.insert(0, os.getcwd())
5
+ from uraas.config.institutions import get_registry
6
+
7
+ registry = get_registry()
8
+ for inst in registry.list_all():
9
+ print(f"{inst.short_name}: {len(inst.staff_names)} staff (File: {inst.staff_file})")
scripts/clean_database.py CHANGED
@@ -1,156 +1,156 @@
1
- """
2
- Database Cleanup Script
3
- Removes bad/misattributed data from the URAAS database.
4
-
5
- Cleanup rules:
6
- 1. Remove items from institutions no longer in the registry
7
- 2. Remove items with no title, no DOI, no URL, and no authors
8
- 3. Remove exact DOI duplicates (keep first by id)
9
- 4. Remove items whose title is fewer than 10 chars
10
- 5. Flag (do not delete) items with institution mismatch in affiliation
11
- 6. Report before/after counts
12
- """
13
-
14
- import logging
15
- import os
16
- import sys
17
- from datetime import datetime
18
-
19
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
20
-
21
- logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
22
- log = logging.getLogger(__name__)
23
-
24
-
25
- def run_cleanup(dry_run: bool = False):
26
- from sqlalchemy import func
27
-
28
- from uraas.config.institutions import get_registry
29
- from uraas.database import Author, Collection, Community, Item, SessionLocal
30
-
31
- registry = get_registry()
32
- valid_inst_names = {c.name for c in registry.list_all()}
33
-
34
- session = SessionLocal()
35
- try:
36
- total_before = session.query(Item).count()
37
- log.info(f"Starting cleanup | Items before: {total_before}")
38
-
39
- removed = 0
40
-
41
- # ── Rule 1: Remove items from removed institutions ────────────────────
42
- all_institutions_in_db = session.query(Item.institution).distinct().all()
43
- stale_insts = [
44
- r[0]
45
- for r in all_institutions_in_db
46
- if r[0] and r[0] not in valid_inst_names
47
- ]
48
-
49
- if stale_insts:
50
- log.info(f"Found stale institutions: {stale_insts}")
51
- for stale in stale_insts:
52
- stale_items = (
53
- session.query(Item).filter(Item.institution == stale).all()
54
- )
55
- log.info(f" [{stale}] {len(stale_items)} items to remove")
56
- if not dry_run:
57
- for item in stale_items:
58
- session.delete(item)
59
- session.commit()
60
- removed += len(stale_items)
61
-
62
- # ── Rule 2: Remove items with no title ────────────────────────────────
63
- no_title = (
64
- session.query(Item)
65
- .filter(
66
- (Item.title == None) | (Item.title == "") | (Item.title == "Untitled")
67
- )
68
- .all()
69
- )
70
- log.info(f"Items with no/empty title: {len(no_title)}")
71
- if not dry_run:
72
- for item in no_title:
73
- session.delete(item)
74
- session.commit()
75
- removed += len(no_title)
76
-
77
- # ── Rule 3: Remove items with title < 10 chars and no DOI ────────────
78
- all_short = session.query(Item).filter(Item.doi == None).all()
79
- short_items = [i for i in all_short if i.title and len(i.title.strip()) < 10]
80
- log.info(
81
- f"Items with very short title (<10 chars) and no DOI: {len(short_items)}"
82
- )
83
- if not dry_run:
84
- for item in short_items:
85
- session.delete(item)
86
- session.commit()
87
- removed += len(short_items)
88
-
89
- # ── Rule 4: Remove exact DOI duplicates (keep lowest id) ─────────────
90
- doi_subq = (
91
- session.query(Item.doi, func.min(Item.id).label("min_id"))
92
- .filter(Item.doi != None)
93
- .group_by(Item.doi)
94
- .subquery()
95
- )
96
-
97
- dup_dois = (
98
- session.query(Item)
99
- .filter(Item.doi != None, Item.id.notin_(session.query(doi_subq.c.min_id)))
100
- .all()
101
- )
102
- log.info(f"Duplicate DOI items to remove: {len(dup_dois)}")
103
- if not dry_run:
104
- for item in dup_dois:
105
- session.delete(item)
106
- session.commit()
107
- removed += len(dup_dois)
108
-
109
- # ── Rule 5: Remove items with no institution tag ──────────────────────
110
- no_inst = (
111
- session.query(Item)
112
- .filter((Item.institution == None) | (Item.institution == ""))
113
- .all()
114
- )
115
- # Only remove those that have no authors either
116
- truly_orphan = [i for i in no_inst if not i.authors]
117
- log.info(f"Items with no institution AND no authors: {len(truly_orphan)}")
118
- if not dry_run:
119
- for item in truly_orphan:
120
- session.delete(item)
121
- session.commit()
122
- removed += len(truly_orphan)
123
-
124
- total_after = session.query(Item).count()
125
-
126
- log.info(f"\n{'='*50}")
127
- log.info(f"CLEANUP COMPLETE {'(DRY RUN)' if dry_run else ''}")
128
- log.info(f" Items before: {total_before}")
129
- log.info(f" Items removed: {removed}")
130
- log.info(f" Items after: {total_after}")
131
- log.info(f"{'='*50}")
132
-
133
- return {
134
- "total_before": total_before,
135
- "removed": removed,
136
- "total_after": total_after,
137
- "dry_run": dry_run,
138
- }
139
-
140
- except Exception as e:
141
- log.error(f"Cleanup error: {e}")
142
- session.rollback()
143
- raise
144
- finally:
145
- session.close()
146
-
147
-
148
- if __name__ == "__main__":
149
- import argparse
150
-
151
- parser = argparse.ArgumentParser(description="URAAS Database Cleanup")
152
- parser.add_argument(
153
- "--dry-run", action="store_true", help="Report without deleting"
154
- )
155
- args = parser.parse_args()
156
- run_cleanup(dry_run=args.dry_run)
 
1
+ """
2
+ Database Cleanup Script
3
+ Removes bad/misattributed data from the URAAS database.
4
+
5
+ Cleanup rules:
6
+ 1. Remove items from institutions no longer in the registry
7
+ 2. Remove items with no title, no DOI, no URL, and no authors
8
+ 3. Remove exact DOI duplicates (keep first by id)
9
+ 4. Remove items whose title is fewer than 10 chars
10
+ 5. Flag (do not delete) items with institution mismatch in affiliation
11
+ 6. Report before/after counts
12
+ """
13
+
14
+ import logging
15
+ import os
16
+ import sys
17
+ from datetime import datetime
18
+
19
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
20
+
21
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
22
+ log = logging.getLogger(__name__)
23
+
24
+
25
+ def run_cleanup(dry_run: bool = False):
26
+ from sqlalchemy import func
27
+
28
+ from uraas.config.institutions import get_registry
29
+ from uraas.database import Author, Collection, Community, Item, SessionLocal
30
+
31
+ registry = get_registry()
32
+ valid_inst_names = {c.name for c in registry.list_all()}
33
+
34
+ session = SessionLocal()
35
+ try:
36
+ total_before = session.query(Item).count()
37
+ log.info(f"Starting cleanup | Items before: {total_before}")
38
+
39
+ removed = 0
40
+
41
+ # ── Rule 1: Remove items from removed institutions ────────────────────
42
+ all_institutions_in_db = session.query(Item.institution).distinct().all()
43
+ stale_insts = [
44
+ r[0]
45
+ for r in all_institutions_in_db
46
+ if r[0] and r[0] not in valid_inst_names
47
+ ]
48
+
49
+ if stale_insts:
50
+ log.info(f"Found stale institutions: {stale_insts}")
51
+ for stale in stale_insts:
52
+ stale_items = (
53
+ session.query(Item).filter(Item.institution == stale).all()
54
+ )
55
+ log.info(f" [{stale}] {len(stale_items)} items to remove")
56
+ if not dry_run:
57
+ for item in stale_items:
58
+ session.delete(item)
59
+ session.commit()
60
+ removed += len(stale_items)
61
+
62
+ # ── Rule 2: Remove items with no title ────────────────────────────────
63
+ no_title = (
64
+ session.query(Item)
65
+ .filter(
66
+ (Item.title == None) | (Item.title == "") | (Item.title == "Untitled")
67
+ )
68
+ .all()
69
+ )
70
+ log.info(f"Items with no/empty title: {len(no_title)}")
71
+ if not dry_run:
72
+ for item in no_title:
73
+ session.delete(item)
74
+ session.commit()
75
+ removed += len(no_title)
76
+
77
+ # ── Rule 3: Remove items with title < 10 chars and no DOI ────────────
78
+ all_short = session.query(Item).filter(Item.doi == None).all()
79
+ short_items = [i for i in all_short if i.title and len(i.title.strip()) < 10]
80
+ log.info(
81
+ f"Items with very short title (<10 chars) and no DOI: {len(short_items)}"
82
+ )
83
+ if not dry_run:
84
+ for item in short_items:
85
+ session.delete(item)
86
+ session.commit()
87
+ removed += len(short_items)
88
+
89
+ # ── Rule 4: Remove exact DOI duplicates (keep lowest id) ─────────────
90
+ doi_subq = (
91
+ session.query(Item.doi, func.min(Item.id).label("min_id"))
92
+ .filter(Item.doi != None)
93
+ .group_by(Item.doi)
94
+ .subquery()
95
+ )
96
+
97
+ dup_dois = (
98
+ session.query(Item)
99
+ .filter(Item.doi != None, Item.id.notin_(session.query(doi_subq.c.min_id)))
100
+ .all()
101
+ )
102
+ log.info(f"Duplicate DOI items to remove: {len(dup_dois)}")
103
+ if not dry_run:
104
+ for item in dup_dois:
105
+ session.delete(item)
106
+ session.commit()
107
+ removed += len(dup_dois)
108
+
109
+ # ── Rule 5: Remove items with no institution tag ──────────────────────
110
+ no_inst = (
111
+ session.query(Item)
112
+ .filter((Item.institution == None) | (Item.institution == ""))
113
+ .all()
114
+ )
115
+ # Only remove those that have no authors either
116
+ truly_orphan = [i for i in no_inst if not i.authors]
117
+ log.info(f"Items with no institution AND no authors: {len(truly_orphan)}")
118
+ if not dry_run:
119
+ for item in truly_orphan:
120
+ session.delete(item)
121
+ session.commit()
122
+ removed += len(truly_orphan)
123
+
124
+ total_after = session.query(Item).count()
125
+
126
+ log.info(f"\n{'='*50}")
127
+ log.info(f"CLEANUP COMPLETE {'(DRY RUN)' if dry_run else ''}")
128
+ log.info(f" Items before: {total_before}")
129
+ log.info(f" Items removed: {removed}")
130
+ log.info(f" Items after: {total_after}")
131
+ log.info(f"{'='*50}")
132
+
133
+ return {
134
+ "total_before": total_before,
135
+ "removed": removed,
136
+ "total_after": total_after,
137
+ "dry_run": dry_run,
138
+ }
139
+
140
+ except Exception as e:
141
+ log.error(f"Cleanup error: {e}")
142
+ session.rollback()
143
+ raise
144
+ finally:
145
+ session.close()
146
+
147
+
148
+ if __name__ == "__main__":
149
+ import argparse
150
+
151
+ parser = argparse.ArgumentParser(description="URAAS Database Cleanup")
152
+ parser.add_argument(
153
+ "--dry-run", action="store_true", help="Report without deleting"
154
+ )
155
+ args = parser.parse_args()
156
+ run_cleanup(dry_run=args.dry_run)
scripts/crawl_multi_institution.py CHANGED
@@ -1,242 +1,242 @@
1
- """
2
- Multi-Institution Crawler
3
- Crawls papers for multiple Nigerian universities simultaneously
4
- """
5
-
6
- import argparse
7
- import os
8
- import subprocess
9
- import sys
10
-
11
- from scrapy.crawler import CrawlerProcess
12
- from scrapy.utils.project import get_project_settings
13
-
14
- # Force unbuffered output so terminal log is in correct order
15
- sys.stdout.reconfigure(line_buffering=True)
16
-
17
- # Add project root to path (parent of scripts/)
18
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
19
-
20
- from uraas.config.institutions import get_registry
21
-
22
-
23
- def main():
24
- parser = argparse.ArgumentParser(
25
- description="Multi-institution research paper crawler"
26
- )
27
- parser.add_argument(
28
- "--institutions",
29
- type=str,
30
- default="all",
31
- help='Comma-separated list of institution short names, or "all" (default: all)',
32
- )
33
- parser.add_argument(
34
- "--target", type=int, default=20, help="Target number of papers per institution"
35
- )
36
- parser.add_argument(
37
- "--spider",
38
- type=str,
39
- default="openalex",
40
- choices=["openalex", "crossref", "arxiv", "orcid", "oai",
41
- "semantic_scholar", "europepmc", "core", "pubmed",
42
- "openaire", "doaj", "ajol", "all"],
43
- help=(
44
- "Spider to use for crawling. "
45
- "'all' fans out across every web source (openalex + crossref + "
46
- "semantic_scholar + europepmc + arxiv + orcid) for maximum coverage."
47
- ),
48
- )
49
- parser.add_argument(
50
- "--from-date",
51
- dest="from_date",
52
- type=str,
53
- default=None,
54
- help="OAI harvest lower bound YYYY-MM-DD (oai spider only; "
55
- "defaults to a recent look-back window)",
56
- )
57
- parser.add_argument(
58
- "--until-date",
59
- dest="until_date",
60
- type=str,
61
- default=None,
62
- help="OAI harvest upper bound YYYY-MM-DD (oai spider only; optional)",
63
- )
64
- parser.add_argument(
65
- "--clean",
66
- action="store_true",
67
- help="Run database cleanup script before crawling",
68
- )
69
- parser.add_argument(
70
- "--no-boost-special",
71
- dest="boost_special",
72
- action="store_false",
73
- help="Disable Special Collections boost waves (default: boost ON)",
74
- )
75
- parser.add_argument(
76
- "--sc-only",
77
- action="store_true",
78
- help="Crawl ONLY Special Collections seed waves (skip generic ROR pass)",
79
- )
80
- parser.set_defaults(boost_special=True)
81
-
82
- args = parser.parse_args()
83
-
84
- if args.clean:
85
- print("\n" + "=" * 60)
86
- print("RUNNING DATABASE CLEANUP")
87
- print("=" * 60)
88
- try:
89
- subprocess.run([sys.executable, "scripts/clean_database.py"], check=True)
90
- print("Cleanup completed successfully.")
91
- except subprocess.CalledProcessError as e:
92
- print(f"Cleanup failed: {e}")
93
- return 1
94
-
95
- registry = get_registry()
96
-
97
- if args.institutions.lower() == "all":
98
- valid_institutions = [inst.short_name.lower() for inst in registry.list_all()]
99
- else:
100
- # Parse institutions
101
- institution_list = [inst.strip() for inst in args.institutions.split(",")]
102
- valid_institutions = []
103
- for inst in institution_list:
104
- config = registry.get(inst)
105
- if config:
106
- valid_institutions.append(config.short_name.lower())
107
- else:
108
- print(f" [NOT FOUND] '{inst}' not found in registry")
109
-
110
- print("\n" + "=" * 60, flush=True)
111
- print("MULTI-INSTITUTION CRAWLER", flush=True)
112
- print("=" * 60, flush=True)
113
- print(f"\nTarget: {args.target} papers total per institution", flush=True)
114
- print(f"Spider: {args.spider}", flush=True)
115
- print(f"\nValidating institutions...", flush=True)
116
-
117
- # Map spider names to classes (defined early so we can validate)
118
- spider_map = {
119
- "openalex": "uraas.spiders.sources.openalex_spider.OpenAlexSpider",
120
- "crossref": "uraas.spiders.sources.crossref_spider.CrossrefSpider",
121
- "arxiv": "uraas.spiders.sources.arxiv_spider.ArxivSpider",
122
- "orcid": "uraas.spiders.sources.orcid_spider.ORCIDSpider",
123
- "oai": "uraas.spiders.sources.oai_spider.OAISpider",
124
- "semantic_scholar":"uraas.spiders.sources.semantic_scholar_spider.SemanticScholarSpider",
125
- "europepmc": "uraas.spiders.sources.europepmc_spider.EuropePMCSpider",
126
- "core": "uraas.spiders.sources.core_spider.CORESpider",
127
- "pubmed": "uraas.spiders.sources.pubmed_spider.PubMedSpider",
128
- "openaire": "uraas.spiders.sources.openaire_spider.OpenAIRESpider",
129
- "doaj": "uraas.spiders.sources.doaj_spider.DOAJSpider",
130
- "ajol": "uraas.spiders.sources.ajol_spider.AJOLSpider",
131
- }
132
-
133
- # "all" = every web-discovery spider (excludes "oai" which reads FROM the IR)
134
- ALL_WEB_SPIDERS = [
135
- "openalex", "crossref", "semantic_scholar", "europepmc",
136
- "core", "pubmed", "openaire", "doaj", "ajol", "arxiv", "orcid",
137
- ]
138
-
139
- if args.spider == "all":
140
- spider_names_to_run = ALL_WEB_SPIDERS
141
- # Divide target across spiders so total ≈ requested target
142
- per_spider_target = max(1, args.target // len(spider_names_to_run))
143
- else:
144
- spider_names_to_run = [args.spider]
145
- per_spider_target = args.target
146
-
147
- # Validate + import all spider classes up front so errors appear early
148
- spider_classes = {}
149
- for sname in spider_names_to_run:
150
- path = spider_map.get(sname)
151
- if not path:
152
- print(f"\n[ERR] Spider '{sname}' not supported", flush=True)
153
- return 1
154
- mod_path, cls_name = path.rsplit(".", 1)
155
- mod = __import__(mod_path, fromlist=[cls_name])
156
- spider_classes[sname] = getattr(mod, cls_name)
157
-
158
- # Legacy single-spider variable (used below)
159
- spider_class = spider_classes.get(spider_names_to_run[0])
160
-
161
- for inst in valid_institutions:
162
- config = registry.get(inst)
163
- print(f" [VALID] {config.name} ({config.short_name})", flush=True)
164
- print(f" ROR: {config.ror}", flush=True)
165
- print(f" Staff: {len(config.staff_names)}", flush=True)
166
-
167
- if not valid_institutions:
168
- print("\n[ERR] No valid institutions found. Exiting.", flush=True)
169
- return 1
170
-
171
- print(f"\n{len(valid_institutions)} institution(s) validated", flush=True)
172
- print("=" * 60, flush=True)
173
-
174
- # Schedule crawls — ONE CrawlerProcess for ALL institutions
175
- print(f"\nScheduling crawls...", flush=True)
176
- settings = get_project_settings()
177
- settings.set(
178
- "ITEM_PIPELINES",
179
- {
180
- "uraas.pipelines.database.DatabaseStoragePipeline": 300,
181
- },
182
- )
183
- settings.set("LOG_LEVEL", "INFO")
184
- settings.set("LOG_SCRAPED_ITEMS", False)
185
- settings.set("TELNETCONSOLE_ENABLED", False)
186
-
187
- process = CrawlerProcess(settings)
188
-
189
- print(f" Boost special collections: {args.boost_special}", flush=True)
190
- print(f" SC-only mode: {args.sc_only}", flush=True)
191
- print(f" Spiders: {', '.join(spider_names_to_run)}", flush=True)
192
-
193
- for inst in valid_institutions:
194
- cfg = registry.get(inst)
195
- print(f" -> {cfg.name}", flush=True)
196
- for sname in spider_names_to_run:
197
- scls = spider_classes[sname]
198
- if sname == "oai":
199
- process.crawl(
200
- scls,
201
- institution=inst,
202
- target=per_spider_target,
203
- from_date=args.from_date,
204
- until_date=args.until_date,
205
- )
206
- else:
207
- process.crawl(
208
- scls,
209
- institution=inst,
210
- target=per_spider_target,
211
- boost_special=args.boost_special,
212
- sc_only=args.sc_only,
213
- )
214
-
215
- print(
216
- f"\nStarting crawl for {len(valid_institutions)} institution(s)...", flush=True
217
- )
218
- print("=" * 60, flush=True)
219
- sys.stdout.flush()
220
-
221
- # Start crawling
222
- try:
223
- process.start()
224
- print("\n" + "=" * 60)
225
- print("CRAWL COMPLETED")
226
- print("=" * 60)
227
- return 0
228
-
229
- except KeyboardInterrupt:
230
- print("\n\n[ERR] Crawl interrupted by user")
231
- return 1
232
-
233
- except Exception as e:
234
- print(f"\n\n[ERR] Crawl failed: {e}")
235
- import traceback
236
-
237
- traceback.print_exc()
238
- return 1
239
-
240
-
241
- if __name__ == "__main__":
242
- sys.exit(main())
 
1
+ """
2
+ Multi-Institution Crawler
3
+ Crawls papers for multiple Nigerian universities simultaneously
4
+ """
5
+
6
+ import argparse
7
+ import os
8
+ import subprocess
9
+ import sys
10
+
11
+ from scrapy.crawler import CrawlerProcess
12
+ from scrapy.utils.project import get_project_settings
13
+
14
+ # Force unbuffered output so terminal log is in correct order
15
+ sys.stdout.reconfigure(line_buffering=True)
16
+
17
+ # Add project root to path (parent of scripts/)
18
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
19
+
20
+ from uraas.config.institutions import get_registry
21
+
22
+
23
+ def main():
24
+ parser = argparse.ArgumentParser(
25
+ description="Multi-institution research paper crawler"
26
+ )
27
+ parser.add_argument(
28
+ "--institutions",
29
+ type=str,
30
+ default="all",
31
+ help='Comma-separated list of institution short names, or "all" (default: all)',
32
+ )
33
+ parser.add_argument(
34
+ "--target", type=int, default=20, help="Target number of papers per institution"
35
+ )
36
+ parser.add_argument(
37
+ "--spider",
38
+ type=str,
39
+ default="openalex",
40
+ choices=["openalex", "crossref", "arxiv", "orcid", "oai",
41
+ "semantic_scholar", "europepmc", "core", "pubmed",
42
+ "openaire", "doaj", "ajol", "all"],
43
+ help=(
44
+ "Spider to use for crawling. "
45
+ "'all' fans out across every web source (openalex + crossref + "
46
+ "semantic_scholar + europepmc + arxiv + orcid) for maximum coverage."
47
+ ),
48
+ )
49
+ parser.add_argument(
50
+ "--from-date",
51
+ dest="from_date",
52
+ type=str,
53
+ default=None,
54
+ help="OAI harvest lower bound YYYY-MM-DD (oai spider only; "
55
+ "defaults to a recent look-back window)",
56
+ )
57
+ parser.add_argument(
58
+ "--until-date",
59
+ dest="until_date",
60
+ type=str,
61
+ default=None,
62
+ help="OAI harvest upper bound YYYY-MM-DD (oai spider only; optional)",
63
+ )
64
+ parser.add_argument(
65
+ "--clean",
66
+ action="store_true",
67
+ help="Run database cleanup script before crawling",
68
+ )
69
+ parser.add_argument(
70
+ "--no-boost-special",
71
+ dest="boost_special",
72
+ action="store_false",
73
+ help="Disable Special Collections boost waves (default: boost ON)",
74
+ )
75
+ parser.add_argument(
76
+ "--sc-only",
77
+ action="store_true",
78
+ help="Crawl ONLY Special Collections seed waves (skip generic ROR pass)",
79
+ )
80
+ parser.set_defaults(boost_special=True)
81
+
82
+ args = parser.parse_args()
83
+
84
+ if args.clean:
85
+ print("\n" + "=" * 60)
86
+ print("RUNNING DATABASE CLEANUP")
87
+ print("=" * 60)
88
+ try:
89
+ subprocess.run([sys.executable, "scripts/clean_database.py"], check=True)
90
+ print("Cleanup completed successfully.")
91
+ except subprocess.CalledProcessError as e:
92
+ print(f"Cleanup failed: {e}")
93
+ return 1
94
+
95
+ registry = get_registry()
96
+
97
+ if args.institutions.lower() == "all":
98
+ valid_institutions = [inst.short_name.lower() for inst in registry.list_all()]
99
+ else:
100
+ # Parse institutions
101
+ institution_list = [inst.strip() for inst in args.institutions.split(",")]
102
+ valid_institutions = []
103
+ for inst in institution_list:
104
+ config = registry.get(inst)
105
+ if config:
106
+ valid_institutions.append(config.short_name.lower())
107
+ else:
108
+ print(f" [NOT FOUND] '{inst}' not found in registry")
109
+
110
+ print("\n" + "=" * 60, flush=True)
111
+ print("MULTI-INSTITUTION CRAWLER", flush=True)
112
+ print("=" * 60, flush=True)
113
+ print(f"\nTarget: {args.target} papers total per institution", flush=True)
114
+ print(f"Spider: {args.spider}", flush=True)
115
+ print(f"\nValidating institutions...", flush=True)
116
+
117
+ # Map spider names to classes (defined early so we can validate)
118
+ spider_map = {
119
+ "openalex": "uraas.spiders.sources.openalex_spider.OpenAlexSpider",
120
+ "crossref": "uraas.spiders.sources.crossref_spider.CrossrefSpider",
121
+ "arxiv": "uraas.spiders.sources.arxiv_spider.ArxivSpider",
122
+ "orcid": "uraas.spiders.sources.orcid_spider.ORCIDSpider",
123
+ "oai": "uraas.spiders.sources.oai_spider.OAISpider",
124
+ "semantic_scholar":"uraas.spiders.sources.semantic_scholar_spider.SemanticScholarSpider",
125
+ "europepmc": "uraas.spiders.sources.europepmc_spider.EuropePMCSpider",
126
+ "core": "uraas.spiders.sources.core_spider.CORESpider",
127
+ "pubmed": "uraas.spiders.sources.pubmed_spider.PubMedSpider",
128
+ "openaire": "uraas.spiders.sources.openaire_spider.OpenAIRESpider",
129
+ "doaj": "uraas.spiders.sources.doaj_spider.DOAJSpider",
130
+ "ajol": "uraas.spiders.sources.ajol_spider.AJOLSpider",
131
+ }
132
+
133
+ # "all" = every web-discovery spider (excludes "oai" which reads FROM the IR)
134
+ ALL_WEB_SPIDERS = [
135
+ "openalex", "crossref", "semantic_scholar", "europepmc",
136
+ "core", "pubmed", "openaire", "doaj", "ajol", "arxiv", "orcid",
137
+ ]
138
+
139
+ if args.spider == "all":
140
+ spider_names_to_run = ALL_WEB_SPIDERS
141
+ # Divide target across spiders so total ≈ requested target
142
+ per_spider_target = max(1, args.target // len(spider_names_to_run))
143
+ else:
144
+ spider_names_to_run = [args.spider]
145
+ per_spider_target = args.target
146
+
147
+ # Validate + import all spider classes up front so errors appear early
148
+ spider_classes = {}
149
+ for sname in spider_names_to_run:
150
+ path = spider_map.get(sname)
151
+ if not path:
152
+ print(f"\n[ERR] Spider '{sname}' not supported", flush=True)
153
+ return 1
154
+ mod_path, cls_name = path.rsplit(".", 1)
155
+ mod = __import__(mod_path, fromlist=[cls_name])
156
+ spider_classes[sname] = getattr(mod, cls_name)
157
+
158
+ # Legacy single-spider variable (used below)
159
+ spider_class = spider_classes.get(spider_names_to_run[0])
160
+
161
+ for inst in valid_institutions:
162
+ config = registry.get(inst)
163
+ print(f" [VALID] {config.name} ({config.short_name})", flush=True)
164
+ print(f" ROR: {config.ror}", flush=True)
165
+ print(f" Staff: {len(config.staff_names)}", flush=True)
166
+
167
+ if not valid_institutions:
168
+ print("\n[ERR] No valid institutions found. Exiting.", flush=True)
169
+ return 1
170
+
171
+ print(f"\n{len(valid_institutions)} institution(s) validated", flush=True)
172
+ print("=" * 60, flush=True)
173
+
174
+ # Schedule crawls — ONE CrawlerProcess for ALL institutions
175
+ print(f"\nScheduling crawls...", flush=True)
176
+ settings = get_project_settings()
177
+ settings.set(
178
+ "ITEM_PIPELINES",
179
+ {
180
+ "uraas.pipelines.database.DatabaseStoragePipeline": 300,
181
+ },
182
+ )
183
+ settings.set("LOG_LEVEL", "INFO")
184
+ settings.set("LOG_SCRAPED_ITEMS", False)
185
+ settings.set("TELNETCONSOLE_ENABLED", False)
186
+
187
+ process = CrawlerProcess(settings)
188
+
189
+ print(f" Boost special collections: {args.boost_special}", flush=True)
190
+ print(f" SC-only mode: {args.sc_only}", flush=True)
191
+ print(f" Spiders: {', '.join(spider_names_to_run)}", flush=True)
192
+
193
+ for inst in valid_institutions:
194
+ cfg = registry.get(inst)
195
+ print(f" -> {cfg.name}", flush=True)
196
+ for sname in spider_names_to_run:
197
+ scls = spider_classes[sname]
198
+ if sname == "oai":
199
+ process.crawl(
200
+ scls,
201
+ institution=inst,
202
+ target=per_spider_target,
203
+ from_date=args.from_date,
204
+ until_date=args.until_date,
205
+ )
206
+ else:
207
+ process.crawl(
208
+ scls,
209
+ institution=inst,
210
+ target=per_spider_target,
211
+ boost_special=args.boost_special,
212
+ sc_only=args.sc_only,
213
+ )
214
+
215
+ print(
216
+ f"\nStarting crawl for {len(valid_institutions)} institution(s)...", flush=True
217
+ )
218
+ print("=" * 60, flush=True)
219
+ sys.stdout.flush()
220
+
221
+ # Start crawling
222
+ try:
223
+ process.start()
224
+ print("\n" + "=" * 60)
225
+ print("CRAWL COMPLETED")
226
+ print("=" * 60)
227
+ return 0
228
+
229
+ except KeyboardInterrupt:
230
+ print("\n\n[ERR] Crawl interrupted by user")
231
+ return 1
232
+
233
+ except Exception as e:
234
+ print(f"\n\n[ERR] Crawl failed: {e}")
235
+ import traceback
236
+
237
+ traceback.print_exc()
238
+ return 1
239
+
240
+
241
+ if __name__ == "__main__":
242
+ sys.exit(main())
scripts/deploy.sh CHANGED
@@ -1,110 +1,110 @@
1
- #!/usr/bin/env bash
2
- # ──────────────────────────────────────────────────────────────────────────────
3
- # URAAS — One-shot deployment script for Ubuntu 22.04 / Debian 12
4
- #
5
- # Run on a fresh VPS as root or a sudo user:
6
- # curl -sSL https://raw.githubusercontent.com/YOUR/repo/main/scripts/deploy.sh | bash
7
- # OR after cloning:
8
- # bash scripts/deploy.sh
9
- #
10
- # What it does:
11
- # 1. Install Docker + Docker Compose plugin
12
- # 2. Generate password hashes interactively
13
- # 3. Build and start all containers (postgres, redis, app, nginx)
14
- # 4. Print the URL to reach the dashboard
15
- # ──────────────────────────────────────────────────────────────────────────────
16
- set -euo pipefail
17
-
18
- REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
19
- cd "$REPO_DIR"
20
-
21
- echo ""
22
- echo "═══════════════════════════════════════════════════"
23
- echo " URAAS Deployment — $(date +%Y-%m-%d)"
24
- echo "═══════════════════════════════════════════════════"
25
-
26
- # ── 1. Docker ─────────────────────────────────────────────────────────────────
27
- if ! command -v docker &>/dev/null; then
28
- echo ""
29
- echo "▶ Installing Docker..."
30
- curl -fsSL https://get.docker.com | sh
31
- usermod -aG docker "$USER" || true
32
- echo " Docker installed. You may need to log out and back in."
33
- fi
34
-
35
- if ! docker compose version &>/dev/null 2>&1; then
36
- echo ""
37
- echo "▶ Installing Docker Compose plugin..."
38
- apt-get install -y docker-compose-plugin 2>/dev/null || \
39
- pip install docker-compose 2>/dev/null || \
40
- echo " Install docker-compose manually from docs.docker.com/compose/install/"
41
- fi
42
-
43
- # ── 2. .env.prod ──────────────────────────────────────────────────────────────
44
- if [ ! -f .env.prod ]; then
45
- echo ""
46
- echo "▶ Creating .env.prod from example..."
47
- cp .env.prod.example .env.prod
48
-
49
- # Get server IP
50
- SERVER_IP=$(curl -s https://ifconfig.me || curl -s https://api.ipify.org || echo "YOUR_SERVER_IP")
51
- sed -i "s/YOUR_SERVER_IP/$SERVER_IP/g" .env.prod
52
-
53
- # Generate secret key
54
- SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))" 2>/dev/null || \
55
- openssl rand -hex 32)
56
- sed -i "s/REPLACE_WITH_STRONG_RANDOM_KEY/$SECRET_KEY/" .env.prod
57
-
58
- # Generate password hashes
59
- echo ""
60
- echo "Enter the ADMIN password (for dashboard login):"
61
- read -rs ADMIN_PASS
62
- ADMIN_HASH=$(python3 -c "from werkzeug.security import generate_password_hash as g; print(g('$ADMIN_PASS'))" 2>/dev/null || \
63
- python3 -c "import hashlib, os; print('pbkdf2:sha256:' + hashlib.pbkdf2_hmac('sha256', b'$ADMIN_PASS', os.urandom(16), 150000).hex())")
64
- sed -i "s|REPLACE_WITH_WERKZEUG_HASH|$ADMIN_HASH|g" .env.prod
65
-
66
- echo ""
67
- echo " .env.prod created. Edit it to add SMTP_PASSWORD and API keys before running."
68
- echo ""
69
- echo " IMPORTANT: Set these in .env.prod before the demo:"
70
- echo " SMTP_PASSWORD=<your-gmail-app-password>"
71
- echo " S2_API_KEY=<from semanticscholar.org>"
72
- echo " CORE_API_KEY=<from core.ac.uk/api-keys>"
73
- fi
74
-
75
- # ── 3. Required directories ───────────────────────────────────────────────────
76
- mkdir -p storage/pdfs data logs backups nginx/ssl
77
-
78
- # ── 4. Build + Start ─────────────────────────────────────────────────────────
79
- echo ""
80
- echo "▶ Building and starting containers (this takes ~3 min first time)..."
81
- echo ""
82
- # docker-compose.demo.yml = HTTP-only, works on bare IP (no SSL cert needed).
83
- # Switch to docker-compose.prod.yml once you have a domain + SSL certificate.
84
- docker compose --env-file .env.prod -f docker-compose.demo.yml up --build -d
85
-
86
- # ── 5. Wait for health ────────────────────────────────────────────────────────
87
- echo ""
88
- echo "▶ Waiting for app to become healthy..."
89
- for i in $(seq 1 20); do
90
- STATUS=$(docker inspect --format='{{.State.Health.Status}}' uraas-app 2>/dev/null || echo "starting")
91
- if [ "$STATUS" = "healthy" ]; then
92
- echo " App is healthy!"
93
- break
94
- fi
95
- echo " [$i/20] Status: $STATUS — waiting 5s..."
96
- sleep 5
97
- done
98
-
99
- # ── 6. Done ───────────────────────────────────────────────────────────────────
100
- SERVER_IP=$(curl -s https://ifconfig.me 2>/dev/null || echo "YOUR_SERVER_IP")
101
- echo ""
102
- echo "═══════════════════════════════════════════════════"
103
- echo " URAAS is running!"
104
- echo ""
105
- echo " Dashboard (direct): http://$SERVER_IP:8080"
106
- echo " Dashboard (nginx): http://$SERVER_IP"
107
- echo ""
108
- echo " Logs: docker compose -f docker-compose.prod.yml logs -f app"
109
- echo " Stop: docker compose -f docker-compose.prod.yml down"
110
- echo "═══════════════════════════════════════════════════"
 
1
+ #!/usr/bin/env bash
2
+ # ──────────────────────────────────────────────────────────────────────────────
3
+ # URAAS — One-shot deployment script for Ubuntu 22.04 / Debian 12
4
+ #
5
+ # Run on a fresh VPS as root or a sudo user:
6
+ # curl -sSL https://raw.githubusercontent.com/YOUR/repo/main/scripts/deploy.sh | bash
7
+ # OR after cloning:
8
+ # bash scripts/deploy.sh
9
+ #
10
+ # What it does:
11
+ # 1. Install Docker + Docker Compose plugin
12
+ # 2. Generate password hashes interactively
13
+ # 3. Build and start all containers (postgres, redis, app, nginx)
14
+ # 4. Print the URL to reach the dashboard
15
+ # ──────────────────────────────────────────────────────────────────────────────
16
+ set -euo pipefail
17
+
18
+ REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
19
+ cd "$REPO_DIR"
20
+
21
+ echo ""
22
+ echo "═══════════════════════════════════════════════════"
23
+ echo " URAAS Deployment — $(date +%Y-%m-%d)"
24
+ echo "═══════════════════════════════════════════════════"
25
+
26
+ # ── 1. Docker ─────────────────────────────────────────────────────────────────
27
+ if ! command -v docker &>/dev/null; then
28
+ echo ""
29
+ echo "▶ Installing Docker..."
30
+ curl -fsSL https://get.docker.com | sh
31
+ usermod -aG docker "$USER" || true
32
+ echo " Docker installed. You may need to log out and back in."
33
+ fi
34
+
35
+ if ! docker compose version &>/dev/null 2>&1; then
36
+ echo ""
37
+ echo "▶ Installing Docker Compose plugin..."
38
+ apt-get install -y docker-compose-plugin 2>/dev/null || \
39
+ pip install docker-compose 2>/dev/null || \
40
+ echo " Install docker-compose manually from docs.docker.com/compose/install/"
41
+ fi
42
+
43
+ # ── 2. .env.prod ──────────────────────────────────────────────────────────────
44
+ if [ ! -f .env.prod ]; then
45
+ echo ""
46
+ echo "▶ Creating .env.prod from example..."
47
+ cp .env.prod.example .env.prod
48
+
49
+ # Get server IP
50
+ SERVER_IP=$(curl -s https://ifconfig.me || curl -s https://api.ipify.org || echo "YOUR_SERVER_IP")
51
+ sed -i "s/YOUR_SERVER_IP/$SERVER_IP/g" .env.prod
52
+
53
+ # Generate secret key
54
+ SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))" 2>/dev/null || \
55
+ openssl rand -hex 32)
56
+ sed -i "s/REPLACE_WITH_STRONG_RANDOM_KEY/$SECRET_KEY/" .env.prod
57
+
58
+ # Generate password hashes
59
+ echo ""
60
+ echo "Enter the ADMIN password (for dashboard login):"
61
+ read -rs ADMIN_PASS
62
+ ADMIN_HASH=$(python3 -c "from werkzeug.security import generate_password_hash as g; print(g('$ADMIN_PASS'))" 2>/dev/null || \
63
+ python3 -c "import hashlib, os; print('pbkdf2:sha256:' + hashlib.pbkdf2_hmac('sha256', b'$ADMIN_PASS', os.urandom(16), 150000).hex())")
64
+ sed -i "s|REPLACE_WITH_WERKZEUG_HASH|$ADMIN_HASH|g" .env.prod
65
+
66
+ echo ""
67
+ echo " .env.prod created. Edit it to add SMTP_PASSWORD and API keys before running."
68
+ echo ""
69
+ echo " IMPORTANT: Set these in .env.prod before the demo:"
70
+ echo " SMTP_PASSWORD=<your-gmail-app-password>"
71
+ echo " S2_API_KEY=<from semanticscholar.org>"
72
+ echo " CORE_API_KEY=<from core.ac.uk/api-keys>"
73
+ fi
74
+
75
+ # ── 3. Required directories ───────────────────────────────────────────────────
76
+ mkdir -p storage/pdfs data logs backups nginx/ssl
77
+
78
+ # ── 4. Build + Start ���─────────────────────────────────────────────────────────
79
+ echo ""
80
+ echo "▶ Building and starting containers (this takes ~3 min first time)..."
81
+ echo ""
82
+ # docker-compose.demo.yml = HTTP-only, works on bare IP (no SSL cert needed).
83
+ # Switch to docker-compose.prod.yml once you have a domain + SSL certificate.
84
+ docker compose --env-file .env.prod -f docker-compose.demo.yml up --build -d
85
+
86
+ # ── 5. Wait for health ────────────────────────────────────────────────────────
87
+ echo ""
88
+ echo "▶ Waiting for app to become healthy..."
89
+ for i in $(seq 1 20); do
90
+ STATUS=$(docker inspect --format='{{.State.Health.Status}}' uraas-app 2>/dev/null || echo "starting")
91
+ if [ "$STATUS" = "healthy" ]; then
92
+ echo " App is healthy!"
93
+ break
94
+ fi
95
+ echo " [$i/20] Status: $STATUS — waiting 5s..."
96
+ sleep 5
97
+ done
98
+
99
+ # ── 6. Done ───────────────────────────────────────────────────────────────────
100
+ SERVER_IP=$(curl -s https://ifconfig.me 2>/dev/null || echo "YOUR_SERVER_IP")
101
+ echo ""
102
+ echo "═══════════════════════════════════════════════════"
103
+ echo " URAAS is running!"
104
+ echo ""
105
+ echo " Dashboard (direct): http://$SERVER_IP:8080"
106
+ echo " Dashboard (nginx): http://$SERVER_IP"
107
+ echo ""
108
+ echo " Logs: docker compose -f docker-compose.prod.yml logs -f app"
109
+ echo " Stop: docker compose -f docker-compose.prod.yml down"
110
+ echo "═══════════════════════════════════════════════════"
scripts/fix_rors.py CHANGED
@@ -1,26 +1,26 @@
1
- import glob
2
- import json
3
- import os
4
- import urllib.parse
5
- import urllib.request
6
-
7
- files = glob.glob("config/institutions/*.json")
8
-
9
- for fpath in files:
10
- with open(fpath, "r", encoding="utf-8") as f:
11
- data = json.load(f)
12
- name = data.get("name")
13
- if not name:
14
- continue
15
-
16
- url = f"https://api.openalex.org/institutions?search={urllib.parse.quote(name)}&per-page=1"
17
- try:
18
- res = json.loads(urllib.request.urlopen(url).read().decode())["results"][0]
19
- correct_ror = res.get("ror")
20
- if correct_ror and correct_ror != data.get("ror"):
21
- print(f"Updating {name}: {data.get('ror')} -> {correct_ror}")
22
- data["ror"] = correct_ror
23
- with open(fpath, "w", encoding="utf-8") as f:
24
- json.dump(data, f, indent=2)
25
- except Exception as e:
26
- print(f"Error for {name}: {e}")
 
1
+ import glob
2
+ import json
3
+ import os
4
+ import urllib.parse
5
+ import urllib.request
6
+
7
+ files = glob.glob("config/institutions/*.json")
8
+
9
+ for fpath in files:
10
+ with open(fpath, "r", encoding="utf-8") as f:
11
+ data = json.load(f)
12
+ name = data.get("name")
13
+ if not name:
14
+ continue
15
+
16
+ url = f"https://api.openalex.org/institutions?search={urllib.parse.quote(name)}&per-page=1"
17
+ try:
18
+ res = json.loads(urllib.request.urlopen(url).read().decode())["results"][0]
19
+ correct_ror = res.get("ror")
20
+ if correct_ror and correct_ror != data.get("ror"):
21
+ print(f"Updating {name}: {data.get('ror')} -> {correct_ror}")
22
+ data["ror"] = correct_ror
23
+ with open(fpath, "w", encoding="utf-8") as f:
24
+ json.dump(data, f, indent=2)
25
+ except Exception as e:
26
+ print(f"Error for {name}: {e}")
scripts/generate_registry.py CHANGED
@@ -1,444 +1,444 @@
1
- import json
2
- import os
3
-
4
- # Sub-regions and countries
5
- subregions = {
6
- "North Africa": ["Egypt", "Morocco", "Algeria", "Tunisia", "Libya", "Sudan"],
7
- "West Africa": [
8
- "Nigeria",
9
- "Ghana",
10
- "Senegal",
11
- "Cote d'Ivoire",
12
- "Benin",
13
- "Burkina Faso",
14
- "Cape Verde",
15
- "Gambia",
16
- "Guinea",
17
- "Guinea-Bissau",
18
- "Liberia",
19
- "Mali",
20
- "Mauritania",
21
- "Niger",
22
- "Sierra Leone",
23
- "Togo",
24
- ],
25
- "East Africa": [
26
- "Kenya",
27
- "Uganda",
28
- "Tanzania",
29
- "Ethiopia",
30
- "Rwanda",
31
- "Burundi",
32
- "Djibouti",
33
- "Eritrea",
34
- "Somalia",
35
- "South Sudan",
36
- "Madagascar",
37
- "Mauritius",
38
- "Seychelles",
39
- "Comoros",
40
- ],
41
- "Southern Africa": [
42
- "South Africa",
43
- "Zimbabwe",
44
- "Zambia",
45
- "Namibia",
46
- "Botswana",
47
- "Lesotho",
48
- "Eswatini",
49
- "Malawi",
50
- "Mozambique",
51
- ],
52
- "Central Africa": [
53
- "Cameroon",
54
- "DR Congo",
55
- "Angola",
56
- "Gabon",
57
- "Republic of the Congo",
58
- "Central African Republic",
59
- "Chad",
60
- "Equatorial Guinea",
61
- "Sao Tome and Principe",
62
- ],
63
- }
64
-
65
- # Major cities for generation if needed
66
- capitals = {
67
- "Egypt": "Cairo",
68
- "Morocco": "Rabat",
69
- "Algeria": "Algiers",
70
- "Tunisia": "Tunis",
71
- "Libya": "Tripoli",
72
- "Sudan": "Khartoum",
73
- "Nigeria": "Abuja",
74
- "Ghana": "Accra",
75
- "Senegal": "Dakar",
76
- "Cote d'Ivoire": "Yamoussoukro",
77
- "Benin": "Porto-Novo",
78
- "Burkina Faso": "Ouagadougou",
79
- "Cape Verde": "Praia",
80
- "Gambia": "Banjul",
81
- "Guinea": "Conakry",
82
- "Guinea-Bissau": "Bissau",
83
- "Liberia": "Monrovia",
84
- "Mali": "Bamako",
85
- "Mauritania": "Nouakchott",
86
- "Niger": "Niamey",
87
- "Sierra Leone": "Freetown",
88
- "Togo": "Lome",
89
- "Kenya": "Nairobi",
90
- "Uganda": "Kampala",
91
- "Tanzania": "Dodoma",
92
- "Ethiopia": "Addis Ababa",
93
- "Rwanda": "Kigali",
94
- "Burundi": "Gitega",
95
- "Djibouti": "Djibouti",
96
- "Eritrea": "Asmara",
97
- "Somalia": "Mogadishu",
98
- "South Sudan": "Juba",
99
- "Madagascar": "Antananarivo",
100
- "Mauritius": "Port Louis",
101
- "Seychelles": "Victoria",
102
- "Comoros": "Moroni",
103
- "South Africa": "Pretoria",
104
- "Zimbabwe": "Harare",
105
- "Zambia": "Lusaka",
106
- "Namibia": "Windhoek",
107
- "Botswana": "Gaborone",
108
- "Lesotho": "Maseru",
109
- "Eswatini": "Mbabane",
110
- "Malawi": "Lilongwe",
111
- "Mozambique": "Maputo",
112
- "Cameroon": "Yaounde",
113
- "DR Congo": "Kinshasa",
114
- "Angola": "Luanda",
115
- "Gabon": "Libreville",
116
- "Republic of the Congo": "Brazzaville",
117
- "Central African Republic": "Bangui",
118
- "Chad": "N'Djamena",
119
- "Equatorial Guinea": "Malabo",
120
- "Sao Tome and Principe": "Sao Tome",
121
- }
122
-
123
- # Hand-curated top universities to include (demo universities)
124
- curated_universities = {
125
- # North Africa
126
- "Egypt": [
127
- {"name": "Cairo University", "ror": "https://ror.org/03c4mpy73"},
128
- {"name": "Ain Shams University", "ror": "https://ror.org/034x7p097"},
129
- {"name": "Alexandria University", "ror": "https://ror.org/02078r490"},
130
- {"name": "Mansoura University", "ror": "https://ror.org/032p18087"},
131
- {"name": "Assiut University", "ror": "https://ror.org/047fpp722"},
132
- ],
133
- "Morocco": [
134
- {"name": "Université Mohammed V de Rabat", "ror": "https://ror.org/03vpy3v17"},
135
- {"name": "Université Cadi Ayyad", "ror": "https://ror.org/0154pcf71"},
136
- {
137
- "name": "Université Hassan II de Casablanca",
138
- "ror": "https://ror.org/013y27r38",
139
- },
140
- ],
141
- "Tunisia": [
142
- {"name": "Université de Tunis El Manar", "ror": "https://ror.org/050j3a172"},
143
- {"name": "Université de Sfax", "ror": "https://ror.org/02157p641"},
144
- {"name": "Université de Carthage", "ror": "https://ror.org/011yvpy71"},
145
- ],
146
- # Central Africa
147
- "Cameroon": [
148
- {"name": "Université de Yaoundé I", "ror": "https://ror.org/04h7g6177"},
149
- {"name": "Université de Dschang", "ror": "https://ror.org/012y7p041"},
150
- {"name": "Université de Douala", "ror": "https://ror.org/041y27r28"},
151
- ],
152
- "DR Congo": [
153
- {"name": "Université de Kinshasa", "ror": "https://ror.org/05vzwad88"},
154
- {"name": "Université de Lubumbashi", "ror": "https://ror.org/02rry3m21"},
155
- ],
156
- "Angola": [
157
- {"name": "Université Agostinho Neto", "ror": "https://ror.org/00z2bpt98"}
158
- ],
159
- "Gabon": [
160
- {
161
- "name": "Université des Sciences et Techniques de Masuku",
162
- "ror": "https://ror.org/059gqse72",
163
- },
164
- {"name": "Université Omar Bongo", "ror": "https://ror.org/041yp1812"},
165
- ],
166
- "Republic of the Congo": [
167
- {"name": "Université Marien Ngouabi", "ror": "https://ror.org/02y1sra05"}
168
- ],
169
- # West Africa
170
- "Nigeria": [
171
- {"name": "University of Lagos", "ror": "https://ror.org/05rk03822"},
172
- {"name": "University of Ibadan", "ror": "https://ror.org/01es5me90"},
173
- {"name": "Covenant University", "ror": "https://ror.org/02n05rk12"},
174
- {"name": "Obafemi Awolowo University", "ror": "https://ror.org/013pcr241"},
175
- {"name": "University of Nigeria Nsukka", "ror": "https://ror.org/02kpy5732"},
176
- ],
177
- "Ghana": [
178
- {"name": "University of Ghana", "ror": "https://ror.org/00zpy3v12"},
179
- {
180
- "name": "Kwame Nkrumah University of Science and Technology",
181
- "ror": "https://ror.org/00x4mpy73",
182
- },
183
- {"name": "University of Cape Coast", "ror": "https://ror.org/01es3v123"},
184
- ],
185
- # Southern Africa
186
- "South Africa": [
187
- {"name": "University of Cape Town", "ror": "https://ror.org/017620319"},
188
- {"name": "Stellenbosch University", "ror": "https://ror.org/05777p686"},
189
- {"name": "University of the Witwatersrand", "ror": "https://ror.org/039482g93"},
190
- {"name": "University of Pretoria", "ror": "https://ror.org/047fpp722"},
191
- {"name": "University of KwaZulu-Natal", "ror": "https://ror.org/01267r312"},
192
- ],
193
- "Zimbabwe": [
194
- {"name": "University of Zimbabwe", "ror": "https://ror.org/03w489125"},
195
- {
196
- "name": "National University of Science and Technology",
197
- "ror": "https://ror.org/01y6mpy73",
198
- },
199
- ],
200
- # East Africa
201
- "Kenya": [
202
- {"name": "University of Nairobi", "ror": "https://ror.org/01078r490"},
203
- {"name": "Kenyatta University", "ror": "https://ror.org/01py3v171"},
204
- {
205
- "name": "Jomo Kenyatta University of Agriculture and Technology",
206
- "ror": "https://ror.org/03pyvpy71",
207
- },
208
- ],
209
- "Uganda": [
210
- {"name": "Makerere University", "ror": "https://ror.org/05vzwad88"},
211
- {
212
- "name": "Mbarara University of Science and Technology",
213
- "ror": "https://ror.org/0155pcf71",
214
- },
215
- ],
216
- "Tanzania": [
217
- {"name": "University of Dar es Salaam", "ror": "https://ror.org/0199e1957"},
218
- {
219
- "name": "Sokoine University of Agriculture",
220
- "ror": "https://ror.org/011y27r38",
221
- },
222
- ],
223
- "Ethiopia": [
224
- {"name": "Addis Ababa University", "ror": "https://ror.org/01py3v171"}
225
- ],
226
- "Rwanda": [{"name": "University of Rwanda", "ror": "https://ror.org/02yr01r27"}],
227
- }
228
-
229
- # Generate 15-20 universities for each country
230
- registry_data = {}
231
- for subregion, countries in subregions.items():
232
- registry_data[subregion] = {}
233
- for country in countries:
234
- cap = capitals.get(country, "City")
235
- # Start with curated list or empty
236
- unis = curated_universities.get(country, []).copy()
237
-
238
- # Add generated universities to reach 15
239
- existing_names = {u["name"] for u in unis}
240
- templates = [
241
- f"University of {cap}",
242
- f"National University of {country}",
243
- f"{country} University of Science and Technology",
244
- f"{cap} Institute of Technology",
245
- f"State University of {cap}",
246
- f"{country} International University",
247
- f"Pan-African University, {cap} Campus",
248
- f"Catholic University of {country}",
249
- f"Technical University of {cap}",
250
- (
251
- f"Ahmadu Bello University of {cap}"
252
- if country == "Nigeria"
253
- else f"Federal University of {cap}"
254
- ),
255
- f"Metropolitan University of {cap}",
256
- f"Central University of {country}",
257
- f"Presbyterian University of {country}",
258
- f"Adventist University of {country}",
259
- f"Islamic University of {country}",
260
- f"Methodist University of {country}",
261
- f"Covenant University of {cap}",
262
- f"{cap} College of Medicine and Health Sciences",
263
- f"Regional Institute of Information Technology, {cap}",
264
- f"Greenfield University, {cap}",
265
- ]
266
-
267
- idx = 0
268
- while len(unis) < 18:
269
- name = templates[idx % len(templates)]
270
- # ensure uniqueness
271
- if name not in existing_names:
272
- unis.append({"name": name, "ror": ""})
273
- existing_names.add(name)
274
- idx += 1
275
-
276
- registry_data[subregion][country] = unis
277
-
278
- # Write university_registry.json
279
- os.makedirs("data", exist_ok=True)
280
- with open("data/university_registry.json", "w", encoding="utf-8") as f:
281
- json.dump(registry_data, f, indent=2, ensure_ascii=False)
282
- print(
283
- "Generated data/university_registry.json with 52 countries and 18 universities each."
284
- )
285
-
286
- # Configurations for the 15 new universities to make a total of 25 demo universities
287
- new_universities = [
288
- # North (5)
289
- {
290
- "file": "cairo.json",
291
- "ror": "https://ror.org/03c4mpy73",
292
- "name": "Cairo University",
293
- "short_name": "Cairo Univ",
294
- "country": "Egypt",
295
- "sub_region": "North Africa",
296
- },
297
- {
298
- "file": "ainshams.json",
299
- "ror": "https://ror.org/034x7p097",
300
- "name": "Ain Shams University",
301
- "short_name": "Ain Shams",
302
- "country": "Egypt",
303
- "sub_region": "North Africa",
304
- },
305
- {
306
- "file": "alexandria.json",
307
- "ror": "https://ror.org/02078r490",
308
- "name": "Alexandria University",
309
- "short_name": "Alexandria",
310
- "country": "Egypt",
311
- "sub_region": "North Africa",
312
- },
313
- {
314
- "file": "tunis.json",
315
- "ror": "https://ror.org/050j3a172",
316
- "name": "Université de Tunis El Manar",
317
- "short_name": "Tunis El Manar",
318
- "country": "Tunisia",
319
- "sub_region": "North Africa",
320
- },
321
- {
322
- "file": "mohammedv.json",
323
- "ror": "https://ror.org/03vpy3v17",
324
- "name": "Université Mohammed V de Rabat",
325
- "short_name": "Mohammed V",
326
- "country": "Morocco",
327
- "sub_region": "North Africa",
328
- },
329
- # Central (5)
330
- {
331
- "file": "yaoundei.json",
332
- "ror": "https://ror.org/04h7g6177",
333
- "name": "Université de Yaoundé I",
334
- "short_name": "Yaoundé I",
335
- "country": "Cameroon",
336
- "sub_region": "Central Africa",
337
- },
338
- {
339
- "file": "kinshasa.json",
340
- "ror": "https://ror.org/05vzwad88",
341
- "name": "Université de Kinshasa",
342
- "short_name": "UNIKIN",
343
- "country": "DR Congo",
344
- "sub_region": "Central Africa",
345
- },
346
- {
347
- "file": "agostinhoneto.json",
348
- "ror": "https://ror.org/00z2bpt98",
349
- "name": "Université Agostinho Neto",
350
- "short_name": "Agostinho Neto",
351
- "country": "Angola",
352
- "sub_region": "Central Africa",
353
- },
354
- {
355
- "file": "marienngouabi.json",
356
- "ror": "https://ror.org/02y1sra05",
357
- "name": "Université Marien Ngouabi",
358
- "short_name": "Marien Ngouabi",
359
- "country": "Republic of the Congo",
360
- "sub_region": "Central Africa",
361
- },
362
- {
363
- "file": "masuku.json",
364
- "ror": "https://ror.org/059gqse72",
365
- "name": "Université des Sciences et Techniques de Masuku",
366
- "short_name": "USTM Masuku",
367
- "country": "Gabon",
368
- "sub_region": "Central Africa",
369
- },
370
- # Southern (+3 new ones)
371
- {
372
- "file": "wits.json",
373
- "ror": "https://ror.org/039482g93",
374
- "name": "University of the Witwatersrand",
375
- "short_name": "Wits",
376
- "country": "South Africa",
377
- "sub_region": "Southern Africa",
378
- },
379
- {
380
- "file": "pretoria.json",
381
- "ror": "https://ror.org/047fpp722",
382
- "name": "University of Pretoria",
383
- "short_name": "UP",
384
- "country": "South Africa",
385
- "sub_region": "Southern Africa",
386
- },
387
- {
388
- "file": "zimbabwe.json",
389
- "ror": "https://ror.org/03w489125",
390
- "name": "University of Zimbabwe",
391
- "short_name": "UZ",
392
- "country": "Zimbabwe",
393
- "sub_region": "Southern Africa",
394
- },
395
- # East (+2 new ones)
396
- {
397
- "file": "daressalaam.json",
398
- "ror": "https://ror.org/0199e1957",
399
- "name": "University of Dar es Salaam",
400
- "short_name": "UDSM",
401
- "country": "Tanzania",
402
- "sub_region": "East Africa",
403
- },
404
- {
405
- "file": "rwanda.json",
406
- "ror": "https://ror.org/02yr01r27",
407
- "name": "University of Rwanda",
408
- "short_name": "UR",
409
- "country": "Rwanda",
410
- "sub_region": "East Africa",
411
- },
412
- ]
413
-
414
- # Write institutional configs
415
- os.makedirs("config/institutions", exist_ok=True)
416
- for u in new_universities:
417
- cfg = {
418
- "ror": u["ror"],
419
- "name": u["name"],
420
- "short_name": u["short_name"],
421
- "country": u["country"],
422
- "sub_region": u["sub_region"], # explicitly add sub-region to config files
423
- "staff_file": f"data/{u['short_name'].lower().replace(' ', '_')}_staff.json",
424
- "affiliation_patterns": [u["name"], u["short_name"], f"{u['name']} Department"],
425
- "faculties": [
426
- "Science",
427
- "Humanities",
428
- "Engineering",
429
- "Medicine",
430
- "Social Sciences",
431
- "Arts",
432
- "Law",
433
- ],
434
- "crawler_settings": {
435
- "rate_limit": 2.0,
436
- "concurrent_requests": 8,
437
- "retry_times": 3,
438
- "download_delay": 2.0,
439
- },
440
- }
441
- with open(f"config/institutions/{u['file']}", "w", encoding="utf-8") as f:
442
- json.dump(cfg, f, indent=2, ensure_ascii=False)
443
-
444
- print("Generated 15 new institutional config files.")
 
1
+ import json
2
+ import os
3
+
4
+ # Sub-regions and countries
5
+ subregions = {
6
+ "North Africa": ["Egypt", "Morocco", "Algeria", "Tunisia", "Libya", "Sudan"],
7
+ "West Africa": [
8
+ "Nigeria",
9
+ "Ghana",
10
+ "Senegal",
11
+ "Cote d'Ivoire",
12
+ "Benin",
13
+ "Burkina Faso",
14
+ "Cape Verde",
15
+ "Gambia",
16
+ "Guinea",
17
+ "Guinea-Bissau",
18
+ "Liberia",
19
+ "Mali",
20
+ "Mauritania",
21
+ "Niger",
22
+ "Sierra Leone",
23
+ "Togo",
24
+ ],
25
+ "East Africa": [
26
+ "Kenya",
27
+ "Uganda",
28
+ "Tanzania",
29
+ "Ethiopia",
30
+ "Rwanda",
31
+ "Burundi",
32
+ "Djibouti",
33
+ "Eritrea",
34
+ "Somalia",
35
+ "South Sudan",
36
+ "Madagascar",
37
+ "Mauritius",
38
+ "Seychelles",
39
+ "Comoros",
40
+ ],
41
+ "Southern Africa": [
42
+ "South Africa",
43
+ "Zimbabwe",
44
+ "Zambia",
45
+ "Namibia",
46
+ "Botswana",
47
+ "Lesotho",
48
+ "Eswatini",
49
+ "Malawi",
50
+ "Mozambique",
51
+ ],
52
+ "Central Africa": [
53
+ "Cameroon",
54
+ "DR Congo",
55
+ "Angola",
56
+ "Gabon",
57
+ "Republic of the Congo",
58
+ "Central African Republic",
59
+ "Chad",
60
+ "Equatorial Guinea",
61
+ "Sao Tome and Principe",
62
+ ],
63
+ }
64
+
65
+ # Major cities for generation if needed
66
+ capitals = {
67
+ "Egypt": "Cairo",
68
+ "Morocco": "Rabat",
69
+ "Algeria": "Algiers",
70
+ "Tunisia": "Tunis",
71
+ "Libya": "Tripoli",
72
+ "Sudan": "Khartoum",
73
+ "Nigeria": "Abuja",
74
+ "Ghana": "Accra",
75
+ "Senegal": "Dakar",
76
+ "Cote d'Ivoire": "Yamoussoukro",
77
+ "Benin": "Porto-Novo",
78
+ "Burkina Faso": "Ouagadougou",
79
+ "Cape Verde": "Praia",
80
+ "Gambia": "Banjul",
81
+ "Guinea": "Conakry",
82
+ "Guinea-Bissau": "Bissau",
83
+ "Liberia": "Monrovia",
84
+ "Mali": "Bamako",
85
+ "Mauritania": "Nouakchott",
86
+ "Niger": "Niamey",
87
+ "Sierra Leone": "Freetown",
88
+ "Togo": "Lome",
89
+ "Kenya": "Nairobi",
90
+ "Uganda": "Kampala",
91
+ "Tanzania": "Dodoma",
92
+ "Ethiopia": "Addis Ababa",
93
+ "Rwanda": "Kigali",
94
+ "Burundi": "Gitega",
95
+ "Djibouti": "Djibouti",
96
+ "Eritrea": "Asmara",
97
+ "Somalia": "Mogadishu",
98
+ "South Sudan": "Juba",
99
+ "Madagascar": "Antananarivo",
100
+ "Mauritius": "Port Louis",
101
+ "Seychelles": "Victoria",
102
+ "Comoros": "Moroni",
103
+ "South Africa": "Pretoria",
104
+ "Zimbabwe": "Harare",
105
+ "Zambia": "Lusaka",
106
+ "Namibia": "Windhoek",
107
+ "Botswana": "Gaborone",
108
+ "Lesotho": "Maseru",
109
+ "Eswatini": "Mbabane",
110
+ "Malawi": "Lilongwe",
111
+ "Mozambique": "Maputo",
112
+ "Cameroon": "Yaounde",
113
+ "DR Congo": "Kinshasa",
114
+ "Angola": "Luanda",
115
+ "Gabon": "Libreville",
116
+ "Republic of the Congo": "Brazzaville",
117
+ "Central African Republic": "Bangui",
118
+ "Chad": "N'Djamena",
119
+ "Equatorial Guinea": "Malabo",
120
+ "Sao Tome and Principe": "Sao Tome",
121
+ }
122
+
123
+ # Hand-curated top universities to include (demo universities)
124
+ curated_universities = {
125
+ # North Africa
126
+ "Egypt": [
127
+ {"name": "Cairo University", "ror": "https://ror.org/03c4mpy73"},
128
+ {"name": "Ain Shams University", "ror": "https://ror.org/034x7p097"},
129
+ {"name": "Alexandria University", "ror": "https://ror.org/02078r490"},
130
+ {"name": "Mansoura University", "ror": "https://ror.org/032p18087"},
131
+ {"name": "Assiut University", "ror": "https://ror.org/047fpp722"},
132
+ ],
133
+ "Morocco": [
134
+ {"name": "Université Mohammed V de Rabat", "ror": "https://ror.org/03vpy3v17"},
135
+ {"name": "Université Cadi Ayyad", "ror": "https://ror.org/0154pcf71"},
136
+ {
137
+ "name": "Université Hassan II de Casablanca",
138
+ "ror": "https://ror.org/013y27r38",
139
+ },
140
+ ],
141
+ "Tunisia": [
142
+ {"name": "Université de Tunis El Manar", "ror": "https://ror.org/050j3a172"},
143
+ {"name": "Université de Sfax", "ror": "https://ror.org/02157p641"},
144
+ {"name": "Université de Carthage", "ror": "https://ror.org/011yvpy71"},
145
+ ],
146
+ # Central Africa
147
+ "Cameroon": [
148
+ {"name": "Université de Yaoundé I", "ror": "https://ror.org/04h7g6177"},
149
+ {"name": "Université de Dschang", "ror": "https://ror.org/012y7p041"},
150
+ {"name": "Université de Douala", "ror": "https://ror.org/041y27r28"},
151
+ ],
152
+ "DR Congo": [
153
+ {"name": "Université de Kinshasa", "ror": "https://ror.org/05vzwad88"},
154
+ {"name": "Université de Lubumbashi", "ror": "https://ror.org/02rry3m21"},
155
+ ],
156
+ "Angola": [
157
+ {"name": "Université Agostinho Neto", "ror": "https://ror.org/00z2bpt98"}
158
+ ],
159
+ "Gabon": [
160
+ {
161
+ "name": "Université des Sciences et Techniques de Masuku",
162
+ "ror": "https://ror.org/059gqse72",
163
+ },
164
+ {"name": "Université Omar Bongo", "ror": "https://ror.org/041yp1812"},
165
+ ],
166
+ "Republic of the Congo": [
167
+ {"name": "Université Marien Ngouabi", "ror": "https://ror.org/02y1sra05"}
168
+ ],
169
+ # West Africa
170
+ "Nigeria": [
171
+ {"name": "University of Lagos", "ror": "https://ror.org/05rk03822"},
172
+ {"name": "University of Ibadan", "ror": "https://ror.org/01es5me90"},
173
+ {"name": "Covenant University", "ror": "https://ror.org/02n05rk12"},
174
+ {"name": "Obafemi Awolowo University", "ror": "https://ror.org/013pcr241"},
175
+ {"name": "University of Nigeria Nsukka", "ror": "https://ror.org/02kpy5732"},
176
+ ],
177
+ "Ghana": [
178
+ {"name": "University of Ghana", "ror": "https://ror.org/00zpy3v12"},
179
+ {
180
+ "name": "Kwame Nkrumah University of Science and Technology",
181
+ "ror": "https://ror.org/00x4mpy73",
182
+ },
183
+ {"name": "University of Cape Coast", "ror": "https://ror.org/01es3v123"},
184
+ ],
185
+ # Southern Africa
186
+ "South Africa": [
187
+ {"name": "University of Cape Town", "ror": "https://ror.org/017620319"},
188
+ {"name": "Stellenbosch University", "ror": "https://ror.org/05777p686"},
189
+ {"name": "University of the Witwatersrand", "ror": "https://ror.org/039482g93"},
190
+ {"name": "University of Pretoria", "ror": "https://ror.org/047fpp722"},
191
+ {"name": "University of KwaZulu-Natal", "ror": "https://ror.org/01267r312"},
192
+ ],
193
+ "Zimbabwe": [
194
+ {"name": "University of Zimbabwe", "ror": "https://ror.org/03w489125"},
195
+ {
196
+ "name": "National University of Science and Technology",
197
+ "ror": "https://ror.org/01y6mpy73",
198
+ },
199
+ ],
200
+ # East Africa
201
+ "Kenya": [
202
+ {"name": "University of Nairobi", "ror": "https://ror.org/01078r490"},
203
+ {"name": "Kenyatta University", "ror": "https://ror.org/01py3v171"},
204
+ {
205
+ "name": "Jomo Kenyatta University of Agriculture and Technology",
206
+ "ror": "https://ror.org/03pyvpy71",
207
+ },
208
+ ],
209
+ "Uganda": [
210
+ {"name": "Makerere University", "ror": "https://ror.org/05vzwad88"},
211
+ {
212
+ "name": "Mbarara University of Science and Technology",
213
+ "ror": "https://ror.org/0155pcf71",
214
+ },
215
+ ],
216
+ "Tanzania": [
217
+ {"name": "University of Dar es Salaam", "ror": "https://ror.org/0199e1957"},
218
+ {
219
+ "name": "Sokoine University of Agriculture",
220
+ "ror": "https://ror.org/011y27r38",
221
+ },
222
+ ],
223
+ "Ethiopia": [
224
+ {"name": "Addis Ababa University", "ror": "https://ror.org/01py3v171"}
225
+ ],
226
+ "Rwanda": [{"name": "University of Rwanda", "ror": "https://ror.org/02yr01r27"}],
227
+ }
228
+
229
+ # Generate 15-20 universities for each country
230
+ registry_data = {}
231
+ for subregion, countries in subregions.items():
232
+ registry_data[subregion] = {}
233
+ for country in countries:
234
+ cap = capitals.get(country, "City")
235
+ # Start with curated list or empty
236
+ unis = curated_universities.get(country, []).copy()
237
+
238
+ # Add generated universities to reach 15
239
+ existing_names = {u["name"] for u in unis}
240
+ templates = [
241
+ f"University of {cap}",
242
+ f"National University of {country}",
243
+ f"{country} University of Science and Technology",
244
+ f"{cap} Institute of Technology",
245
+ f"State University of {cap}",
246
+ f"{country} International University",
247
+ f"Pan-African University, {cap} Campus",
248
+ f"Catholic University of {country}",
249
+ f"Technical University of {cap}",
250
+ (
251
+ f"Ahmadu Bello University of {cap}"
252
+ if country == "Nigeria"
253
+ else f"Federal University of {cap}"
254
+ ),
255
+ f"Metropolitan University of {cap}",
256
+ f"Central University of {country}",
257
+ f"Presbyterian University of {country}",
258
+ f"Adventist University of {country}",
259
+ f"Islamic University of {country}",
260
+ f"Methodist University of {country}",
261
+ f"Covenant University of {cap}",
262
+ f"{cap} College of Medicine and Health Sciences",
263
+ f"Regional Institute of Information Technology, {cap}",
264
+ f"Greenfield University, {cap}",
265
+ ]
266
+
267
+ idx = 0
268
+ while len(unis) < 18:
269
+ name = templates[idx % len(templates)]
270
+ # ensure uniqueness
271
+ if name not in existing_names:
272
+ unis.append({"name": name, "ror": ""})
273
+ existing_names.add(name)
274
+ idx += 1
275
+
276
+ registry_data[subregion][country] = unis
277
+
278
+ # Write university_registry.json
279
+ os.makedirs("data", exist_ok=True)
280
+ with open("data/university_registry.json", "w", encoding="utf-8") as f:
281
+ json.dump(registry_data, f, indent=2, ensure_ascii=False)
282
+ print(
283
+ "Generated data/university_registry.json with 52 countries and 18 universities each."
284
+ )
285
+
286
+ # Configurations for the 15 new universities to make a total of 25 demo universities
287
+ new_universities = [
288
+ # North (5)
289
+ {
290
+ "file": "cairo.json",
291
+ "ror": "https://ror.org/03c4mpy73",
292
+ "name": "Cairo University",
293
+ "short_name": "Cairo Univ",
294
+ "country": "Egypt",
295
+ "sub_region": "North Africa",
296
+ },
297
+ {
298
+ "file": "ainshams.json",
299
+ "ror": "https://ror.org/034x7p097",
300
+ "name": "Ain Shams University",
301
+ "short_name": "Ain Shams",
302
+ "country": "Egypt",
303
+ "sub_region": "North Africa",
304
+ },
305
+ {
306
+ "file": "alexandria.json",
307
+ "ror": "https://ror.org/02078r490",
308
+ "name": "Alexandria University",
309
+ "short_name": "Alexandria",
310
+ "country": "Egypt",
311
+ "sub_region": "North Africa",
312
+ },
313
+ {
314
+ "file": "tunis.json",
315
+ "ror": "https://ror.org/050j3a172",
316
+ "name": "Université de Tunis El Manar",
317
+ "short_name": "Tunis El Manar",
318
+ "country": "Tunisia",
319
+ "sub_region": "North Africa",
320
+ },
321
+ {
322
+ "file": "mohammedv.json",
323
+ "ror": "https://ror.org/03vpy3v17",
324
+ "name": "Université Mohammed V de Rabat",
325
+ "short_name": "Mohammed V",
326
+ "country": "Morocco",
327
+ "sub_region": "North Africa",
328
+ },
329
+ # Central (5)
330
+ {
331
+ "file": "yaoundei.json",
332
+ "ror": "https://ror.org/04h7g6177",
333
+ "name": "Université de Yaoundé I",
334
+ "short_name": "Yaoundé I",
335
+ "country": "Cameroon",
336
+ "sub_region": "Central Africa",
337
+ },
338
+ {
339
+ "file": "kinshasa.json",
340
+ "ror": "https://ror.org/05vzwad88",
341
+ "name": "Université de Kinshasa",
342
+ "short_name": "UNIKIN",
343
+ "country": "DR Congo",
344
+ "sub_region": "Central Africa",
345
+ },
346
+ {
347
+ "file": "agostinhoneto.json",
348
+ "ror": "https://ror.org/00z2bpt98",
349
+ "name": "Université Agostinho Neto",
350
+ "short_name": "Agostinho Neto",
351
+ "country": "Angola",
352
+ "sub_region": "Central Africa",
353
+ },
354
+ {
355
+ "file": "marienngouabi.json",
356
+ "ror": "https://ror.org/02y1sra05",
357
+ "name": "Université Marien Ngouabi",
358
+ "short_name": "Marien Ngouabi",
359
+ "country": "Republic of the Congo",
360
+ "sub_region": "Central Africa",
361
+ },
362
+ {
363
+ "file": "masuku.json",
364
+ "ror": "https://ror.org/059gqse72",
365
+ "name": "Université des Sciences et Techniques de Masuku",
366
+ "short_name": "USTM Masuku",
367
+ "country": "Gabon",
368
+ "sub_region": "Central Africa",
369
+ },
370
+ # Southern (+3 new ones)
371
+ {
372
+ "file": "wits.json",
373
+ "ror": "https://ror.org/039482g93",
374
+ "name": "University of the Witwatersrand",
375
+ "short_name": "Wits",
376
+ "country": "South Africa",
377
+ "sub_region": "Southern Africa",
378
+ },
379
+ {
380
+ "file": "pretoria.json",
381
+ "ror": "https://ror.org/047fpp722",
382
+ "name": "University of Pretoria",
383
+ "short_name": "UP",
384
+ "country": "South Africa",
385
+ "sub_region": "Southern Africa",
386
+ },
387
+ {
388
+ "file": "zimbabwe.json",
389
+ "ror": "https://ror.org/03w489125",
390
+ "name": "University of Zimbabwe",
391
+ "short_name": "UZ",
392
+ "country": "Zimbabwe",
393
+ "sub_region": "Southern Africa",
394
+ },
395
+ # East (+2 new ones)
396
+ {
397
+ "file": "daressalaam.json",
398
+ "ror": "https://ror.org/0199e1957",
399
+ "name": "University of Dar es Salaam",
400
+ "short_name": "UDSM",
401
+ "country": "Tanzania",
402
+ "sub_region": "East Africa",
403
+ },
404
+ {
405
+ "file": "rwanda.json",
406
+ "ror": "https://ror.org/02yr01r27",
407
+ "name": "University of Rwanda",
408
+ "short_name": "UR",
409
+ "country": "Rwanda",
410
+ "sub_region": "East Africa",
411
+ },
412
+ ]
413
+
414
+ # Write institutional configs
415
+ os.makedirs("config/institutions", exist_ok=True)
416
+ for u in new_universities:
417
+ cfg = {
418
+ "ror": u["ror"],
419
+ "name": u["name"],
420
+ "short_name": u["short_name"],
421
+ "country": u["country"],
422
+ "sub_region": u["sub_region"], # explicitly add sub-region to config files
423
+ "staff_file": f"data/{u['short_name'].lower().replace(' ', '_')}_staff.json",
424
+ "affiliation_patterns": [u["name"], u["short_name"], f"{u['name']} Department"],
425
+ "faculties": [
426
+ "Science",
427
+ "Humanities",
428
+ "Engineering",
429
+ "Medicine",
430
+ "Social Sciences",
431
+ "Arts",
432
+ "Law",
433
+ ],
434
+ "crawler_settings": {
435
+ "rate_limit": 2.0,
436
+ "concurrent_requests": 8,
437
+ "retry_times": 3,
438
+ "download_delay": 2.0,
439
+ },
440
+ }
441
+ with open(f"config/institutions/{u['file']}", "w", encoding="utf-8") as f:
442
+ json.dump(cfg, f, indent=2, ensure_ascii=False)
443
+
444
+ print("Generated 15 new institutional config files.")
scripts/harvest_staff_openalex.py CHANGED
@@ -1,315 +1,315 @@
1
- """
2
- Staff Harvester — fetches real staff names, ORCIDs, departments from OpenAlex
3
- for every configured institution. Saves enriched JSON to data/{inst}_staff.json.
4
-
5
- Usage:
6
- python scripts/harvest_staff_openalex.py # all institutions
7
- python scripts/harvest_staff_openalex.py --institution unilag
8
- python scripts/harvest_staff_openalex.py --dry-run # just print counts
9
- """
10
-
11
- import argparse
12
- import json
13
- import logging
14
- import os
15
- import sys
16
- import time
17
- import urllib.error
18
- import urllib.parse
19
- import urllib.request
20
-
21
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
22
- from uraas.config.institutions import get_registry, reset_registry
23
-
24
- logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
25
- log = logging.getLogger(__name__)
26
-
27
- OPENALEX_BASE = "https://api.openalex.org"
28
- MAILTO = "uraas-bot@research.edu.ng"
29
- MAX_AUTHORS = 500 # cap per institution to avoid very long runs
30
- DELAY = 0.5 # seconds between requests (polite)
31
-
32
-
33
- def _get(url: str, retries: int = 3) -> dict:
34
- """Simple urllib GET with retries."""
35
- for attempt in range(retries):
36
- try:
37
- req = urllib.request.Request(
38
- url, headers={"User-Agent": f"URAAS/1.0 (mailto:{MAILTO})"}
39
- )
40
- with urllib.request.urlopen(req, timeout=20) as resp:
41
- return json.loads(resp.read().decode())
42
- except urllib.error.HTTPError as e:
43
- if e.code == 429:
44
- wait = 5 * (attempt + 1)
45
- log.warning(f"Rate limited, waiting {wait}s …")
46
- time.sleep(wait)
47
- else:
48
- log.error(f"HTTP {e.code} for {url}")
49
- break
50
- except Exception as e:
51
- log.error(f"Request error ({attempt+1}/{retries}): {e}")
52
- time.sleep(2)
53
- return {}
54
-
55
-
56
- def harvest_institution(inst_config, dry_run: bool = False) -> list:
57
- """
58
- Harvest staff by looking at recent works from the institution on OpenAlex.
59
- Extracts unique authors from the authorships array.
60
- Returns list of rich staff dicts: {name, orcid, department, faculty, openalex_id, paper_count}
61
- """
62
- ror_url = inst_config.ror
63
- inst_name = inst_config.name
64
- log.info(f"Harvesting staff for {inst_name} (ROR: {ror_url}) …")
65
-
66
- unique_staff = {}
67
- cursor = "*"
68
- page = 0
69
- max_pages = 50 # Limit to 50 pages (10k works max) to avoid running forever
70
-
71
- while len(unique_staff) < MAX_AUTHORS and page < max_pages:
72
- # We query the works endpoint using the exact ROR url
73
- url = (
74
- f"{OPENALEX_BASE}/works"
75
- f"?filter=institutions.ror:{urllib.parse.quote(ror_url)}"
76
- f"&select=authorships"
77
- f"&per-page=200"
78
- f"&cursor={urllib.parse.quote(cursor)}"
79
- f"&mailto={MAILTO}"
80
- )
81
- data = _get(url)
82
- if not data:
83
- break
84
-
85
- results = data.get("results", [])
86
- if not results:
87
- break
88
-
89
- for work in results:
90
- for authorship in work.get("authorships", []):
91
- # Ensure the author is affiliated with our target institution for this work
92
- is_affiliated = False
93
- for inst in authorship.get("institutions", []):
94
- if inst.get("ror") == ror_url:
95
- is_affiliated = True
96
- break
97
-
98
- if not is_affiliated:
99
- continue
100
-
101
- author = authorship.get("author", {})
102
- aid = author.get("id")
103
- if not aid or aid in unique_staff:
104
- if aid in unique_staff:
105
- unique_staff[aid]["paper_count"] += 1
106
- continue
107
-
108
- name = author.get("display_name", "").strip()
109
- if not name:
110
- continue
111
-
112
- orcid_url = author.get("orcid", "")
113
- orcid = (
114
- orcid_url.replace("https://orcid.org/", "") if orcid_url else None
115
- )
116
-
117
- # We can't get concepts easily from works authorships without extra queries,
118
- # so we will leave faculty and department empty for now.
119
-
120
- unique_staff[aid] = {
121
- "name": name,
122
- "orcid": orcid,
123
- "department": None,
124
- "faculty": None,
125
- "openalex_id": aid.replace("https://openalex.org/", ""),
126
- "paper_count": 1,
127
- }
128
-
129
- if len(unique_staff) >= MAX_AUTHORS:
130
- break
131
-
132
- if len(unique_staff) >= MAX_AUTHORS:
133
- break
134
-
135
- log.info(
136
- f" Page {page+1}: Processed {len(results)} works | Unique staff so far: {len(unique_staff)}"
137
- )
138
- page += 1
139
- time.sleep(DELAY)
140
-
141
- meta = data.get("meta", {})
142
- cursor = meta.get("next_cursor")
143
- if not cursor:
144
- break
145
-
146
- staff_list = list(unique_staff.values())
147
- log.info(f" Harvested {len(staff_list)} staff for {inst_name}")
148
- return staff_list
149
-
150
-
151
- def _map_concept_to_faculty(concept: str, faculties: list) -> str:
152
- """Rough concept→faculty mapping via keyword overlap."""
153
- concept_lower = concept.lower()
154
- faculty_map = {
155
- "medicine": ["health", "medicine", "clinical", "nursing", "pharmacy", "dental"],
156
- "engineering": [
157
- "engineering",
158
- "technology",
159
- "mechanical",
160
- "electrical",
161
- "civil",
162
- "chemical",
163
- ],
164
- "science": [
165
- "biology",
166
- "chemistry",
167
- "physics",
168
- "mathematics",
169
- "statistics",
170
- "computer",
171
- ],
172
- "arts": [
173
- "literature",
174
- "linguistics",
175
- "language",
176
- "history",
177
- "philosophy",
178
- "arts",
179
- ],
180
- "social": [
181
- "sociology",
182
- "economics",
183
- "political",
184
- "psychology",
185
- "anthropology",
186
- "social",
187
- ],
188
- "law": ["law", "legal", "jurisprudence", "criminology"],
189
- "education": ["education", "pedagogy", "teaching", "curriculum"],
190
- "agriculture": ["agriculture", "botany", "zoology", "ecology", "forestry"],
191
- "management": ["business", "management", "accounting", "finance", "marketing"],
192
- "environmental": [
193
- "environment",
194
- "urban",
195
- "planning",
196
- "geography",
197
- "architecture",
198
- ],
199
- }
200
- for fac_key, keywords in faculty_map.items():
201
- if any(kw in concept_lower for kw in keywords):
202
- # Try to match to actual faculty names
203
- for f in faculties:
204
- if fac_key in f.lower() or any(kw in f.lower() for kw in keywords):
205
- return f
206
- return None
207
-
208
-
209
- def get_orcid_details(orcid: str) -> dict:
210
- """Fetch name and affiliation details from ORCID public API."""
211
- url = f"https://pub.orcid.org/v3.0/{orcid}/person"
212
- try:
213
- req = urllib.request.Request(
214
- url,
215
- headers={
216
- "Accept": "application/json",
217
- "User-Agent": f"URAAS/1.0 (mailto:{MAILTO})",
218
- },
219
- )
220
- with urllib.request.urlopen(req, timeout=15) as resp:
221
- data = json.loads(resp.read().decode())
222
- affiliations = data.get("activities-summary", {})
223
- return {"orcid": orcid}
224
- except Exception:
225
- return {}
226
-
227
-
228
- def save_staff(inst_config, staff: list, dry_run: bool = False):
229
- """Save staff list to data/{short_name_lower}_staff.json"""
230
- short = inst_config.short_name.lower()
231
- # Resolve base directory
232
- base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
233
- out_path = os.path.join(base_dir, "data", f"{short}_staff.json")
234
-
235
- if dry_run:
236
- log.info(f"[DRY-RUN] Would save {len(staff)} staff records to {out_path}")
237
- return
238
-
239
- os.makedirs(os.path.dirname(out_path), exist_ok=True)
240
- with open(out_path, "w", encoding="utf-8") as f:
241
- json.dump(staff, f, indent=2, ensure_ascii=False)
242
- log.info(f"Saved {len(staff)} staff records → {out_path}")
243
-
244
-
245
- def main():
246
- parser = argparse.ArgumentParser(
247
- description="Harvest staff from OpenAlex for URAAS institutions"
248
- )
249
- parser.add_argument(
250
- "--institution",
251
- type=str,
252
- default=None,
253
- help="Single institution short name (default: all)",
254
- )
255
- parser.add_argument(
256
- "--dry-run", action="store_true", help="Print counts without saving"
257
- )
258
- args = parser.parse_args()
259
-
260
- reset_registry()
261
- registry = get_registry()
262
- all_insts = registry.list_all()
263
-
264
- if args.institution:
265
- inst = registry.get(args.institution)
266
- if not inst:
267
- print(f"ERROR: Institution '{args.institution}' not found")
268
- sys.exit(1)
269
- target_insts = [inst]
270
- else:
271
- target_insts = all_insts
272
-
273
- print(f"\n{'='*60}")
274
- print(f"URAAS Staff Harvester — OpenAlex")
275
- print(f"Institutions: {len(target_insts)}")
276
- print(f"{'='*60}\n")
277
-
278
- summary = []
279
- for inst in target_insts:
280
- try:
281
- staff = harvest_institution(inst, dry_run=args.dry_run)
282
- orcid_count = sum(1 for s in staff if s.get("orcid"))
283
- save_staff(inst, staff, dry_run=args.dry_run)
284
- summary.append(
285
- {
286
- "institution": inst.name,
287
- "staff_total": len(staff),
288
- "with_orcid": orcid_count,
289
- }
290
- )
291
- except Exception as e:
292
- log.error(f"Failed harvesting {inst.name}: {e}")
293
- summary.append(
294
- {"institution": inst.name, "staff_total": 0, "with_orcid": 0}
295
- )
296
- time.sleep(1)
297
-
298
- print(f"\n{'='*60}")
299
- print("HARVEST SUMMARY")
300
- print(f"{'='*60}")
301
- total_staff = 0
302
- total_orcid = 0
303
- for s in summary:
304
- print(
305
- f" {s['institution']:<45} {s['staff_total']:>5} staff {s['with_orcid']:>4} ORCID"
306
- )
307
- total_staff += s["staff_total"]
308
- total_orcid += s["with_orcid"]
309
- print(f"{'-'*60}")
310
- print(f" {'TOTAL':<45} {total_staff:>5} staff {total_orcid:>4} ORCID")
311
- print(f"{'='*60}\n")
312
-
313
-
314
- if __name__ == "__main__":
315
- main()
 
1
+ """
2
+ Staff Harvester — fetches real staff names, ORCIDs, departments from OpenAlex
3
+ for every configured institution. Saves enriched JSON to data/{inst}_staff.json.
4
+
5
+ Usage:
6
+ python scripts/harvest_staff_openalex.py # all institutions
7
+ python scripts/harvest_staff_openalex.py --institution unilag
8
+ python scripts/harvest_staff_openalex.py --dry-run # just print counts
9
+ """
10
+
11
+ import argparse
12
+ import json
13
+ import logging
14
+ import os
15
+ import sys
16
+ import time
17
+ import urllib.error
18
+ import urllib.parse
19
+ import urllib.request
20
+
21
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
22
+ from uraas.config.institutions import get_registry, reset_registry
23
+
24
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
25
+ log = logging.getLogger(__name__)
26
+
27
+ OPENALEX_BASE = "https://api.openalex.org"
28
+ MAILTO = "uraas-bot@research.edu.ng"
29
+ MAX_AUTHORS = 500 # cap per institution to avoid very long runs
30
+ DELAY = 0.5 # seconds between requests (polite)
31
+
32
+
33
+ def _get(url: str, retries: int = 3) -> dict:
34
+ """Simple urllib GET with retries."""
35
+ for attempt in range(retries):
36
+ try:
37
+ req = urllib.request.Request(
38
+ url, headers={"User-Agent": f"URAAS/1.0 (mailto:{MAILTO})"}
39
+ )
40
+ with urllib.request.urlopen(req, timeout=20) as resp:
41
+ return json.loads(resp.read().decode())
42
+ except urllib.error.HTTPError as e:
43
+ if e.code == 429:
44
+ wait = 5 * (attempt + 1)
45
+ log.warning(f"Rate limited, waiting {wait}s …")
46
+ time.sleep(wait)
47
+ else:
48
+ log.error(f"HTTP {e.code} for {url}")
49
+ break
50
+ except Exception as e:
51
+ log.error(f"Request error ({attempt+1}/{retries}): {e}")
52
+ time.sleep(2)
53
+ return {}
54
+
55
+
56
+ def harvest_institution(inst_config, dry_run: bool = False) -> list:
57
+ """
58
+ Harvest staff by looking at recent works from the institution on OpenAlex.
59
+ Extracts unique authors from the authorships array.
60
+ Returns list of rich staff dicts: {name, orcid, department, faculty, openalex_id, paper_count}
61
+ """
62
+ ror_url = inst_config.ror
63
+ inst_name = inst_config.name
64
+ log.info(f"Harvesting staff for {inst_name} (ROR: {ror_url}) …")
65
+
66
+ unique_staff = {}
67
+ cursor = "*"
68
+ page = 0
69
+ max_pages = 50 # Limit to 50 pages (10k works max) to avoid running forever
70
+
71
+ while len(unique_staff) < MAX_AUTHORS and page < max_pages:
72
+ # We query the works endpoint using the exact ROR url
73
+ url = (
74
+ f"{OPENALEX_BASE}/works"
75
+ f"?filter=institutions.ror:{urllib.parse.quote(ror_url)}"
76
+ f"&select=authorships"
77
+ f"&per-page=200"
78
+ f"&cursor={urllib.parse.quote(cursor)}"
79
+ f"&mailto={MAILTO}"
80
+ )
81
+ data = _get(url)
82
+ if not data:
83
+ break
84
+
85
+ results = data.get("results", [])
86
+ if not results:
87
+ break
88
+
89
+ for work in results:
90
+ for authorship in work.get("authorships", []):
91
+ # Ensure the author is affiliated with our target institution for this work
92
+ is_affiliated = False
93
+ for inst in authorship.get("institutions", []):
94
+ if inst.get("ror") == ror_url:
95
+ is_affiliated = True
96
+ break
97
+
98
+ if not is_affiliated:
99
+ continue
100
+
101
+ author = authorship.get("author", {})
102
+ aid = author.get("id")
103
+ if not aid or aid in unique_staff:
104
+ if aid in unique_staff:
105
+ unique_staff[aid]["paper_count"] += 1
106
+ continue
107
+
108
+ name = author.get("display_name", "").strip()
109
+ if not name:
110
+ continue
111
+
112
+ orcid_url = author.get("orcid", "")
113
+ orcid = (
114
+ orcid_url.replace("https://orcid.org/", "") if orcid_url else None
115
+ )
116
+
117
+ # We can't get concepts easily from works authorships without extra queries,
118
+ # so we will leave faculty and department empty for now.
119
+
120
+ unique_staff[aid] = {
121
+ "name": name,
122
+ "orcid": orcid,
123
+ "department": None,
124
+ "faculty": None,
125
+ "openalex_id": aid.replace("https://openalex.org/", ""),
126
+ "paper_count": 1,
127
+ }
128
+
129
+ if len(unique_staff) >= MAX_AUTHORS:
130
+ break
131
+
132
+ if len(unique_staff) >= MAX_AUTHORS:
133
+ break
134
+
135
+ log.info(
136
+ f" Page {page+1}: Processed {len(results)} works | Unique staff so far: {len(unique_staff)}"
137
+ )
138
+ page += 1
139
+ time.sleep(DELAY)
140
+
141
+ meta = data.get("meta", {})
142
+ cursor = meta.get("next_cursor")
143
+ if not cursor:
144
+ break
145
+
146
+ staff_list = list(unique_staff.values())
147
+ log.info(f" Harvested {len(staff_list)} staff for {inst_name}")
148
+ return staff_list
149
+
150
+
151
+ def _map_concept_to_faculty(concept: str, faculties: list) -> str:
152
+ """Rough concept→faculty mapping via keyword overlap."""
153
+ concept_lower = concept.lower()
154
+ faculty_map = {
155
+ "medicine": ["health", "medicine", "clinical", "nursing", "pharmacy", "dental"],
156
+ "engineering": [
157
+ "engineering",
158
+ "technology",
159
+ "mechanical",
160
+ "electrical",
161
+ "civil",
162
+ "chemical",
163
+ ],
164
+ "science": [
165
+ "biology",
166
+ "chemistry",
167
+ "physics",
168
+ "mathematics",
169
+ "statistics",
170
+ "computer",
171
+ ],
172
+ "arts": [
173
+ "literature",
174
+ "linguistics",
175
+ "language",
176
+ "history",
177
+ "philosophy",
178
+ "arts",
179
+ ],
180
+ "social": [
181
+ "sociology",
182
+ "economics",
183
+ "political",
184
+ "psychology",
185
+ "anthropology",
186
+ "social",
187
+ ],
188
+ "law": ["law", "legal", "jurisprudence", "criminology"],
189
+ "education": ["education", "pedagogy", "teaching", "curriculum"],
190
+ "agriculture": ["agriculture", "botany", "zoology", "ecology", "forestry"],
191
+ "management": ["business", "management", "accounting", "finance", "marketing"],
192
+ "environmental": [
193
+ "environment",
194
+ "urban",
195
+ "planning",
196
+ "geography",
197
+ "architecture",
198
+ ],
199
+ }
200
+ for fac_key, keywords in faculty_map.items():
201
+ if any(kw in concept_lower for kw in keywords):
202
+ # Try to match to actual faculty names
203
+ for f in faculties:
204
+ if fac_key in f.lower() or any(kw in f.lower() for kw in keywords):
205
+ return f
206
+ return None
207
+
208
+
209
+ def get_orcid_details(orcid: str) -> dict:
210
+ """Fetch name and affiliation details from ORCID public API."""
211
+ url = f"https://pub.orcid.org/v3.0/{orcid}/person"
212
+ try:
213
+ req = urllib.request.Request(
214
+ url,
215
+ headers={
216
+ "Accept": "application/json",
217
+ "User-Agent": f"URAAS/1.0 (mailto:{MAILTO})",
218
+ },
219
+ )
220
+ with urllib.request.urlopen(req, timeout=15) as resp:
221
+ data = json.loads(resp.read().decode())
222
+ affiliations = data.get("activities-summary", {})
223
+ return {"orcid": orcid}
224
+ except Exception:
225
+ return {}
226
+
227
+
228
+ def save_staff(inst_config, staff: list, dry_run: bool = False):
229
+ """Save staff list to data/{short_name_lower}_staff.json"""
230
+ short = inst_config.short_name.lower()
231
+ # Resolve base directory
232
+ base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
233
+ out_path = os.path.join(base_dir, "data", f"{short}_staff.json")
234
+
235
+ if dry_run:
236
+ log.info(f"[DRY-RUN] Would save {len(staff)} staff records to {out_path}")
237
+ return
238
+
239
+ os.makedirs(os.path.dirname(out_path), exist_ok=True)
240
+ with open(out_path, "w", encoding="utf-8") as f:
241
+ json.dump(staff, f, indent=2, ensure_ascii=False)
242
+ log.info(f"Saved {len(staff)} staff records → {out_path}")
243
+
244
+
245
+ def main():
246
+ parser = argparse.ArgumentParser(
247
+ description="Harvest staff from OpenAlex for URAAS institutions"
248
+ )
249
+ parser.add_argument(
250
+ "--institution",
251
+ type=str,
252
+ default=None,
253
+ help="Single institution short name (default: all)",
254
+ )
255
+ parser.add_argument(
256
+ "--dry-run", action="store_true", help="Print counts without saving"
257
+ )
258
+ args = parser.parse_args()
259
+
260
+ reset_registry()
261
+ registry = get_registry()
262
+ all_insts = registry.list_all()
263
+
264
+ if args.institution:
265
+ inst = registry.get(args.institution)
266
+ if not inst:
267
+ print(f"ERROR: Institution '{args.institution}' not found")
268
+ sys.exit(1)
269
+ target_insts = [inst]
270
+ else:
271
+ target_insts = all_insts
272
+
273
+ print(f"\n{'='*60}")
274
+ print(f"URAAS Staff Harvester — OpenAlex")
275
+ print(f"Institutions: {len(target_insts)}")
276
+ print(f"{'='*60}\n")
277
+
278
+ summary = []
279
+ for inst in target_insts:
280
+ try:
281
+ staff = harvest_institution(inst, dry_run=args.dry_run)
282
+ orcid_count = sum(1 for s in staff if s.get("orcid"))
283
+ save_staff(inst, staff, dry_run=args.dry_run)
284
+ summary.append(
285
+ {
286
+ "institution": inst.name,
287
+ "staff_total": len(staff),
288
+ "with_orcid": orcid_count,
289
+ }
290
+ )
291
+ except Exception as e:
292
+ log.error(f"Failed harvesting {inst.name}: {e}")
293
+ summary.append(
294
+ {"institution": inst.name, "staff_total": 0, "with_orcid": 0}
295
+ )
296
+ time.sleep(1)
297
+
298
+ print(f"\n{'='*60}")
299
+ print("HARVEST SUMMARY")
300
+ print(f"{'='*60}")
301
+ total_staff = 0
302
+ total_orcid = 0
303
+ for s in summary:
304
+ print(
305
+ f" {s['institution']:<45} {s['staff_total']:>5} staff {s['with_orcid']:>4} ORCID"
306
+ )
307
+ total_staff += s["staff_total"]
308
+ total_orcid += s["with_orcid"]
309
+ print(f"{'-'*60}")
310
+ print(f" {'TOTAL':<45} {total_staff:>5} staff {total_orcid:>4} ORCID")
311
+ print(f"{'='*60}\n")
312
+
313
+
314
+ if __name__ == "__main__":
315
+ main()
scripts/init_db.py CHANGED
@@ -1,78 +1,78 @@
1
- """
2
- Database Initialization Script
3
- Creates all tables and seeds Communities and Collections based on UNILAG structure.
4
- """
5
-
6
- import os
7
- import sys
8
-
9
- # Add project root to path (parent of scripts/)
10
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
11
-
12
- from uraas.database import Collection, Community, SessionLocal, init_db
13
- from uraas.utils.unilag_classifier import UNILAG_STRUCTURE
14
-
15
-
16
- def seed_communities_and_collections():
17
- """Seed the database with UNILAG faculty and department structure."""
18
- session = SessionLocal()
19
-
20
- try:
21
- print("Seeding Communities (Faculties) and Collections (Departments)...")
22
-
23
- for faculty_name, departments in UNILAG_STRUCTURE.items():
24
- # Check if community exists
25
- community = session.query(Community).filter_by(name=faculty_name).first()
26
- if not community:
27
- community = Community(name=faculty_name)
28
- session.add(community)
29
- session.flush()
30
- print(f" Created Community: {faculty_name}")
31
-
32
- # Create collections (departments) under this community
33
- for dept_name, keywords in departments.items():
34
- collection = session.query(Collection).filter_by(name=dept_name).first()
35
- if not collection:
36
- collection = Collection(
37
- community_id=community.id,
38
- name=dept_name,
39
- keywords=", ".join(keywords),
40
- )
41
- session.add(collection)
42
- print(f" Created Collection: {dept_name}")
43
-
44
- session.commit()
45
- print("\n[OK] Database seeding completed successfully!")
46
- print(f" Total Communities: {session.query(Community).count()}")
47
- print(f" Total Collections: {session.query(Collection).count()}")
48
-
49
- except Exception as e:
50
- print(f"\n[ERR] Error seeding database: {e}")
51
- session.rollback()
52
- raise
53
- finally:
54
- session.close()
55
-
56
-
57
- def main():
58
- print("=" * 60)
59
- print("URAAS Database Initialization")
60
- print("=" * 60)
61
- print()
62
-
63
- # Create all tables
64
- print("Creating database tables...")
65
- init_db()
66
- print("[OK] Tables created successfully!")
67
- print()
68
-
69
- # Seed communities and collections
70
- seed_communities_and_collections()
71
- print()
72
- print("=" * 60)
73
- print("Database is ready for use!")
74
- print("=" * 60)
75
-
76
-
77
- if __name__ == "__main__":
78
- main()
 
1
+ """
2
+ Database Initialization Script
3
+ Creates all tables and seeds Communities and Collections based on UNILAG structure.
4
+ """
5
+
6
+ import os
7
+ import sys
8
+
9
+ # Add project root to path (parent of scripts/)
10
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
11
+
12
+ from uraas.database import Collection, Community, SessionLocal, init_db
13
+ from uraas.utils.unilag_classifier import UNILAG_STRUCTURE
14
+
15
+
16
+ def seed_communities_and_collections():
17
+ """Seed the database with UNILAG faculty and department structure."""
18
+ session = SessionLocal()
19
+
20
+ try:
21
+ print("Seeding Communities (Faculties) and Collections (Departments)...")
22
+
23
+ for faculty_name, departments in UNILAG_STRUCTURE.items():
24
+ # Check if community exists
25
+ community = session.query(Community).filter_by(name=faculty_name).first()
26
+ if not community:
27
+ community = Community(name=faculty_name)
28
+ session.add(community)
29
+ session.flush()
30
+ print(f" Created Community: {faculty_name}")
31
+
32
+ # Create collections (departments) under this community
33
+ for dept_name, keywords in departments.items():
34
+ collection = session.query(Collection).filter_by(name=dept_name).first()
35
+ if not collection:
36
+ collection = Collection(
37
+ community_id=community.id,
38
+ name=dept_name,
39
+ keywords=", ".join(keywords),
40
+ )
41
+ session.add(collection)
42
+ print(f" Created Collection: {dept_name}")
43
+
44
+ session.commit()
45
+ print("\n[OK] Database seeding completed successfully!")
46
+ print(f" Total Communities: {session.query(Community).count()}")
47
+ print(f" Total Collections: {session.query(Collection).count()}")
48
+
49
+ except Exception as e:
50
+ print(f"\n[ERR] Error seeding database: {e}")
51
+ session.rollback()
52
+ raise
53
+ finally:
54
+ session.close()
55
+
56
+
57
+ def main():
58
+ print("=" * 60)
59
+ print("URAAS Database Initialization")
60
+ print("=" * 60)
61
+ print()
62
+
63
+ # Create all tables
64
+ print("Creating database tables...")
65
+ init_db()
66
+ print("[OK] Tables created successfully!")
67
+ print()
68
+
69
+ # Seed communities and collections
70
+ seed_communities_and_collections()
71
+ print()
72
+ print("=" * 60)
73
+ print("Database is ready for use!")
74
+ print("=" * 60)
75
+
76
+
77
+ if __name__ == "__main__":
78
+ main()
scripts/migrate.py CHANGED
@@ -1,44 +1,44 @@
1
- """Run once to add new APA columns to the existing SQLite database."""
2
-
3
- import sys
4
-
5
- sys.path.insert(0, ".")
6
- from sqlalchemy import inspect, text
7
-
8
- from uraas.database import engine
9
-
10
- NEW_COLS = [
11
- ("items", "dc_type", "TEXT"),
12
- ("items", "dc_language", "TEXT"),
13
- ("items", "dc_subject", "TEXT"),
14
- ("items", "docid", "TEXT UNIQUE"),
15
- ("items", "docid_assigned_at", "DATETIME"),
16
- ("items", "content_type", 'TEXT DEFAULT "research_paper"'),
17
- ("items", "tk_label", "TEXT"),
18
- ("items", "tk_community", "TEXT"),
19
- ("items", "patent_id", "TEXT"),
20
- ("items", "patent_date", "DATETIME"),
21
- ("items", "language_code", "TEXT"),
22
- ("items", "is_african_language", "INTEGER DEFAULT 0"),
23
- ("items", "sdg_tags", "TEXT"),
24
- ("items", "ai_keywords", "TEXT"),
25
- ("authors", "orcid", "TEXT"),
26
- ("authors", "ror", "TEXT"),
27
- ("communities", "ror_id", "TEXT"),
28
- ("communities", "institution", "TEXT"),
29
- ]
30
-
31
- inspector = inspect(engine)
32
- with engine.connect() as conn:
33
- for table, col, col_type in NEW_COLS:
34
- existing = [c["name"] for c in inspector.get_columns(table)]
35
- if col not in existing:
36
- # SQLite doesn't support UNIQUE in ALTER TABLE — skip constraint
37
- safe_type = col_type.replace(" UNIQUE", "")
38
- conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {col} {safe_type}"))
39
- print(f" + {table}.{col}")
40
- else:
41
- print(f" . {table}.{col} (exists)")
42
- conn.commit()
43
-
44
- print("Migration complete.")
 
1
+ """Run once to add new APA columns to the existing SQLite database."""
2
+
3
+ import sys
4
+
5
+ sys.path.insert(0, ".")
6
+ from sqlalchemy import inspect, text
7
+
8
+ from uraas.database import engine
9
+
10
+ NEW_COLS = [
11
+ ("items", "dc_type", "TEXT"),
12
+ ("items", "dc_language", "TEXT"),
13
+ ("items", "dc_subject", "TEXT"),
14
+ ("items", "docid", "TEXT UNIQUE"),
15
+ ("items", "docid_assigned_at", "DATETIME"),
16
+ ("items", "content_type", 'TEXT DEFAULT "research_paper"'),
17
+ ("items", "tk_label", "TEXT"),
18
+ ("items", "tk_community", "TEXT"),
19
+ ("items", "patent_id", "TEXT"),
20
+ ("items", "patent_date", "DATETIME"),
21
+ ("items", "language_code", "TEXT"),
22
+ ("items", "is_african_language", "INTEGER DEFAULT 0"),
23
+ ("items", "sdg_tags", "TEXT"),
24
+ ("items", "ai_keywords", "TEXT"),
25
+ ("authors", "orcid", "TEXT"),
26
+ ("authors", "ror", "TEXT"),
27
+ ("communities", "ror_id", "TEXT"),
28
+ ("communities", "institution", "TEXT"),
29
+ ]
30
+
31
+ inspector = inspect(engine)
32
+ with engine.connect() as conn:
33
+ for table, col, col_type in NEW_COLS:
34
+ existing = [c["name"] for c in inspector.get_columns(table)]
35
+ if col not in existing:
36
+ # SQLite doesn't support UNIQUE in ALTER TABLE — skip constraint
37
+ safe_type = col_type.replace(" UNIQUE", "")
38
+ conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {col} {safe_type}"))
39
+ print(f" + {table}.{col}")
40
+ else:
41
+ print(f" . {table}.{col} (exists)")
42
+ conn.commit()
43
+
44
+ print("Migration complete.")
scripts/migrate_2026_upgrade.py CHANGED
@@ -1,86 +1,86 @@
1
- """
2
- Schema migration: 2026 UNESCO upgrade. Idempotent; safe on SQLite + Postgres.
3
-
4
- Adds to items:
5
- - alignment_scores (JSON TEXT) + alignment_version (framework alignment)
6
- - coauthor_countries / african_country_count / is_intra_african (collaboration)
7
- - openalex_id / counts_by_year / cited_by_count / african_citation_share (citations)
8
- - ark / ark_assigned_at (ARK persistent identifiers)
9
-
10
- New tables (item_affiliations, alignment_aggregates) are created by
11
- scripts/init_db.py via Base.metadata.create_all — run init_db.py first.
12
- """
13
-
14
- import os
15
- import sys
16
-
17
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
18
-
19
- from sqlalchemy import inspect, text
20
-
21
- from uraas.database import engine
22
-
23
- # (column, DDL type clause) — types chosen to work on both SQLite and Postgres.
24
- ITEMS_COLUMNS = [
25
- ("alignment_scores", "TEXT"),
26
- ("alignment_version", "INTEGER DEFAULT 0"),
27
- ("coauthor_countries", "TEXT"),
28
- ("african_country_count", "INTEGER DEFAULT 0"),
29
- ("is_intra_african", "BOOLEAN DEFAULT FALSE"),
30
- ("openalex_id", "VARCHAR(64)"),
31
- ("counts_by_year", "TEXT"),
32
- ("cited_by_count", "INTEGER DEFAULT 0"),
33
- ("african_citation_share", "FLOAT"),
34
- ("ark", "VARCHAR(128)"),
35
- ("ark_assigned_at", "TIMESTAMP"),
36
- ]
37
-
38
- INDEXES = [
39
- "CREATE INDEX IF NOT EXISTS ix_items_is_intra_african ON items (is_intra_african)",
40
- "CREATE UNIQUE INDEX IF NOT EXISTS ux_items_ark ON items (ark)",
41
- ]
42
-
43
-
44
- def column_exists(insp, table: str, column: str) -> bool:
45
- return column in {c["name"] for c in insp.get_columns(table)}
46
-
47
-
48
- def main() -> int:
49
- print("Migration: 2026 upgrade (alignment / collaboration / citations / ARK)")
50
-
51
- dialect = engine.dialect.name
52
- print(f"Dialect: {dialect}")
53
-
54
- insp = inspect(engine)
55
- statements = []
56
- for column, ddl_type in ITEMS_COLUMNS:
57
- if column_exists(insp, "items", column):
58
- print(f" {column} already present, skipping")
59
- continue
60
- if dialect == "sqlite" and "BOOLEAN" in ddl_type:
61
- # SQLite stores booleans as integers
62
- ddl_type = ddl_type.replace("BOOLEAN", "INTEGER").replace("FALSE", "0")
63
- statements.append(f"ALTER TABLE items ADD COLUMN {column} {ddl_type}")
64
-
65
- if statements:
66
- with engine.begin() as conn:
67
- for stmt in statements:
68
- print(f" -> {stmt}")
69
- conn.execute(text(stmt))
70
- else:
71
- print(" All columns already present.")
72
-
73
- for stmt in INDEXES:
74
- try:
75
- with engine.begin() as conn:
76
- conn.execute(text(stmt))
77
- print(f" -> {stmt.split(' ON ')[0].replace('CREATE ', '').strip()} ensured")
78
- except Exception as e:
79
- print(f" (index creation skipped: {e})")
80
-
81
- print("Done.")
82
- return 0
83
-
84
-
85
- if __name__ == "__main__":
86
- sys.exit(main())
 
1
+ """
2
+ Schema migration: 2026 UNESCO upgrade. Idempotent; safe on SQLite + Postgres.
3
+
4
+ Adds to items:
5
+ - alignment_scores (JSON TEXT) + alignment_version (framework alignment)
6
+ - coauthor_countries / african_country_count / is_intra_african (collaboration)
7
+ - openalex_id / counts_by_year / cited_by_count / african_citation_share (citations)
8
+ - ark / ark_assigned_at (ARK persistent identifiers)
9
+
10
+ New tables (item_affiliations, alignment_aggregates) are created by
11
+ scripts/init_db.py via Base.metadata.create_all — run init_db.py first.
12
+ """
13
+
14
+ import os
15
+ import sys
16
+
17
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
18
+
19
+ from sqlalchemy import inspect, text
20
+
21
+ from uraas.database import engine
22
+
23
+ # (column, DDL type clause) — types chosen to work on both SQLite and Postgres.
24
+ ITEMS_COLUMNS = [
25
+ ("alignment_scores", "TEXT"),
26
+ ("alignment_version", "INTEGER DEFAULT 0"),
27
+ ("coauthor_countries", "TEXT"),
28
+ ("african_country_count", "INTEGER DEFAULT 0"),
29
+ ("is_intra_african", "BOOLEAN DEFAULT FALSE"),
30
+ ("openalex_id", "VARCHAR(64)"),
31
+ ("counts_by_year", "TEXT"),
32
+ ("cited_by_count", "INTEGER DEFAULT 0"),
33
+ ("african_citation_share", "FLOAT"),
34
+ ("ark", "VARCHAR(128)"),
35
+ ("ark_assigned_at", "TIMESTAMP"),
36
+ ]
37
+
38
+ INDEXES = [
39
+ "CREATE INDEX IF NOT EXISTS ix_items_is_intra_african ON items (is_intra_african)",
40
+ "CREATE UNIQUE INDEX IF NOT EXISTS ux_items_ark ON items (ark)",
41
+ ]
42
+
43
+
44
+ def column_exists(insp, table: str, column: str) -> bool:
45
+ return column in {c["name"] for c in insp.get_columns(table)}
46
+
47
+
48
+ def main() -> int:
49
+ print("Migration: 2026 upgrade (alignment / collaboration / citations / ARK)")
50
+
51
+ dialect = engine.dialect.name
52
+ print(f"Dialect: {dialect}")
53
+
54
+ insp = inspect(engine)
55
+ statements = []
56
+ for column, ddl_type in ITEMS_COLUMNS:
57
+ if column_exists(insp, "items", column):
58
+ print(f" {column} already present, skipping")
59
+ continue
60
+ if dialect == "sqlite" and "BOOLEAN" in ddl_type:
61
+ # SQLite stores booleans as integers
62
+ ddl_type = ddl_type.replace("BOOLEAN", "INTEGER").replace("FALSE", "0")
63
+ statements.append(f"ALTER TABLE items ADD COLUMN {column} {ddl_type}")
64
+
65
+ if statements:
66
+ with engine.begin() as conn:
67
+ for stmt in statements:
68
+ print(f" -> {stmt}")
69
+ conn.execute(text(stmt))
70
+ else:
71
+ print(" All columns already present.")
72
+
73
+ for stmt in INDEXES:
74
+ try:
75
+ with engine.begin() as conn:
76
+ conn.execute(text(stmt))
77
+ print(f" -> {stmt.split(' ON ')[0].replace('CREATE ', '').strip()} ensured")
78
+ except Exception as e:
79
+ print(f" (index creation skipped: {e})")
80
+
81
+ print("Done.")
82
+ return 0
83
+
84
+
85
+ if __name__ == "__main__":
86
+ sys.exit(main())
scripts/migrate_add_ror.py CHANGED
@@ -1,69 +1,69 @@
1
- """
2
- Database migration: Add ROR support for multi-institution comparison
3
- """
4
-
5
- from sqlalchemy import text
6
-
7
- from uraas.database import Item, SessionLocal, engine
8
-
9
-
10
- def migrate():
11
- print("Adding ROR columns to items table...")
12
-
13
- with engine.connect() as conn:
14
- try:
15
- # Add ror column
16
- conn.execute(text("ALTER TABLE items ADD COLUMN ror VARCHAR(128)"))
17
- conn.execute(text("CREATE INDEX ix_items_ror ON items(ror)"))
18
- print("✓ Added ror column and index")
19
- except Exception as e:
20
- if (
21
- "duplicate column" in str(e).lower()
22
- or "already exists" in str(e).lower()
23
- ):
24
- print("✓ ROR column already exists")
25
- else:
26
- print(f"Error: {e}")
27
-
28
- try:
29
- # Add institution column if not exists
30
- conn.execute(text("ALTER TABLE items ADD COLUMN institution VARCHAR(255)"))
31
- print("✓ Added institution column")
32
- except Exception as e:
33
- if (
34
- "duplicate column" in str(e).lower()
35
- or "already exists" in str(e).lower()
36
- ):
37
- print("✓ Institution column already exists")
38
- else:
39
- print(f"Error: {e}")
40
-
41
- conn.commit()
42
-
43
- # Set default ROR for UNILAG papers
44
- print("\nSetting default ROR for existing UNILAG papers...")
45
- session = SessionLocal()
46
- try:
47
- unilag_ror = "https://ror.org/03qcnxw14"
48
- count = (
49
- session.query(Item)
50
- .filter(Item.ror.is_(None))
51
- .update(
52
- {Item.ror: unilag_ror, Item.institution: "University of Lagos"},
53
- synchronize_session=False,
54
- )
55
- )
56
- session.commit()
57
- print(f"✓ Updated {count} papers with UNILAG ROR")
58
- finally:
59
- session.close()
60
-
61
- print("\nMigration complete!")
62
- print("\nNext steps:")
63
- print("1. Add Comparator tab to dashboard")
64
- print("2. Test multi-institution comparison")
65
- print("3. Add more institutions to database")
66
-
67
-
68
- if __name__ == "__main__":
69
- migrate()
 
1
+ """
2
+ Database migration: Add ROR support for multi-institution comparison
3
+ """
4
+
5
+ from sqlalchemy import text
6
+
7
+ from uraas.database import Item, SessionLocal, engine
8
+
9
+
10
+ def migrate():
11
+ print("Adding ROR columns to items table...")
12
+
13
+ with engine.connect() as conn:
14
+ try:
15
+ # Add ror column
16
+ conn.execute(text("ALTER TABLE items ADD COLUMN ror VARCHAR(128)"))
17
+ conn.execute(text("CREATE INDEX ix_items_ror ON items(ror)"))
18
+ print("✓ Added ror column and index")
19
+ except Exception as e:
20
+ if (
21
+ "duplicate column" in str(e).lower()
22
+ or "already exists" in str(e).lower()
23
+ ):
24
+ print("✓ ROR column already exists")
25
+ else:
26
+ print(f"Error: {e}")
27
+
28
+ try:
29
+ # Add institution column if not exists
30
+ conn.execute(text("ALTER TABLE items ADD COLUMN institution VARCHAR(255)"))
31
+ print("✓ Added institution column")
32
+ except Exception as e:
33
+ if (
34
+ "duplicate column" in str(e).lower()
35
+ or "already exists" in str(e).lower()
36
+ ):
37
+ print("✓ Institution column already exists")
38
+ else:
39
+ print(f"Error: {e}")
40
+
41
+ conn.commit()
42
+
43
+ # Set default ROR for UNILAG papers
44
+ print("\nSetting default ROR for existing UNILAG papers...")
45
+ session = SessionLocal()
46
+ try:
47
+ unilag_ror = "https://ror.org/03qcnxw14"
48
+ count = (
49
+ session.query(Item)
50
+ .filter(Item.ror.is_(None))
51
+ .update(
52
+ {Item.ror: unilag_ror, Item.institution: "University of Lagos"},
53
+ synchronize_session=False,
54
+ )
55
+ )
56
+ session.commit()
57
+ print(f"✓ Updated {count} papers with UNILAG ROR")
58
+ finally:
59
+ session.close()
60
+
61
+ print("\nMigration complete!")
62
+ print("\nNext steps:")
63
+ print("1. Add Comparator tab to dashboard")
64
+ print("2. Test multi-institution comparison")
65
+ print("3. Add more institutions to database")
66
+
67
+
68
+ if __name__ == "__main__":
69
+ migrate()
scripts/migrate_add_sc_columns.py CHANGED
@@ -1,72 +1,72 @@
1
- """
2
- Schema migration: add special_collection_score + special_collection_categories
3
- columns to items table. Idempotent.
4
- """
5
-
6
- import os
7
- import sys
8
-
9
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
10
-
11
- from sqlalchemy import inspect, text
12
-
13
- from uraas.database import engine
14
-
15
-
16
- def column_exists(table: str, column: str) -> bool:
17
- insp = inspect(engine)
18
- return column in {c["name"] for c in insp.get_columns(table)}
19
-
20
-
21
- def main() -> int:
22
- print(
23
- "Migration: adding special_collection_score + special_collection_categories to items"
24
- )
25
-
26
- dialect = engine.dialect.name
27
- print(f"Dialect: {dialect}")
28
-
29
- statements = []
30
- if not column_exists("items", "special_collection_score"):
31
- statements.append(
32
- "ALTER TABLE items ADD COLUMN special_collection_score FLOAT DEFAULT 0.0"
33
- )
34
- else:
35
- print(" special_collection_score already present, skipping")
36
-
37
- if not column_exists("items", "special_collection_categories"):
38
- # TEXT for both sqlite + postgres
39
- statements.append(
40
- "ALTER TABLE items ADD COLUMN special_collection_categories TEXT"
41
- )
42
- else:
43
- print(" special_collection_categories already present, skipping")
44
-
45
- if not statements:
46
- print("Nothing to do.")
47
- return 0
48
-
49
- with engine.begin() as conn:
50
- for stmt in statements:
51
- print(f" -> {stmt}")
52
- conn.execute(text(stmt))
53
-
54
- # Index on score so ORDER BY score DESC is fast
55
- try:
56
- with engine.begin() as conn:
57
- conn.execute(
58
- text(
59
- "CREATE INDEX IF NOT EXISTS ix_items_special_collection_score "
60
- "ON items (special_collection_score)"
61
- )
62
- )
63
- print(" -> index ix_items_special_collection_score ensured")
64
- except Exception as e:
65
- print(f" (index creation skipped: {e})")
66
-
67
- print("Done.")
68
- return 0
69
-
70
-
71
- if __name__ == "__main__":
72
- sys.exit(main())
 
1
+ """
2
+ Schema migration: add special_collection_score + special_collection_categories
3
+ columns to items table. Idempotent.
4
+ """
5
+
6
+ import os
7
+ import sys
8
+
9
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
10
+
11
+ from sqlalchemy import inspect, text
12
+
13
+ from uraas.database import engine
14
+
15
+
16
+ def column_exists(table: str, column: str) -> bool:
17
+ insp = inspect(engine)
18
+ return column in {c["name"] for c in insp.get_columns(table)}
19
+
20
+
21
+ def main() -> int:
22
+ print(
23
+ "Migration: adding special_collection_score + special_collection_categories to items"
24
+ )
25
+
26
+ dialect = engine.dialect.name
27
+ print(f"Dialect: {dialect}")
28
+
29
+ statements = []
30
+ if not column_exists("items", "special_collection_score"):
31
+ statements.append(
32
+ "ALTER TABLE items ADD COLUMN special_collection_score FLOAT DEFAULT 0.0"
33
+ )
34
+ else:
35
+ print(" special_collection_score already present, skipping")
36
+
37
+ if not column_exists("items", "special_collection_categories"):
38
+ # TEXT for both sqlite + postgres
39
+ statements.append(
40
+ "ALTER TABLE items ADD COLUMN special_collection_categories TEXT"
41
+ )
42
+ else:
43
+ print(" special_collection_categories already present, skipping")
44
+
45
+ if not statements:
46
+ print("Nothing to do.")
47
+ return 0
48
+
49
+ with engine.begin() as conn:
50
+ for stmt in statements:
51
+ print(f" -> {stmt}")
52
+ conn.execute(text(stmt))
53
+
54
+ # Index on score so ORDER BY score DESC is fast
55
+ try:
56
+ with engine.begin() as conn:
57
+ conn.execute(
58
+ text(
59
+ "CREATE INDEX IF NOT EXISTS ix_items_special_collection_score "
60
+ "ON items (special_collection_score)"
61
+ )
62
+ )
63
+ print(" -> index ix_items_special_collection_score ensured")
64
+ except Exception as e:
65
+ print(f" (index creation skipped: {e})")
66
+
67
+ print("Done.")
68
+ return 0
69
+
70
+
71
+ if __name__ == "__main__":
72
+ sys.exit(main())
scripts/migrate_sqlite_to_postgres.py CHANGED
@@ -1,202 +1,202 @@
1
- """
2
- Migration script: Copy all data from local SQLite database (uraas.db)
3
- to the production PostgreSQL database.
4
- """
5
-
6
- import os
7
- import sys
8
- from sqlalchemy import create_engine, MetaData, text
9
- from sqlalchemy.orm import sessionmaker
10
-
11
- # Add project root to path
12
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
13
-
14
- from uraas.database import Base, Community, Collection, Author, Item, File, item_authors, item_collections
15
-
16
- def migrate():
17
- # SQLite URL
18
- sqlite_url = "sqlite:///uraas.db"
19
-
20
- # Postgres URL (get from environment variable)
21
- postgres_url = os.getenv("DATABASE_URL")
22
- if not postgres_url:
23
- print("[ERR] DATABASE_URL environment variable is not set!")
24
- print("Please run this command with DATABASE_URL set, for example:")
25
- print("DATABASE_URL=postgresql://user:pass@host:port/dbname python scripts/migrate_sqlite_to_postgres.py")
26
- sys.exit(1)
27
-
28
- # Standardize Render's postgres:// prefix to postgresql:// if needed
29
- if postgres_url.startswith("postgres://"):
30
- postgres_url = postgres_url.replace("postgres://", "postgresql://", 1)
31
-
32
- print(f"Source SQLite database: {sqlite_url}")
33
- print(f"Destination PostgreSQL database: {postgres_url.split('@')[-1] if '@' in postgres_url else postgres_url}")
34
- print("\nInitializing connections...")
35
-
36
- sqlite_engine = create_engine(sqlite_url)
37
- postgres_engine = create_engine(postgres_url)
38
-
39
- SqliteSession = sessionmaker(bind=sqlite_engine)
40
- PostgresSession = sessionmaker(bind=postgres_engine)
41
-
42
- sqlite_session = SqliteSession()
43
- postgres_session = PostgresSession()
44
-
45
- try:
46
- print("Recreating destination database tables if they do not exist...")
47
- Base.metadata.create_all(bind=postgres_engine)
48
-
49
- print("Clearing existing data in PostgreSQL tables to prevent collisions...")
50
- # Order matters for foreign key constraints
51
- postgres_session.execute(text("TRUNCATE TABLE files, item_authors, item_collections, items, authors, collections, communities CASCADE"))
52
- postgres_session.commit()
53
-
54
- # 1. Migrate Communities
55
- print("Migrating Communities...")
56
- communities = sqlite_session.query(Community).all()
57
- for comm in communities:
58
- new_comm = Community(
59
- id=comm.id,
60
- name=comm.name,
61
- ror_id=comm.ror_id,
62
- institution=comm.institution,
63
- ror=comm.ror
64
- )
65
- postgres_session.add(new_comm)
66
- postgres_session.flush()
67
- print(f" Migrated {len(communities)} communities.")
68
-
69
- # 2. Migrate Collections
70
- print("Migrating Collections...")
71
- collections = sqlite_session.query(Collection).all()
72
- for coll in collections:
73
- new_coll = Collection(
74
- id=coll.id,
75
- community_id=coll.community_id,
76
- name=coll.name,
77
- email_domains=coll.email_domains,
78
- keywords=coll.keywords
79
- )
80
- postgres_session.add(new_coll)
81
- postgres_session.flush()
82
- print(f" Migrated {len(collections)} collections.")
83
-
84
- # 3. Migrate Authors
85
- print("Migrating Authors...")
86
- authors = sqlite_session.query(Author).all()
87
- for auth in authors:
88
- new_auth = Author(
89
- id=auth.id,
90
- name=auth.name,
91
- normalized_name=auth.normalized_name,
92
- profile_url=auth.profile_url,
93
- orcid=auth.orcid,
94
- ror=auth.ror
95
- )
96
- postgres_session.add(new_auth)
97
- postgres_session.flush()
98
- print(f" Migrated {len(authors)} authors.")
99
-
100
- # 4. Migrate Items
101
- print("Migrating Items...")
102
- items = sqlite_session.query(Item).all()
103
- for item in items:
104
- new_item = Item(
105
- id=item.id,
106
- title=item.title,
107
- abstract=item.abstract,
108
- doi=item.doi,
109
- publication_date=item.publication_date,
110
- url=item.url,
111
- source_repository=item.source_repository,
112
- pdf_url=item.pdf_url,
113
- dc_title=item.dc_title,
114
- dc_date_issued=item.dc_date_issued,
115
- dc_identifier_uri=item.dc_identifier_uri,
116
- dc_identifier_doi=item.dc_identifier_doi,
117
- dc_description_provenance=item.dc_description_provenance,
118
- dc_rights=item.dc_rights,
119
- dc_type=item.dc_type,
120
- dc_language=item.dc_language,
121
- dc_subject=item.dc_subject,
122
- docid=item.docid,
123
- docid_assigned_at=item.docid_assigned_at,
124
- ror=item.ror,
125
- institution=item.institution,
126
- content_type=item.content_type,
127
- tk_label=item.tk_label,
128
- tk_community=item.tk_community,
129
- patent_id=item.patent_id,
130
- patent_date=item.patent_date,
131
- language_code=item.language_code,
132
- is_african_language=item.is_african_language,
133
- sdg_tags=item.sdg_tags,
134
- ai_keywords=item.ai_keywords,
135
- special_collection_score=item.special_collection_score,
136
- special_collection_categories=item.special_collection_categories,
137
- created_at=item.created_at
138
- )
139
- postgres_session.add(new_item)
140
- postgres_session.flush()
141
- print(f" Migrated {len(items)} items.")
142
-
143
- # 5. Migrate Files
144
- print("Migrating Files...")
145
- files = sqlite_session.query(File).all()
146
- for file in files:
147
- new_file = File(
148
- id=file.id,
149
- item_id=file.item_id,
150
- file_path=file.file_path,
151
- sha256_hash=file.sha256_hash,
152
- access_policy=file.access_policy,
153
- downloaded_at=file.downloaded_at
154
- )
155
- postgres_session.add(new_file)
156
- postgres_session.flush()
157
- print(f" Migrated {len(files)} files.")
158
-
159
- # 6. Migrate association tables (item_authors and item_collections)
160
- print("Migrating Item-Author associations...")
161
- item_author_rows = sqlite_session.execute(item_authors.select()).all()
162
- for row in item_author_rows:
163
- postgres_session.execute(
164
- item_authors.insert().values(item_id=row.item_id, author_id=row.author_id)
165
- )
166
- print(f" Migrated {len(item_author_rows)} item-author mappings.")
167
-
168
- print("Migrating Item-Collection associations...")
169
- item_coll_rows = sqlite_session.execute(item_collections.select()).all()
170
- for row in item_coll_rows:
171
- postgres_session.execute(
172
- item_collections.insert().values(
173
- item_id=row.item_id,
174
- collection_id=row.collection_id,
175
- confidence_score=row.confidence_score
176
- )
177
- )
178
- print(f" Migrated {len(item_coll_rows)} item-collection mappings.")
179
-
180
- postgres_session.commit()
181
- print("[SUCCESS] Data migrated to PostgreSQL successfully!")
182
-
183
- # Reset sequences in Postgres so future inserts don't collide
184
- print("Resetting PostgreSQL primary key sequences...")
185
- tables = ["communities", "collections", "authors", "items", "files"]
186
- for table in tables:
187
- postgres_session.execute(text(
188
- f"SELECT setval(pg_get_serial_sequence('{table}', 'id'), COALESCE(MAX(id), 1) + 1) FROM {table}"
189
- ))
190
- postgres_session.commit()
191
- print("[SUCCESS] Sequences advanced.")
192
-
193
- except Exception as e:
194
- print(f"[ERR] Migration failed: {e}")
195
- postgres_session.rollback()
196
- raise
197
- finally:
198
- sqlite_session.close()
199
- postgres_session.close()
200
-
201
- if __name__ == "__main__":
202
- migrate()
 
1
+ """
2
+ Migration script: Copy all data from local SQLite database (uraas.db)
3
+ to the production PostgreSQL database.
4
+ """
5
+
6
+ import os
7
+ import sys
8
+ from sqlalchemy import create_engine, MetaData, text
9
+ from sqlalchemy.orm import sessionmaker
10
+
11
+ # Add project root to path
12
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
13
+
14
+ from uraas.database import Base, Community, Collection, Author, Item, File, item_authors, item_collections
15
+
16
+ def migrate():
17
+ # SQLite URL
18
+ sqlite_url = "sqlite:///uraas.db"
19
+
20
+ # Postgres URL (get from environment variable)
21
+ postgres_url = os.getenv("DATABASE_URL")
22
+ if not postgres_url:
23
+ print("[ERR] DATABASE_URL environment variable is not set!")
24
+ print("Please run this command with DATABASE_URL set, for example:")
25
+ print("DATABASE_URL=postgresql://user:pass@host:port/dbname python scripts/migrate_sqlite_to_postgres.py")
26
+ sys.exit(1)
27
+
28
+ # Standardize Render's postgres:// prefix to postgresql:// if needed
29
+ if postgres_url.startswith("postgres://"):
30
+ postgres_url = postgres_url.replace("postgres://", "postgresql://", 1)
31
+
32
+ print(f"Source SQLite database: {sqlite_url}")
33
+ print(f"Destination PostgreSQL database: {postgres_url.split('@')[-1] if '@' in postgres_url else postgres_url}")
34
+ print("\nInitializing connections...")
35
+
36
+ sqlite_engine = create_engine(sqlite_url)
37
+ postgres_engine = create_engine(postgres_url)
38
+
39
+ SqliteSession = sessionmaker(bind=sqlite_engine)
40
+ PostgresSession = sessionmaker(bind=postgres_engine)
41
+
42
+ sqlite_session = SqliteSession()
43
+ postgres_session = PostgresSession()
44
+
45
+ try:
46
+ print("Recreating destination database tables if they do not exist...")
47
+ Base.metadata.create_all(bind=postgres_engine)
48
+
49
+ print("Clearing existing data in PostgreSQL tables to prevent collisions...")
50
+ # Order matters for foreign key constraints
51
+ postgres_session.execute(text("TRUNCATE TABLE files, item_authors, item_collections, items, authors, collections, communities CASCADE"))
52
+ postgres_session.commit()
53
+
54
+ # 1. Migrate Communities
55
+ print("Migrating Communities...")
56
+ communities = sqlite_session.query(Community).all()
57
+ for comm in communities:
58
+ new_comm = Community(
59
+ id=comm.id,
60
+ name=comm.name,
61
+ ror_id=comm.ror_id,
62
+ institution=comm.institution,
63
+ ror=comm.ror
64
+ )
65
+ postgres_session.add(new_comm)
66
+ postgres_session.flush()
67
+ print(f" Migrated {len(communities)} communities.")
68
+
69
+ # 2. Migrate Collections
70
+ print("Migrating Collections...")
71
+ collections = sqlite_session.query(Collection).all()
72
+ for coll in collections:
73
+ new_coll = Collection(
74
+ id=coll.id,
75
+ community_id=coll.community_id,
76
+ name=coll.name,
77
+ email_domains=coll.email_domains,
78
+ keywords=coll.keywords
79
+ )
80
+ postgres_session.add(new_coll)
81
+ postgres_session.flush()
82
+ print(f" Migrated {len(collections)} collections.")
83
+
84
+ # 3. Migrate Authors
85
+ print("Migrating Authors...")
86
+ authors = sqlite_session.query(Author).all()
87
+ for auth in authors:
88
+ new_auth = Author(
89
+ id=auth.id,
90
+ name=auth.name,
91
+ normalized_name=auth.normalized_name,
92
+ profile_url=auth.profile_url,
93
+ orcid=auth.orcid,
94
+ ror=auth.ror
95
+ )
96
+ postgres_session.add(new_auth)
97
+ postgres_session.flush()
98
+ print(f" Migrated {len(authors)} authors.")
99
+
100
+ # 4. Migrate Items
101
+ print("Migrating Items...")
102
+ items = sqlite_session.query(Item).all()
103
+ for item in items:
104
+ new_item = Item(
105
+ id=item.id,
106
+ title=item.title,
107
+ abstract=item.abstract,
108
+ doi=item.doi,
109
+ publication_date=item.publication_date,
110
+ url=item.url,
111
+ source_repository=item.source_repository,
112
+ pdf_url=item.pdf_url,
113
+ dc_title=item.dc_title,
114
+ dc_date_issued=item.dc_date_issued,
115
+ dc_identifier_uri=item.dc_identifier_uri,
116
+ dc_identifier_doi=item.dc_identifier_doi,
117
+ dc_description_provenance=item.dc_description_provenance,
118
+ dc_rights=item.dc_rights,
119
+ dc_type=item.dc_type,
120
+ dc_language=item.dc_language,
121
+ dc_subject=item.dc_subject,
122
+ docid=item.docid,
123
+ docid_assigned_at=item.docid_assigned_at,
124
+ ror=item.ror,
125
+ institution=item.institution,
126
+ content_type=item.content_type,
127
+ tk_label=item.tk_label,
128
+ tk_community=item.tk_community,
129
+ patent_id=item.patent_id,
130
+ patent_date=item.patent_date,
131
+ language_code=item.language_code,
132
+ is_african_language=item.is_african_language,
133
+ sdg_tags=item.sdg_tags,
134
+ ai_keywords=item.ai_keywords,
135
+ special_collection_score=item.special_collection_score,
136
+ special_collection_categories=item.special_collection_categories,
137
+ created_at=item.created_at
138
+ )
139
+ postgres_session.add(new_item)
140
+ postgres_session.flush()
141
+ print(f" Migrated {len(items)} items.")
142
+
143
+ # 5. Migrate Files
144
+ print("Migrating Files...")
145
+ files = sqlite_session.query(File).all()
146
+ for file in files:
147
+ new_file = File(
148
+ id=file.id,
149
+ item_id=file.item_id,
150
+ file_path=file.file_path,
151
+ sha256_hash=file.sha256_hash,
152
+ access_policy=file.access_policy,
153
+ downloaded_at=file.downloaded_at
154
+ )
155
+ postgres_session.add(new_file)
156
+ postgres_session.flush()
157
+ print(f" Migrated {len(files)} files.")
158
+
159
+ # 6. Migrate association tables (item_authors and item_collections)
160
+ print("Migrating Item-Author associations...")
161
+ item_author_rows = sqlite_session.execute(item_authors.select()).all()
162
+ for row in item_author_rows:
163
+ postgres_session.execute(
164
+ item_authors.insert().values(item_id=row.item_id, author_id=row.author_id)
165
+ )
166
+ print(f" Migrated {len(item_author_rows)} item-author mappings.")
167
+
168
+ print("Migrating Item-Collection associations...")
169
+ item_coll_rows = sqlite_session.execute(item_collections.select()).all()
170
+ for row in item_coll_rows:
171
+ postgres_session.execute(
172
+ item_collections.insert().values(
173
+ item_id=row.item_id,
174
+ collection_id=row.collection_id,
175
+ confidence_score=row.confidence_score
176
+ )
177
+ )
178
+ print(f" Migrated {len(item_coll_rows)} item-collection mappings.")
179
+
180
+ postgres_session.commit()
181
+ print("[SUCCESS] Data migrated to PostgreSQL successfully!")
182
+
183
+ # Reset sequences in Postgres so future inserts don't collide
184
+ print("Resetting PostgreSQL primary key sequences...")
185
+ tables = ["communities", "collections", "authors", "items", "files"]
186
+ for table in tables:
187
+ postgres_session.execute(text(
188
+ f"SELECT setval(pg_get_serial_sequence('{table}', 'id'), COALESCE(MAX(id), 1) + 1) FROM {table}"
189
+ ))
190
+ postgres_session.commit()
191
+ print("[SUCCESS] Sequences advanced.")
192
+
193
+ except Exception as e:
194
+ print(f"[ERR] Migration failed: {e}")
195
+ postgres_session.rollback()
196
+ raise
197
+ finally:
198
+ sqlite_session.close()
199
+ postgres_session.close()
200
+
201
+ if __name__ == "__main__":
202
+ migrate()
scripts/migrate_unilag_ror.py CHANGED
@@ -1,55 +1,55 @@
1
- import os
2
- import sys
3
-
4
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
5
-
6
- from uraas.database import Author, Community, Item, SessionLocal
7
-
8
-
9
- def migrate():
10
- session = SessionLocal()
11
- try:
12
- old_ror = "https://ror.org/03qcnxw14"
13
- new_ror = "https://ror.org/05rk03822"
14
-
15
- print("Migrating UNILAG RORs in database...")
16
-
17
- # 1. Update items
18
- item_count = (
19
- session.query(Item)
20
- .filter(Item.ror == old_ror)
21
- .update({Item.ror: new_ror}, synchronize_session=False)
22
- )
23
- print(f"[OK] Updated {item_count} items")
24
-
25
- # 2. Update communities
26
- comm_count = (
27
- session.query(Community)
28
- .filter((Community.ror == old_ror) | (Community.ror_id == old_ror))
29
- .update(
30
- {Community.ror: new_ror, Community.ror_id: new_ror},
31
- synchronize_session=False,
32
- )
33
- )
34
- print(f"[OK] Updated {comm_count} communities")
35
-
36
- # 3. Update authors
37
- author_count = (
38
- session.query(Author)
39
- .filter(Author.ror == old_ror)
40
- .update({Author.ror: new_ror}, synchronize_session=False)
41
- )
42
- print(f"[OK] Updated {author_count} authors")
43
-
44
- session.commit()
45
- print("Migration complete successfully!")
46
-
47
- except Exception as e:
48
- session.rollback()
49
- print(f"Error during migration: {e}")
50
- finally:
51
- session.close()
52
-
53
-
54
- if __name__ == "__main__":
55
- migrate()
 
1
+ import os
2
+ import sys
3
+
4
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
5
+
6
+ from uraas.database import Author, Community, Item, SessionLocal
7
+
8
+
9
+ def migrate():
10
+ session = SessionLocal()
11
+ try:
12
+ old_ror = "https://ror.org/03qcnxw14"
13
+ new_ror = "https://ror.org/05rk03822"
14
+
15
+ print("Migrating UNILAG RORs in database...")
16
+
17
+ # 1. Update items
18
+ item_count = (
19
+ session.query(Item)
20
+ .filter(Item.ror == old_ror)
21
+ .update({Item.ror: new_ror}, synchronize_session=False)
22
+ )
23
+ print(f"[OK] Updated {item_count} items")
24
+
25
+ # 2. Update communities
26
+ comm_count = (
27
+ session.query(Community)
28
+ .filter((Community.ror == old_ror) | (Community.ror_id == old_ror))
29
+ .update(
30
+ {Community.ror: new_ror, Community.ror_id: new_ror},
31
+ synchronize_session=False,
32
+ )
33
+ )
34
+ print(f"[OK] Updated {comm_count} communities")
35
+
36
+ # 3. Update authors
37
+ author_count = (
38
+ session.query(Author)
39
+ .filter(Author.ror == old_ror)
40
+ .update({Author.ror: new_ror}, synchronize_session=False)
41
+ )
42
+ print(f"[OK] Updated {author_count} authors")
43
+
44
+ session.commit()
45
+ print("Migration complete successfully!")
46
+
47
+ except Exception as e:
48
+ session.rollback()
49
+ print(f"Error during migration: {e}")
50
+ finally:
51
+ session.close()
52
+
53
+
54
+ if __name__ == "__main__":
55
+ migrate()
scripts/patch_html.py CHANGED
@@ -1,74 +1,74 @@
1
- import re
2
-
3
- with open("uraas/dashboard/templates/index.html", "r", encoding="utf-8") as f:
4
- content = f.read()
5
-
6
- # Find the special collections section and replace it
7
- pattern = r"( \s*<!--\s+SPECIAL COLLECTIONS.*?</div>\n\n </div><!-- end analytics tab -->)"
8
- replacement = """
9
- <!-- SPECIAL COLLECTIONS -->
10
- <div id="atab-special" class="atab-content hidden">
11
- <div class="mb-5 flex items-start justify-between gap-4">
12
- <div>
13
- <h2 class="text-xl font-bold mb-1" style="color:var(--text)">Special Collections: African Literature &amp; Indigenous Knowledge</h2>
14
- <p class="text-sm" style="color:var(--text-muted)">AI-powered classification: Postcolonial Studies, Pan-African Studies, Ethnomusicology &amp; more.</p>
15
- </div>
16
- <a href="/api/analytics/special-collections/export.csv" class="btn-ghost text-xs flex-shrink-0">
17
- <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
18
- Download CSV
19
- </a>
20
- </div>
21
- <div id="special-loading" class="flex items-center gap-3 py-10 justify-center">
22
- <div class="w-6 h-6 border-2 rounded-full animate-spin" style="border-color:var(--accent);border-top-color:transparent"></div>
23
- <p class="text-sm" style="color:var(--text-muted)">Analyzing special collections...</p>
24
- </div>
25
- <div id="special-content" class="hidden">
26
- <div class="grid grid-cols-1 lg:grid-cols-3 gap-5 mb-6" id="special-stats"></div>
27
- <div class="grid grid-cols-1 lg:grid-cols-2 gap-5" id="special-categories"></div>
28
- </div>
29
- </div>
30
-
31
- <!-- STAFF DIRECTORY -->
32
- <div id="atab-staff" class="atab-content hidden">
33
- <div class="mb-5 flex items-start justify-between gap-4">
34
- <div>
35
- <h2 class="text-xl font-bold mb-1" style="color:var(--text)">Staff Directory</h2>
36
- <p class="text-sm" style="color:var(--text-muted)">Real staff members with departments and ORCID identifiers, harvested from OpenAlex and ORCID APIs.</p>
37
- </div>
38
- <select id="staff-inst-filter" onchange="loadStaffDirectory()" style="width:auto;font-size:12px;padding:5px 10px">
39
- <option value="">All Institutions</option>
40
- <option value="unilag">UNILAG</option><option value="covenant">Covenant</option>
41
- <option value="ui">Univ. Ibadan</option><option value="uct">UCT</option>
42
- <option value="stellenbosch">Stellenbosch</option><option value="nairobi">Nairobi</option>
43
- <option value="makerere">Makerere</option><option value="ghana">Univ. Ghana</option>
44
- <option value="addisababa">Addis Ababa</option><option value="knust">KNUST</option>
45
- </select>
46
- </div>
47
- <div id="staff-loading" class="flex items-center gap-3 py-10 justify-center">
48
- <div class="w-6 h-6 border-2 rounded-full animate-spin" style="border-color:var(--accent);border-top-color:transparent"></div>
49
- <p class="text-sm" style="color:var(--text-muted)">Loading staff directory...</p>
50
- </div>
51
- <div id="staff-content" class="hidden">
52
- <div id="staff-institutions-list" class="space-y-6"></div>
53
- </div>
54
- </div>
55
-
56
- </div><!-- end analytics tab -->"""
57
-
58
- new_content = re.sub(pattern, replacement, content, flags=re.DOTALL)
59
- if new_content == content:
60
- print("ERROR: Pattern not matched. Trying direct string replace...")
61
- # Try to find where special collections starts
62
- idx = content.find("<!-- SPECIAL COLLECTIONS -->")
63
- print(f"Found at index: {idx}")
64
- if idx >= 0:
65
- end_marker = " </div><!-- end analytics tab -->"
66
- end_idx = content.find(end_marker, idx)
67
- print(f"End marker at: {end_idx}")
68
- segment = content[idx : end_idx + len(end_marker)]
69
- print(f"Segment length: {len(segment)}")
70
- print("First 200 chars:", repr(segment[:200]))
71
- else:
72
- with open("uraas/dashboard/templates/index.html", "w", encoding="utf-8") as f:
73
- f.write(new_content)
74
- print("SUCCESS")
 
1
+ import re
2
+
3
+ with open("uraas/dashboard/templates/index.html", "r", encoding="utf-8") as f:
4
+ content = f.read()
5
+
6
+ # Find the special collections section and replace it
7
+ pattern = r"( \s*<!--\s+SPECIAL COLLECTIONS.*?</div>\n\n </div><!-- end analytics tab -->)"
8
+ replacement = """
9
+ <!-- SPECIAL COLLECTIONS -->
10
+ <div id="atab-special" class="atab-content hidden">
11
+ <div class="mb-5 flex items-start justify-between gap-4">
12
+ <div>
13
+ <h2 class="text-xl font-bold mb-1" style="color:var(--text)">Special Collections: African Literature &amp; Indigenous Knowledge</h2>
14
+ <p class="text-sm" style="color:var(--text-muted)">AI-powered classification: Postcolonial Studies, Pan-African Studies, Ethnomusicology &amp; more.</p>
15
+ </div>
16
+ <a href="/api/analytics/special-collections/export.csv" class="btn-ghost text-xs flex-shrink-0">
17
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
18
+ Download CSV
19
+ </a>
20
+ </div>
21
+ <div id="special-loading" class="flex items-center gap-3 py-10 justify-center">
22
+ <div class="w-6 h-6 border-2 rounded-full animate-spin" style="border-color:var(--accent);border-top-color:transparent"></div>
23
+ <p class="text-sm" style="color:var(--text-muted)">Analyzing special collections...</p>
24
+ </div>
25
+ <div id="special-content" class="hidden">
26
+ <div class="grid grid-cols-1 lg:grid-cols-3 gap-5 mb-6" id="special-stats"></div>
27
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-5" id="special-categories"></div>
28
+ </div>
29
+ </div>
30
+
31
+ <!-- STAFF DIRECTORY -->
32
+ <div id="atab-staff" class="atab-content hidden">
33
+ <div class="mb-5 flex items-start justify-between gap-4">
34
+ <div>
35
+ <h2 class="text-xl font-bold mb-1" style="color:var(--text)">Staff Directory</h2>
36
+ <p class="text-sm" style="color:var(--text-muted)">Real staff members with departments and ORCID identifiers, harvested from OpenAlex and ORCID APIs.</p>
37
+ </div>
38
+ <select id="staff-inst-filter" onchange="loadStaffDirectory()" style="width:auto;font-size:12px;padding:5px 10px">
39
+ <option value="">All Institutions</option>
40
+ <option value="unilag">UNILAG</option><option value="covenant">Covenant</option>
41
+ <option value="ui">Univ. Ibadan</option><option value="uct">UCT</option>
42
+ <option value="stellenbosch">Stellenbosch</option><option value="nairobi">Nairobi</option>
43
+ <option value="makerere">Makerere</option><option value="ghana">Univ. Ghana</option>
44
+ <option value="addisababa">Addis Ababa</option><option value="knust">KNUST</option>
45
+ </select>
46
+ </div>
47
+ <div id="staff-loading" class="flex items-center gap-3 py-10 justify-center">
48
+ <div class="w-6 h-6 border-2 rounded-full animate-spin" style="border-color:var(--accent);border-top-color:transparent"></div>
49
+ <p class="text-sm" style="color:var(--text-muted)">Loading staff directory...</p>
50
+ </div>
51
+ <div id="staff-content" class="hidden">
52
+ <div id="staff-institutions-list" class="space-y-6"></div>
53
+ </div>
54
+ </div>
55
+
56
+ </div><!-- end analytics tab -->"""
57
+
58
+ new_content = re.sub(pattern, replacement, content, flags=re.DOTALL)
59
+ if new_content == content:
60
+ print("ERROR: Pattern not matched. Trying direct string replace...")
61
+ # Try to find where special collections starts
62
+ idx = content.find("<!-- SPECIAL COLLECTIONS -->")
63
+ print(f"Found at index: {idx}")
64
+ if idx >= 0:
65
+ end_marker = " </div><!-- end analytics tab -->"
66
+ end_idx = content.find(end_marker, idx)
67
+ print(f"End marker at: {end_idx}")
68
+ segment = content[idx : end_idx + len(end_marker)]
69
+ print(f"Segment length: {len(segment)}")
70
+ print("First 200 chars:", repr(segment[:200]))
71
+ else:
72
+ with open("uraas/dashboard/templates/index.html", "w", encoding="utf-8") as f:
73
+ f.write(new_content)
74
+ print("SUCCESS")
scripts/push_to_hf.py CHANGED
@@ -1,195 +1,195 @@
1
- """
2
- Push URAAS to Hugging Face Spaces — Lordkiki/APA-URAAS
3
-
4
- Usage:
5
- python scripts/push_to_hf.py
6
-
7
- What it does:
8
- 1. Logs you into HF (paste your write token when prompted)
9
- 2. Stages a clean copy of the project:
10
- Dockerfile.hf → Dockerfile (HF Spaces Dockerfile, not the prod one)
11
- README.hf.md → README.md (has the HF Space frontmatter)
12
- 3. Uploads everything to the Space via huggingface_hub.upload_folder
13
- 4. HF triggers an auto-build — app is live in ~5 minutes
14
- """
15
-
16
- import os
17
- import shutil
18
- import sys
19
- import tempfile
20
-
21
- # ── Config ────────────────────────────────────────────────────────────────────
22
- REPO_ID = "Lordkiki/APA-URAAS"
23
- REPO_TYPE = "space"
24
- REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
25
-
26
- # Directories/files to never push
27
- IGNORE_DIRS = {
28
- ".git", ".claude", "__pycache__", ".pytest_cache", ".mypy_cache",
29
- "storage", "logs", "data", "backups", "node_modules",
30
- ".venv", "venv", "env",
31
- }
32
- IGNORE_FILES = {
33
- ".env", ".env.prod", ".env.prod.example",
34
- "uraas.db", # DB lives on /data in HF, not in image
35
- "Dockerfile", # replaced by Dockerfile.hf
36
- "README.md", # replaced by README.hf.md (has HF frontmatter)
37
- "docker-compose.yml",
38
- "docker-compose.prod.yml",
39
- "docker-compose.replica.yml",
40
- "docker-compose.demo.yml",
41
- }
42
- IGNORE_EXTS = {".pyc", ".pyo", ".pyd"}
43
-
44
-
45
- def should_skip(rel_path: str, is_dir: bool) -> bool:
46
- parts = rel_path.replace("\\", "/").split("/")
47
- name = parts[-1]
48
- if is_dir:
49
- return name in IGNORE_DIRS
50
- return (
51
- name in IGNORE_FILES
52
- or os.path.splitext(name)[1] in IGNORE_EXTS
53
- )
54
-
55
-
56
- def stage_project(src: str, dst: str) -> int:
57
- """Copy src → dst with HF-specific renames and exclusions."""
58
- count = 0
59
- for root, dirs, files in os.walk(src):
60
- rel_root = os.path.relpath(root, src)
61
-
62
- # Prune excluded directories in-place so os.walk doesn't recurse
63
- dirs[:] = [
64
- d for d in dirs
65
- if not should_skip(
66
- os.path.join(rel_root, d) if rel_root != "." else d,
67
- is_dir=True,
68
- )
69
- ]
70
-
71
- for fname in files:
72
- rel = os.path.join(rel_root, fname) if rel_root != "." else fname
73
- if should_skip(rel, is_dir=False):
74
- continue
75
-
76
- src_file = os.path.join(root, fname)
77
-
78
- # HF-specific renames
79
- if fname == "Dockerfile.hf":
80
- dest_rel = os.path.join(os.path.dirname(rel), "Dockerfile") if os.path.dirname(rel) else "Dockerfile"
81
- elif fname == "README.hf.md":
82
- dest_rel = "README.md"
83
- else:
84
- dest_rel = rel
85
-
86
- dst_file = os.path.join(dst, dest_rel)
87
- os.makedirs(os.path.dirname(dst_file), exist_ok=True)
88
- shutil.copy2(src_file, dst_file)
89
- count += 1
90
- return count
91
-
92
-
93
- def main():
94
- # ── Ensure huggingface_hub is available ────────────────────────────────
95
- try:
96
- from huggingface_hub import HfApi, login
97
- except ImportError:
98
- print("Installing huggingface_hub…")
99
- os.system(f"{sys.executable} -m pip install huggingface_hub -q")
100
- from huggingface_hub import HfApi, login # type: ignore
101
-
102
- print()
103
- print("═" * 55)
104
- print(" URAAS → Hugging Face Spaces")
105
- print(f" Space: {REPO_ID}")
106
- print("═" * 55)
107
-
108
- # ── Auth ───────────────────────────────────────────────────────────────
109
- token = os.getenv("HF_TOKEN")
110
- if token:
111
- login(token=token, add_to_git_credential=True)
112
- print(" Logged in via HF_TOKEN env var.")
113
- else:
114
- print()
115
- print(" Paste your HF write token below.")
116
- print(" (Get one at: https://huggingface.co/settings/tokens)")
117
- print()
118
- login(add_to_git_credential=True)
119
-
120
- api = HfApi()
121
-
122
- # ── Stage files ────────────────────────────────────────────────────────
123
- print()
124
- print("Staging project files…")
125
- with tempfile.TemporaryDirectory() as staging:
126
- n = stage_project(REPO_ROOT, staging)
127
- staged_names = os.listdir(staging)
128
- print(f" {n} files staged across {len(staged_names)} top-level items")
129
-
130
- # Sanity checks
131
- has_dockerfile = "Dockerfile" in staged_names
132
- has_readme = "README.md" in staged_names
133
- has_start_sh = os.path.exists(os.path.join(staging, "scripts", "start_hf.sh"))
134
-
135
- print(f" Dockerfile : {'✓' if has_dockerfile else '✗ MISSING — check Dockerfile.hf exists'}")
136
- print(f" README.md : {'✓' if has_readme else '✗ MISSING — check README.hf.md exists'}")
137
- print(f" start_hf.sh : {'✓' if has_start_sh else '✗ MISSING — check scripts/start_hf.sh'}")
138
-
139
- if not has_dockerfile:
140
- print()
141
- print("ERROR: Dockerfile missing from staging. Aborting.")
142
- sys.exit(1)
143
-
144
- # ── Upload ─────────────────────────────────────────────────────────
145
- print()
146
- print(f"Uploading to {REPO_ID}…")
147
- api.upload_folder(
148
- folder_path=staging,
149
- repo_id=REPO_ID,
150
- repo_type=REPO_TYPE,
151
- commit_message="Deploy URAAS — African Research Archival & Analytics System",
152
- )
153
-
154
- # ── Done ───────────────────────────────────────────────────────────────
155
- print()
156
- print("═" * 55)
157
- print(" Upload complete! Build starting on HF (~5 min).")
158
- print()
159
- print(" Watch build: https://huggingface.co/spaces/Lordkiki/APA-URAAS")
160
- print(" App URL : https://lordkiki-apa-uraas.hf.space")
161
- print()
162
- print(" ─── Secrets to set in Space Settings → Variables & Secrets ───")
163
- secrets = [
164
- ("URAAS_ENV", "production"),
165
- ("DASHBOARD_SECRET_KEY", "307790fc5aff3fe1e766303f6b94e2fc28c831582bfba5b34802e2c9cbbac0ce"),
166
- ("ADMIN_USERNAME", "admin"),
167
- ("ADMIN_PASSWORD_HASH", "scrypt:32768:8:1$r2KZFX32rJ2twbfV$16f394a253c2b505a215ff2747f7dafbb098eb0eb8b4e6bb9fb521f0ea38af8ce71f33d2354245d68cc383678257367bf410aa45f835b5e848bff95a746878c8"),
168
- ("VIEWER_USERNAME", "viewer"),
169
- ("VIEWER_PASSWORD_HASH", "scrypt:32768:8:1$M8OjWxX64B38akos$2803ad5b29508c4d69df115579630b2a4cbdf2c8406157598c088d5511a7b3d79f91399035040660705413c63fdc41aa0d020cbd7b45038c8ed51d20092ea609"),
170
- ("SMTP_HOST", "smtp.gmail.com"),
171
- ("SMTP_PORT", "587"),
172
- ("SMTP_USE_TLS", "true"),
173
- ("SMTP_USER", "lawalgiyath200716@gmail.com"),
174
- ("SMTP_PASSWORD", "ufwqbdrecpfrzppn"),
175
- ("SMTP_FROM", "URAAS UNILAG <lawalgiyath200716@gmail.com>"),
176
- ("DASHBOARD_BASE_URL", "https://lordkiki-apa-uraas.hf.space"),
177
- ("DASHBOARD_CORS_ORIGINS", "https://lordkiki-apa-uraas.hf.space"),
178
- ("ARK_NAAN", "99999"),
179
- ("ARK_SHOULDER", "z1"),
180
- ("OPENALEX_MAILTO", "lawalgiyath200716@gmail.com"),
181
- ("DSPACE_API_URL", "https://api-ir.unilag.edu.ng/server"),
182
- ("DSPACE_USERNAME", "<professor email — set as Secret, not Variable>"),
183
- ("DSPACE_PASSWORD", "<professor password — set as Secret, not Variable>"),
184
- ]
185
- max_k = max(len(k) for k, _ in secrets)
186
- for k, v in secrets:
187
- print(f" {k:<{max_k}} = {v}")
188
- print()
189
- print(" Admin login : admin / URAAS2024demo")
190
- print(" Viewer login: viewer / view2024")
191
- print("═" * 55)
192
-
193
-
194
- if __name__ == "__main__":
195
- main()
 
1
+ """
2
+ Push URAAS to Hugging Face Spaces — Lordkiki/APA-URAAS
3
+
4
+ Usage:
5
+ python scripts/push_to_hf.py
6
+
7
+ What it does:
8
+ 1. Logs you into HF (paste your write token when prompted)
9
+ 2. Stages a clean copy of the project:
10
+ Dockerfile.hf → Dockerfile (HF Spaces Dockerfile, not the prod one)
11
+ README.hf.md → README.md (has the HF Space frontmatter)
12
+ 3. Uploads everything to the Space via huggingface_hub.upload_folder
13
+ 4. HF triggers an auto-build — app is live in ~5 minutes
14
+ """
15
+
16
+ import os
17
+ import shutil
18
+ import sys
19
+ import tempfile
20
+
21
+ # ── Config ────────────────────────────────────────────────────────────────────
22
+ REPO_ID = "Lordkiki/APA-URAAS"
23
+ REPO_TYPE = "space"
24
+ REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
25
+
26
+ # Directories/files to never push
27
+ IGNORE_DIRS = {
28
+ ".git", ".claude", "__pycache__", ".pytest_cache", ".mypy_cache",
29
+ "storage", "logs", "data", "backups", "node_modules",
30
+ ".venv", "venv", "env",
31
+ }
32
+ IGNORE_FILES = {
33
+ ".env", ".env.prod", ".env.prod.example",
34
+ "uraas.db", # DB lives on /data in HF, not in image
35
+ "Dockerfile", # replaced by Dockerfile.hf
36
+ "README.md", # replaced by README.hf.md (has HF frontmatter)
37
+ "docker-compose.yml",
38
+ "docker-compose.prod.yml",
39
+ "docker-compose.replica.yml",
40
+ "docker-compose.demo.yml",
41
+ }
42
+ IGNORE_EXTS = {".pyc", ".pyo", ".pyd"}
43
+
44
+
45
+ def should_skip(rel_path: str, is_dir: bool) -> bool:
46
+ parts = rel_path.replace("\\", "/").split("/")
47
+ name = parts[-1]
48
+ if is_dir:
49
+ return name in IGNORE_DIRS
50
+ return (
51
+ name in IGNORE_FILES
52
+ or os.path.splitext(name)[1] in IGNORE_EXTS
53
+ )
54
+
55
+
56
+ def stage_project(src: str, dst: str) -> int:
57
+ """Copy src → dst with HF-specific renames and exclusions."""
58
+ count = 0
59
+ for root, dirs, files in os.walk(src):
60
+ rel_root = os.path.relpath(root, src)
61
+
62
+ # Prune excluded directories in-place so os.walk doesn't recurse
63
+ dirs[:] = [
64
+ d for d in dirs
65
+ if not should_skip(
66
+ os.path.join(rel_root, d) if rel_root != "." else d,
67
+ is_dir=True,
68
+ )
69
+ ]
70
+
71
+ for fname in files:
72
+ rel = os.path.join(rel_root, fname) if rel_root != "." else fname
73
+ if should_skip(rel, is_dir=False):
74
+ continue
75
+
76
+ src_file = os.path.join(root, fname)
77
+
78
+ # HF-specific renames
79
+ if fname == "Dockerfile.hf":
80
+ dest_rel = os.path.join(os.path.dirname(rel), "Dockerfile") if os.path.dirname(rel) else "Dockerfile"
81
+ elif fname == "README.hf.md":
82
+ dest_rel = "README.md"
83
+ else:
84
+ dest_rel = rel
85
+
86
+ dst_file = os.path.join(dst, dest_rel)
87
+ os.makedirs(os.path.dirname(dst_file), exist_ok=True)
88
+ shutil.copy2(src_file, dst_file)
89
+ count += 1
90
+ return count
91
+
92
+
93
+ def main():
94
+ # ── Ensure huggingface_hub is available ────────────────────────────────
95
+ try:
96
+ from huggingface_hub import HfApi, login
97
+ except ImportError:
98
+ print("Installing huggingface_hub…")
99
+ os.system(f"{sys.executable} -m pip install huggingface_hub -q")
100
+ from huggingface_hub import HfApi, login # type: ignore
101
+
102
+ print()
103
+ print("═" * 55)
104
+ print(" URAAS → Hugging Face Spaces")
105
+ print(f" Space: {REPO_ID}")
106
+ print("═" * 55)
107
+
108
+ # ── Auth ───────────────────────────────────────────────────────────────
109
+ token = os.getenv("HF_TOKEN")
110
+ if token:
111
+ login(token=token, add_to_git_credential=True)
112
+ print(" Logged in via HF_TOKEN env var.")
113
+ else:
114
+ print()
115
+ print(" Paste your HF write token below.")
116
+ print(" (Get one at: https://huggingface.co/settings/tokens)")
117
+ print()
118
+ login(add_to_git_credential=True)
119
+
120
+ api = HfApi()
121
+
122
+ # ── Stage files ────────────────────────────────────────────────────────
123
+ print()
124
+ print("Staging project files…")
125
+ with tempfile.TemporaryDirectory() as staging:
126
+ n = stage_project(REPO_ROOT, staging)
127
+ staged_names = os.listdir(staging)
128
+ print(f" {n} files staged across {len(staged_names)} top-level items")
129
+
130
+ # Sanity checks
131
+ has_dockerfile = "Dockerfile" in staged_names
132
+ has_readme = "README.md" in staged_names
133
+ has_start_sh = os.path.exists(os.path.join(staging, "scripts", "start_hf.sh"))
134
+
135
+ print(f" Dockerfile : {'✓' if has_dockerfile else '✗ MISSING — check Dockerfile.hf exists'}")
136
+ print(f" README.md : {'✓' if has_readme else '✗ MISSING — check README.hf.md exists'}")
137
+ print(f" start_hf.sh : {'✓' if has_start_sh else '✗ MISSING — check scripts/start_hf.sh'}")
138
+
139
+ if not has_dockerfile:
140
+ print()
141
+ print("ERROR: Dockerfile missing from staging. Aborting.")
142
+ sys.exit(1)
143
+
144
+ # ── Upload ─────────────────────────────────────────────────────────
145
+ print()
146
+ print(f"Uploading to {REPO_ID}…")
147
+ api.upload_folder(
148
+ folder_path=staging,
149
+ repo_id=REPO_ID,
150
+ repo_type=REPO_TYPE,
151
+ commit_message="Deploy URAAS — African Research Archival & Analytics System",
152
+ )
153
+
154
+ # ── Done ───────────────────────────────────────────────────────────────
155
+ print()
156
+ print("═" * 55)
157
+ print(" Upload complete! Build starting on HF (~5 min).")
158
+ print()
159
+ print(" Watch build: https://huggingface.co/spaces/Lordkiki/APA-URAAS")
160
+ print(" App URL : https://lordkiki-apa-uraas.hf.space")
161
+ print()
162
+ print(" ─── Secrets to set in Space Settings → Variables & Secrets ───")
163
+ secrets = [
164
+ ("URAAS_ENV", "production"),
165
+ ("DASHBOARD_SECRET_KEY", "307790fc5aff3fe1e766303f6b94e2fc28c831582bfba5b34802e2c9cbbac0ce"),
166
+ ("ADMIN_USERNAME", "admin"),
167
+ ("ADMIN_PASSWORD_HASH", "scrypt:32768:8:1$r2KZFX32rJ2twbfV$16f394a253c2b505a215ff2747f7dafbb098eb0eb8b4e6bb9fb521f0ea38af8ce71f33d2354245d68cc383678257367bf410aa45f835b5e848bff95a746878c8"),
168
+ ("VIEWER_USERNAME", "viewer"),
169
+ ("VIEWER_PASSWORD_HASH", "scrypt:32768:8:1$M8OjWxX64B38akos$2803ad5b29508c4d69df115579630b2a4cbdf2c8406157598c088d5511a7b3d79f91399035040660705413c63fdc41aa0d020cbd7b45038c8ed51d20092ea609"),
170
+ ("SMTP_HOST", "smtp.gmail.com"),
171
+ ("SMTP_PORT", "587"),
172
+ ("SMTP_USE_TLS", "true"),
173
+ ("SMTP_USER", "lawalgiyath200716@gmail.com"),
174
+ ("SMTP_PASSWORD", "ufwqbdrecpfrzppn"),
175
+ ("SMTP_FROM", "URAAS UNILAG <lawalgiyath200716@gmail.com>"),
176
+ ("DASHBOARD_BASE_URL", "https://lordkiki-apa-uraas.hf.space"),
177
+ ("DASHBOARD_CORS_ORIGINS", "https://lordkiki-apa-uraas.hf.space"),
178
+ ("ARK_NAAN", "99999"),
179
+ ("ARK_SHOULDER", "z1"),
180
+ ("OPENALEX_MAILTO", "lawalgiyath200716@gmail.com"),
181
+ ("DSPACE_API_URL", "https://api-ir.unilag.edu.ng/server"),
182
+ ("DSPACE_USERNAME", "<professor email — set as Secret, not Variable>"),
183
+ ("DSPACE_PASSWORD", "<professor password — set as Secret, not Variable>"),
184
+ ]
185
+ max_k = max(len(k) for k, _ in secrets)
186
+ for k, v in secrets:
187
+ print(f" {k:<{max_k}} = {v}")
188
+ print()
189
+ print(" Admin login : admin / URAAS2024demo")
190
+ print(" Viewer login: viewer / view2024")
191
+ print("═" * 55)
192
+
193
+
194
+ if __name__ == "__main__":
195
+ main()
scripts/reclassify_and_prune_sc.py CHANGED
@@ -1,199 +1,199 @@
1
- """
2
- Re-classify every Item with the Special Collections decision engine and prune
3
- everything that is not a genuine special collection.
4
-
5
- The platform is Special-Collections-only: papers that the engine scores 0 are
6
- research noise (STEM/medical/jargon) and must be removed.
7
-
8
- Usage:
9
- python scripts/reclassify_and_prune_sc.py # DRY RUN (default) — no writes
10
- python scripts/reclassify_and_prune_sc.py --apply # re-score + delete non-SC
11
-
12
- The --apply pass:
13
- 1. Backs up uraas.db -> uraas.db.bak (SQLite only).
14
- 2. Re-scores all items, writing special_collection_score / _categories.
15
- 3. Deletes items with score == 0 (ORM delete so association/file rows cascade),
16
- then removes orphan authors / empty collections / empty communities.
17
- 4. Flushes the analytics cache.
18
-
19
- Run with the dashboard and any crawler STOPPED to avoid SQLite write locks.
20
- """
21
-
22
- import argparse
23
- import os
24
- import shutil
25
- import sys
26
-
27
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
28
-
29
- from sqlalchemy import text
30
-
31
- from uraas.config import config
32
- from uraas.database import (
33
- Author,
34
- Collection,
35
- Community,
36
- Item,
37
- SessionLocal,
38
- engine,
39
- item_authors,
40
- )
41
- from uraas.services.sc_engine import is_special_collection
42
- from uraas.utils.analytics_cache import analytics_cache
43
-
44
-
45
- def backup_sqlite():
46
- url = (config.DATABASE_URL or "").lower()
47
- if not url.startswith("sqlite"):
48
- print(f"[backup] Non-SQLite DB ({url[:30]}...) — skipping file backup.")
49
- return
50
- db_path = config.DATABASE_URL.split("///")[-1]
51
- if not os.path.exists(db_path):
52
- print(f"[backup] DB file not found at {db_path}; nothing to back up.")
53
- return
54
- bak = db_path + ".bak"
55
- shutil.copy2(db_path, bak)
56
- print(f"[backup] {db_path} -> {bak}")
57
-
58
-
59
- def rescore(session, apply: bool):
60
- """Re-score every item. Returns (keep_ids, drop_ids)."""
61
- items = session.query(Item).all()
62
- keep_ids, drop_ids = [], []
63
- for it in items:
64
- is_sc, score, cats = is_special_collection(
65
- it.title or "", it.abstract or "", it.dc_subject or ""
66
- )
67
- if apply:
68
- it.special_collection_score = float(score)
69
- it.special_collection_categories = ",".join(cats) if is_sc else ""
70
- (keep_ids if is_sc else drop_ids).append(it.id)
71
- if apply:
72
- session.commit()
73
- return keep_ids, drop_ids
74
-
75
-
76
- def prune(session, drop_ids):
77
- """Delete non-SC items + orphan authors/collections/communities."""
78
- # Enforce FK cascade for this SQLite connection (default is OFF).
79
- session.execute(text("PRAGMA foreign_keys=ON"))
80
-
81
- deleted = 0
82
- for chunk_start in range(0, len(drop_ids), 500):
83
- chunk = drop_ids[chunk_start : chunk_start + 500]
84
- for it in session.query(Item).filter(Item.id.in_(chunk)).all():
85
- session.delete(it) # ORM delete -> association + file rows cascade
86
- deleted += 1
87
- session.commit()
88
- print(f"[prune] deleted {deleted} non-SC items")
89
-
90
- # Sweep stray association rows that referenced deleted items (SQLite FK
91
- # cascade is unreliable for raw association tables across chunked deletes).
92
- session.execute(
93
- text(
94
- "DELETE FROM item_authors WHERE item_id NOT IN (SELECT id FROM items) "
95
- "OR author_id NOT IN (SELECT id FROM authors)"
96
- )
97
- )
98
- session.execute(
99
- text(
100
- "DELETE FROM item_collections WHERE item_id NOT IN (SELECT id FROM items) "
101
- "OR collection_id NOT IN (SELECT id FROM collections)"
102
- )
103
- )
104
- session.commit()
105
-
106
- # Orphan authors: no remaining item associations.
107
- orphan_authors = (
108
- session.query(Author)
109
- .filter(~Author.id.in_(session.query(item_authors.c.author_id)))
110
- .all()
111
- )
112
- for a in orphan_authors:
113
- session.delete(a)
114
- print(f"[prune] deleted {len(orphan_authors)} orphan authors")
115
-
116
- # Empty collections (no items) and then empty communities (no collections).
117
- empty_colls = [c for c in session.query(Collection).all() if not c.items]
118
- for c in empty_colls:
119
- session.delete(c)
120
- session.commit()
121
- print(f"[prune] deleted {len(empty_colls)} empty collections")
122
-
123
- empty_comms = [c for c in session.query(Community).all() if not c.collections]
124
- for c in empty_comms:
125
- session.delete(c)
126
- session.commit()
127
- print(f"[prune] deleted {len(empty_comms)} empty communities")
128
-
129
-
130
- def main():
131
- parser = argparse.ArgumentParser(description="Re-classify & prune non-SC papers")
132
- parser.add_argument(
133
- "--apply", action="store_true", help="Actually re-score and delete (default: dry run)"
134
- )
135
- parser.add_argument(
136
- "--samples", type=int, default=20, help="How many borderline drops to print"
137
- )
138
- args = parser.parse_args()
139
-
140
- session = SessionLocal()
141
- try:
142
- total = session.query(Item).count()
143
- old_sc = session.query(Item).filter(Item.special_collection_score > 0).count()
144
- print("=" * 64)
145
- print(f"Total items: {total} (old score>0: {old_sc})")
146
- print("=" * 64)
147
-
148
- if args.apply:
149
- backup_sqlite()
150
-
151
- keep_ids, drop_ids = rescore(session, apply=args.apply)
152
- print(f"\nKEEP (special collections): {len(keep_ids)}")
153
- print(f"DROP (not special collections): {len(drop_ids)}")
154
-
155
- # Show a sample of what would be / was dropped that previously scored > 0
156
- # (these are the meaningful changes to eyeball).
157
- prev_sc = {
158
- i for (i,) in session.query(Item.id).filter(Item.special_collection_score >= 0).all()
159
- } if not args.apply else set()
160
- sample = (
161
- session.query(Item.title)
162
- .filter(Item.id.in_(drop_ids[: args.samples]))
163
- .all()
164
- )
165
- print(f"\n--- sample of dropped titles (first {args.samples}) ---")
166
- for (t,) in sample:
167
- safe = (t or "").encode("ascii", "replace").decode()
168
- print(" DROP:", safe[:90])
169
-
170
- if not args.apply:
171
- print("\n[DRY RUN] No changes written. Re-run with --apply to prune.")
172
- return 0
173
-
174
- prune(session, drop_ids)
175
- analytics_cache.invalidate_all()
176
-
177
- remaining = session.query(Item).count()
178
- sc_remaining = (
179
- session.query(Item).filter(Item.special_collection_score > 0).count()
180
- )
181
- orphan_left = (
182
- session.query(Author)
183
- .filter(~Author.id.in_(session.query(item_authors.c.author_id)))
184
- .count()
185
- )
186
- print("\n" + "=" * 64)
187
- print(f"DONE. Items remaining: {remaining} (score>0: {sc_remaining})")
188
- print(f"Orphan authors remaining: {orphan_left}")
189
- assert remaining == sc_remaining, "Mismatch: non-SC rows survived!"
190
- assert orphan_left == 0, "Orphan authors survived!"
191
- print("Invariants OK. Restart the dashboard to serve fresh data.")
192
- print("=" * 64)
193
- return 0
194
- finally:
195
- session.close()
196
-
197
-
198
- if __name__ == "__main__":
199
- sys.exit(main())
 
1
+ """
2
+ Re-classify every Item with the Special Collections decision engine and prune
3
+ everything that is not a genuine special collection.
4
+
5
+ The platform is Special-Collections-only: papers that the engine scores 0 are
6
+ research noise (STEM/medical/jargon) and must be removed.
7
+
8
+ Usage:
9
+ python scripts/reclassify_and_prune_sc.py # DRY RUN (default) — no writes
10
+ python scripts/reclassify_and_prune_sc.py --apply # re-score + delete non-SC
11
+
12
+ The --apply pass:
13
+ 1. Backs up uraas.db -> uraas.db.bak (SQLite only).
14
+ 2. Re-scores all items, writing special_collection_score / _categories.
15
+ 3. Deletes items with score == 0 (ORM delete so association/file rows cascade),
16
+ then removes orphan authors / empty collections / empty communities.
17
+ 4. Flushes the analytics cache.
18
+
19
+ Run with the dashboard and any crawler STOPPED to avoid SQLite write locks.
20
+ """
21
+
22
+ import argparse
23
+ import os
24
+ import shutil
25
+ import sys
26
+
27
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
28
+
29
+ from sqlalchemy import text
30
+
31
+ from uraas.config import config
32
+ from uraas.database import (
33
+ Author,
34
+ Collection,
35
+ Community,
36
+ Item,
37
+ SessionLocal,
38
+ engine,
39
+ item_authors,
40
+ )
41
+ from uraas.services.sc_engine import is_special_collection
42
+ from uraas.utils.analytics_cache import analytics_cache
43
+
44
+
45
+ def backup_sqlite():
46
+ url = (config.DATABASE_URL or "").lower()
47
+ if not url.startswith("sqlite"):
48
+ print(f"[backup] Non-SQLite DB ({url[:30]}...) — skipping file backup.")
49
+ return
50
+ db_path = config.DATABASE_URL.split("///")[-1]
51
+ if not os.path.exists(db_path):
52
+ print(f"[backup] DB file not found at {db_path}; nothing to back up.")
53
+ return
54
+ bak = db_path + ".bak"
55
+ shutil.copy2(db_path, bak)
56
+ print(f"[backup] {db_path} -> {bak}")
57
+
58
+
59
+ def rescore(session, apply: bool):
60
+ """Re-score every item. Returns (keep_ids, drop_ids)."""
61
+ items = session.query(Item).all()
62
+ keep_ids, drop_ids = [], []
63
+ for it in items:
64
+ is_sc, score, cats = is_special_collection(
65
+ it.title or "", it.abstract or "", it.dc_subject or ""
66
+ )
67
+ if apply:
68
+ it.special_collection_score = float(score)
69
+ it.special_collection_categories = ",".join(cats) if is_sc else ""
70
+ (keep_ids if is_sc else drop_ids).append(it.id)
71
+ if apply:
72
+ session.commit()
73
+ return keep_ids, drop_ids
74
+
75
+
76
+ def prune(session, drop_ids):
77
+ """Delete non-SC items + orphan authors/collections/communities."""
78
+ # Enforce FK cascade for this SQLite connection (default is OFF).
79
+ session.execute(text("PRAGMA foreign_keys=ON"))
80
+
81
+ deleted = 0
82
+ for chunk_start in range(0, len(drop_ids), 500):
83
+ chunk = drop_ids[chunk_start : chunk_start + 500]
84
+ for it in session.query(Item).filter(Item.id.in_(chunk)).all():
85
+ session.delete(it) # ORM delete -> association + file rows cascade
86
+ deleted += 1
87
+ session.commit()
88
+ print(f"[prune] deleted {deleted} non-SC items")
89
+
90
+ # Sweep stray association rows that referenced deleted items (SQLite FK
91
+ # cascade is unreliable for raw association tables across chunked deletes).
92
+ session.execute(
93
+ text(
94
+ "DELETE FROM item_authors WHERE item_id NOT IN (SELECT id FROM items) "
95
+ "OR author_id NOT IN (SELECT id FROM authors)"
96
+ )
97
+ )
98
+ session.execute(
99
+ text(
100
+ "DELETE FROM item_collections WHERE item_id NOT IN (SELECT id FROM items) "
101
+ "OR collection_id NOT IN (SELECT id FROM collections)"
102
+ )
103
+ )
104
+ session.commit()
105
+
106
+ # Orphan authors: no remaining item associations.
107
+ orphan_authors = (
108
+ session.query(Author)
109
+ .filter(~Author.id.in_(session.query(item_authors.c.author_id)))
110
+ .all()
111
+ )
112
+ for a in orphan_authors:
113
+ session.delete(a)
114
+ print(f"[prune] deleted {len(orphan_authors)} orphan authors")
115
+
116
+ # Empty collections (no items) and then empty communities (no collections).
117
+ empty_colls = [c for c in session.query(Collection).all() if not c.items]
118
+ for c in empty_colls:
119
+ session.delete(c)
120
+ session.commit()
121
+ print(f"[prune] deleted {len(empty_colls)} empty collections")
122
+
123
+ empty_comms = [c for c in session.query(Community).all() if not c.collections]
124
+ for c in empty_comms:
125
+ session.delete(c)
126
+ session.commit()
127
+ print(f"[prune] deleted {len(empty_comms)} empty communities")
128
+
129
+
130
+ def main():
131
+ parser = argparse.ArgumentParser(description="Re-classify & prune non-SC papers")
132
+ parser.add_argument(
133
+ "--apply", action="store_true", help="Actually re-score and delete (default: dry run)"
134
+ )
135
+ parser.add_argument(
136
+ "--samples", type=int, default=20, help="How many borderline drops to print"
137
+ )
138
+ args = parser.parse_args()
139
+
140
+ session = SessionLocal()
141
+ try:
142
+ total = session.query(Item).count()
143
+ old_sc = session.query(Item).filter(Item.special_collection_score > 0).count()
144
+ print("=" * 64)
145
+ print(f"Total items: {total} (old score>0: {old_sc})")
146
+ print("=" * 64)
147
+
148
+ if args.apply:
149
+ backup_sqlite()
150
+
151
+ keep_ids, drop_ids = rescore(session, apply=args.apply)
152
+ print(f"\nKEEP (special collections): {len(keep_ids)}")
153
+ print(f"DROP (not special collections): {len(drop_ids)}")
154
+
155
+ # Show a sample of what would be / was dropped that previously scored > 0
156
+ # (these are the meaningful changes to eyeball).
157
+ prev_sc = {
158
+ i for (i,) in session.query(Item.id).filter(Item.special_collection_score >= 0).all()
159
+ } if not args.apply else set()
160
+ sample = (
161
+ session.query(Item.title)
162
+ .filter(Item.id.in_(drop_ids[: args.samples]))
163
+ .all()
164
+ )
165
+ print(f"\n--- sample of dropped titles (first {args.samples}) ---")
166
+ for (t,) in sample:
167
+ safe = (t or "").encode("ascii", "replace").decode()
168
+ print(" DROP:", safe[:90])
169
+
170
+ if not args.apply:
171
+ print("\n[DRY RUN] No changes written. Re-run with --apply to prune.")
172
+ return 0
173
+
174
+ prune(session, drop_ids)
175
+ analytics_cache.invalidate_all()
176
+
177
+ remaining = session.query(Item).count()
178
+ sc_remaining = (
179
+ session.query(Item).filter(Item.special_collection_score > 0).count()
180
+ )
181
+ orphan_left = (
182
+ session.query(Author)
183
+ .filter(~Author.id.in_(session.query(item_authors.c.author_id)))
184
+ .count()
185
+ )
186
+ print("\n" + "=" * 64)
187
+ print(f"DONE. Items remaining: {remaining} (score>0: {sc_remaining})")
188
+ print(f"Orphan authors remaining: {orphan_left}")
189
+ assert remaining == sc_remaining, "Mismatch: non-SC rows survived!"
190
+ assert orphan_left == 0, "Orphan authors survived!"
191
+ print("Invariants OK. Restart the dashboard to serve fresh data.")
192
+ print("=" * 64)
193
+ return 0
194
+ finally:
195
+ session.close()
196
+
197
+
198
+ if __name__ == "__main__":
199
+ sys.exit(main())
scripts/scrape_nigerian_universities.py CHANGED
@@ -1,618 +1,618 @@
1
- """
2
- Comprehensive scraper for Nigerian university faculty directories
3
- Collects full staff names with high accuracy
4
- """
5
-
6
- import json
7
- import re
8
- import time
9
- from typing import Dict, List, Set
10
- from urllib.parse import urljoin, urlparse
11
-
12
- import requests
13
- from bs4 import BeautifulSoup
14
-
15
-
16
- class UniversityStaffScraper:
17
- """Base class for university staff scraping"""
18
-
19
- def __init__(self, institution_name: str, base_url: str):
20
- self.institution_name = institution_name
21
- self.base_url = base_url
22
- self.session = requests.Session()
23
- self.session.headers.update(
24
- {
25
- "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
26
- }
27
- )
28
- self.staff_data = []
29
- self.staff_names = set()
30
-
31
- def clean_name(self, name: str) -> str:
32
- """Clean and standardize name"""
33
- if not name:
34
- return ""
35
-
36
- # Remove extra whitespace
37
- name = re.sub(r"\s+", " ", name).strip()
38
-
39
- # Remove common artifacts
40
- name = re.sub(r"\s*\([^)]*\)\s*", " ", name) # Remove parentheses content
41
- name = re.sub(r"\s*\[[^\]]*\]\s*", " ", name) # Remove brackets content
42
- name = re.sub(r"\s+", " ", name).strip()
43
-
44
- # Ensure proper capitalization
45
- if name.isupper() or name.islower():
46
- name = name.title()
47
-
48
- return name
49
-
50
- def is_valid_name(self, name: str) -> bool:
51
- """Validate if string is a proper name"""
52
- if not name or len(name) < 5:
53
- return False
54
-
55
- # Must have at least 2 words
56
- words = name.split()
57
- if len(words) < 2:
58
- return False
59
-
60
- # Must contain letters
61
- if not re.search(r"[a-zA-Z]", name):
62
- return False
63
-
64
- # Reject if too many numbers
65
- if len(re.findall(r"\d", name)) > 3:
66
- return False
67
-
68
- # Reject common non-name patterns
69
- reject_patterns = [
70
- r"^(page|home|about|contact|staff|faculty|department)",
71
- r"(\.pdf|\.doc|\.jpg|\.png)$",
72
- r"^(dr|prof|mr|mrs|ms)\.?$",
73
- r"^\d+$",
74
- ]
75
-
76
- for pattern in reject_patterns:
77
- if re.search(pattern, name.lower()):
78
- return False
79
-
80
- return True
81
-
82
- def save_to_json(self, filename: str):
83
- """Save collected staff data to JSON"""
84
- output = {
85
- "institution": self.institution_name,
86
- "total_staff": len(self.staff_names),
87
- "collection_date": time.strftime("%Y-%m-%d"),
88
- "staff": sorted(list(self.staff_names)),
89
- "detailed_records": self.staff_data,
90
- }
91
-
92
- with open(filename, "w", encoding="utf-8") as f:
93
- json.dump(output, f, indent=2, ensure_ascii=False)
94
-
95
- print(f"\n✓ Saved {len(self.staff_names)} staff members to {filename}")
96
-
97
- def scrape(self):
98
- """Override in subclass"""
99
- raise NotImplementedError
100
-
101
-
102
- class UIStaffScraper(UniversityStaffScraper):
103
- """University of Ibadan staff scraper"""
104
-
105
- def __init__(self):
106
- super().__init__("University of Ibadan", "https://www.ui.edu.ng")
107
-
108
- def scrape(self):
109
- """Scrape UI faculty directory"""
110
- print(f"\n{'='*60}")
111
- print(f"Scraping: {self.institution_name}")
112
- print(f"{'='*60}")
113
-
114
- # UI faculty pages
115
- faculty_urls = [
116
- "/faculties/arts",
117
- "/faculties/science",
118
- "/faculties/technology",
119
- "/faculties/agriculture-and-forestry",
120
- "/faculties/veterinary-medicine",
121
- "/faculties/medicine",
122
- "/faculties/dentistry",
123
- "/faculties/pharmacy",
124
- "/faculties/public-health",
125
- "/faculties/social-sciences",
126
- "/faculties/law",
127
- "/faculties/education",
128
- "/faculties/environmental-design-and-management",
129
- ]
130
-
131
- # Try to scrape from staff directory if available
132
- try:
133
- response = self.session.get(f"{self.base_url}/staff-directory", timeout=10)
134
- if response.status_code == 200:
135
- self._parse_staff_page(response.text, "Staff Directory")
136
- except Exception as e:
137
- print(f" Note: Staff directory not accessible: {e}")
138
-
139
- # Try faculty pages
140
- for faculty_url in faculty_urls:
141
- try:
142
- url = urljoin(self.base_url, faculty_url)
143
- print(f" Checking: {url}")
144
- response = self.session.get(url, timeout=10)
145
-
146
- if response.status_code == 200:
147
- self._parse_staff_page(response.text, faculty_url)
148
- time.sleep(1) # Be polite
149
-
150
- except Exception as e:
151
- print(f" Error accessing {faculty_url}: {e}")
152
-
153
- print(f"\n Total staff collected: {len(self.staff_names)}")
154
-
155
- def _parse_staff_page(self, html: str, source: str):
156
- """Parse HTML page for staff names"""
157
- soup = BeautifulSoup(html, "html.parser")
158
-
159
- # Look for common patterns
160
- patterns = [
161
- ("div", {"class": re.compile(r"staff|faculty|member|person")}),
162
- ("li", {"class": re.compile(r"staff|faculty|member")}),
163
- ("h3", {}),
164
- ("h4", {}),
165
- ("p", {"class": re.compile(r"name|staff")}),
166
- ]
167
-
168
- for tag, attrs in patterns:
169
- elements = soup.find_all(tag, attrs)
170
- for elem in elements:
171
- text = elem.get_text(strip=True)
172
- name = self.clean_name(text)
173
-
174
- if self.is_valid_name(name) and name not in self.staff_names:
175
- self.staff_names.add(name)
176
- self.staff_data.append(
177
- {"name": name, "source": source, "faculty": "Unknown"}
178
- )
179
-
180
-
181
- class OAUStaffScraper(UniversityStaffScraper):
182
- """Obafemi Awolowo University staff scraper"""
183
-
184
- def __init__(self):
185
- super().__init__("Obafemi Awolowo University", "https://oauife.edu.ng")
186
-
187
- def scrape(self):
188
- """Scrape OAU faculty directory"""
189
- print(f"\n{'='*60}")
190
- print(f"Scraping: {self.institution_name}")
191
- print(f"{'='*60}")
192
-
193
- # OAU faculty pages
194
- faculty_urls = [
195
- "/faculties/arts",
196
- "/faculties/science",
197
- "/faculties/technology",
198
- "/faculties/agriculture",
199
- "/faculties/basic-medical-sciences",
200
- "/faculties/clinical-sciences",
201
- "/faculties/dentistry",
202
- "/faculties/pharmacy",
203
- "/faculties/social-sciences",
204
- "/faculties/law",
205
- "/faculties/education",
206
- "/faculties/environmental-design",
207
- ]
208
-
209
- # Try staff directory
210
- try:
211
- response = self.session.get(f"{self.base_url}/staff", timeout=10)
212
- if response.status_code == 200:
213
- self._parse_staff_page(response.text, "Staff Directory")
214
- except Exception as e:
215
- print(f" Note: Staff directory not accessible: {e}")
216
-
217
- # Try faculty pages
218
- for faculty_url in faculty_urls:
219
- try:
220
- url = urljoin(self.base_url, faculty_url)
221
- print(f" Checking: {url}")
222
- response = self.session.get(url, timeout=10)
223
-
224
- if response.status_code == 200:
225
- self._parse_staff_page(response.text, faculty_url)
226
- time.sleep(1)
227
-
228
- except Exception as e:
229
- print(f" Error accessing {faculty_url}: {e}")
230
-
231
- print(f"\n Total staff collected: {len(self.staff_names)}")
232
-
233
- def _parse_staff_page(self, html: str, source: str):
234
- """Parse HTML page for staff names"""
235
- soup = BeautifulSoup(html, "html.parser")
236
-
237
- # Look for staff names
238
- patterns = [
239
- ("div", {"class": re.compile(r"staff|faculty|member|person")}),
240
- ("li", {"class": re.compile(r"staff|faculty|member")}),
241
- ("h3", {}),
242
- ("h4", {}),
243
- ("span", {"class": re.compile(r"name")}),
244
- ]
245
-
246
- for tag, attrs in patterns:
247
- elements = soup.find_all(tag, attrs)
248
- for elem in elements:
249
- text = elem.get_text(strip=True)
250
- name = self.clean_name(text)
251
-
252
- if self.is_valid_name(name) and name not in self.staff_names:
253
- self.staff_names.add(name)
254
- self.staff_data.append(
255
- {"name": name, "source": source, "faculty": "Unknown"}
256
- )
257
-
258
-
259
- class UNNStaffScraper(UniversityStaffScraper):
260
- """University of Nigeria, Nsukka staff scraper"""
261
-
262
- def __init__(self):
263
- super().__init__("University of Nigeria, Nsukka", "https://www.unn.edu.ng")
264
-
265
- def scrape(self):
266
- """Scrape UNN faculty directory"""
267
- print(f"\n{'='*60}")
268
- print(f"Scraping: {self.institution_name}")
269
- print(f"{'='*60}")
270
-
271
- # UNN faculty pages
272
- faculty_urls = [
273
- "/faculties/arts",
274
- "/faculties/biological-sciences",
275
- "/faculties/physical-sciences",
276
- "/faculties/engineering",
277
- "/faculties/agriculture",
278
- "/faculties/veterinary-medicine",
279
- "/faculties/medical-sciences",
280
- "/faculties/dentistry",
281
- "/faculties/pharmaceutical-sciences",
282
- "/faculties/health-sciences",
283
- "/faculties/social-sciences",
284
- "/faculties/law",
285
- "/faculties/education",
286
- "/faculties/environmental-studies",
287
- "/faculties/business-administration",
288
- ]
289
-
290
- # Try staff directory
291
- try:
292
- response = self.session.get(f"{self.base_url}/staff-directory", timeout=10)
293
- if response.status_code == 200:
294
- self._parse_staff_page(response.text, "Staff Directory")
295
- except Exception as e:
296
- print(f" Note: Staff directory not accessible: {e}")
297
-
298
- # Try faculty pages
299
- for faculty_url in faculty_urls:
300
- try:
301
- url = urljoin(self.base_url, faculty_url)
302
- print(f" Checking: {url}")
303
- response = self.session.get(url, timeout=10)
304
-
305
- if response.status_code == 200:
306
- self._parse_staff_page(response.text, faculty_url)
307
- time.sleep(1)
308
-
309
- except Exception as e:
310
- print(f" Error accessing {faculty_url}: {e}")
311
-
312
- print(f"\n Total staff collected: {len(self.staff_names)}")
313
-
314
- def _parse_staff_page(self, html: str, source: str):
315
- """Parse HTML page for staff names"""
316
- soup = BeautifulSoup(html, "html.parser")
317
-
318
- # Look for staff names
319
- patterns = [
320
- ("div", {"class": re.compile(r"staff|faculty|member|person")}),
321
- ("li", {"class": re.compile(r"staff|faculty|member")}),
322
- ("h3", {}),
323
- ("h4", {}),
324
- ("td", {}),
325
- ]
326
-
327
- for tag, attrs in patterns:
328
- elements = soup.find_all(tag, attrs)
329
- for elem in elements:
330
- text = elem.get_text(strip=True)
331
- name = self.clean_name(text)
332
-
333
- if self.is_valid_name(name) and name not in self.staff_names:
334
- self.staff_names.add(name)
335
- self.staff_data.append(
336
- {"name": name, "source": source, "faculty": "Unknown"}
337
- )
338
-
339
-
340
- class ABUStaffScraper(UniversityStaffScraper):
341
- """Ahmadu Bello University staff scraper"""
342
-
343
- def __init__(self):
344
- super().__init__("Ahmadu Bello University", "https://www.abu.edu.ng")
345
-
346
- def scrape(self):
347
- """Scrape ABU faculty directory"""
348
- print(f"\n{'='*60}")
349
- print(f"Scraping: {self.institution_name}")
350
- print(f"{'='*60}")
351
-
352
- # ABU faculty pages
353
- faculty_urls = [
354
- "/faculties/arts-and-islamic-studies",
355
- "/faculties/science",
356
- "/faculties/engineering",
357
- "/faculties/agriculture",
358
- "/faculties/veterinary-medicine",
359
- "/faculties/medicine",
360
- "/faculties/dentistry",
361
- "/faculties/pharmaceutical-sciences",
362
- "/faculties/allied-health-sciences",
363
- "/faculties/social-sciences",
364
- "/faculties/law",
365
- "/faculties/education",
366
- "/faculties/environmental-design",
367
- "/faculties/administration",
368
- ]
369
-
370
- # Try staff directory
371
- try:
372
- response = self.session.get(f"{self.base_url}/staff", timeout=10)
373
- if response.status_code == 200:
374
- self._parse_staff_page(response.text, "Staff Directory")
375
- except Exception as e:
376
- print(f" Note: Staff directory not accessible: {e}")
377
-
378
- # Try faculty pages
379
- for faculty_url in faculty_urls:
380
- try:
381
- url = urljoin(self.base_url, faculty_url)
382
- print(f" Checking: {url}")
383
- response = self.session.get(url, timeout=10)
384
-
385
- if response.status_code == 200:
386
- self._parse_staff_page(response.text, faculty_url)
387
- time.sleep(1)
388
-
389
- except Exception as e:
390
- print(f" Error accessing {faculty_url}: {e}")
391
-
392
- print(f"\n Total staff collected: {len(self.staff_names)}")
393
-
394
- def _parse_staff_page(self, html: str, source: str):
395
- """Parse HTML page for staff names"""
396
- soup = BeautifulSoup(html, "html.parser")
397
-
398
- # Look for staff names
399
- patterns = [
400
- ("div", {"class": re.compile(r"staff|faculty|member|person")}),
401
- ("li", {"class": re.compile(r"staff|faculty|member")}),
402
- ("h3", {}),
403
- ("h4", {}),
404
- ("span", {"class": re.compile(r"name")}),
405
- ]
406
-
407
- for tag, attrs in patterns:
408
- elements = soup.find_all(tag, attrs)
409
- for elem in elements:
410
- text = elem.get_text(strip=True)
411
- name = self.clean_name(text)
412
-
413
- if self.is_valid_name(name) and name not in self.staff_names:
414
- self.staff_names.add(name)
415
- self.staff_data.append(
416
- {"name": name, "source": source, "faculty": "Unknown"}
417
- )
418
-
419
-
420
- def generate_sample_names(institution: str, count: int) -> List[str]:
421
- """
422
- Generate realistic Nigerian academic staff names as fallback
423
- Uses common Nigerian naming patterns
424
- """
425
-
426
- # Common Nigerian surnames by region
427
- yoruba_surnames = [
428
- "Adeyemi",
429
- "Ogunlana",
430
- "Oluwaseun",
431
- "Babatunde",
432
- "Adebayo",
433
- "Oladipo",
434
- "Adekunle",
435
- "Olatunji",
436
- "Adewale",
437
- "Olaniyan",
438
- "Afolabi",
439
- "Ogunbiyi",
440
- "Adeyinka",
441
- "Oladele",
442
- "Adebisi",
443
- "Ogunleye",
444
- "Adeola",
445
- "Olayinka",
446
- ]
447
-
448
- igbo_surnames = [
449
- "Okonkwo",
450
- "Nwosu",
451
- "Okeke",
452
- "Eze",
453
- "Okafor",
454
- "Nwankwo",
455
- "Chukwu",
456
- "Onyeka",
457
- "Ikechukwu",
458
- "Obiora",
459
- "Emeka",
460
- "Chinedu",
461
- "Ugochukwu",
462
- "Nnamdi",
463
- "Chibueze",
464
- "Obinna",
465
- "Kelechi",
466
- "Chukwuemeka",
467
- ]
468
-
469
- hausa_surnames = [
470
- "Ibrahim",
471
- "Mohammed",
472
- "Abdullahi",
473
- "Usman",
474
- "Ahmad",
475
- "Hassan",
476
- "Aliyu",
477
- "Musa",
478
- "Abubakar",
479
- "Suleiman",
480
- "Yusuf",
481
- "Ismail",
482
- "Bello",
483
- "Garba",
484
- "Sani",
485
- "Umar",
486
- "Tijjani",
487
- "Kabir",
488
- ]
489
-
490
- # Common first names
491
- first_names = [
492
- "Oluwaseun",
493
- "Chinedu",
494
- "Abubakar",
495
- "Ngozi",
496
- "Fatima",
497
- "Chiamaka",
498
- "Tunde",
499
- "Emeka",
500
- "Musa",
501
- "Adaeze",
502
- "Zainab",
503
- "Chioma",
504
- "Segun",
505
- "Obinna",
506
- "Aliyu",
507
- "Amaka",
508
- "Aisha",
509
- "Ifeoma",
510
- ]
511
-
512
- # Academic titles
513
- titles = ["Prof.", "Dr.", "Mr.", "Mrs.", "Ms."]
514
-
515
- # Middle initials
516
- initials = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
517
-
518
- import random
519
-
520
- random.seed(42) # For reproducibility
521
-
522
- all_surnames = yoruba_surnames + igbo_surnames + hausa_surnames
523
- names = set()
524
-
525
- while len(names) < count:
526
- title = random.choice(titles)
527
- first = random.choice(first_names)
528
- middle = random.choice(initials)
529
- surname = random.choice(all_surnames)
530
-
531
- # Various name formats
532
- formats = [
533
- f"{title} {first} {middle}. {surname}",
534
- f"{title} {first} {surname}",
535
- f"{first} {middle}. {surname}",
536
- f"{surname}, {first} {middle}.",
537
- ]
538
-
539
- name = random.choice(formats)
540
- names.add(name)
541
-
542
- return sorted(list(names))
543
-
544
-
545
- def main():
546
- """Main scraping function"""
547
- print("\n" + "=" * 60)
548
- print("NIGERIAN UNIVERSITIES STAFF DATA COLLECTION")
549
- print("=" * 60)
550
- print("\nTarget: Collect full staff names from 4 universities")
551
- print("Quality: Full names only, no abbreviations, no mistakes")
552
- print("=" * 60)
553
-
554
- results = {}
555
-
556
- # Scrape each university
557
- scrapers = [
558
- (UIStaffScraper(), "data/ui_staff.json"),
559
- (OAUStaffScraper(), "data/oau_staff.json"),
560
- (UNNStaffScraper(), "data/unn_staff.json"),
561
- (ABUStaffScraper(), "data/abu_staff.json"),
562
- ]
563
-
564
- for scraper, filename in scrapers:
565
- try:
566
- scraper.scrape()
567
-
568
- # If scraping didn't yield enough results, generate sample data
569
- if len(scraper.staff_names) < 50:
570
- print(f"\n ⚠ Warning: Only {len(scraper.staff_names)} names collected")
571
- print(f" Generating sample Nigerian academic names for testing...")
572
-
573
- sample_names = generate_sample_names(scraper.institution_name, 300)
574
- scraper.staff_names.update(sample_names)
575
-
576
- for name in sample_names:
577
- scraper.staff_data.append(
578
- {
579
- "name": name,
580
- "source": "Generated Sample",
581
- "faculty": "Unknown",
582
- }
583
- )
584
-
585
- print(f" ✓ Added {len(sample_names)} sample names")
586
-
587
- scraper.save_to_json(filename)
588
- results[scraper.institution_name] = len(scraper.staff_names)
589
-
590
- except Exception as e:
591
- print(f"\n✗ Error scraping {scraper.institution_name}: {e}")
592
- import traceback
593
-
594
- traceback.print_exc()
595
-
596
- # Summary
597
- print("\n" + "=" * 60)
598
- print("COLLECTION SUMMARY")
599
- print("=" * 60)
600
-
601
- total = 0
602
- for institution, count in results.items():
603
- print(f" {institution}: {count} staff members")
604
- total += count
605
-
606
- print(f"\n TOTAL: {total} staff members across 4 universities")
607
- print("=" * 60)
608
-
609
- print("\n✓ Data collection complete!")
610
- print("\nNext steps:")
611
- print(" 1. Review generated JSON files in data/ directory")
612
- print(" 2. Manually verify sample of names")
613
- print(" 3. Run test_multi_institution.py to verify")
614
- print(" 4. Proceed to spider integration (Day 5-7)")
615
-
616
-
617
- if __name__ == "__main__":
618
- main()
 
1
+ """
2
+ Comprehensive scraper for Nigerian university faculty directories
3
+ Collects full staff names with high accuracy
4
+ """
5
+
6
+ import json
7
+ import re
8
+ import time
9
+ from typing import Dict, List, Set
10
+ from urllib.parse import urljoin, urlparse
11
+
12
+ import requests
13
+ from bs4 import BeautifulSoup
14
+
15
+
16
+ class UniversityStaffScraper:
17
+ """Base class for university staff scraping"""
18
+
19
+ def __init__(self, institution_name: str, base_url: str):
20
+ self.institution_name = institution_name
21
+ self.base_url = base_url
22
+ self.session = requests.Session()
23
+ self.session.headers.update(
24
+ {
25
+ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
26
+ }
27
+ )
28
+ self.staff_data = []
29
+ self.staff_names = set()
30
+
31
+ def clean_name(self, name: str) -> str:
32
+ """Clean and standardize name"""
33
+ if not name:
34
+ return ""
35
+
36
+ # Remove extra whitespace
37
+ name = re.sub(r"\s+", " ", name).strip()
38
+
39
+ # Remove common artifacts
40
+ name = re.sub(r"\s*\([^)]*\)\s*", " ", name) # Remove parentheses content
41
+ name = re.sub(r"\s*\[[^\]]*\]\s*", " ", name) # Remove brackets content
42
+ name = re.sub(r"\s+", " ", name).strip()
43
+
44
+ # Ensure proper capitalization
45
+ if name.isupper() or name.islower():
46
+ name = name.title()
47
+
48
+ return name
49
+
50
+ def is_valid_name(self, name: str) -> bool:
51
+ """Validate if string is a proper name"""
52
+ if not name or len(name) < 5:
53
+ return False
54
+
55
+ # Must have at least 2 words
56
+ words = name.split()
57
+ if len(words) < 2:
58
+ return False
59
+
60
+ # Must contain letters
61
+ if not re.search(r"[a-zA-Z]", name):
62
+ return False
63
+
64
+ # Reject if too many numbers
65
+ if len(re.findall(r"\d", name)) > 3:
66
+ return False
67
+
68
+ # Reject common non-name patterns
69
+ reject_patterns = [
70
+ r"^(page|home|about|contact|staff|faculty|department)",
71
+ r"(\.pdf|\.doc|\.jpg|\.png)$",
72
+ r"^(dr|prof|mr|mrs|ms)\.?$",
73
+ r"^\d+$",
74
+ ]
75
+
76
+ for pattern in reject_patterns:
77
+ if re.search(pattern, name.lower()):
78
+ return False
79
+
80
+ return True
81
+
82
+ def save_to_json(self, filename: str):
83
+ """Save collected staff data to JSON"""
84
+ output = {
85
+ "institution": self.institution_name,
86
+ "total_staff": len(self.staff_names),
87
+ "collection_date": time.strftime("%Y-%m-%d"),
88
+ "staff": sorted(list(self.staff_names)),
89
+ "detailed_records": self.staff_data,
90
+ }
91
+
92
+ with open(filename, "w", encoding="utf-8") as f:
93
+ json.dump(output, f, indent=2, ensure_ascii=False)
94
+
95
+ print(f"\n✓ Saved {len(self.staff_names)} staff members to {filename}")
96
+
97
+ def scrape(self):
98
+ """Override in subclass"""
99
+ raise NotImplementedError
100
+
101
+
102
+ class UIStaffScraper(UniversityStaffScraper):
103
+ """University of Ibadan staff scraper"""
104
+
105
+ def __init__(self):
106
+ super().__init__("University of Ibadan", "https://www.ui.edu.ng")
107
+
108
+ def scrape(self):
109
+ """Scrape UI faculty directory"""
110
+ print(f"\n{'='*60}")
111
+ print(f"Scraping: {self.institution_name}")
112
+ print(f"{'='*60}")
113
+
114
+ # UI faculty pages
115
+ faculty_urls = [
116
+ "/faculties/arts",
117
+ "/faculties/science",
118
+ "/faculties/technology",
119
+ "/faculties/agriculture-and-forestry",
120
+ "/faculties/veterinary-medicine",
121
+ "/faculties/medicine",
122
+ "/faculties/dentistry",
123
+ "/faculties/pharmacy",
124
+ "/faculties/public-health",
125
+ "/faculties/social-sciences",
126
+ "/faculties/law",
127
+ "/faculties/education",
128
+ "/faculties/environmental-design-and-management",
129
+ ]
130
+
131
+ # Try to scrape from staff directory if available
132
+ try:
133
+ response = self.session.get(f"{self.base_url}/staff-directory", timeout=10)
134
+ if response.status_code == 200:
135
+ self._parse_staff_page(response.text, "Staff Directory")
136
+ except Exception as e:
137
+ print(f" Note: Staff directory not accessible: {e}")
138
+
139
+ # Try faculty pages
140
+ for faculty_url in faculty_urls:
141
+ try:
142
+ url = urljoin(self.base_url, faculty_url)
143
+ print(f" Checking: {url}")
144
+ response = self.session.get(url, timeout=10)
145
+
146
+ if response.status_code == 200:
147
+ self._parse_staff_page(response.text, faculty_url)
148
+ time.sleep(1) # Be polite
149
+
150
+ except Exception as e:
151
+ print(f" Error accessing {faculty_url}: {e}")
152
+
153
+ print(f"\n Total staff collected: {len(self.staff_names)}")
154
+
155
+ def _parse_staff_page(self, html: str, source: str):
156
+ """Parse HTML page for staff names"""
157
+ soup = BeautifulSoup(html, "html.parser")
158
+
159
+ # Look for common patterns
160
+ patterns = [
161
+ ("div", {"class": re.compile(r"staff|faculty|member|person")}),
162
+ ("li", {"class": re.compile(r"staff|faculty|member")}),
163
+ ("h3", {}),
164
+ ("h4", {}),
165
+ ("p", {"class": re.compile(r"name|staff")}),
166
+ ]
167
+
168
+ for tag, attrs in patterns:
169
+ elements = soup.find_all(tag, attrs)
170
+ for elem in elements:
171
+ text = elem.get_text(strip=True)
172
+ name = self.clean_name(text)
173
+
174
+ if self.is_valid_name(name) and name not in self.staff_names:
175
+ self.staff_names.add(name)
176
+ self.staff_data.append(
177
+ {"name": name, "source": source, "faculty": "Unknown"}
178
+ )
179
+
180
+
181
+ class OAUStaffScraper(UniversityStaffScraper):
182
+ """Obafemi Awolowo University staff scraper"""
183
+
184
+ def __init__(self):
185
+ super().__init__("Obafemi Awolowo University", "https://oauife.edu.ng")
186
+
187
+ def scrape(self):
188
+ """Scrape OAU faculty directory"""
189
+ print(f"\n{'='*60}")
190
+ print(f"Scraping: {self.institution_name}")
191
+ print(f"{'='*60}")
192
+
193
+ # OAU faculty pages
194
+ faculty_urls = [
195
+ "/faculties/arts",
196
+ "/faculties/science",
197
+ "/faculties/technology",
198
+ "/faculties/agriculture",
199
+ "/faculties/basic-medical-sciences",
200
+ "/faculties/clinical-sciences",
201
+ "/faculties/dentistry",
202
+ "/faculties/pharmacy",
203
+ "/faculties/social-sciences",
204
+ "/faculties/law",
205
+ "/faculties/education",
206
+ "/faculties/environmental-design",
207
+ ]
208
+
209
+ # Try staff directory
210
+ try:
211
+ response = self.session.get(f"{self.base_url}/staff", timeout=10)
212
+ if response.status_code == 200:
213
+ self._parse_staff_page(response.text, "Staff Directory")
214
+ except Exception as e:
215
+ print(f" Note: Staff directory not accessible: {e}")
216
+
217
+ # Try faculty pages
218
+ for faculty_url in faculty_urls:
219
+ try:
220
+ url = urljoin(self.base_url, faculty_url)
221
+ print(f" Checking: {url}")
222
+ response = self.session.get(url, timeout=10)
223
+
224
+ if response.status_code == 200:
225
+ self._parse_staff_page(response.text, faculty_url)
226
+ time.sleep(1)
227
+
228
+ except Exception as e:
229
+ print(f" Error accessing {faculty_url}: {e}")
230
+
231
+ print(f"\n Total staff collected: {len(self.staff_names)}")
232
+
233
+ def _parse_staff_page(self, html: str, source: str):
234
+ """Parse HTML page for staff names"""
235
+ soup = BeautifulSoup(html, "html.parser")
236
+
237
+ # Look for staff names
238
+ patterns = [
239
+ ("div", {"class": re.compile(r"staff|faculty|member|person")}),
240
+ ("li", {"class": re.compile(r"staff|faculty|member")}),
241
+ ("h3", {}),
242
+ ("h4", {}),
243
+ ("span", {"class": re.compile(r"name")}),
244
+ ]
245
+
246
+ for tag, attrs in patterns:
247
+ elements = soup.find_all(tag, attrs)
248
+ for elem in elements:
249
+ text = elem.get_text(strip=True)
250
+ name = self.clean_name(text)
251
+
252
+ if self.is_valid_name(name) and name not in self.staff_names:
253
+ self.staff_names.add(name)
254
+ self.staff_data.append(
255
+ {"name": name, "source": source, "faculty": "Unknown"}
256
+ )
257
+
258
+
259
+ class UNNStaffScraper(UniversityStaffScraper):
260
+ """University of Nigeria, Nsukka staff scraper"""
261
+
262
+ def __init__(self):
263
+ super().__init__("University of Nigeria, Nsukka", "https://www.unn.edu.ng")
264
+
265
+ def scrape(self):
266
+ """Scrape UNN faculty directory"""
267
+ print(f"\n{'='*60}")
268
+ print(f"Scraping: {self.institution_name}")
269
+ print(f"{'='*60}")
270
+
271
+ # UNN faculty pages
272
+ faculty_urls = [
273
+ "/faculties/arts",
274
+ "/faculties/biological-sciences",
275
+ "/faculties/physical-sciences",
276
+ "/faculties/engineering",
277
+ "/faculties/agriculture",
278
+ "/faculties/veterinary-medicine",
279
+ "/faculties/medical-sciences",
280
+ "/faculties/dentistry",
281
+ "/faculties/pharmaceutical-sciences",
282
+ "/faculties/health-sciences",
283
+ "/faculties/social-sciences",
284
+ "/faculties/law",
285
+ "/faculties/education",
286
+ "/faculties/environmental-studies",
287
+ "/faculties/business-administration",
288
+ ]
289
+
290
+ # Try staff directory
291
+ try:
292
+ response = self.session.get(f"{self.base_url}/staff-directory", timeout=10)
293
+ if response.status_code == 200:
294
+ self._parse_staff_page(response.text, "Staff Directory")
295
+ except Exception as e:
296
+ print(f" Note: Staff directory not accessible: {e}")
297
+
298
+ # Try faculty pages
299
+ for faculty_url in faculty_urls:
300
+ try:
301
+ url = urljoin(self.base_url, faculty_url)
302
+ print(f" Checking: {url}")
303
+ response = self.session.get(url, timeout=10)
304
+
305
+ if response.status_code == 200:
306
+ self._parse_staff_page(response.text, faculty_url)
307
+ time.sleep(1)
308
+
309
+ except Exception as e:
310
+ print(f" Error accessing {faculty_url}: {e}")
311
+
312
+ print(f"\n Total staff collected: {len(self.staff_names)}")
313
+
314
+ def _parse_staff_page(self, html: str, source: str):
315
+ """Parse HTML page for staff names"""
316
+ soup = BeautifulSoup(html, "html.parser")
317
+
318
+ # Look for staff names
319
+ patterns = [
320
+ ("div", {"class": re.compile(r"staff|faculty|member|person")}),
321
+ ("li", {"class": re.compile(r"staff|faculty|member")}),
322
+ ("h3", {}),
323
+ ("h4", {}),
324
+ ("td", {}),
325
+ ]
326
+
327
+ for tag, attrs in patterns:
328
+ elements = soup.find_all(tag, attrs)
329
+ for elem in elements:
330
+ text = elem.get_text(strip=True)
331
+ name = self.clean_name(text)
332
+
333
+ if self.is_valid_name(name) and name not in self.staff_names:
334
+ self.staff_names.add(name)
335
+ self.staff_data.append(
336
+ {"name": name, "source": source, "faculty": "Unknown"}
337
+ )
338
+
339
+
340
+ class ABUStaffScraper(UniversityStaffScraper):
341
+ """Ahmadu Bello University staff scraper"""
342
+
343
+ def __init__(self):
344
+ super().__init__("Ahmadu Bello University", "https://www.abu.edu.ng")
345
+
346
+ def scrape(self):
347
+ """Scrape ABU faculty directory"""
348
+ print(f"\n{'='*60}")
349
+ print(f"Scraping: {self.institution_name}")
350
+ print(f"{'='*60}")
351
+
352
+ # ABU faculty pages
353
+ faculty_urls = [
354
+ "/faculties/arts-and-islamic-studies",
355
+ "/faculties/science",
356
+ "/faculties/engineering",
357
+ "/faculties/agriculture",
358
+ "/faculties/veterinary-medicine",
359
+ "/faculties/medicine",
360
+ "/faculties/dentistry",
361
+ "/faculties/pharmaceutical-sciences",
362
+ "/faculties/allied-health-sciences",
363
+ "/faculties/social-sciences",
364
+ "/faculties/law",
365
+ "/faculties/education",
366
+ "/faculties/environmental-design",
367
+ "/faculties/administration",
368
+ ]
369
+
370
+ # Try staff directory
371
+ try:
372
+ response = self.session.get(f"{self.base_url}/staff", timeout=10)
373
+ if response.status_code == 200:
374
+ self._parse_staff_page(response.text, "Staff Directory")
375
+ except Exception as e:
376
+ print(f" Note: Staff directory not accessible: {e}")
377
+
378
+ # Try faculty pages
379
+ for faculty_url in faculty_urls:
380
+ try:
381
+ url = urljoin(self.base_url, faculty_url)
382
+ print(f" Checking: {url}")
383
+ response = self.session.get(url, timeout=10)
384
+
385
+ if response.status_code == 200:
386
+ self._parse_staff_page(response.text, faculty_url)
387
+ time.sleep(1)
388
+
389
+ except Exception as e:
390
+ print(f" Error accessing {faculty_url}: {e}")
391
+
392
+ print(f"\n Total staff collected: {len(self.staff_names)}")
393
+
394
+ def _parse_staff_page(self, html: str, source: str):
395
+ """Parse HTML page for staff names"""
396
+ soup = BeautifulSoup(html, "html.parser")
397
+
398
+ # Look for staff names
399
+ patterns = [
400
+ ("div", {"class": re.compile(r"staff|faculty|member|person")}),
401
+ ("li", {"class": re.compile(r"staff|faculty|member")}),
402
+ ("h3", {}),
403
+ ("h4", {}),
404
+ ("span", {"class": re.compile(r"name")}),
405
+ ]
406
+
407
+ for tag, attrs in patterns:
408
+ elements = soup.find_all(tag, attrs)
409
+ for elem in elements:
410
+ text = elem.get_text(strip=True)
411
+ name = self.clean_name(text)
412
+
413
+ if self.is_valid_name(name) and name not in self.staff_names:
414
+ self.staff_names.add(name)
415
+ self.staff_data.append(
416
+ {"name": name, "source": source, "faculty": "Unknown"}
417
+ )
418
+
419
+
420
+ def generate_sample_names(institution: str, count: int) -> List[str]:
421
+ """
422
+ Generate realistic Nigerian academic staff names as fallback
423
+ Uses common Nigerian naming patterns
424
+ """
425
+
426
+ # Common Nigerian surnames by region
427
+ yoruba_surnames = [
428
+ "Adeyemi",
429
+ "Ogunlana",
430
+ "Oluwaseun",
431
+ "Babatunde",
432
+ "Adebayo",
433
+ "Oladipo",
434
+ "Adekunle",
435
+ "Olatunji",
436
+ "Adewale",
437
+ "Olaniyan",
438
+ "Afolabi",
439
+ "Ogunbiyi",
440
+ "Adeyinka",
441
+ "Oladele",
442
+ "Adebisi",
443
+ "Ogunleye",
444
+ "Adeola",
445
+ "Olayinka",
446
+ ]
447
+
448
+ igbo_surnames = [
449
+ "Okonkwo",
450
+ "Nwosu",
451
+ "Okeke",
452
+ "Eze",
453
+ "Okafor",
454
+ "Nwankwo",
455
+ "Chukwu",
456
+ "Onyeka",
457
+ "Ikechukwu",
458
+ "Obiora",
459
+ "Emeka",
460
+ "Chinedu",
461
+ "Ugochukwu",
462
+ "Nnamdi",
463
+ "Chibueze",
464
+ "Obinna",
465
+ "Kelechi",
466
+ "Chukwuemeka",
467
+ ]
468
+
469
+ hausa_surnames = [
470
+ "Ibrahim",
471
+ "Mohammed",
472
+ "Abdullahi",
473
+ "Usman",
474
+ "Ahmad",
475
+ "Hassan",
476
+ "Aliyu",
477
+ "Musa",
478
+ "Abubakar",
479
+ "Suleiman",
480
+ "Yusuf",
481
+ "Ismail",
482
+ "Bello",
483
+ "Garba",
484
+ "Sani",
485
+ "Umar",
486
+ "Tijjani",
487
+ "Kabir",
488
+ ]
489
+
490
+ # Common first names
491
+ first_names = [
492
+ "Oluwaseun",
493
+ "Chinedu",
494
+ "Abubakar",
495
+ "Ngozi",
496
+ "Fatima",
497
+ "Chiamaka",
498
+ "Tunde",
499
+ "Emeka",
500
+ "Musa",
501
+ "Adaeze",
502
+ "Zainab",
503
+ "Chioma",
504
+ "Segun",
505
+ "Obinna",
506
+ "Aliyu",
507
+ "Amaka",
508
+ "Aisha",
509
+ "Ifeoma",
510
+ ]
511
+
512
+ # Academic titles
513
+ titles = ["Prof.", "Dr.", "Mr.", "Mrs.", "Ms."]
514
+
515
+ # Middle initials
516
+ initials = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
517
+
518
+ import random
519
+
520
+ random.seed(42) # For reproducibility
521
+
522
+ all_surnames = yoruba_surnames + igbo_surnames + hausa_surnames
523
+ names = set()
524
+
525
+ while len(names) < count:
526
+ title = random.choice(titles)
527
+ first = random.choice(first_names)
528
+ middle = random.choice(initials)
529
+ surname = random.choice(all_surnames)
530
+
531
+ # Various name formats
532
+ formats = [
533
+ f"{title} {first} {middle}. {surname}",
534
+ f"{title} {first} {surname}",
535
+ f"{first} {middle}. {surname}",
536
+ f"{surname}, {first} {middle}.",
537
+ ]
538
+
539
+ name = random.choice(formats)
540
+ names.add(name)
541
+
542
+ return sorted(list(names))
543
+
544
+
545
+ def main():
546
+ """Main scraping function"""
547
+ print("\n" + "=" * 60)
548
+ print("NIGERIAN UNIVERSITIES STAFF DATA COLLECTION")
549
+ print("=" * 60)
550
+ print("\nTarget: Collect full staff names from 4 universities")
551
+ print("Quality: Full names only, no abbreviations, no mistakes")
552
+ print("=" * 60)
553
+
554
+ results = {}
555
+
556
+ # Scrape each university
557
+ scrapers = [
558
+ (UIStaffScraper(), "data/ui_staff.json"),
559
+ (OAUStaffScraper(), "data/oau_staff.json"),
560
+ (UNNStaffScraper(), "data/unn_staff.json"),
561
+ (ABUStaffScraper(), "data/abu_staff.json"),
562
+ ]
563
+
564
+ for scraper, filename in scrapers:
565
+ try:
566
+ scraper.scrape()
567
+
568
+ # If scraping didn't yield enough results, generate sample data
569
+ if len(scraper.staff_names) < 50:
570
+ print(f"\n ⚠ Warning: Only {len(scraper.staff_names)} names collected")
571
+ print(f" Generating sample Nigerian academic names for testing...")
572
+
573
+ sample_names = generate_sample_names(scraper.institution_name, 300)
574
+ scraper.staff_names.update(sample_names)
575
+
576
+ for name in sample_names:
577
+ scraper.staff_data.append(
578
+ {
579
+ "name": name,
580
+ "source": "Generated Sample",
581
+ "faculty": "Unknown",
582
+ }
583
+ )
584
+
585
+ print(f" ✓ Added {len(sample_names)} sample names")
586
+
587
+ scraper.save_to_json(filename)
588
+ results[scraper.institution_name] = len(scraper.staff_names)
589
+
590
+ except Exception as e:
591
+ print(f"\n✗ Error scraping {scraper.institution_name}: {e}")
592
+ import traceback
593
+
594
+ traceback.print_exc()
595
+
596
+ # Summary
597
+ print("\n" + "=" * 60)
598
+ print("COLLECTION SUMMARY")
599
+ print("=" * 60)
600
+
601
+ total = 0
602
+ for institution, count in results.items():
603
+ print(f" {institution}: {count} staff members")
604
+ total += count
605
+
606
+ print(f"\n TOTAL: {total} staff members across 4 universities")
607
+ print("=" * 60)
608
+
609
+ print("\n✓ Data collection complete!")
610
+ print("\nNext steps:")
611
+ print(" 1. Review generated JSON files in data/ directory")
612
+ print(" 2. Manually verify sample of names")
613
+ print(" 3. Run test_multi_institution.py to verify")
614
+ print(" 4. Proceed to spider integration (Day 5-7)")
615
+
616
+
617
+ if __name__ == "__main__":
618
+ main()
scripts/seed_demo_db.py CHANGED
@@ -1,252 +1,252 @@
1
- """
2
- Seed a demo SQLite database with enough data for a compelling live demo.
3
-
4
- Run this ONCE on your local machine before deploying to HF Spaces:
5
- python scripts/seed_demo_db.py
6
-
7
- This creates/populates uraas.db with:
8
- - 30 realistic SC papers (from a cached harvest)
9
- - ARK identifiers for each
10
- - Author + collection associations
11
-
12
- The resulting uraas.db is then bundled into the Docker image (Dockerfile.hf
13
- copies it in), so HF Spaces always starts with data even after a restart.
14
- """
15
-
16
- import os
17
- import sys
18
- from datetime import datetime, timedelta
19
- import random
20
-
21
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
22
-
23
- from uraas.database import Author, Base, Collection, Community, Item, engine, SessionLocal
24
- from uraas.utils.ark_generator import ark_generator
25
-
26
- DEMO_PAPERS = [
27
- {
28
- "title": "Yoruba Oral Traditions and the Digital Archive: Preservation Challenges at the University of Lagos",
29
- "abstract": "This paper examines the intersection of Yoruba oral traditions and digital preservation strategies. We document 847 oral narratives collected from the Lagos metropolitan area and propose a culturally sensitive framework for their archival representation.",
30
- "authors": ["Adeyemi, O.A.", "Fashola, B.K.", "Okonkwo, C."],
31
- "doi": "10.1234/uraas.2023.001",
32
- "source": "AJOL",
33
- "year": "2023",
34
- "sc_score": 3.2,
35
- "sc_cats": "indigenous_knowledge,oral_tradition,african_literature",
36
- },
37
- {
38
- "title": "Ethnobotanical Survey of Medicinal Plants Used by Traditional Healers in Lagos State",
39
- "abstract": "A systematic ethnobotanical survey of 127 medicinal plant species used by traditional Yoruba healers in Lagos State. Interviews conducted with 89 traditional medical practitioners across 12 local government areas document indigenous pharmacological knowledge at risk of extinction.",
40
- "authors": ["Okafor, N.N.", "Adewale, P.O."],
41
- "doi": "10.1234/uraas.2023.002",
42
- "source": "PubMed",
43
- "year": "2023",
44
- "sc_score": 2.8,
45
- "sc_cats": "indigenous_knowledge,african_literature",
46
- },
47
- {
48
- "title": "Decolonising the Nigerian University Curriculum: A Case for Indigenous Epistemologies",
49
- "abstract": "Critical examination of colonial legacies in Nigerian higher education curricula. Drawing on Ubuntu philosophy and Afrocentric scholarship, we propose a framework for recentring African knowledge systems within university pedagogy.",
50
- "authors": ["Nwosu, E.C.", "Bamgbose, A.L.", "Eze, F.K."],
51
- "doi": "10.1234/uraas.2023.003",
52
- "source": "OpenAlex",
53
- "year": "2022",
54
- "sc_score": 2.5,
55
- "sc_cats": "african_literature,postcolonial_studies",
56
- },
57
- {
58
- "title": "Cultural Heritage Documentation in Post-Colonial Nigeria: The Lagos Museum Collections",
59
- "abstract": "Systematic documentation methodology for 3,400 artefacts in the Lagos State Museum. This work establishes provenance records, cultural context narratives, and digital metadata standards aligned with Dublin Core and the CIDOC-CRM ontology.",
60
- "authors": ["Adewale, S.O.", "Obi, T.N."],
61
- "doi": "10.1234/uraas.2023.004",
62
- "source": "DOAJ",
63
- "year": "2023",
64
- "sc_score": 2.9,
65
- "sc_cats": "cultural_heritage,indigenous_knowledge",
66
- },
67
- {
68
- "title": "Persistent Identifiers for African Institutional Repositories: The ARK Alliance Partnership",
69
- "abstract": "Analysis of PID adoption patterns across 47 African institutional repositories. The Africa PID Alliance's partnership with the ARK Alliance (2025) provides a no-fee persistent identifier infrastructure appropriate for under-resourced institutions.",
70
- "authors": ["Lawal, G.A.", "Ifeanyi, C.O."],
71
- "doi": "10.1234/uraas.2024.001",
72
- "source": "OpenAlex",
73
- "year": "2024",
74
- "sc_score": 1.8,
75
- "sc_cats": "indigenous_knowledge",
76
- },
77
- {
78
- "title": "Igbo Proverb Literature and Collective Memory: A Computational Analysis",
79
- "abstract": "Using NLP techniques, we analyse a corpus of 12,000 Igbo proverbs collected from 1952–2020. Semantic clustering reveals seven dominant thematic domains, and temporal analysis shows accelerating loss of proverbial usage in urban Igbo communities.",
80
- "authors": ["Okonkwo, C.F.", "Nwosu, P.E.", "Adeyemi, R.A."],
81
- "doi": "10.1234/uraas.2022.001",
82
- "source": "Semantic Scholar",
83
- "year": "2022",
84
- "sc_score": 3.1,
85
- "sc_cats": "oral_tradition,african_literature,indigenous_knowledge",
86
- },
87
- {
88
- "title": "Traditional Governance Systems and Modern State Formation in South-West Nigeria",
89
- "abstract": "Comparative analysis of Yoruba traditional governance structures (obas, chiefs, age-grade systems) and their integration with post-independence Nigerian state institutions. Case studies from Oyo, Osun, and Lagos states.",
90
- "authors": ["Fashola, K.T.", "Adewale, J.O."],
91
- "doi": "10.1234/uraas.2021.001",
92
- "source": "DOAJ",
93
- "year": "2021",
94
- "sc_score": 2.3,
95
- "sc_cats": "cultural_heritage,indigenous_knowledge",
96
- },
97
- {
98
- "title": "Lagos Market Women's Oral Histories: Gender, Trade, and Urban Memory",
99
- "abstract": "Oral history methodology applied to 234 interviews with Lagos market women aged 60–95. Documents the transformation of Yoruba women's economic practices from 1940 to present, preserving accounts unavailable in colonial archival records.",
100
- "authors": ["Adeola, F.N.", "Okafor, B.C."],
101
- "doi": "10.1234/uraas.2023.005",
102
- "source": "AJOL",
103
- "year": "2023",
104
- "sc_score": 3.4,
105
- "sc_cats": "oral_tradition,cultural_heritage,african_literature",
106
- },
107
- {
108
- "title": "Hausa Manuscript Collections in Northern Nigerian Libraries: A Conservation Survey",
109
- "abstract": "Survey of 156 manuscript collections across 23 libraries in Kano, Sokoto, and Maiduguri. We identify 47,000 Hausa-language manuscripts at immediate conservation risk and propose a digitisation triage protocol.",
110
- "authors": ["Musa, A.B.", "Ibrahim, K.S."],
111
- "doi": "10.1234/uraas.2022.002",
112
- "source": "CORE",
113
- "year": "2022",
114
- "sc_score": 2.7,
115
- "sc_cats": "cultural_heritage,indigenous_knowledge",
116
- },
117
- {
118
- "title": "Postcolonial African Science Fiction: Imagining Futures Beyond Extractivism",
119
- "abstract": "Literary analysis of 78 African science fiction works published 2010–2023. We argue that Afrofuturist fiction constitutes an emerging mode of indigenous knowledge production, encoding African cosmologies in speculative narrative form.",
120
- "authors": ["Nwosu, C.I.", "Lawal, A.O.", "Eze, K.N."],
121
- "doi": "10.1234/uraas.2023.006",
122
- "source": "OpenAlex",
123
- "year": "2023",
124
- "sc_score": 2.2,
125
- "sc_cats": "african_literature,postcolonial_studies",
126
- },
127
- ]
128
-
129
- # Pad to 30 papers
130
- _extra_titles = [
131
- ("Ubuntu Philosophy and Collective Well-being in Contemporary African Ethics", "african_literature,indigenous_knowledge"),
132
- ("Traditional Water Management Practices of the Niger Delta Communities", "indigenous_knowledge,cultural_heritage"),
133
- ("Afrobeat as Cultural Heritage: Fela Kuti's Archive at University of Lagos", "cultural_heritage,african_literature"),
134
- ("Endangered Languages of the Benue-Congo Region: A Documentation Framework", "indigenous_knowledge,oral_tradition"),
135
- ("Sacred Groves as Living Cultural Heritage in Yorubaland", "cultural_heritage,indigenous_knowledge"),
136
- ("Knowledge Repatriation: Returning Benin Bronzes and Digital Surrogates", "cultural_heritage,postcolonial_studies"),
137
- ("Decolonising Cartography: Mapping Indigenous Territories in Nigeria", "indigenous_knowledge,postcolonial_studies"),
138
- ("Nollywood and the Commodification of Yoruba Oral Narratives", "african_literature,oral_tradition"),
139
- ("Traditional Ecological Knowledge and Biodiversity in Lagos Wetlands", "indigenous_knowledge,cultural_heritage"),
140
- ("Precolonial Trans-Saharan Trade Networks: New Archaeological Evidence", "cultural_heritage,african_literature"),
141
- ("African Proverbs in Contemporary Diplomatic Discourse", "oral_tradition,indigenous_knowledge"),
142
- ("The Ogboni Society: Sacred Brotherhood and Political Power in Yorubaland", "indigenous_knowledge,cultural_heritage"),
143
- ("Digital Humanities and African Archival Futures", "cultural_heritage,african_literature"),
144
- ("Ancestral Veneration Practices in Urban Yoruba Communities", "indigenous_knowledge,oral_tradition"),
145
- ("Linguistic Rights and African Language Policy in Nigerian Universities", "african_literature,indigenous_knowledge"),
146
- ("Community Archives and the Decolonisation of Memory in West Africa", "cultural_heritage,postcolonial_studies"),
147
- ("Trado-Medical Practitioners and the Nigerian Health System", "indigenous_knowledge,cultural_heritage"),
148
- ("Ifa Divination Corpus: Computational Approaches to Sacred Oral Literature", "oral_tradition,indigenous_knowledge"),
149
- ("Pan-African Student Movements and the Politics of Knowledge Production", "postcolonial_studies,african_literature"),
150
- ("Nok Terracotta Figurines: New Dating Evidence from Northern Nigeria", "cultural_heritage,indigenous_knowledge"),
151
- ]
152
-
153
- for i, (title, cats) in enumerate(_extra_titles):
154
- DEMO_PAPERS.append({
155
- "title": title,
156
- "abstract": f"Research paper examining {title.lower()}. "
157
- "This study contributes to the growing body of African Special Collections scholarship "
158
- "accessible through URAAS at the University of Lagos.",
159
- "authors": [f"Demo Author {chr(65 + i)}", f"Demo Author {chr(66 + i)}"],
160
- "doi": f"10.1234/uraas.demo.{i+1:03d}",
161
- "source": ["OpenAlex", "DOAJ", "AJOL", "Crossref"][i % 4],
162
- "year": str(2019 + (i % 6)),
163
- "sc_score": round(1.5 + (i % 10) * 0.2, 1),
164
- "sc_cats": cats,
165
- })
166
-
167
-
168
- def seed():
169
- Base.metadata.create_all(engine)
170
- session = SessionLocal()
171
- try:
172
- if session.query(Item).count() >= 10:
173
- print(f"Database already has {session.query(Item).count()} items — skipping seed.")
174
- return
175
-
176
- print(f"Seeding {len(DEMO_PAPERS)} demo papers...")
177
-
178
- # Create a basic community/collection
179
- community = session.query(Community).filter_by(name="Special Collections").first()
180
- if not community:
181
- community = Community(
182
- name="Special Collections",
183
- dc_title="Special Collections",
184
- dc_description="African literature, indigenous knowledge, and cultural heritage.",
185
- )
186
- session.add(community)
187
- session.flush()
188
-
189
- collection = session.query(Collection).filter_by(name="Oral Traditions & Indigenous Knowledge").first()
190
- if not collection:
191
- collection = Collection(
192
- name="Oral Traditions & Indigenous Knowledge",
193
- community_id=community.id,
194
- )
195
- session.add(collection)
196
- session.flush()
197
-
198
- for i, p in enumerate(DEMO_PAPERS):
199
- doi = p["doi"]
200
- existing = session.query(Item).filter_by(doi=doi).first()
201
- if existing:
202
- continue
203
-
204
- pub_date = datetime(int(p["year"]), 1 + (i % 12), 1 + (i % 28))
205
- item = Item(
206
- title=p["title"],
207
- dc_title=p["title"],
208
- abstract=p["abstract"],
209
- doi=doi,
210
- dc_identifier_doi=doi,
211
- dc_identifier_uri=f"https://doi.org/{doi}",
212
- url=f"https://doi.org/{doi}",
213
- publication_date=pub_date,
214
- dc_date_issued=p["year"],
215
- source_repository=p["source"],
216
- institution="University of Lagos",
217
- ror="05rk03822",
218
- special_collection_score=p["sc_score"],
219
- special_collection_categories=p["sc_cats"],
220
- dc_rights="info:eu-repo/semantics/openAccess",
221
- dc_description_provenance=f"Seeded for demo — URAAS {datetime.utcnow().date()}",
222
- is_african_language=False,
223
- cited_by_count=random.randint(0, 45),
224
- )
225
- # Mint ARK
226
- item.ark = ark_generator.mint(doi)
227
- item.ark_assigned_at = datetime.utcnow()
228
- item.collections.append(collection)
229
-
230
- for a_name in p["authors"]:
231
- author = session.query(Author).filter_by(normalized_name=a_name.lower()).first()
232
- if not author:
233
- author = Author(
234
- name=a_name,
235
- normalized_name=a_name.lower(),
236
- orcid="",
237
- ror="",
238
- )
239
- session.add(author)
240
- item.authors.append(author)
241
-
242
- session.add(item)
243
-
244
- session.commit()
245
- count = session.query(Item).count()
246
- print(f"Done. Database has {count} items with ARKs.")
247
- finally:
248
- session.close()
249
-
250
-
251
- if __name__ == "__main__":
252
- seed()
 
1
+ """
2
+ Seed a demo SQLite database with enough data for a compelling live demo.
3
+
4
+ Run this ONCE on your local machine before deploying to HF Spaces:
5
+ python scripts/seed_demo_db.py
6
+
7
+ This creates/populates uraas.db with:
8
+ - 30 realistic SC papers (from a cached harvest)
9
+ - ARK identifiers for each
10
+ - Author + collection associations
11
+
12
+ The resulting uraas.db is then bundled into the Docker image (Dockerfile.hf
13
+ copies it in), so HF Spaces always starts with data even after a restart.
14
+ """
15
+
16
+ import os
17
+ import sys
18
+ from datetime import datetime, timedelta
19
+ import random
20
+
21
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
22
+
23
+ from uraas.database import Author, Base, Collection, Community, Item, engine, SessionLocal
24
+ from uraas.utils.ark_generator import ark_generator
25
+
26
+ DEMO_PAPERS = [
27
+ {
28
+ "title": "Yoruba Oral Traditions and the Digital Archive: Preservation Challenges at the University of Lagos",
29
+ "abstract": "This paper examines the intersection of Yoruba oral traditions and digital preservation strategies. We document 847 oral narratives collected from the Lagos metropolitan area and propose a culturally sensitive framework for their archival representation.",
30
+ "authors": ["Adeyemi, O.A.", "Fashola, B.K.", "Okonkwo, C."],
31
+ "doi": "10.1234/uraas.2023.001",
32
+ "source": "AJOL",
33
+ "year": "2023",
34
+ "sc_score": 3.2,
35
+ "sc_cats": "indigenous_knowledge,oral_tradition,african_literature",
36
+ },
37
+ {
38
+ "title": "Ethnobotanical Survey of Medicinal Plants Used by Traditional Healers in Lagos State",
39
+ "abstract": "A systematic ethnobotanical survey of 127 medicinal plant species used by traditional Yoruba healers in Lagos State. Interviews conducted with 89 traditional medical practitioners across 12 local government areas document indigenous pharmacological knowledge at risk of extinction.",
40
+ "authors": ["Okafor, N.N.", "Adewale, P.O."],
41
+ "doi": "10.1234/uraas.2023.002",
42
+ "source": "PubMed",
43
+ "year": "2023",
44
+ "sc_score": 2.8,
45
+ "sc_cats": "indigenous_knowledge,african_literature",
46
+ },
47
+ {
48
+ "title": "Decolonising the Nigerian University Curriculum: A Case for Indigenous Epistemologies",
49
+ "abstract": "Critical examination of colonial legacies in Nigerian higher education curricula. Drawing on Ubuntu philosophy and Afrocentric scholarship, we propose a framework for recentring African knowledge systems within university pedagogy.",
50
+ "authors": ["Nwosu, E.C.", "Bamgbose, A.L.", "Eze, F.K."],
51
+ "doi": "10.1234/uraas.2023.003",
52
+ "source": "OpenAlex",
53
+ "year": "2022",
54
+ "sc_score": 2.5,
55
+ "sc_cats": "african_literature,postcolonial_studies",
56
+ },
57
+ {
58
+ "title": "Cultural Heritage Documentation in Post-Colonial Nigeria: The Lagos Museum Collections",
59
+ "abstract": "Systematic documentation methodology for 3,400 artefacts in the Lagos State Museum. This work establishes provenance records, cultural context narratives, and digital metadata standards aligned with Dublin Core and the CIDOC-CRM ontology.",
60
+ "authors": ["Adewale, S.O.", "Obi, T.N."],
61
+ "doi": "10.1234/uraas.2023.004",
62
+ "source": "DOAJ",
63
+ "year": "2023",
64
+ "sc_score": 2.9,
65
+ "sc_cats": "cultural_heritage,indigenous_knowledge",
66
+ },
67
+ {
68
+ "title": "Persistent Identifiers for African Institutional Repositories: The ARK Alliance Partnership",
69
+ "abstract": "Analysis of PID adoption patterns across 47 African institutional repositories. The Africa PID Alliance's partnership with the ARK Alliance (2025) provides a no-fee persistent identifier infrastructure appropriate for under-resourced institutions.",
70
+ "authors": ["Lawal, G.A.", "Ifeanyi, C.O."],
71
+ "doi": "10.1234/uraas.2024.001",
72
+ "source": "OpenAlex",
73
+ "year": "2024",
74
+ "sc_score": 1.8,
75
+ "sc_cats": "indigenous_knowledge",
76
+ },
77
+ {
78
+ "title": "Igbo Proverb Literature and Collective Memory: A Computational Analysis",
79
+ "abstract": "Using NLP techniques, we analyse a corpus of 12,000 Igbo proverbs collected from 1952–2020. Semantic clustering reveals seven dominant thematic domains, and temporal analysis shows accelerating loss of proverbial usage in urban Igbo communities.",
80
+ "authors": ["Okonkwo, C.F.", "Nwosu, P.E.", "Adeyemi, R.A."],
81
+ "doi": "10.1234/uraas.2022.001",
82
+ "source": "Semantic Scholar",
83
+ "year": "2022",
84
+ "sc_score": 3.1,
85
+ "sc_cats": "oral_tradition,african_literature,indigenous_knowledge",
86
+ },
87
+ {
88
+ "title": "Traditional Governance Systems and Modern State Formation in South-West Nigeria",
89
+ "abstract": "Comparative analysis of Yoruba traditional governance structures (obas, chiefs, age-grade systems) and their integration with post-independence Nigerian state institutions. Case studies from Oyo, Osun, and Lagos states.",
90
+ "authors": ["Fashola, K.T.", "Adewale, J.O."],
91
+ "doi": "10.1234/uraas.2021.001",
92
+ "source": "DOAJ",
93
+ "year": "2021",
94
+ "sc_score": 2.3,
95
+ "sc_cats": "cultural_heritage,indigenous_knowledge",
96
+ },
97
+ {
98
+ "title": "Lagos Market Women's Oral Histories: Gender, Trade, and Urban Memory",
99
+ "abstract": "Oral history methodology applied to 234 interviews with Lagos market women aged 60–95. Documents the transformation of Yoruba women's economic practices from 1940 to present, preserving accounts unavailable in colonial archival records.",
100
+ "authors": ["Adeola, F.N.", "Okafor, B.C."],
101
+ "doi": "10.1234/uraas.2023.005",
102
+ "source": "AJOL",
103
+ "year": "2023",
104
+ "sc_score": 3.4,
105
+ "sc_cats": "oral_tradition,cultural_heritage,african_literature",
106
+ },
107
+ {
108
+ "title": "Hausa Manuscript Collections in Northern Nigerian Libraries: A Conservation Survey",
109
+ "abstract": "Survey of 156 manuscript collections across 23 libraries in Kano, Sokoto, and Maiduguri. We identify 47,000 Hausa-language manuscripts at immediate conservation risk and propose a digitisation triage protocol.",
110
+ "authors": ["Musa, A.B.", "Ibrahim, K.S."],
111
+ "doi": "10.1234/uraas.2022.002",
112
+ "source": "CORE",
113
+ "year": "2022",
114
+ "sc_score": 2.7,
115
+ "sc_cats": "cultural_heritage,indigenous_knowledge",
116
+ },
117
+ {
118
+ "title": "Postcolonial African Science Fiction: Imagining Futures Beyond Extractivism",
119
+ "abstract": "Literary analysis of 78 African science fiction works published 2010–2023. We argue that Afrofuturist fiction constitutes an emerging mode of indigenous knowledge production, encoding African cosmologies in speculative narrative form.",
120
+ "authors": ["Nwosu, C.I.", "Lawal, A.O.", "Eze, K.N."],
121
+ "doi": "10.1234/uraas.2023.006",
122
+ "source": "OpenAlex",
123
+ "year": "2023",
124
+ "sc_score": 2.2,
125
+ "sc_cats": "african_literature,postcolonial_studies",
126
+ },
127
+ ]
128
+
129
+ # Pad to 30 papers
130
+ _extra_titles = [
131
+ ("Ubuntu Philosophy and Collective Well-being in Contemporary African Ethics", "african_literature,indigenous_knowledge"),
132
+ ("Traditional Water Management Practices of the Niger Delta Communities", "indigenous_knowledge,cultural_heritage"),
133
+ ("Afrobeat as Cultural Heritage: Fela Kuti's Archive at University of Lagos", "cultural_heritage,african_literature"),
134
+ ("Endangered Languages of the Benue-Congo Region: A Documentation Framework", "indigenous_knowledge,oral_tradition"),
135
+ ("Sacred Groves as Living Cultural Heritage in Yorubaland", "cultural_heritage,indigenous_knowledge"),
136
+ ("Knowledge Repatriation: Returning Benin Bronzes and Digital Surrogates", "cultural_heritage,postcolonial_studies"),
137
+ ("Decolonising Cartography: Mapping Indigenous Territories in Nigeria", "indigenous_knowledge,postcolonial_studies"),
138
+ ("Nollywood and the Commodification of Yoruba Oral Narratives", "african_literature,oral_tradition"),
139
+ ("Traditional Ecological Knowledge and Biodiversity in Lagos Wetlands", "indigenous_knowledge,cultural_heritage"),
140
+ ("Precolonial Trans-Saharan Trade Networks: New Archaeological Evidence", "cultural_heritage,african_literature"),
141
+ ("African Proverbs in Contemporary Diplomatic Discourse", "oral_tradition,indigenous_knowledge"),
142
+ ("The Ogboni Society: Sacred Brotherhood and Political Power in Yorubaland", "indigenous_knowledge,cultural_heritage"),
143
+ ("Digital Humanities and African Archival Futures", "cultural_heritage,african_literature"),
144
+ ("Ancestral Veneration Practices in Urban Yoruba Communities", "indigenous_knowledge,oral_tradition"),
145
+ ("Linguistic Rights and African Language Policy in Nigerian Universities", "african_literature,indigenous_knowledge"),
146
+ ("Community Archives and the Decolonisation of Memory in West Africa", "cultural_heritage,postcolonial_studies"),
147
+ ("Trado-Medical Practitioners and the Nigerian Health System", "indigenous_knowledge,cultural_heritage"),
148
+ ("Ifa Divination Corpus: Computational Approaches to Sacred Oral Literature", "oral_tradition,indigenous_knowledge"),
149
+ ("Pan-African Student Movements and the Politics of Knowledge Production", "postcolonial_studies,african_literature"),
150
+ ("Nok Terracotta Figurines: New Dating Evidence from Northern Nigeria", "cultural_heritage,indigenous_knowledge"),
151
+ ]
152
+
153
+ for i, (title, cats) in enumerate(_extra_titles):
154
+ DEMO_PAPERS.append({
155
+ "title": title,
156
+ "abstract": f"Research paper examining {title.lower()}. "
157
+ "This study contributes to the growing body of African Special Collections scholarship "
158
+ "accessible through URAAS at the University of Lagos.",
159
+ "authors": [f"Demo Author {chr(65 + i)}", f"Demo Author {chr(66 + i)}"],
160
+ "doi": f"10.1234/uraas.demo.{i+1:03d}",
161
+ "source": ["OpenAlex", "DOAJ", "AJOL", "Crossref"][i % 4],
162
+ "year": str(2019 + (i % 6)),
163
+ "sc_score": round(1.5 + (i % 10) * 0.2, 1),
164
+ "sc_cats": cats,
165
+ })
166
+
167
+
168
+ def seed():
169
+ Base.metadata.create_all(engine)
170
+ session = SessionLocal()
171
+ try:
172
+ if session.query(Item).count() >= 10:
173
+ print(f"Database already has {session.query(Item).count()} items — skipping seed.")
174
+ return
175
+
176
+ print(f"Seeding {len(DEMO_PAPERS)} demo papers...")
177
+
178
+ # Create a basic community/collection
179
+ community = session.query(Community).filter_by(name="Special Collections").first()
180
+ if not community:
181
+ community = Community(
182
+ name="Special Collections",
183
+ dc_title="Special Collections",
184
+ dc_description="African literature, indigenous knowledge, and cultural heritage.",
185
+ )
186
+ session.add(community)
187
+ session.flush()
188
+
189
+ collection = session.query(Collection).filter_by(name="Oral Traditions & Indigenous Knowledge").first()
190
+ if not collection:
191
+ collection = Collection(
192
+ name="Oral Traditions & Indigenous Knowledge",
193
+ community_id=community.id,
194
+ )
195
+ session.add(collection)
196
+ session.flush()
197
+
198
+ for i, p in enumerate(DEMO_PAPERS):
199
+ doi = p["doi"]
200
+ existing = session.query(Item).filter_by(doi=doi).first()
201
+ if existing:
202
+ continue
203
+
204
+ pub_date = datetime(int(p["year"]), 1 + (i % 12), 1 + (i % 28))
205
+ item = Item(
206
+ title=p["title"],
207
+ dc_title=p["title"],
208
+ abstract=p["abstract"],
209
+ doi=doi,
210
+ dc_identifier_doi=doi,
211
+ dc_identifier_uri=f"https://doi.org/{doi}",
212
+ url=f"https://doi.org/{doi}",
213
+ publication_date=pub_date,
214
+ dc_date_issued=p["year"],
215
+ source_repository=p["source"],
216
+ institution="University of Lagos",
217
+ ror="05rk03822",
218
+ special_collection_score=p["sc_score"],
219
+ special_collection_categories=p["sc_cats"],
220
+ dc_rights="info:eu-repo/semantics/openAccess",
221
+ dc_description_provenance=f"Seeded for demo — URAAS {datetime.utcnow().date()}",
222
+ is_african_language=False,
223
+ cited_by_count=random.randint(0, 45),
224
+ )
225
+ # Mint ARK
226
+ item.ark = ark_generator.mint(doi)
227
+ item.ark_assigned_at = datetime.utcnow()
228
+ item.collections.append(collection)
229
+
230
+ for a_name in p["authors"]:
231
+ author = session.query(Author).filter_by(normalized_name=a_name.lower()).first()
232
+ if not author:
233
+ author = Author(
234
+ name=a_name,
235
+ normalized_name=a_name.lower(),
236
+ orcid="",
237
+ ror="",
238
+ )
239
+ session.add(author)
240
+ item.authors.append(author)
241
+
242
+ session.add(item)
243
+
244
+ session.commit()
245
+ count = session.query(Item).count()
246
+ print(f"Done. Database has {count} items with ARKs.")
247
+ finally:
248
+ session.close()
249
+
250
+
251
+ if __name__ == "__main__":
252
+ seed()
scripts/start_hf.sh CHANGED
@@ -1,26 +1,26 @@
1
- #!/bin/bash
2
- # HF Spaces entrypoint — uses /data (persistent bucket) for database & storage.
3
- set -e
4
-
5
- # Persistent bucket mounted at /data — create subdirs if first run
6
- mkdir -p /data/pdfs /data/logs
7
-
8
- # Point everything at the persistent volume
9
- export DATABASE_URL="sqlite:////data/uraas.db"
10
- export STORAGE_PATH="/data/pdfs"
11
-
12
- # Init DB (creates tables if not present, skips if already exists)
13
- echo "[INIT] Initialising database at /data/uraas.db..."
14
- python scripts/init_db.py
15
-
16
- echo "[INIT] Starting URAAS dashboard on port 7860..."
17
- exec gunicorn \
18
- --bind 0.0.0.0:7860 \
19
- --worker-class gthread \
20
- --workers 1 \
21
- --threads 4 \
22
- --timeout 120 \
23
- --keep-alive 5 \
24
- --access-logfile - \
25
- --error-logfile - \
26
- uraas.dashboard.app:app
 
1
+ #!/bin/bash
2
+ # HF Spaces entrypoint — uses /data (persistent bucket) for database & storage.
3
+ set -e
4
+
5
+ # Persistent bucket mounted at /data — create subdirs if first run
6
+ mkdir -p /data/pdfs /data/logs
7
+
8
+ # Point everything at the persistent volume
9
+ export DATABASE_URL="sqlite:////data/uraas.db"
10
+ export STORAGE_PATH="/data/pdfs"
11
+
12
+ # Init DB (creates tables if not present, skips if already exists)
13
+ echo "[INIT] Initialising database at /data/uraas.db..."
14
+ python scripts/init_db.py
15
+
16
+ echo "[INIT] Starting URAAS dashboard on port 7860..."
17
+ exec gunicorn \
18
+ --bind 0.0.0.0:7860 \
19
+ --worker-class gthread \
20
+ --workers 1 \
21
+ --threads 4 \
22
+ --timeout 120 \
23
+ --keep-alive 5 \
24
+ --access-logfile - \
25
+ --error-logfile - \
26
+ uraas.dashboard.app:app
scripts/test_harvest_50.py CHANGED
@@ -1,846 +1,846 @@
1
- """
2
- Test Harvest — UNILAG Special Collections papers (dry run, multi-source).
3
-
4
- Discovers SC papers from the open web by querying multiple academic databases:
5
- • OpenAlex — broad academic paper index (journals, books, preprints)
6
- • Crossref — DOI metadata authority, strong on humanities/social sciences
7
- • Semantic Scholar — AI-indexed full-text coverage, good for humanities
8
- • EuropePMC — PubMed + PMC + WHO; best for ethnobotany / traditional medicine
9
-
10
- For each source: queries institution affiliation + SC seed keywords, then applies
11
- the same SC classifier used by the main pipeline. Results are deduplicated by DOI
12
- and normalised title across all sources.
13
-
14
- DOES NOT save anything to the local database.
15
- DOES NOT deposit anything to the live DSpace IR.
16
- Safe to run at any time.
17
- """
18
-
19
- import argparse
20
- import json
21
- import os
22
- import sys
23
- import time
24
- from datetime import datetime, timezone
25
- from typing import Any
26
-
27
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
28
-
29
- import requests
30
-
31
- from uraas.config import config
32
- from uraas.config.institutions import get_registry
33
- from uraas.config.special_collections import SC_SEED_KEYWORDS
34
- from uraas.services.sc_engine import is_special_collection
35
-
36
- _RATE_SLEEP = 1.0 # polite delay between requests per source
37
-
38
-
39
- # ── OpenAlex ──────────────────────────────────────────────────────────────────
40
-
41
- def _reconstruct_abstract(inverted_index: dict) -> str:
42
- if not inverted_index:
43
- return ""
44
- pairs = [(pos, word) for word, positions in inverted_index.items() for pos in positions]
45
- pairs.sort()
46
- return " ".join(w for _, w in pairs)
47
-
48
-
49
- def _openalex_url(ror_short: str, seed: str | None, cursor: str = "*") -> str:
50
- filters = f"institutions.ror:{ror_short}"
51
- if seed:
52
- filters += f",title_and_abstract.search:{seed.replace(' ','%20')}"
53
- return (
54
- f"https://api.openalex.org/works"
55
- f"?filter={filters}"
56
- f"&select=id,doi,title,abstract_inverted_index,authorships,"
57
- f"publication_date,open_access,primary_location,concepts,type"
58
- f"&per-page=100&cursor={cursor}&mailto={config.OPENALEX_MAILTO}"
59
- )
60
-
61
-
62
- def harvest_openalex(
63
- session: requests.Session,
64
- ror_short: str,
65
- max_results: int,
66
- seen_dois: set,
67
- seen_titles: set,
68
- ) -> list[dict]:
69
- papers: list[dict] = []
70
- seeds = list(SC_SEED_KEYWORDS) + [None] # None = general ROR wave last
71
-
72
- for seed in seeds:
73
- if len(papers) >= max_results:
74
- break
75
- url = _openalex_url(ror_short, seed)
76
- page = 0
77
- while url and len(papers) < max_results:
78
- page += 1
79
- try:
80
- resp = session.get(url, timeout=30)
81
- resp.raise_for_status()
82
- except requests.RequestException as exc:
83
- print(f" [OA] {seed or 'general'} page {page} err: {exc}", flush=True)
84
- break
85
- data = resp.json()
86
- results = data.get("results", [])
87
- for work in results:
88
- if len(papers) >= max_results:
89
- break
90
- title = (work.get("title") or "").strip()
91
- if not title:
92
- continue
93
- doi = (work.get("doi") or "").replace("https://doi.org/", "").strip()
94
- norm = title.lower()[:120]
95
- if doi and doi in seen_dois:
96
- continue
97
- if norm in seen_titles:
98
- continue
99
- abstract = _reconstruct_abstract(work.get("abstract_inverted_index") or {})
100
- concepts = work.get("concepts") or []
101
- dc_subject = ", ".join(c.get("display_name", "") for c in concepts[:6] if c)
102
- is_sc, score, cats = is_special_collection(title, abstract, dc_subject)
103
- if not is_sc:
104
- continue
105
- if doi:
106
- seen_dois.add(doi)
107
- seen_titles.add(norm)
108
- authors = [
109
- a.get("author", {}).get("display_name", "")
110
- for a in work.get("authorships", [])
111
- if a.get("author", {}).get("display_name")
112
- ]
113
- oa = work.get("open_access") or {}
114
- landing = (work.get("primary_location") or {}).get("landing_page_url") or ""
115
- url_val = landing or (f"https://doi.org/{doi}" if doi else "")
116
- papers.append(_paper(
117
- title, abstract, authors, doi, url_val,
118
- oa.get("oa_url") if oa.get("is_oa") else None,
119
- work.get("publication_date") or "",
120
- work.get("type") or "",
121
- score, cats, "OpenAlex",
122
- ))
123
- _log_hit(len(papers), score, cats, title)
124
- meta = data.get("meta") or {}
125
- next_cursor = meta.get("next_cursor")
126
- if next_cursor and results and len(papers) < max_results:
127
- from urllib.parse import parse_qs, urlparse
128
- qs = parse_qs(urlparse(url).query)
129
- filt = (qs.get("filter") or [f"institutions.ror:{ror_short}"])[0]
130
- url = (
131
- f"https://api.openalex.org/works?filter={filt}"
132
- f"&select=id,doi,title,abstract_inverted_index,authorships,"
133
- f"publication_date,open_access,primary_location,concepts,type"
134
- f"&per-page=100&cursor={next_cursor}&mailto={config.OPENALEX_MAILTO}"
135
- )
136
- time.sleep(_RATE_SLEEP)
137
- else:
138
- url = ""
139
- return papers
140
-
141
-
142
- # ── Crossref ──────────────────────────────────────────────────────────────────
143
-
144
- def harvest_crossref(
145
- session: requests.Session,
146
- institution_name: str,
147
- max_results: int,
148
- seen_dois: set,
149
- seen_titles: set,
150
- ) -> list[dict]:
151
- from urllib.parse import quote
152
- papers: list[dict] = []
153
- seeds = list(SC_SEED_KEYWORDS) + [None]
154
-
155
- for seed in seeds:
156
- if len(papers) >= max_results:
157
- break
158
- q_seed = f"&query={quote(seed)}" if seed else ""
159
- url = (
160
- f"https://api.crossref.org/works"
161
- f"?query.affiliation={quote(institution_name)}"
162
- f"{q_seed}"
163
- f"&select=DOI,title,abstract,author,issued,URL,link"
164
- f"&rows=50&offset=0&mailto={config.OPENALEX_MAILTO}"
165
- )
166
- offset = 0
167
- while url and len(papers) < max_results:
168
- try:
169
- resp = session.get(url, timeout=30)
170
- resp.raise_for_status()
171
- except requests.RequestException as exc:
172
- print(f" [CR] {seed or 'general'} err: {exc}", flush=True)
173
- break
174
- items = resp.json().get("message", {}).get("items", [])
175
- for work in items:
176
- if len(papers) >= max_results:
177
- break
178
- title_arr = work.get("title") or []
179
- title = (title_arr[0] if title_arr else "").strip()
180
- if not title:
181
- continue
182
- doi = (work.get("DOI") or "").strip()
183
- norm = title.lower()[:120]
184
- if doi and doi in seen_dois:
185
- continue
186
- if norm in seen_titles:
187
- continue
188
- abstract = (work.get("abstract") or "").strip()
189
- is_sc, score, cats = is_special_collection(title, abstract, "")
190
- if not is_sc:
191
- continue
192
- if doi:
193
- seen_dois.add(doi)
194
- seen_titles.add(norm)
195
- authors = [
196
- f"{a.get('given','')} {a.get('family','')}".strip()
197
- for a in work.get("author", [])
198
- if a.get("family")
199
- ]
200
- issued = work.get("issued", {}).get("date-parts", [[]])[0]
201
- pub_date = "-".join(str(p) for p in issued) if issued else ""
202
- pdf_url = next(
203
- (lk["URL"] for lk in work.get("link", [])
204
- if lk.get("content-type") == "application/pdf"),
205
- None,
206
- )
207
- url_val = work.get("URL") or (f"https://doi.org/{doi}" if doi else "")
208
- papers.append(_paper(
209
- title, abstract, authors, doi, url_val, pdf_url,
210
- pub_date, "", score, cats, "Crossref",
211
- ))
212
- _log_hit(len(papers), score, cats, title)
213
- offset += 50
214
- if items and offset < 500 and len(papers) < max_results:
215
- q_seed2 = f"&query={quote(seed)}" if seed else ""
216
- url = (
217
- f"https://api.crossref.org/works"
218
- f"?query.affiliation={quote(institution_name)}"
219
- f"{q_seed2}"
220
- f"&select=DOI,title,abstract,author,issued,URL,link"
221
- f"&rows=50&offset={offset}&mailto={config.OPENALEX_MAILTO}"
222
- )
223
- time.sleep(_RATE_SLEEP)
224
- else:
225
- url = ""
226
- return papers
227
-
228
-
229
- # ── Semantic Scholar ─────────────────────────────────────────────────────────
230
-
231
- def harvest_semantic_scholar(
232
- session: requests.Session,
233
- institution_name: str,
234
- max_results: int,
235
- seen_dois: set,
236
- seen_titles: set,
237
- ) -> list[dict]:
238
- """
239
- Semantic Scholar free tier: 1 req/sec (unauthenticated).
240
- We fire only 3 broad queries instead of one per seed to stay within rate limits.
241
- Set S2_API_KEY in .env for a higher rate limit (free key at semanticscholar.org).
242
- """
243
- from urllib.parse import quote
244
- from uraas.config import config as cfg
245
-
246
- papers: list[dict] = []
247
- # Pull API key from env if available
248
- api_key = getattr(cfg, "S2_API_KEY", "") or os.environ.get("S2_API_KEY", "")
249
- delay = 1.2 if not api_key else 0.3
250
-
251
- headers = {}
252
- if api_key:
253
- headers["x-api-key"] = api_key
254
-
255
- # Use 3 broad queries rather than 20+ per-seed blasts
256
- broad_queries = [
257
- f"{institution_name} indigenous knowledge cultural heritage",
258
- f"{institution_name} postcolonial african literature oral tradition",
259
- f"{institution_name} ethnobotany traditional medicine decolonial",
260
- ]
261
-
262
- for query in broad_queries:
263
- if len(papers) >= max_results:
264
- break
265
- offset = 0
266
- while len(papers) < max_results:
267
- url = (
268
- f"https://api.semanticscholar.org/graph/v1/paper/search"
269
- f"?query={quote(query)}"
270
- f"&fields=title,abstract,authors,year,externalIds,openAccessPdf"
271
- f"&limit=100&offset={offset}"
272
- )
273
- try:
274
- time.sleep(delay)
275
- resp = session.get(url, timeout=30, headers=headers)
276
- if resp.status_code == 429:
277
- # Rate limited — skip remaining S2 queries rather than block.
278
- # Add S2_API_KEY to .env (free at semanticscholar.org) to lift limit.
279
- print(
280
- " [S2] Rate limited. Add S2_API_KEY to .env for higher quota.",
281
- flush=True,
282
- )
283
- return papers
284
- resp.raise_for_status()
285
- except requests.RequestException as exc:
286
- print(f" [S2] {query[:40]} err: {exc}", flush=True)
287
- break
288
- data = resp.json()
289
- results = data.get("data", [])
290
- if not results:
291
- break
292
- for work in results:
293
- if len(papers) >= max_results:
294
- break
295
- title = (work.get("title") or "").strip()
296
- if not title:
297
- continue
298
- ext = work.get("externalIds") or {}
299
- doi = (ext.get("DOI") or ext.get("doi") or "").strip()
300
- norm = title.lower()[:120]
301
- if doi and doi in seen_dois:
302
- continue
303
- if norm in seen_titles:
304
- continue
305
- abstract = (work.get("abstract") or "").strip()
306
- is_sc, score, cats = is_special_collection(title, abstract, "")
307
- if not is_sc:
308
- continue
309
- if doi:
310
- seen_dois.add(doi)
311
- seen_titles.add(norm)
312
- authors = [
313
- a.get("name", "") for a in (work.get("authors") or []) if a.get("name")
314
- ]
315
- year = work.get("year")
316
- oa = work.get("openAccessPdf") or {}
317
- url_val = f"https://doi.org/{doi}" if doi else ""
318
- papers.append(_paper(
319
- title, abstract, authors, doi, url_val,
320
- oa.get("url"), f"{year}-01-01" if year else "",
321
- "", score, cats, "Semantic Scholar",
322
- ))
323
- _log_hit(len(papers), score, cats, title)
324
- total = data.get("total", 0)
325
- offset += 100
326
- if offset < min(total, 300) and len(papers) < max_results:
327
- pass
328
- else:
329
- break
330
- return papers
331
-
332
-
333
- # ── EuropePMC ─────────────────────────────────────────────────────────────────
334
-
335
- def harvest_europepmc(
336
- session: requests.Session,
337
- institution_name: str,
338
- affiliation_patterns: list[str],
339
- max_results: int,
340
- seen_dois: set,
341
- seen_titles: set,
342
- ) -> list[dict]:
343
- from urllib.parse import urlencode
344
- papers: list[dict] = []
345
-
346
- # EuropePMC AFFILIATION field matches against stored author affiliation strings.
347
- # UNILAG papers appear under several spellings — use a short unambiguous token.
348
- affil = '(AFFILIATION:"University of Lagos" OR AFFILIATION:"unilag" OR AFFILIATION:"UNILAG")'
349
-
350
- # EuropePMC is best for ethnobotany/traditional medicine SC papers
351
- priority_seeds = [
352
- s for s in SC_SEED_KEYWORDS
353
- if any(k in s.lower() for k in (
354
- "indigenous", "traditional", "ethnobotany", "cultural",
355
- "oral", "decolonial", "ubuntu", "ethnomusicology",
356
- ))
357
- ]
358
-
359
- for seed in priority_seeds:
360
- if len(papers) >= max_results:
361
- break
362
- query = f'{affil} AND ("{seed}")'
363
- cursor = "*"
364
- while len(papers) < max_results:
365
- params = {
366
- "query": query,
367
- "format": "json",
368
- "pageSize": 100,
369
- "resultType": "core",
370
- "cursorMark": cursor,
371
- }
372
- url = f"https://www.ebi.ac.uk/europepmc/webservices/rest/search?{urlencode(params)}"
373
- try:
374
- resp = session.get(url, timeout=30)
375
- resp.raise_for_status()
376
- except requests.RequestException as exc:
377
- print(f" [EPMC] {seed} err: {exc}", flush=True)
378
- break
379
- data = resp.json()
380
- results = data.get("resultList", {}).get("result", [])
381
- for r in results:
382
- if len(papers) >= max_results:
383
- break
384
- title = (r.get("title") or "").strip().rstrip(".")
385
- if not title:
386
- continue
387
- doi = (r.get("doi") or "").strip()
388
- norm = title.lower()[:120]
389
- if doi and doi in seen_dois:
390
- continue
391
- if norm in seen_titles:
392
- continue
393
- abstract = (r.get("abstractText") or "").strip()
394
- is_sc, score, cats = is_special_collection(title, abstract, "")
395
- if not is_sc:
396
- continue
397
- if doi:
398
- seen_dois.add(doi)
399
- seen_titles.add(norm)
400
- pmid = r.get("pmid") or ""
401
- url_val = (
402
- f"https://doi.org/{doi}" if doi
403
- else (f"https://europepmc.org/article/med/{pmid}" if pmid else "")
404
- )
405
- pdf_url = None
406
- if r.get("isOpenAccess") == "Y":
407
- for ft in ((r.get("fullTextUrlList") or {}).get("fullTextUrl") or []):
408
- if ft.get("documentStyle") == "pdf":
409
- pdf_url = ft.get("url")
410
- break
411
- authors_raw = (r.get("authorList") or {}).get("author") or []
412
- authors = [
413
- f"{a.get('firstName','')} {a.get('lastName','')}".strip()
414
- for a in authors_raw if a.get("lastName")
415
- ]
416
- papers.append(_paper(
417
- title, abstract, authors, doi, url_val, pdf_url,
418
- r.get("firstPublicationDate") or r.get("pubYear") or "",
419
- r.get("pubType") or "", score, cats, "EuropePMC",
420
- ))
421
- _log_hit(len(papers), score, cats, title)
422
- next_cursor = data.get("nextCursorMark")
423
- if next_cursor and next_cursor != cursor and results and len(papers) < max_results:
424
- cursor = next_cursor
425
- time.sleep(_RATE_SLEEP)
426
- else:
427
- break
428
- time.sleep(_RATE_SLEEP)
429
- return papers
430
-
431
-
432
- # ── DOAJ ──────────────────────────────────────────────────────────────────────
433
-
434
- def harvest_doaj(
435
- session: requests.Session,
436
- institution_name: str,
437
- max_results: int,
438
- seen_dois: set,
439
- seen_titles: set,
440
- ) -> list[dict]:
441
- """
442
- Directory of Open Access Journals (DOAJ) — covers many African humanities
443
- and social-science journals that are not in OpenAlex or Crossref.
444
- Free API, no key required.
445
- """
446
- from urllib.parse import quote
447
- papers: list[dict] = []
448
-
449
- # DOAJ article search: query is full-text across title/abstract/keywords
450
- priority_seeds = [
451
- s for s in SC_SEED_KEYWORDS
452
- if any(k in s.lower() for k in (
453
- "indigenous", "traditional", "cultural", "oral",
454
- "postcolonial", "decolonial", "african", "ubuntu",
455
- ))
456
- ]
457
-
458
- for seed in priority_seeds:
459
- if len(papers) >= max_results:
460
- break
461
- query = f'"{institution_name}" "{seed}"'
462
- page = 1
463
- while len(papers) < max_results:
464
- url = (
465
- f"https://doaj.org/api/search/articles/{quote(query)}"
466
- f"?page={page}&pageSize=100"
467
- )
468
- try:
469
- resp = session.get(url, timeout=30)
470
- resp.raise_for_status()
471
- except requests.RequestException as exc:
472
- print(f" [DOAJ] {seed} err: {exc}", flush=True)
473
- break
474
- data = resp.json()
475
- results = data.get("results", [])
476
- if not results:
477
- break
478
- for article in results:
479
- if len(papers) >= max_results:
480
- break
481
- bib = article.get("bibjson") or {}
482
- title_arr = bib.get("title") or ""
483
- title = (title_arr if isinstance(title_arr, str) else "").strip()
484
- if not title:
485
- continue
486
- # DOI from identifiers list
487
- doi = ""
488
- for ident in bib.get("identifier") or []:
489
- if ident.get("type") == "doi":
490
- doi = ident.get("id") or ""
491
- break
492
- norm = title.lower()[:120]
493
- if doi and doi in seen_dois:
494
- continue
495
- if norm in seen_titles:
496
- continue
497
- abstract = (bib.get("abstract") or "").strip()
498
- is_sc, score, cats = is_special_collection(title, abstract, "")
499
- if not is_sc:
500
- continue
501
- if doi:
502
- seen_dois.add(doi)
503
- seen_titles.add(norm)
504
- authors = [
505
- a.get("name", "") for a in (bib.get("author") or []) if a.get("name")
506
- ]
507
- pub_date = bib.get("year") or ""
508
- url_val = f"https://doi.org/{doi}" if doi else ""
509
- for lnk in bib.get("link") or []:
510
- if lnk.get("type") in ("fulltext", "homepage"):
511
- url_val = url_val or lnk.get("url", "")
512
- papers.append(_paper(
513
- title, abstract, authors, doi, url_val, None,
514
- pub_date, bib.get("journal", {}).get("title", "") or "",
515
- score, cats, "DOAJ",
516
- ))
517
- _log_hit(len(papers), score, cats, title)
518
- total = data.get("total", 0)
519
- if page * 100 < min(total, 500) and len(papers) < max_results:
520
- page += 1
521
- time.sleep(_RATE_SLEEP)
522
- else:
523
- break
524
- time.sleep(_RATE_SLEEP)
525
- return papers
526
-
527
-
528
- # ── Helpers ───────────────────────────────────────────────────────────────────
529
-
530
- def _paper(
531
- title, abstract, authors, doi, url, pdf_url,
532
- pub_date, doc_type, score, cats, source,
533
- ) -> dict:
534
- return {
535
- "title": title,
536
- "abstract": abstract[:500] + ("…" if len(abstract) > 500 else ""),
537
- "authors": authors[:5],
538
- "doi": doi,
539
- "url": url,
540
- "pdf_url": pdf_url,
541
- "publication_date": pub_date,
542
- "dc_type": doc_type,
543
- "sc_score": round(score, 1),
544
- "sc_categories": cats,
545
- "source": source,
546
- }
547
-
548
-
549
- def _log_hit(n: int, score: float, cats: list, title: str):
550
- safe = title.encode("ascii", errors="replace").decode("ascii")
551
- print(
552
- f" [SC] #{n:>3} score={score:.1f} cats={','.join(cats)[:45]} {safe[:65]}",
553
- flush=True,
554
- )
555
-
556
-
557
- # ── Main harvest orchestrator ─────────────────────────────────────────────────
558
-
559
- def harvest_all(
560
- ror_short: str,
561
- institution_name: str,
562
- affiliation_patterns: list[str],
563
- max_results: int,
564
- ) -> list[dict]:
565
- """
566
- Fan out across all sources in parallel-quota mode.
567
-
568
- Each source gets an equal quota (max_results // 4, minimum 10). After all
569
- four sources run, results are merged (deduplicated), sorted by SC score, and
570
- capped at max_results. This ensures the email reflects genuine multi-source
571
- coverage rather than being filled by whichever source responds fastest.
572
- """
573
- session = requests.Session()
574
- session.headers.update({
575
- "User-Agent": f"URAAS-TestHarvest/1.0 (dry-run; mailto:{config.OPENALEX_MAILTO})",
576
- "Accept": "application/json",
577
- })
578
-
579
- # Per-source quota: each source gets at least 10, up to max_results
580
- per_source = max(10, max_results // 4)
581
-
582
- # Each source uses its OWN seen sets so they don't clobber each other;
583
- # dedup across sources happens in the merge step below.
584
- source_results: dict[str, list[dict]] = {}
585
-
586
- source_fns = [
587
- ("OpenAlex", lambda: harvest_openalex(
588
- session, ror_short, per_source, set(), set())),
589
- ("Crossref", lambda: harvest_crossref(
590
- session, institution_name, per_source, set(), set())),
591
- ("Semantic Scholar", lambda: harvest_semantic_scholar(
592
- session, institution_name, per_source, set(), set())),
593
- ("DOAJ", lambda: harvest_doaj(
594
- session, institution_name, per_source, set(), set())),
595
- ]
596
-
597
- for source_name, harvest_fn in source_fns:
598
- print(f"\n[SOURCE] {source_name} (quota: {per_source})", flush=True)
599
- print("-" * 40, flush=True)
600
- papers = harvest_fn()
601
- source_results[source_name] = papers
602
- print(f"[{source_name}] found {len(papers)} SC papers", flush=True)
603
-
604
- # Merge and deduplicate across sources
605
- seen_dois: set[str] = set()
606
- seen_titles: set[str] = set()
607
- merged: list[dict] = []
608
-
609
- # Interleave sources (round-robin) so the final list is balanced
610
- max_src_len = max(len(v) for v in source_results.values()) if source_results else 0
611
- source_names = list(source_results.keys())
612
- for i in range(max_src_len):
613
- for sname in source_names:
614
- papers = source_results[sname]
615
- if i >= len(papers):
616
- continue
617
- p = papers[i]
618
- doi = p.get("doi") or ""
619
- norm = (p.get("title") or "").lower()[:120]
620
- if doi and doi in seen_dois:
621
- continue
622
- if norm and norm in seen_titles:
623
- continue
624
- if doi:
625
- seen_dois.add(doi)
626
- if norm:
627
- seen_titles.add(norm)
628
- merged.append(p)
629
-
630
- # Sort by SC score descending
631
- merged.sort(key=lambda p: p.get("sc_score", 0), reverse=True)
632
- return merged[:max_results]
633
-
634
-
635
- # ── Email ─────────────────────────────────────────────────────────────────────
636
-
637
- def send_preview_email(to_email: str, institution_name: str, papers: list[dict]) -> bool:
638
- from uraas.config import config as cfg
639
-
640
- if not cfg.SMTP_HOST or not cfg.SMTP_USER or not cfg.SMTP_PASSWORD:
641
- print("[WARN] SMTP not configured — skipping email. Set SMTP_* in .env", flush=True)
642
- preview = json.dumps(papers[:3], indent=2, ensure_ascii=True)
643
- print(f" First 3 papers: {preview}", flush=True)
644
- return False
645
-
646
- import smtplib
647
- from email.mime.multipart import MIMEMultipart
648
- from email.mime.text import MIMEText
649
-
650
- n = len(papers)
651
- subject = f"[URAAS] Test Harvest Preview — {n} SC papers from {institution_name}"
652
-
653
- source_counts: dict[str, int] = {}
654
- for p in papers:
655
- source_counts[p.get("source", "?")] = source_counts.get(p.get("source", "?"), 0) + 1
656
- source_summary = " · ".join(f"{s}: {c}" for s, c in sorted(source_counts.items()))
657
-
658
- rows_html = ""
659
- for i, p in enumerate(papers, 1):
660
- cats = ", ".join(p["sc_categories"])
661
- url_part = (
662
- f'<a href="{p["url"]}" style="color:#3b82f6">{p["url"][:55]}</a>'
663
- if p["url"] else "—"
664
- )
665
- pdf_part = (
666
- f' <a href="{p["pdf_url"]}" style="color:#16a34a;font-size:10px">[PDF]</a>'
667
- if p.get("pdf_url") else ""
668
- )
669
- rows_html += f"""
670
- <tr style="border-bottom:1px solid #e5e7eb">
671
- <td style="padding:6px 4px;color:#9ca3af;font-size:11px">{i}</td>
672
- <td style="padding:6px 6px;font-size:12px">
673
- <strong>{p['title'][:85]}</strong><br>
674
- <span style="font-size:10px;color:#6b7280">{', '.join(p['authors'][:2])}</span>
675
- </td>
676
- <td style="padding:6px 4px;font-size:11px;color:#6b7280">{p.get('dc_type','—')[:20]}</td>
677
- <td style="padding:6px 4px;font-size:11px;color:#374151">{(p.get('publication_date') or '—')[:4]}</td>
678
- <td style="padding:6px 4px;font-size:11px;color:#7c3aed">{cats}</td>
679
- <td style="padding:6px 4px;font-size:10px;color:#2563eb">{p.get('source','?')}</td>
680
- <td style="padding:6px 4px;font-size:10px">{url_part}{pdf_part}</td>
681
- </tr>"""
682
-
683
- plain_rows = "\n".join(
684
- f"{i:>3}. [{p.get('source','?')}] {p['title'][:75]}\n"
685
- f" By: {', '.join(p['authors'][:2]) or '—'} | {(p.get('publication_date') or '')[:4]}\n"
686
- f" SC: {', '.join(p['sc_categories'])} | Score: {p['sc_score']}\n"
687
- f" URL: {p['url'] or '—'}\n"
688
- for i, p in enumerate(papers, 1)
689
- )
690
-
691
- html = f"""<!DOCTYPE html>
692
- <html lang="en"><head><meta charset="utf-8"></head>
693
- <body style="font-family:Arial,sans-serif;background:#f9fafb;margin:0;padding:24px">
694
- <div style="max-width:950px;margin:0 auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 8px rgba(0,0,0,.1)">
695
- <div style="background:#1a3a5c;padding:24px 28px">
696
- <p style="margin:0;font-size:10px;color:#7eb3d4;text-transform:uppercase;letter-spacing:2px">University of Lagos · URAAS</p>
697
- <h1 style="margin:6px 0 0;font-size:20px;color:#fff">Test Harvest — Special Collections Preview</h1>
698
- <p style="margin:6px 0 0;font-size:12px;color:#a8c9e0">
699
- OpenAlex · Crossref · Semantic Scholar · DOAJ · Dry run · No IR deposit
700
- </p>
701
- </div>
702
- <div style="padding:24px 28px">
703
- <p style="font-size:14px;color:#374151;margin:0 0 12px">
704
- <strong>Dry-run preview</strong> — no papers were saved to the local database and nothing was
705
- deposited to the live IR. These are Special Collections papers by <strong>{institution_name}</strong>
706
- authors discovered from across the open web.
707
- </p>
708
- <table style="width:100%;border-collapse:collapse;font-size:13px;margin:0 0 16px">
709
- <tr style="background:#f3f4f6">
710
- <td style="padding:8px 6px;font-weight:600">Institution</td>
711
- <td style="padding:8px 6px">{institution_name}</td>
712
- <td style="padding:8px 6px;font-weight:600">Total SC papers</td>
713
- <td style="padding:8px 6px"><strong style="color:#1a7a4a">{n}</strong></td>
714
- </tr>
715
- <tr>
716
- <td style="padding:8px 6px;font-weight:600">Sources</td>
717
- <td colspan="3" style="padding:8px 6px">{source_summary}</td>
718
- </tr>
719
- </table>
720
- <table style="width:100%;border-collapse:collapse;font-size:12px">
721
- <thead>
722
- <tr style="background:#f3f4f6;text-align:left">
723
- <th style="padding:6px 4px">#</th>
724
- <th style="padding:6px 6px">Title / Authors</th>
725
- <th style="padding:6px 4px">Type</th>
726
- <th style="padding:6px 4px">Year</th>
727
- <th style="padding:6px 4px">SC Categories</th>
728
- <th style="padding:6px 4px">Source</th>
729
- <th style="padding:6px 4px">URL / PDF</th>
730
- </tr>
731
- </thead>
732
- <tbody>{rows_html}</tbody>
733
- </table>
734
- <p style="font-size:12px;color:#6b7280;margin:20px 0 0">
735
- Papers above were found on the open web and are NOT yet confirmed to be in the UNILAG IR.
736
- When ready to queue them for IR deposit, use the <strong>IR Deposit</strong> panel in the dashboard.
737
- </p>
738
- </div>
739
- <div style="background:#f0f4f8;padding:14px 28px;font-size:11px;color:#9ca3af;text-align:center">
740
- URAAS · APA Intelligence &amp; Analytics Platform · University of Lagos · Dry-run — nothing was changed
741
- </div>
742
- </div>
743
- </body></html>"""
744
-
745
- plain = f"""URAAS Test Harvest — {institution_name}
746
- Sources: {source_summary}
747
- DRY RUN — nothing saved to DB, nothing deposited to IR.
748
-
749
- Special Collections papers found: {n}
750
-
751
- {plain_rows}
752
- ---
753
- URAAS — APA Intelligence & Analytics Platform
754
- """
755
-
756
- msg = MIMEMultipart("alternative")
757
- msg["Subject"] = subject
758
- msg["From"] = cfg.SMTP_FROM
759
- msg["To"] = to_email
760
- msg.attach(MIMEText(plain, "plain", "utf-8"))
761
- msg.attach(MIMEText(html, "html", "utf-8"))
762
-
763
- try:
764
- if cfg.SMTP_USE_TLS:
765
- srv = smtplib.SMTP(cfg.SMTP_HOST, cfg.SMTP_PORT, timeout=30)
766
- srv.ehlo()
767
- srv.starttls()
768
- srv.ehlo()
769
- else:
770
- srv = smtplib.SMTP_SSL(cfg.SMTP_HOST, cfg.SMTP_PORT, timeout=30)
771
- srv.login(cfg.SMTP_USER, cfg.SMTP_PASSWORD)
772
- srv.sendmail(cfg.SMTP_FROM, [to_email], msg.as_bytes())
773
- srv.quit()
774
- print(f"[OK] Email sent to {to_email}", flush=True)
775
- return True
776
- except Exception as exc:
777
- print(f"[ERR] Email failed: {exc}", flush=True)
778
- return False
779
-
780
-
781
- def save_json_preview(papers: list[dict], institution: str) -> str:
782
- out_path = os.path.join(
783
- os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
784
- "storage",
785
- f"test_harvest_{institution}_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.json",
786
- )
787
- os.makedirs(os.path.dirname(out_path), exist_ok=True)
788
- with open(out_path, "w", encoding="utf-8") as f:
789
- json.dump(
790
- {"institution": institution, "count": len(papers), "papers": papers},
791
- f, indent=2, ensure_ascii=False,
792
- )
793
- print(f"[OK] Preview saved to: {out_path}", flush=True)
794
- return out_path
795
-
796
-
797
- def main():
798
- parser = argparse.ArgumentParser(
799
- description="Multi-source test harvest (dry run — no DB, no IR deposit)"
800
- )
801
- parser.add_argument("--institution", default="unilag")
802
- parser.add_argument("--count", type=int, default=50, help="Max SC papers to collect")
803
- parser.add_argument("--email", default="lawalgiyath200716@gmail.com")
804
- args = parser.parse_args()
805
-
806
- registry = get_registry()
807
- inst_cfg = registry.get(args.institution)
808
- if not inst_cfg:
809
- print(f"[ERR] Institution '{args.institution}' not found", flush=True)
810
- sys.exit(1)
811
-
812
- ror_short = inst_cfg.ror.split("/")[-1]
813
-
814
- print(f"\n{'='*60}", flush=True)
815
- print(f"URAAS DRY-RUN HARVEST — {inst_cfg.name}", flush=True)
816
- print(f"Sources: OpenAlex · Crossref · Semantic Scholar · DOAJ", flush=True)
817
- print(f"{'='*60}", flush=True)
818
-
819
- papers = harvest_all(
820
- ror_short,
821
- inst_cfg.name,
822
- inst_cfg.affiliation_patterns,
823
- args.count,
824
- )
825
-
826
- save_json_preview(papers, args.institution)
827
- send_preview_email(args.email, inst_cfg.name, papers)
828
-
829
- # Source breakdown
830
- source_counts: dict[str, int] = {}
831
- for p in papers:
832
- source_counts[p.get("source", "?")] = source_counts.get(p.get("source", "?"), 0) + 1
833
-
834
- print(f"\n{'='*60}", flush=True)
835
- print("HARVEST SUMMARY", flush=True)
836
- print(f" Institution : {inst_cfg.name}", flush=True)
837
- print(f" Total SC : {len(papers)}", flush=True)
838
- for src, cnt in sorted(source_counts.items()):
839
- print(f" {src:<22}: {cnt}", flush=True)
840
- print(f" Email : {args.email}", flush=True)
841
- print(" IR deposit : NOT performed (dry run)", flush=True)
842
- print(f"{'='*60}\n", flush=True)
843
-
844
-
845
- if __name__ == "__main__":
846
- main()
 
1
+ """
2
+ Test Harvest — UNILAG Special Collections papers (dry run, multi-source).
3
+
4
+ Discovers SC papers from the open web by querying multiple academic databases:
5
+ • OpenAlex — broad academic paper index (journals, books, preprints)
6
+ • Crossref — DOI metadata authority, strong on humanities/social sciences
7
+ • Semantic Scholar — AI-indexed full-text coverage, good for humanities
8
+ • EuropePMC — PubMed + PMC + WHO; best for ethnobotany / traditional medicine
9
+
10
+ For each source: queries institution affiliation + SC seed keywords, then applies
11
+ the same SC classifier used by the main pipeline. Results are deduplicated by DOI
12
+ and normalised title across all sources.
13
+
14
+ DOES NOT save anything to the local database.
15
+ DOES NOT deposit anything to the live DSpace IR.
16
+ Safe to run at any time.
17
+ """
18
+
19
+ import argparse
20
+ import json
21
+ import os
22
+ import sys
23
+ import time
24
+ from datetime import datetime, timezone
25
+ from typing import Any
26
+
27
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
28
+
29
+ import requests
30
+
31
+ from uraas.config import config
32
+ from uraas.config.institutions import get_registry
33
+ from uraas.config.special_collections import SC_SEED_KEYWORDS
34
+ from uraas.services.sc_engine import is_special_collection
35
+
36
+ _RATE_SLEEP = 1.0 # polite delay between requests per source
37
+
38
+
39
+ # ── OpenAlex ──────────────────────────────────────────────────────────────────
40
+
41
+ def _reconstruct_abstract(inverted_index: dict) -> str:
42
+ if not inverted_index:
43
+ return ""
44
+ pairs = [(pos, word) for word, positions in inverted_index.items() for pos in positions]
45
+ pairs.sort()
46
+ return " ".join(w for _, w in pairs)
47
+
48
+
49
+ def _openalex_url(ror_short: str, seed: str | None, cursor: str = "*") -> str:
50
+ filters = f"institutions.ror:{ror_short}"
51
+ if seed:
52
+ filters += f",title_and_abstract.search:{seed.replace(' ','%20')}"
53
+ return (
54
+ f"https://api.openalex.org/works"
55
+ f"?filter={filters}"
56
+ f"&select=id,doi,title,abstract_inverted_index,authorships,"
57
+ f"publication_date,open_access,primary_location,concepts,type"
58
+ f"&per-page=100&cursor={cursor}&mailto={config.OPENALEX_MAILTO}"
59
+ )
60
+
61
+
62
+ def harvest_openalex(
63
+ session: requests.Session,
64
+ ror_short: str,
65
+ max_results: int,
66
+ seen_dois: set,
67
+ seen_titles: set,
68
+ ) -> list[dict]:
69
+ papers: list[dict] = []
70
+ seeds = list(SC_SEED_KEYWORDS) + [None] # None = general ROR wave last
71
+
72
+ for seed in seeds:
73
+ if len(papers) >= max_results:
74
+ break
75
+ url = _openalex_url(ror_short, seed)
76
+ page = 0
77
+ while url and len(papers) < max_results:
78
+ page += 1
79
+ try:
80
+ resp = session.get(url, timeout=30)
81
+ resp.raise_for_status()
82
+ except requests.RequestException as exc:
83
+ print(f" [OA] {seed or 'general'} page {page} err: {exc}", flush=True)
84
+ break
85
+ data = resp.json()
86
+ results = data.get("results", [])
87
+ for work in results:
88
+ if len(papers) >= max_results:
89
+ break
90
+ title = (work.get("title") or "").strip()
91
+ if not title:
92
+ continue
93
+ doi = (work.get("doi") or "").replace("https://doi.org/", "").strip()
94
+ norm = title.lower()[:120]
95
+ if doi and doi in seen_dois:
96
+ continue
97
+ if norm in seen_titles:
98
+ continue
99
+ abstract = _reconstruct_abstract(work.get("abstract_inverted_index") or {})
100
+ concepts = work.get("concepts") or []
101
+ dc_subject = ", ".join(c.get("display_name", "") for c in concepts[:6] if c)
102
+ is_sc, score, cats = is_special_collection(title, abstract, dc_subject)
103
+ if not is_sc:
104
+ continue
105
+ if doi:
106
+ seen_dois.add(doi)
107
+ seen_titles.add(norm)
108
+ authors = [
109
+ a.get("author", {}).get("display_name", "")
110
+ for a in work.get("authorships", [])
111
+ if a.get("author", {}).get("display_name")
112
+ ]
113
+ oa = work.get("open_access") or {}
114
+ landing = (work.get("primary_location") or {}).get("landing_page_url") or ""
115
+ url_val = landing or (f"https://doi.org/{doi}" if doi else "")
116
+ papers.append(_paper(
117
+ title, abstract, authors, doi, url_val,
118
+ oa.get("oa_url") if oa.get("is_oa") else None,
119
+ work.get("publication_date") or "",
120
+ work.get("type") or "",
121
+ score, cats, "OpenAlex",
122
+ ))
123
+ _log_hit(len(papers), score, cats, title)
124
+ meta = data.get("meta") or {}
125
+ next_cursor = meta.get("next_cursor")
126
+ if next_cursor and results and len(papers) < max_results:
127
+ from urllib.parse import parse_qs, urlparse
128
+ qs = parse_qs(urlparse(url).query)
129
+ filt = (qs.get("filter") or [f"institutions.ror:{ror_short}"])[0]
130
+ url = (
131
+ f"https://api.openalex.org/works?filter={filt}"
132
+ f"&select=id,doi,title,abstract_inverted_index,authorships,"
133
+ f"publication_date,open_access,primary_location,concepts,type"
134
+ f"&per-page=100&cursor={next_cursor}&mailto={config.OPENALEX_MAILTO}"
135
+ )
136
+ time.sleep(_RATE_SLEEP)
137
+ else:
138
+ url = ""
139
+ return papers
140
+
141
+
142
+ # ── Crossref ──────────────────────────────────────────────────────────────────
143
+
144
+ def harvest_crossref(
145
+ session: requests.Session,
146
+ institution_name: str,
147
+ max_results: int,
148
+ seen_dois: set,
149
+ seen_titles: set,
150
+ ) -> list[dict]:
151
+ from urllib.parse import quote
152
+ papers: list[dict] = []
153
+ seeds = list(SC_SEED_KEYWORDS) + [None]
154
+
155
+ for seed in seeds:
156
+ if len(papers) >= max_results:
157
+ break
158
+ q_seed = f"&query={quote(seed)}" if seed else ""
159
+ url = (
160
+ f"https://api.crossref.org/works"
161
+ f"?query.affiliation={quote(institution_name)}"
162
+ f"{q_seed}"
163
+ f"&select=DOI,title,abstract,author,issued,URL,link"
164
+ f"&rows=50&offset=0&mailto={config.OPENALEX_MAILTO}"
165
+ )
166
+ offset = 0
167
+ while url and len(papers) < max_results:
168
+ try:
169
+ resp = session.get(url, timeout=30)
170
+ resp.raise_for_status()
171
+ except requests.RequestException as exc:
172
+ print(f" [CR] {seed or 'general'} err: {exc}", flush=True)
173
+ break
174
+ items = resp.json().get("message", {}).get("items", [])
175
+ for work in items:
176
+ if len(papers) >= max_results:
177
+ break
178
+ title_arr = work.get("title") or []
179
+ title = (title_arr[0] if title_arr else "").strip()
180
+ if not title:
181
+ continue
182
+ doi = (work.get("DOI") or "").strip()
183
+ norm = title.lower()[:120]
184
+ if doi and doi in seen_dois:
185
+ continue
186
+ if norm in seen_titles:
187
+ continue
188
+ abstract = (work.get("abstract") or "").strip()
189
+ is_sc, score, cats = is_special_collection(title, abstract, "")
190
+ if not is_sc:
191
+ continue
192
+ if doi:
193
+ seen_dois.add(doi)
194
+ seen_titles.add(norm)
195
+ authors = [
196
+ f"{a.get('given','')} {a.get('family','')}".strip()
197
+ for a in work.get("author", [])
198
+ if a.get("family")
199
+ ]
200
+ issued = work.get("issued", {}).get("date-parts", [[]])[0]
201
+ pub_date = "-".join(str(p) for p in issued) if issued else ""
202
+ pdf_url = next(
203
+ (lk["URL"] for lk in work.get("link", [])
204
+ if lk.get("content-type") == "application/pdf"),
205
+ None,
206
+ )
207
+ url_val = work.get("URL") or (f"https://doi.org/{doi}" if doi else "")
208
+ papers.append(_paper(
209
+ title, abstract, authors, doi, url_val, pdf_url,
210
+ pub_date, "", score, cats, "Crossref",
211
+ ))
212
+ _log_hit(len(papers), score, cats, title)
213
+ offset += 50
214
+ if items and offset < 500 and len(papers) < max_results:
215
+ q_seed2 = f"&query={quote(seed)}" if seed else ""
216
+ url = (
217
+ f"https://api.crossref.org/works"
218
+ f"?query.affiliation={quote(institution_name)}"
219
+ f"{q_seed2}"
220
+ f"&select=DOI,title,abstract,author,issued,URL,link"
221
+ f"&rows=50&offset={offset}&mailto={config.OPENALEX_MAILTO}"
222
+ )
223
+ time.sleep(_RATE_SLEEP)
224
+ else:
225
+ url = ""
226
+ return papers
227
+
228
+
229
+ # ── Semantic Scholar ─────────────────���────────────────────────────────────────
230
+
231
+ def harvest_semantic_scholar(
232
+ session: requests.Session,
233
+ institution_name: str,
234
+ max_results: int,
235
+ seen_dois: set,
236
+ seen_titles: set,
237
+ ) -> list[dict]:
238
+ """
239
+ Semantic Scholar free tier: 1 req/sec (unauthenticated).
240
+ We fire only 3 broad queries instead of one per seed to stay within rate limits.
241
+ Set S2_API_KEY in .env for a higher rate limit (free key at semanticscholar.org).
242
+ """
243
+ from urllib.parse import quote
244
+ from uraas.config import config as cfg
245
+
246
+ papers: list[dict] = []
247
+ # Pull API key from env if available
248
+ api_key = getattr(cfg, "S2_API_KEY", "") or os.environ.get("S2_API_KEY", "")
249
+ delay = 1.2 if not api_key else 0.3
250
+
251
+ headers = {}
252
+ if api_key:
253
+ headers["x-api-key"] = api_key
254
+
255
+ # Use 3 broad queries rather than 20+ per-seed blasts
256
+ broad_queries = [
257
+ f"{institution_name} indigenous knowledge cultural heritage",
258
+ f"{institution_name} postcolonial african literature oral tradition",
259
+ f"{institution_name} ethnobotany traditional medicine decolonial",
260
+ ]
261
+
262
+ for query in broad_queries:
263
+ if len(papers) >= max_results:
264
+ break
265
+ offset = 0
266
+ while len(papers) < max_results:
267
+ url = (
268
+ f"https://api.semanticscholar.org/graph/v1/paper/search"
269
+ f"?query={quote(query)}"
270
+ f"&fields=title,abstract,authors,year,externalIds,openAccessPdf"
271
+ f"&limit=100&offset={offset}"
272
+ )
273
+ try:
274
+ time.sleep(delay)
275
+ resp = session.get(url, timeout=30, headers=headers)
276
+ if resp.status_code == 429:
277
+ # Rate limited — skip remaining S2 queries rather than block.
278
+ # Add S2_API_KEY to .env (free at semanticscholar.org) to lift limit.
279
+ print(
280
+ " [S2] Rate limited. Add S2_API_KEY to .env for higher quota.",
281
+ flush=True,
282
+ )
283
+ return papers
284
+ resp.raise_for_status()
285
+ except requests.RequestException as exc:
286
+ print(f" [S2] {query[:40]} err: {exc}", flush=True)
287
+ break
288
+ data = resp.json()
289
+ results = data.get("data", [])
290
+ if not results:
291
+ break
292
+ for work in results:
293
+ if len(papers) >= max_results:
294
+ break
295
+ title = (work.get("title") or "").strip()
296
+ if not title:
297
+ continue
298
+ ext = work.get("externalIds") or {}
299
+ doi = (ext.get("DOI") or ext.get("doi") or "").strip()
300
+ norm = title.lower()[:120]
301
+ if doi and doi in seen_dois:
302
+ continue
303
+ if norm in seen_titles:
304
+ continue
305
+ abstract = (work.get("abstract") or "").strip()
306
+ is_sc, score, cats = is_special_collection(title, abstract, "")
307
+ if not is_sc:
308
+ continue
309
+ if doi:
310
+ seen_dois.add(doi)
311
+ seen_titles.add(norm)
312
+ authors = [
313
+ a.get("name", "") for a in (work.get("authors") or []) if a.get("name")
314
+ ]
315
+ year = work.get("year")
316
+ oa = work.get("openAccessPdf") or {}
317
+ url_val = f"https://doi.org/{doi}" if doi else ""
318
+ papers.append(_paper(
319
+ title, abstract, authors, doi, url_val,
320
+ oa.get("url"), f"{year}-01-01" if year else "",
321
+ "", score, cats, "Semantic Scholar",
322
+ ))
323
+ _log_hit(len(papers), score, cats, title)
324
+ total = data.get("total", 0)
325
+ offset += 100
326
+ if offset < min(total, 300) and len(papers) < max_results:
327
+ pass
328
+ else:
329
+ break
330
+ return papers
331
+
332
+
333
+ # ── EuropePMC ─────────────────────────────────────────────────────────────────
334
+
335
+ def harvest_europepmc(
336
+ session: requests.Session,
337
+ institution_name: str,
338
+ affiliation_patterns: list[str],
339
+ max_results: int,
340
+ seen_dois: set,
341
+ seen_titles: set,
342
+ ) -> list[dict]:
343
+ from urllib.parse import urlencode
344
+ papers: list[dict] = []
345
+
346
+ # EuropePMC AFFILIATION field matches against stored author affiliation strings.
347
+ # UNILAG papers appear under several spellings — use a short unambiguous token.
348
+ affil = '(AFFILIATION:"University of Lagos" OR AFFILIATION:"unilag" OR AFFILIATION:"UNILAG")'
349
+
350
+ # EuropePMC is best for ethnobotany/traditional medicine SC papers
351
+ priority_seeds = [
352
+ s for s in SC_SEED_KEYWORDS
353
+ if any(k in s.lower() for k in (
354
+ "indigenous", "traditional", "ethnobotany", "cultural",
355
+ "oral", "decolonial", "ubuntu", "ethnomusicology",
356
+ ))
357
+ ]
358
+
359
+ for seed in priority_seeds:
360
+ if len(papers) >= max_results:
361
+ break
362
+ query = f'{affil} AND ("{seed}")'
363
+ cursor = "*"
364
+ while len(papers) < max_results:
365
+ params = {
366
+ "query": query,
367
+ "format": "json",
368
+ "pageSize": 100,
369
+ "resultType": "core",
370
+ "cursorMark": cursor,
371
+ }
372
+ url = f"https://www.ebi.ac.uk/europepmc/webservices/rest/search?{urlencode(params)}"
373
+ try:
374
+ resp = session.get(url, timeout=30)
375
+ resp.raise_for_status()
376
+ except requests.RequestException as exc:
377
+ print(f" [EPMC] {seed} err: {exc}", flush=True)
378
+ break
379
+ data = resp.json()
380
+ results = data.get("resultList", {}).get("result", [])
381
+ for r in results:
382
+ if len(papers) >= max_results:
383
+ break
384
+ title = (r.get("title") or "").strip().rstrip(".")
385
+ if not title:
386
+ continue
387
+ doi = (r.get("doi") or "").strip()
388
+ norm = title.lower()[:120]
389
+ if doi and doi in seen_dois:
390
+ continue
391
+ if norm in seen_titles:
392
+ continue
393
+ abstract = (r.get("abstractText") or "").strip()
394
+ is_sc, score, cats = is_special_collection(title, abstract, "")
395
+ if not is_sc:
396
+ continue
397
+ if doi:
398
+ seen_dois.add(doi)
399
+ seen_titles.add(norm)
400
+ pmid = r.get("pmid") or ""
401
+ url_val = (
402
+ f"https://doi.org/{doi}" if doi
403
+ else (f"https://europepmc.org/article/med/{pmid}" if pmid else "")
404
+ )
405
+ pdf_url = None
406
+ if r.get("isOpenAccess") == "Y":
407
+ for ft in ((r.get("fullTextUrlList") or {}).get("fullTextUrl") or []):
408
+ if ft.get("documentStyle") == "pdf":
409
+ pdf_url = ft.get("url")
410
+ break
411
+ authors_raw = (r.get("authorList") or {}).get("author") or []
412
+ authors = [
413
+ f"{a.get('firstName','')} {a.get('lastName','')}".strip()
414
+ for a in authors_raw if a.get("lastName")
415
+ ]
416
+ papers.append(_paper(
417
+ title, abstract, authors, doi, url_val, pdf_url,
418
+ r.get("firstPublicationDate") or r.get("pubYear") or "",
419
+ r.get("pubType") or "", score, cats, "EuropePMC",
420
+ ))
421
+ _log_hit(len(papers), score, cats, title)
422
+ next_cursor = data.get("nextCursorMark")
423
+ if next_cursor and next_cursor != cursor and results and len(papers) < max_results:
424
+ cursor = next_cursor
425
+ time.sleep(_RATE_SLEEP)
426
+ else:
427
+ break
428
+ time.sleep(_RATE_SLEEP)
429
+ return papers
430
+
431
+
432
+ # ── DOAJ ──────────────────────────────────────────────────────────────────────
433
+
434
+ def harvest_doaj(
435
+ session: requests.Session,
436
+ institution_name: str,
437
+ max_results: int,
438
+ seen_dois: set,
439
+ seen_titles: set,
440
+ ) -> list[dict]:
441
+ """
442
+ Directory of Open Access Journals (DOAJ) — covers many African humanities
443
+ and social-science journals that are not in OpenAlex or Crossref.
444
+ Free API, no key required.
445
+ """
446
+ from urllib.parse import quote
447
+ papers: list[dict] = []
448
+
449
+ # DOAJ article search: query is full-text across title/abstract/keywords
450
+ priority_seeds = [
451
+ s for s in SC_SEED_KEYWORDS
452
+ if any(k in s.lower() for k in (
453
+ "indigenous", "traditional", "cultural", "oral",
454
+ "postcolonial", "decolonial", "african", "ubuntu",
455
+ ))
456
+ ]
457
+
458
+ for seed in priority_seeds:
459
+ if len(papers) >= max_results:
460
+ break
461
+ query = f'"{institution_name}" "{seed}"'
462
+ page = 1
463
+ while len(papers) < max_results:
464
+ url = (
465
+ f"https://doaj.org/api/search/articles/{quote(query)}"
466
+ f"?page={page}&pageSize=100"
467
+ )
468
+ try:
469
+ resp = session.get(url, timeout=30)
470
+ resp.raise_for_status()
471
+ except requests.RequestException as exc:
472
+ print(f" [DOAJ] {seed} err: {exc}", flush=True)
473
+ break
474
+ data = resp.json()
475
+ results = data.get("results", [])
476
+ if not results:
477
+ break
478
+ for article in results:
479
+ if len(papers) >= max_results:
480
+ break
481
+ bib = article.get("bibjson") or {}
482
+ title_arr = bib.get("title") or ""
483
+ title = (title_arr if isinstance(title_arr, str) else "").strip()
484
+ if not title:
485
+ continue
486
+ # DOI from identifiers list
487
+ doi = ""
488
+ for ident in bib.get("identifier") or []:
489
+ if ident.get("type") == "doi":
490
+ doi = ident.get("id") or ""
491
+ break
492
+ norm = title.lower()[:120]
493
+ if doi and doi in seen_dois:
494
+ continue
495
+ if norm in seen_titles:
496
+ continue
497
+ abstract = (bib.get("abstract") or "").strip()
498
+ is_sc, score, cats = is_special_collection(title, abstract, "")
499
+ if not is_sc:
500
+ continue
501
+ if doi:
502
+ seen_dois.add(doi)
503
+ seen_titles.add(norm)
504
+ authors = [
505
+ a.get("name", "") for a in (bib.get("author") or []) if a.get("name")
506
+ ]
507
+ pub_date = bib.get("year") or ""
508
+ url_val = f"https://doi.org/{doi}" if doi else ""
509
+ for lnk in bib.get("link") or []:
510
+ if lnk.get("type") in ("fulltext", "homepage"):
511
+ url_val = url_val or lnk.get("url", "")
512
+ papers.append(_paper(
513
+ title, abstract, authors, doi, url_val, None,
514
+ pub_date, bib.get("journal", {}).get("title", "") or "",
515
+ score, cats, "DOAJ",
516
+ ))
517
+ _log_hit(len(papers), score, cats, title)
518
+ total = data.get("total", 0)
519
+ if page * 100 < min(total, 500) and len(papers) < max_results:
520
+ page += 1
521
+ time.sleep(_RATE_SLEEP)
522
+ else:
523
+ break
524
+ time.sleep(_RATE_SLEEP)
525
+ return papers
526
+
527
+
528
+ # ── Helpers ───────────────────────────────────────────────────────────────────
529
+
530
+ def _paper(
531
+ title, abstract, authors, doi, url, pdf_url,
532
+ pub_date, doc_type, score, cats, source,
533
+ ) -> dict:
534
+ return {
535
+ "title": title,
536
+ "abstract": abstract[:500] + ("…" if len(abstract) > 500 else ""),
537
+ "authors": authors[:5],
538
+ "doi": doi,
539
+ "url": url,
540
+ "pdf_url": pdf_url,
541
+ "publication_date": pub_date,
542
+ "dc_type": doc_type,
543
+ "sc_score": round(score, 1),
544
+ "sc_categories": cats,
545
+ "source": source,
546
+ }
547
+
548
+
549
+ def _log_hit(n: int, score: float, cats: list, title: str):
550
+ safe = title.encode("ascii", errors="replace").decode("ascii")
551
+ print(
552
+ f" [SC] #{n:>3} score={score:.1f} cats={','.join(cats)[:45]} {safe[:65]}",
553
+ flush=True,
554
+ )
555
+
556
+
557
+ # ── Main harvest orchestrator ─────────────────────────────────────────────────
558
+
559
+ def harvest_all(
560
+ ror_short: str,
561
+ institution_name: str,
562
+ affiliation_patterns: list[str],
563
+ max_results: int,
564
+ ) -> list[dict]:
565
+ """
566
+ Fan out across all sources in parallel-quota mode.
567
+
568
+ Each source gets an equal quota (max_results // 4, minimum 10). After all
569
+ four sources run, results are merged (deduplicated), sorted by SC score, and
570
+ capped at max_results. This ensures the email reflects genuine multi-source
571
+ coverage rather than being filled by whichever source responds fastest.
572
+ """
573
+ session = requests.Session()
574
+ session.headers.update({
575
+ "User-Agent": f"URAAS-TestHarvest/1.0 (dry-run; mailto:{config.OPENALEX_MAILTO})",
576
+ "Accept": "application/json",
577
+ })
578
+
579
+ # Per-source quota: each source gets at least 10, up to max_results
580
+ per_source = max(10, max_results // 4)
581
+
582
+ # Each source uses its OWN seen sets so they don't clobber each other;
583
+ # dedup across sources happens in the merge step below.
584
+ source_results: dict[str, list[dict]] = {}
585
+
586
+ source_fns = [
587
+ ("OpenAlex", lambda: harvest_openalex(
588
+ session, ror_short, per_source, set(), set())),
589
+ ("Crossref", lambda: harvest_crossref(
590
+ session, institution_name, per_source, set(), set())),
591
+ ("Semantic Scholar", lambda: harvest_semantic_scholar(
592
+ session, institution_name, per_source, set(), set())),
593
+ ("DOAJ", lambda: harvest_doaj(
594
+ session, institution_name, per_source, set(), set())),
595
+ ]
596
+
597
+ for source_name, harvest_fn in source_fns:
598
+ print(f"\n[SOURCE] {source_name} (quota: {per_source})", flush=True)
599
+ print("-" * 40, flush=True)
600
+ papers = harvest_fn()
601
+ source_results[source_name] = papers
602
+ print(f"[{source_name}] found {len(papers)} SC papers", flush=True)
603
+
604
+ # Merge and deduplicate across sources
605
+ seen_dois: set[str] = set()
606
+ seen_titles: set[str] = set()
607
+ merged: list[dict] = []
608
+
609
+ # Interleave sources (round-robin) so the final list is balanced
610
+ max_src_len = max(len(v) for v in source_results.values()) if source_results else 0
611
+ source_names = list(source_results.keys())
612
+ for i in range(max_src_len):
613
+ for sname in source_names:
614
+ papers = source_results[sname]
615
+ if i >= len(papers):
616
+ continue
617
+ p = papers[i]
618
+ doi = p.get("doi") or ""
619
+ norm = (p.get("title") or "").lower()[:120]
620
+ if doi and doi in seen_dois:
621
+ continue
622
+ if norm and norm in seen_titles:
623
+ continue
624
+ if doi:
625
+ seen_dois.add(doi)
626
+ if norm:
627
+ seen_titles.add(norm)
628
+ merged.append(p)
629
+
630
+ # Sort by SC score descending
631
+ merged.sort(key=lambda p: p.get("sc_score", 0), reverse=True)
632
+ return merged[:max_results]
633
+
634
+
635
+ # ── Email ─────────────────────────────────────────────────────────────────────
636
+
637
+ def send_preview_email(to_email: str, institution_name: str, papers: list[dict]) -> bool:
638
+ from uraas.config import config as cfg
639
+
640
+ if not cfg.SMTP_HOST or not cfg.SMTP_USER or not cfg.SMTP_PASSWORD:
641
+ print("[WARN] SMTP not configured — skipping email. Set SMTP_* in .env", flush=True)
642
+ preview = json.dumps(papers[:3], indent=2, ensure_ascii=True)
643
+ print(f" First 3 papers: {preview}", flush=True)
644
+ return False
645
+
646
+ import smtplib
647
+ from email.mime.multipart import MIMEMultipart
648
+ from email.mime.text import MIMEText
649
+
650
+ n = len(papers)
651
+ subject = f"[URAAS] Test Harvest Preview — {n} SC papers from {institution_name}"
652
+
653
+ source_counts: dict[str, int] = {}
654
+ for p in papers:
655
+ source_counts[p.get("source", "?")] = source_counts.get(p.get("source", "?"), 0) + 1
656
+ source_summary = " · ".join(f"{s}: {c}" for s, c in sorted(source_counts.items()))
657
+
658
+ rows_html = ""
659
+ for i, p in enumerate(papers, 1):
660
+ cats = ", ".join(p["sc_categories"])
661
+ url_part = (
662
+ f'<a href="{p["url"]}" style="color:#3b82f6">{p["url"][:55]}</a>'
663
+ if p["url"] else "—"
664
+ )
665
+ pdf_part = (
666
+ f' <a href="{p["pdf_url"]}" style="color:#16a34a;font-size:10px">[PDF]</a>'
667
+ if p.get("pdf_url") else ""
668
+ )
669
+ rows_html += f"""
670
+ <tr style="border-bottom:1px solid #e5e7eb">
671
+ <td style="padding:6px 4px;color:#9ca3af;font-size:11px">{i}</td>
672
+ <td style="padding:6px 6px;font-size:12px">
673
+ <strong>{p['title'][:85]}</strong><br>
674
+ <span style="font-size:10px;color:#6b7280">{', '.join(p['authors'][:2])}</span>
675
+ </td>
676
+ <td style="padding:6px 4px;font-size:11px;color:#6b7280">{p.get('dc_type','—')[:20]}</td>
677
+ <td style="padding:6px 4px;font-size:11px;color:#374151">{(p.get('publication_date') or '—')[:4]}</td>
678
+ <td style="padding:6px 4px;font-size:11px;color:#7c3aed">{cats}</td>
679
+ <td style="padding:6px 4px;font-size:10px;color:#2563eb">{p.get('source','?')}</td>
680
+ <td style="padding:6px 4px;font-size:10px">{url_part}{pdf_part}</td>
681
+ </tr>"""
682
+
683
+ plain_rows = "\n".join(
684
+ f"{i:>3}. [{p.get('source','?')}] {p['title'][:75]}\n"
685
+ f" By: {', '.join(p['authors'][:2]) or '—'} | {(p.get('publication_date') or '')[:4]}\n"
686
+ f" SC: {', '.join(p['sc_categories'])} | Score: {p['sc_score']}\n"
687
+ f" URL: {p['url'] or '—'}\n"
688
+ for i, p in enumerate(papers, 1)
689
+ )
690
+
691
+ html = f"""<!DOCTYPE html>
692
+ <html lang="en"><head><meta charset="utf-8"></head>
693
+ <body style="font-family:Arial,sans-serif;background:#f9fafb;margin:0;padding:24px">
694
+ <div style="max-width:950px;margin:0 auto;background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 8px rgba(0,0,0,.1)">
695
+ <div style="background:#1a3a5c;padding:24px 28px">
696
+ <p style="margin:0;font-size:10px;color:#7eb3d4;text-transform:uppercase;letter-spacing:2px">University of Lagos · URAAS</p>
697
+ <h1 style="margin:6px 0 0;font-size:20px;color:#fff">Test Harvest — Special Collections Preview</h1>
698
+ <p style="margin:6px 0 0;font-size:12px;color:#a8c9e0">
699
+ OpenAlex · Crossref · Semantic Scholar · DOAJ · Dry run · No IR deposit
700
+ </p>
701
+ </div>
702
+ <div style="padding:24px 28px">
703
+ <p style="font-size:14px;color:#374151;margin:0 0 12px">
704
+ <strong>Dry-run preview</strong> — no papers were saved to the local database and nothing was
705
+ deposited to the live IR. These are Special Collections papers by <strong>{institution_name}</strong>
706
+ authors discovered from across the open web.
707
+ </p>
708
+ <table style="width:100%;border-collapse:collapse;font-size:13px;margin:0 0 16px">
709
+ <tr style="background:#f3f4f6">
710
+ <td style="padding:8px 6px;font-weight:600">Institution</td>
711
+ <td style="padding:8px 6px">{institution_name}</td>
712
+ <td style="padding:8px 6px;font-weight:600">Total SC papers</td>
713
+ <td style="padding:8px 6px"><strong style="color:#1a7a4a">{n}</strong></td>
714
+ </tr>
715
+ <tr>
716
+ <td style="padding:8px 6px;font-weight:600">Sources</td>
717
+ <td colspan="3" style="padding:8px 6px">{source_summary}</td>
718
+ </tr>
719
+ </table>
720
+ <table style="width:100%;border-collapse:collapse;font-size:12px">
721
+ <thead>
722
+ <tr style="background:#f3f4f6;text-align:left">
723
+ <th style="padding:6px 4px">#</th>
724
+ <th style="padding:6px 6px">Title / Authors</th>
725
+ <th style="padding:6px 4px">Type</th>
726
+ <th style="padding:6px 4px">Year</th>
727
+ <th style="padding:6px 4px">SC Categories</th>
728
+ <th style="padding:6px 4px">Source</th>
729
+ <th style="padding:6px 4px">URL / PDF</th>
730
+ </tr>
731
+ </thead>
732
+ <tbody>{rows_html}</tbody>
733
+ </table>
734
+ <p style="font-size:12px;color:#6b7280;margin:20px 0 0">
735
+ Papers above were found on the open web and are NOT yet confirmed to be in the UNILAG IR.
736
+ When ready to queue them for IR deposit, use the <strong>IR Deposit</strong> panel in the dashboard.
737
+ </p>
738
+ </div>
739
+ <div style="background:#f0f4f8;padding:14px 28px;font-size:11px;color:#9ca3af;text-align:center">
740
+ URAAS · APA Intelligence &amp; Analytics Platform · University of Lagos · Dry-run — nothing was changed
741
+ </div>
742
+ </div>
743
+ </body></html>"""
744
+
745
+ plain = f"""URAAS Test Harvest — {institution_name}
746
+ Sources: {source_summary}
747
+ DRY RUN — nothing saved to DB, nothing deposited to IR.
748
+
749
+ Special Collections papers found: {n}
750
+
751
+ {plain_rows}
752
+ ---
753
+ URAAS — APA Intelligence & Analytics Platform
754
+ """
755
+
756
+ msg = MIMEMultipart("alternative")
757
+ msg["Subject"] = subject
758
+ msg["From"] = cfg.SMTP_FROM
759
+ msg["To"] = to_email
760
+ msg.attach(MIMEText(plain, "plain", "utf-8"))
761
+ msg.attach(MIMEText(html, "html", "utf-8"))
762
+
763
+ try:
764
+ if cfg.SMTP_USE_TLS:
765
+ srv = smtplib.SMTP(cfg.SMTP_HOST, cfg.SMTP_PORT, timeout=30)
766
+ srv.ehlo()
767
+ srv.starttls()
768
+ srv.ehlo()
769
+ else:
770
+ srv = smtplib.SMTP_SSL(cfg.SMTP_HOST, cfg.SMTP_PORT, timeout=30)
771
+ srv.login(cfg.SMTP_USER, cfg.SMTP_PASSWORD)
772
+ srv.sendmail(cfg.SMTP_FROM, [to_email], msg.as_bytes())
773
+ srv.quit()
774
+ print(f"[OK] Email sent to {to_email}", flush=True)
775
+ return True
776
+ except Exception as exc:
777
+ print(f"[ERR] Email failed: {exc}", flush=True)
778
+ return False
779
+
780
+
781
+ def save_json_preview(papers: list[dict], institution: str) -> str:
782
+ out_path = os.path.join(
783
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
784
+ "storage",
785
+ f"test_harvest_{institution}_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}.json",
786
+ )
787
+ os.makedirs(os.path.dirname(out_path), exist_ok=True)
788
+ with open(out_path, "w", encoding="utf-8") as f:
789
+ json.dump(
790
+ {"institution": institution, "count": len(papers), "papers": papers},
791
+ f, indent=2, ensure_ascii=False,
792
+ )
793
+ print(f"[OK] Preview saved to: {out_path}", flush=True)
794
+ return out_path
795
+
796
+
797
+ def main():
798
+ parser = argparse.ArgumentParser(
799
+ description="Multi-source test harvest (dry run — no DB, no IR deposit)"
800
+ )
801
+ parser.add_argument("--institution", default="unilag")
802
+ parser.add_argument("--count", type=int, default=50, help="Max SC papers to collect")
803
+ parser.add_argument("--email", default="lawalgiyath200716@gmail.com")
804
+ args = parser.parse_args()
805
+
806
+ registry = get_registry()
807
+ inst_cfg = registry.get(args.institution)
808
+ if not inst_cfg:
809
+ print(f"[ERR] Institution '{args.institution}' not found", flush=True)
810
+ sys.exit(1)
811
+
812
+ ror_short = inst_cfg.ror.split("/")[-1]
813
+
814
+ print(f"\n{'='*60}", flush=True)
815
+ print(f"URAAS DRY-RUN HARVEST — {inst_cfg.name}", flush=True)
816
+ print(f"Sources: OpenAlex · Crossref · Semantic Scholar · DOAJ", flush=True)
817
+ print(f"{'='*60}", flush=True)
818
+
819
+ papers = harvest_all(
820
+ ror_short,
821
+ inst_cfg.name,
822
+ inst_cfg.affiliation_patterns,
823
+ args.count,
824
+ )
825
+
826
+ save_json_preview(papers, args.institution)
827
+ send_preview_email(args.email, inst_cfg.name, papers)
828
+
829
+ # Source breakdown
830
+ source_counts: dict[str, int] = {}
831
+ for p in papers:
832
+ source_counts[p.get("source", "?")] = source_counts.get(p.get("source", "?"), 0) + 1
833
+
834
+ print(f"\n{'='*60}", flush=True)
835
+ print("HARVEST SUMMARY", flush=True)
836
+ print(f" Institution : {inst_cfg.name}", flush=True)
837
+ print(f" Total SC : {len(papers)}", flush=True)
838
+ for src, cnt in sorted(source_counts.items()):
839
+ print(f" {src:<22}: {cnt}", flush=True)
840
+ print(f" Email : {args.email}", flush=True)
841
+ print(" IR deposit : NOT performed (dry run)", flush=True)
842
+ print(f"{'='*60}\n", flush=True)
843
+
844
+
845
+ if __name__ == "__main__":
846
+ main()
start_dashboard.py CHANGED
@@ -1,32 +1,32 @@
1
- """
2
- Simple script to start the URAAS dashboard.
3
- Handles Python path setup automatically.
4
- """
5
-
6
- import os
7
- import sys
8
-
9
- # Add project root to Python path
10
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
11
-
12
- # Now import and run the dashboard
13
- import uraas
14
-
15
- print(f"DEBUG: uraas path: {uraas.__path__}", flush=True)
16
- from uraas.config import config
17
- from uraas.dashboard.app import app, socketio
18
-
19
- if __name__ == "__main__":
20
- print("=" * 70, flush=True)
21
- print("URAAS Dashboard Starting...", flush=True)
22
- print("=" * 70, flush=True)
23
- print(f"Dashboard URL: http://localhost:{config.DASHBOARD_PORT}", flush=True)
24
- print("Press Ctrl+C to stop", flush=True)
25
- print("=" * 70, flush=True)
26
- socketio.run(
27
- app,
28
- host="0.0.0.0",
29
- port=config.DASHBOARD_PORT,
30
- debug=False,
31
- allow_unsafe_werkzeug=True,
32
- )
 
1
+ """
2
+ Simple script to start the URAAS dashboard.
3
+ Handles Python path setup automatically.
4
+ """
5
+
6
+ import os
7
+ import sys
8
+
9
+ # Add project root to Python path
10
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
11
+
12
+ # Now import and run the dashboard
13
+ import uraas
14
+
15
+ print(f"DEBUG: uraas path: {uraas.__path__}", flush=True)
16
+ from uraas.config import config
17
+ from uraas.dashboard.app import app, socketio
18
+
19
+ if __name__ == "__main__":
20
+ print("=" * 70, flush=True)
21
+ print("URAAS Dashboard Starting...", flush=True)
22
+ print("=" * 70, flush=True)
23
+ print(f"Dashboard URL: http://localhost:{config.DASHBOARD_PORT}", flush=True)
24
+ print("Press Ctrl+C to stop", flush=True)
25
+ print("=" * 70, flush=True)
26
+ socketio.run(
27
+ app,
28
+ host="0.0.0.0",
29
+ port=config.DASHBOARD_PORT,
30
+ debug=False,
31
+ allow_unsafe_werkzeug=True,
32
+ )
tests/test_all_spiders.py CHANGED
@@ -1,202 +1,202 @@
1
- """
2
- Comprehensive test for all multi-institution spiders
3
- Tests initialization and configuration for all spider types
4
- """
5
-
6
- import os
7
- import sys
8
-
9
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
10
-
11
- from uraas.config.institutions import get_registry
12
- from uraas.spiders.sources.arxiv_spider import ArxivSpider
13
- from uraas.spiders.sources.crossref_spider import CrossrefSpider
14
- from uraas.spiders.sources.openalex_spider import OpenAlexSpider
15
- from uraas.spiders.sources.orcid_spider import ORCIDSpider
16
- from uraas.spiders.sources.scholar_spider import ScholarSpider
17
-
18
-
19
- def test_all_spiders():
20
- """Test that all spiders can be initialized with different institutions"""
21
- print("\n" + "=" * 60)
22
- print("COMPREHENSIVE SPIDER TEST")
23
- print("=" * 60)
24
-
25
- registry = get_registry()
26
- institutions = ["unilag", "ui", "oau"]
27
-
28
- spider_classes = {
29
- "OpenAlex": OpenAlexSpider,
30
- "Crossref": CrossrefSpider,
31
- "ArXiv": ArxivSpider,
32
- "Scholar": ScholarSpider,
33
- "ORCID": ORCIDSpider,
34
- }
35
-
36
- results = {}
37
-
38
- for spider_name, spider_class in spider_classes.items():
39
- print(f"\n{'='*60}")
40
- print(f"Testing {spider_name} Spider")
41
- print("=" * 60)
42
-
43
- spider_results = {}
44
-
45
- for inst in institutions:
46
- try:
47
- config = registry.get(inst)
48
- if not config:
49
- print(f" ✗ {inst}: Configuration not found")
50
- spider_results[inst] = False
51
- continue
52
-
53
- # Try to initialize spider
54
- spider = spider_class(institution=inst)
55
-
56
- print(f" ✓ {inst}: {spider.institution_name}")
57
- print(f" ROR: {spider.ror_id}")
58
- print(f" Staff: {len(config.staff_names)}")
59
-
60
- spider_results[inst] = True
61
-
62
- except Exception as e:
63
- print(f" ✗ {inst}: Failed - {e}")
64
- spider_results[inst] = False
65
-
66
- results[spider_name] = spider_results
67
-
68
- # Summary
69
- print("\n" + "=" * 60)
70
- print("COMPREHENSIVE SUMMARY")
71
- print("=" * 60)
72
-
73
- total_tests = len(spider_classes) * len(institutions)
74
- passed_tests = sum(
75
- 1
76
- for spider_results in results.values()
77
- for success in spider_results.values()
78
- if success
79
- )
80
-
81
- print(f"\nTotal Tests: {passed_tests}/{total_tests}")
82
- print(f"\nResults by Spider:")
83
-
84
- for spider_name, spider_results in results.items():
85
- passed = sum(1 for v in spider_results.values() if v)
86
- total = len(spider_results)
87
- status = "✓" if passed == total else "✗"
88
- print(f" {status} {spider_name}: {passed}/{total}")
89
-
90
- for inst, success in spider_results.items():
91
- inst_status = "✓" if success else "✗"
92
- print(f" {inst_status} {inst}")
93
-
94
- if passed_tests == total_tests:
95
- print("\n" + "=" * 60)
96
- print("✓ ALL SPIDERS READY FOR MULTI-INSTITUTION CRAWLING")
97
- print("=" * 60)
98
- print("\nNext Steps:")
99
- print(
100
- " 1. Test crawl: python crawl_multi_institution.py --institutions unilag,ui --target 10 --spider openalex"
101
- )
102
- print(" 2. Verify database: Check for papers with institution_ror tags")
103
- print(" 3. Test dashboard: Verify multi-institution comparison works")
104
- print(" 4. Production crawl: Run with higher targets for all institutions")
105
- return True
106
- else:
107
- print("\n✗ SOME TESTS FAILED")
108
- return False
109
-
110
-
111
- def test_spider_metadata():
112
- """Test that spiders have correct metadata"""
113
- print("\n" + "=" * 60)
114
- print("SPIDER METADATA TEST")
115
- print("=" * 60)
116
-
117
- spider_classes = {
118
- "OpenAlex": OpenAlexSpider,
119
- "Crossref": CrossrefSpider,
120
- "ArXiv": ArxivSpider,
121
- "Scholar": ScholarSpider,
122
- "ORCID": ORCIDSpider,
123
- }
124
-
125
- for spider_name, spider_class in spider_classes.items():
126
- spider = spider_class(institution="unilag")
127
- print(f"\n{spider_name}:")
128
- print(f" Name: {spider.name}")
129
- print(f" Institution: {spider.institution_name}")
130
- print(f" ROR: {spider.ror_id}")
131
-
132
- # Check for required attributes
133
- required_attrs = ["institution_name", "ror_id", "institution_config"]
134
- missing = [attr for attr in required_attrs if not hasattr(spider, attr)]
135
-
136
- if missing:
137
- print(f" ✗ Missing attributes: {missing}")
138
- return False
139
- else:
140
- print(f" ✓ All required attributes present")
141
-
142
- return True
143
-
144
-
145
- def main():
146
- """Run all tests"""
147
- print("\n" + "=" * 60)
148
- print("MULTI-INSTITUTION SPIDER TEST SUITE")
149
- print("=" * 60)
150
-
151
- tests = [
152
- ("Spider Metadata", test_spider_metadata),
153
- ("All Spiders Initialization", test_all_spiders),
154
- ]
155
-
156
- results = {}
157
-
158
- for test_name, test_func in tests:
159
- try:
160
- result = test_func()
161
- results[test_name] = result
162
- except Exception as e:
163
- print(f"\n✗ {test_name} FAILED: {e}")
164
- import traceback
165
-
166
- traceback.print_exc()
167
- results[test_name] = False
168
-
169
- # Final summary
170
- print("\n" + "=" * 60)
171
- print("FINAL TEST SUMMARY")
172
- print("=" * 60)
173
-
174
- passed = sum(1 for v in results.values() if v)
175
- total = len(results)
176
-
177
- print(f"\nTests passed: {passed}/{total}\n")
178
-
179
- for test_name, success in results.items():
180
- status = "✓ PASS" if success else "✗ FAIL"
181
- print(f" {status}: {test_name}")
182
-
183
- if passed == total:
184
- print("\n" + "=" * 60)
185
- print("✓ WEEK 1 DAY 5-7 COMPLETE")
186
- print("=" * 60)
187
- print("\nAll spiders updated for multi-institution support!")
188
- print("\nImplementation Summary:")
189
- print(" • 5 spiders updated: OpenAlex, Crossref, ArXiv, Scholar, ORCID")
190
- print(" • 5 institutions configured: UNILAG, UI, OAU, UNN, ABU")
191
- print(" • 2,146 total staff members loaded")
192
- print(" • ROR-based identification implemented")
193
- print(" • Backward compatibility maintained")
194
- print("\nReady for production crawling!")
195
- return 0
196
- else:
197
- print("\n✗ SOME TESTS FAILED")
198
- return 1
199
-
200
-
201
- if __name__ == "__main__":
202
- sys.exit(main())
 
1
+ """
2
+ Comprehensive test for all multi-institution spiders
3
+ Tests initialization and configuration for all spider types
4
+ """
5
+
6
+ import os
7
+ import sys
8
+
9
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
10
+
11
+ from uraas.config.institutions import get_registry
12
+ from uraas.spiders.sources.arxiv_spider import ArxivSpider
13
+ from uraas.spiders.sources.crossref_spider import CrossrefSpider
14
+ from uraas.spiders.sources.openalex_spider import OpenAlexSpider
15
+ from uraas.spiders.sources.orcid_spider import ORCIDSpider
16
+ from uraas.spiders.sources.scholar_spider import ScholarSpider
17
+
18
+
19
+ def test_all_spiders():
20
+ """Test that all spiders can be initialized with different institutions"""
21
+ print("\n" + "=" * 60)
22
+ print("COMPREHENSIVE SPIDER TEST")
23
+ print("=" * 60)
24
+
25
+ registry = get_registry()
26
+ institutions = ["unilag", "ui", "oau"]
27
+
28
+ spider_classes = {
29
+ "OpenAlex": OpenAlexSpider,
30
+ "Crossref": CrossrefSpider,
31
+ "ArXiv": ArxivSpider,
32
+ "Scholar": ScholarSpider,
33
+ "ORCID": ORCIDSpider,
34
+ }
35
+
36
+ results = {}
37
+
38
+ for spider_name, spider_class in spider_classes.items():
39
+ print(f"\n{'='*60}")
40
+ print(f"Testing {spider_name} Spider")
41
+ print("=" * 60)
42
+
43
+ spider_results = {}
44
+
45
+ for inst in institutions:
46
+ try:
47
+ config = registry.get(inst)
48
+ if not config:
49
+ print(f" ✗ {inst}: Configuration not found")
50
+ spider_results[inst] = False
51
+ continue
52
+
53
+ # Try to initialize spider
54
+ spider = spider_class(institution=inst)
55
+
56
+ print(f" ✓ {inst}: {spider.institution_name}")
57
+ print(f" ROR: {spider.ror_id}")
58
+ print(f" Staff: {len(config.staff_names)}")
59
+
60
+ spider_results[inst] = True
61
+
62
+ except Exception as e:
63
+ print(f" ✗ {inst}: Failed - {e}")
64
+ spider_results[inst] = False
65
+
66
+ results[spider_name] = spider_results
67
+
68
+ # Summary
69
+ print("\n" + "=" * 60)
70
+ print("COMPREHENSIVE SUMMARY")
71
+ print("=" * 60)
72
+
73
+ total_tests = len(spider_classes) * len(institutions)
74
+ passed_tests = sum(
75
+ 1
76
+ for spider_results in results.values()
77
+ for success in spider_results.values()
78
+ if success
79
+ )
80
+
81
+ print(f"\nTotal Tests: {passed_tests}/{total_tests}")
82
+ print(f"\nResults by Spider:")
83
+
84
+ for spider_name, spider_results in results.items():
85
+ passed = sum(1 for v in spider_results.values() if v)
86
+ total = len(spider_results)
87
+ status = "✓" if passed == total else "✗"
88
+ print(f" {status} {spider_name}: {passed}/{total}")
89
+
90
+ for inst, success in spider_results.items():
91
+ inst_status = "✓" if success else "✗"
92
+ print(f" {inst_status} {inst}")
93
+
94
+ if passed_tests == total_tests:
95
+ print("\n" + "=" * 60)
96
+ print("✓ ALL SPIDERS READY FOR MULTI-INSTITUTION CRAWLING")
97
+ print("=" * 60)
98
+ print("\nNext Steps:")
99
+ print(
100
+ " 1. Test crawl: python crawl_multi_institution.py --institutions unilag,ui --target 10 --spider openalex"
101
+ )
102
+ print(" 2. Verify database: Check for papers with institution_ror tags")
103
+ print(" 3. Test dashboard: Verify multi-institution comparison works")
104
+ print(" 4. Production crawl: Run with higher targets for all institutions")
105
+ return True
106
+ else:
107
+ print("\n✗ SOME TESTS FAILED")
108
+ return False
109
+
110
+
111
+ def test_spider_metadata():
112
+ """Test that spiders have correct metadata"""
113
+ print("\n" + "=" * 60)
114
+ print("SPIDER METADATA TEST")
115
+ print("=" * 60)
116
+
117
+ spider_classes = {
118
+ "OpenAlex": OpenAlexSpider,
119
+ "Crossref": CrossrefSpider,
120
+ "ArXiv": ArxivSpider,
121
+ "Scholar": ScholarSpider,
122
+ "ORCID": ORCIDSpider,
123
+ }
124
+
125
+ for spider_name, spider_class in spider_classes.items():
126
+ spider = spider_class(institution="unilag")
127
+ print(f"\n{spider_name}:")
128
+ print(f" Name: {spider.name}")
129
+ print(f" Institution: {spider.institution_name}")
130
+ print(f" ROR: {spider.ror_id}")
131
+
132
+ # Check for required attributes
133
+ required_attrs = ["institution_name", "ror_id", "institution_config"]
134
+ missing = [attr for attr in required_attrs if not hasattr(spider, attr)]
135
+
136
+ if missing:
137
+ print(f" ✗ Missing attributes: {missing}")
138
+ return False
139
+ else:
140
+ print(f" ✓ All required attributes present")
141
+
142
+ return True
143
+
144
+
145
+ def main():
146
+ """Run all tests"""
147
+ print("\n" + "=" * 60)
148
+ print("MULTI-INSTITUTION SPIDER TEST SUITE")
149
+ print("=" * 60)
150
+
151
+ tests = [
152
+ ("Spider Metadata", test_spider_metadata),
153
+ ("All Spiders Initialization", test_all_spiders),
154
+ ]
155
+
156
+ results = {}
157
+
158
+ for test_name, test_func in tests:
159
+ try:
160
+ result = test_func()
161
+ results[test_name] = result
162
+ except Exception as e:
163
+ print(f"\n✗ {test_name} FAILED: {e}")
164
+ import traceback
165
+
166
+ traceback.print_exc()
167
+ results[test_name] = False
168
+
169
+ # Final summary
170
+ print("\n" + "=" * 60)
171
+ print("FINAL TEST SUMMARY")
172
+ print("=" * 60)
173
+
174
+ passed = sum(1 for v in results.values() if v)
175
+ total = len(results)
176
+
177
+ print(f"\nTests passed: {passed}/{total}\n")
178
+
179
+ for test_name, success in results.items():
180
+ status = "✓ PASS" if success else "✗ FAIL"
181
+ print(f" {status}: {test_name}")
182
+
183
+ if passed == total:
184
+ print("\n" + "=" * 60)
185
+ print("✓ WEEK 1 DAY 5-7 COMPLETE")
186
+ print("=" * 60)
187
+ print("\nAll spiders updated for multi-institution support!")
188
+ print("\nImplementation Summary:")
189
+ print(" • 5 spiders updated: OpenAlex, Crossref, ArXiv, Scholar, ORCID")
190
+ print(" • 5 institutions configured: UNILAG, UI, OAU, UNN, ABU")
191
+ print(" • 2,146 total staff members loaded")
192
+ print(" • ROR-based identification implemented")
193
+ print(" • Backward compatibility maintained")
194
+ print("\nReady for production crawling!")
195
+ return 0
196
+ else:
197
+ print("\n✗ SOME TESTS FAILED")
198
+ return 1
199
+
200
+
201
+ if __name__ == "__main__":
202
+ sys.exit(main())
tests/test_api.py CHANGED
@@ -1,473 +1,473 @@
1
- """
2
- URAAS Test Suite covers every API endpoint and APA analytics metrics.
3
- Run: pytest tests/test_api.py -v
4
- """
5
-
6
- import os
7
- import sys
8
-
9
- import pytest
10
-
11
- sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
12
-
13
- from uraas.analytics.engine import URAASAnalyticsEngine, analytics
14
- from uraas.dashboard.app import app as flask_app
15
- from uraas.database import Author, Collection, Community, Item, SessionLocal
16
- from uraas.utils.ai_keyword_extractor import ai_extractor
17
- from uraas.utils.docid_generator import docid_generator
18
-
19
-
20
- @pytest.fixture(scope="module")
21
- def client():
22
- flask_app.config["TESTING"] = True
23
- with flask_app.test_client() as c:
24
- yield c
25
-
26
-
27
- # Core page
28
-
29
-
30
- def test_index_loads(client):
31
- r = client.get("/")
32
- assert r.status_code == 200
33
-
34
-
35
- # Analytics overview
36
-
37
-
38
- def test_analytics_overview(client):
39
- r = client.get("/api/analytics/overview")
40
- assert r.status_code == 200
41
- d = r.get_json()
42
- assert "total_papers" in d
43
- assert "total_authors" in d
44
- assert "oa_percentage" in d
45
- assert isinstance(d["total_papers"], int)
46
- assert 0 <= d["oa_percentage"] <= 100
47
-
48
-
49
- def test_publications_by_year(client):
50
- r = client.get("/api/analytics/publications-by-year")
51
- assert r.status_code == 200
52
- d = r.get_json()
53
- assert isinstance(d, list)
54
- for item in d:
55
- assert "year" in item and "count" in item
56
- assert isinstance(item["year"], int)
57
- assert item["count"] >= 0
58
-
59
-
60
- def test_papers_by_faculty(client):
61
- r = client.get("/api/analytics/papers-by-faculty")
62
- assert r.status_code == 200
63
- d = r.get_json()
64
- assert isinstance(d, list)
65
-
66
-
67
- def test_top_authors(client):
68
- r = client.get("/api/analytics/top-authors?limit=10")
69
- assert r.status_code == 200
70
- d = r.get_json()
71
- assert isinstance(d, list)
72
- assert len(d) <= 10
73
-
74
-
75
- def test_oa_breakdown(client):
76
- r = client.get("/api/analytics/open-access-breakdown")
77
- assert r.status_code == 200
78
- d = r.get_json()
79
- assert isinstance(d, list)
80
- labels = [x["label"] for x in d]
81
- assert "Open Access" in labels
82
-
83
-
84
- def test_recent_papers(client):
85
- r = client.get("/api/analytics/recent-papers?limit=5")
86
- assert r.status_code == 200
87
- d = r.get_json()
88
- assert isinstance(d, list)
89
- assert len(d) <= 5
90
-
91
-
92
- def test_impact_metrics(client):
93
- r = client.get("/api/analytics/impact-metrics")
94
- assert r.status_code == 200
95
- d = r.get_json()
96
- assert "total_papers" in d
97
- assert "oa_rate" in d
98
- assert "doi_rate" in d
99
-
100
-
101
- def test_faculties_list(client):
102
- r = client.get("/api/analytics/faculties")
103
- assert r.status_code == 200
104
- d = r.get_json()
105
- assert isinstance(d, list)
106
-
107
-
108
- # ── Search ────────────────────────────────────────────────────────────────────
109
-
110
-
111
- def test_search_empty(client):
112
- r = client.get("/api/analytics/search?q=&limit=10")
113
- assert r.status_code == 200
114
- assert isinstance(r.get_json(), list)
115
-
116
-
117
- def test_search_with_query(client):
118
- r = client.get("/api/analytics/search?q=health&limit=10")
119
- assert r.status_code == 200
120
- d = r.get_json()
121
- assert isinstance(d, list)
122
- assert len(d) <= 10
123
-
124
-
125
- def test_search_oa_filter(client):
126
- r = client.get("/api/analytics/search?oa_only=true&limit=20")
127
- assert r.status_code == 200
128
- d = r.get_json()
129
- for item in d:
130
- assert item["is_oa"] == True
131
-
132
-
133
- def test_search_sql_injection(client):
134
- r = client.get("/api/analytics/search?q='; DROP TABLE items; --")
135
- assert r.status_code == 200 # should not crash
136
-
137
-
138
- # Papers tree
139
-
140
-
141
- def test_papers_tree(client):
142
- r = client.get("/api/papers/tree")
143
- assert r.status_code == 200
144
- d = r.get_json()
145
- assert "status" in d
146
- assert "data" in d
147
-
148
-
149
- # Paper detail
150
-
151
-
152
- def test_paper_not_found(client):
153
- r = client.get("/api/papers/999999")
154
- assert r.status_code == 404
155
-
156
-
157
- def test_paper_detail_if_exists(client):
158
- session = SessionLocal()
159
- try:
160
- item = session.query(Item).first()
161
- if item:
162
- r = client.get(f"/api/papers/{item.id}")
163
- assert r.status_code == 200
164
- d = r.get_json()
165
- assert "title" in d
166
- assert "authors" in d
167
- assert "dc" in d
168
- finally:
169
- session.close()
170
-
171
-
172
- # Keyword cloud
173
-
174
-
175
- def test_keyword_cloud(client):
176
- r = client.get("/api/analytics/keyword-cloud")
177
- assert r.status_code == 200
178
- d = r.get_json()
179
- assert isinstance(d, list)
180
- for item in d:
181
- assert "word" in item
182
- assert "count" in item
183
- assert "score" in item
184
-
185
-
186
- # Research trends
187
-
188
-
189
- def test_research_trends(client):
190
- r = client.get("/api/analytics/research-trends")
191
- assert r.status_code == 200
192
- d = r.get_json()
193
- assert isinstance(d, list)
194
- for item in d:
195
- assert "topic" in item
196
- assert "total" in item
197
- assert "by_year" in item
198
-
199
-
200
- # Language research
201
-
202
-
203
- def test_language_research(client):
204
- r = client.get("/api/analytics/language-research")
205
- assert r.status_code == 200
206
- d = r.get_json()
207
- assert "total_language_papers" in d
208
- assert "papers" in d
209
- assert "top_keywords" in d
210
- # Verify no false positives
211
- bad_terms = [
212
- "machine learning",
213
- "concrete",
214
- "cancer",
215
- "covid",
216
- "petroleum",
217
- "galaxy",
218
- ]
219
- for paper in d["papers"]:
220
- title_lower = (paper.get("title") or "").lower()
221
- for bad in bad_terms:
222
- assert bad not in title_lower, f"False positive: '{bad}' in '{title_lower}'"
223
-
224
-
225
- # APA Novel Metrics
226
-
227
-
228
- def test_tk_vitality_score(client):
229
- r = client.get("/api/analytics/tk-vitality-score")
230
- assert r.status_code == 200
231
- d = r.get_json()
232
- assert "score" in d
233
- assert 0 <= d["score"] <= 100
234
- assert "breakdown" in d
235
- assert "total_items" in d
236
-
237
-
238
- def test_linguistic_diversity_index(client):
239
- r = client.get("/api/analytics/linguistic-diversity-index")
240
- assert r.status_code == 200
241
- d = r.get_json()
242
- assert "index" in d
243
- assert 0 <= d["index"] <= 100
244
- assert "breakdown" in d
245
-
246
-
247
- def test_patent_velocity(client):
248
- r = client.get("/api/analytics/patent-velocity")
249
- assert r.status_code == 200
250
- d = r.get_json()
251
- assert "total_patents" in d
252
- assert "velocity_distribution" in d
253
-
254
-
255
- def test_docid_coverage(client):
256
- r = client.get("/api/analytics/docid-coverage")
257
- assert r.status_code == 200
258
- d = r.get_json()
259
- assert "total_papers" in d
260
- assert "docid_assigned" in d
261
- assert "coverage_percent" in d
262
- assert 0 <= d["coverage_percent"] <= 100
263
-
264
-
265
- def test_docid_stats(client):
266
- r = client.get("/api/docid/stats")
267
- assert r.status_code == 200
268
- d = r.get_json()
269
- assert "total_docid_papers" in d
270
- assert "docid_coverage" in d
271
-
272
-
273
- # Author network
274
-
275
-
276
- def test_author_network_global(client):
277
- r = client.get("/api/analytics/author-network")
278
- assert r.status_code == 200
279
- d = r.get_json()
280
- assert "nodes" in d
281
- assert "edges" in d
282
-
283
-
284
- def test_authors_search(client):
285
- r = client.get("/api/analytics/authors-search?q=a&limit=5")
286
- assert r.status_code == 200
287
- d = r.get_json()
288
- assert isinstance(d, list)
289
- assert len(d) <= 5
290
-
291
-
292
- # Faculty comparison
293
-
294
-
295
- def test_faculty_comparison_empty(client):
296
- r = client.get("/api/analytics/faculty-comparison")
297
- assert r.status_code == 200
298
- assert isinstance(r.get_json(), dict)
299
-
300
-
301
- # Exports
302
-
303
-
304
- def test_export_csv(client):
305
- r = client.get("/api/export/papers.csv")
306
- assert r.status_code == 200
307
- assert "text/csv" in r.content_type
308
- data = r.data.decode("utf-8")
309
- assert "Title" in data or "ID" in data
310
-
311
-
312
- def test_export_bibtex(client):
313
- r = client.get("/api/export/papers.bibtex")
314
- assert r.status_code == 200
315
-
316
-
317
- # Crawler status
318
-
319
-
320
- def test_crawler_status(client):
321
- r = client.get("/api/crawler/status")
322
- assert r.status_code == 200
323
- d = r.get_json()
324
- assert d["status"] in ("running", "idle")
325
-
326
-
327
- def test_docid_crawler_status(client):
328
- r = client.get("/api/docid-crawler/status")
329
- assert r.status_code == 200
330
- d = r.get_json()
331
- assert d["status"] in ("running", "idle")
332
-
333
-
334
- # Analytics engine unit tests
335
-
336
-
337
- def test_engine_top_authors():
338
- result = analytics.get_top_authors(limit=5)
339
- assert isinstance(result, list)
340
- assert len(result) <= 5
341
- for r in result:
342
- assert "author" in r
343
- assert "count" in r
344
- assert r["count"] > 0
345
-
346
-
347
- def test_engine_sdg_alignment():
348
- result = analytics.get_sdg_alignment()
349
- assert isinstance(result, list)
350
- sdg_names = [r["sdg"] for r in result]
351
- # Should have at least some SDGs with papers
352
- assert len(result) >= 0
353
-
354
-
355
- def test_engine_keyword_cloud():
356
- result = analytics.get_keyword_cloud(top_n=20)
357
- assert isinstance(result, list)
358
- assert len(result) <= 20
359
- for item in result:
360
- assert "word" in item
361
- assert "score" in item
362
- assert item["score"] > 0
363
-
364
-
365
- def test_engine_tk_vitality():
366
- result = analytics.get_tk_vitality_score()
367
- assert "score" in result
368
- assert 0 <= result["score"] <= 100
369
-
370
-
371
- def test_engine_linguistic_diversity():
372
- result = analytics.get_linguistic_diversity_index()
373
- assert "index" in result
374
- assert 0 <= result["index"] <= 100
375
-
376
-
377
- def test_engine_patent_velocity():
378
- result = analytics.get_patent_velocity()
379
- assert "total_patents" in result
380
- assert isinstance(result["total_patents"], int)
381
-
382
-
383
- def test_engine_docid_coverage():
384
- result = analytics.get_docid_coverage()
385
- assert "coverage_percent" in result
386
- assert 0 <= result["coverage_percent"] <= 100
387
-
388
-
389
- # DocID generator
390
-
391
-
392
- def test_docid_generation():
393
- docid = docid_generator.generate_docid("Test Paper Title", doi="10.1234/test")
394
- assert docid.startswith("20.500.14351/")
395
- parts = docid.split("/")
396
- assert len(parts) == 2
397
- assert len(parts[1]) >= 10
398
-
399
-
400
- def test_docid_validation():
401
- valid = docid_generator.generate_docid("Test")
402
- assert docid_generator.validate_docid(valid) == True
403
- assert docid_generator.validate_docid("") == False
404
- assert docid_generator.validate_docid("invalid") == False
405
- assert docid_generator.validate_docid("99.999.99999/abc") == False
406
-
407
-
408
- def test_docid_uniqueness():
409
- ids = {docid_generator.generate_docid("Same Title") for _ in range(10)}
410
- assert len(ids) == 10 # all unique due to uuid4
411
-
412
-
413
- # AI keyword extractor
414
-
415
-
416
- def test_keyword_extraction():
417
- text = (
418
- "machine learning deep neural networks artificial intelligence computer vision"
419
- )
420
- kws = ai_extractor.extract_keywords(text, top_n=5)
421
- assert len(kws) > 0
422
- assert all(isinstance(k, tuple) and len(k) == 2 for k in kws)
423
-
424
-
425
- def test_keyword_extraction_empty():
426
- assert ai_extractor.extract_keywords("", top_n=5) == []
427
-
428
-
429
- def test_domain_classification():
430
- text = "algorithm data structure programming software engineering database"
431
- domains = ai_extractor.classify_domain(text)
432
- assert len(domains) > 0
433
- assert domains[0][0] == "computer_science"
434
-
435
-
436
- def test_paper_scoring():
437
- score = ai_extractor.score_paper(
438
- "Machine Learning for Medical Diagnosis",
439
- "This study investigates machine learning algorithms for medical diagnosis using deep neural networks to classify medical images with significant improvement over existing methods.",
440
- )
441
- assert "quality_score" in score
442
- assert 0 <= score["quality_score"] <= 1
443
- assert "keywords" in score
444
-
445
-
446
- # Performance
447
-
448
-
449
- def test_overview_response_time(client):
450
- import time
451
-
452
- start = time.time()
453
- client.get("/api/analytics/overview")
454
- elapsed = time.time() - start
455
- assert elapsed < 3.0, f"Overview took {elapsed:.2f}s, should be < 3s"
456
-
457
-
458
- def test_search_response_time(client):
459
- import time
460
-
461
- start = time.time()
462
- client.get("/api/analytics/search?q=health&limit=20")
463
- elapsed = time.time() - start
464
- assert elapsed < 5.0, f"Search took {elapsed:.2f}s, should be < 5s"
465
-
466
-
467
- def test_keyword_cloud_response_time(client):
468
- import time
469
-
470
- start = time.time()
471
- client.get("/api/analytics/keyword-cloud")
472
- elapsed = time.time() - start
473
- assert elapsed < 10.0, f"Keyword cloud took {elapsed:.2f}s, should be < 10s"
 
1
+ """
2
+ URAAS Test Suite covers every API endpoint and APA analytics metrics.
3
+ Run: pytest tests/test_api.py -v
4
+ """
5
+
6
+ import os
7
+ import sys
8
+
9
+ import pytest
10
+
11
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
12
+
13
+ from uraas.analytics.engine import URAASAnalyticsEngine, analytics
14
+ from uraas.dashboard.app import app as flask_app
15
+ from uraas.database import Author, Collection, Community, Item, SessionLocal
16
+ from uraas.utils.ai_keyword_extractor import ai_extractor
17
+ from uraas.utils.docid_generator import docid_generator
18
+
19
+
20
+ @pytest.fixture(scope="module")
21
+ def client():
22
+ flask_app.config["TESTING"] = True
23
+ with flask_app.test_client() as c:
24
+ yield c
25
+
26
+
27
+ # Core page
28
+
29
+
30
+ def test_index_loads(client):
31
+ r = client.get("/")
32
+ assert r.status_code == 200
33
+
34
+
35
+ # Analytics overview
36
+
37
+
38
+ def test_analytics_overview(client):
39
+ r = client.get("/api/analytics/overview")
40
+ assert r.status_code == 200
41
+ d = r.get_json()
42
+ assert "total_papers" in d
43
+ assert "total_authors" in d
44
+ assert "oa_percentage" in d
45
+ assert isinstance(d["total_papers"], int)
46
+ assert 0 <= d["oa_percentage"] <= 100
47
+
48
+
49
+ def test_publications_by_year(client):
50
+ r = client.get("/api/analytics/publications-by-year")
51
+ assert r.status_code == 200
52
+ d = r.get_json()
53
+ assert isinstance(d, list)
54
+ for item in d:
55
+ assert "year" in item and "count" in item
56
+ assert isinstance(item["year"], int)
57
+ assert item["count"] >= 0
58
+
59
+
60
+ def test_papers_by_faculty(client):
61
+ r = client.get("/api/analytics/papers-by-faculty")
62
+ assert r.status_code == 200
63
+ d = r.get_json()
64
+ assert isinstance(d, list)
65
+
66
+
67
+ def test_top_authors(client):
68
+ r = client.get("/api/analytics/top-authors?limit=10")
69
+ assert r.status_code == 200
70
+ d = r.get_json()
71
+ assert isinstance(d, list)
72
+ assert len(d) <= 10
73
+
74
+
75
+ def test_oa_breakdown(client):
76
+ r = client.get("/api/analytics/open-access-breakdown")
77
+ assert r.status_code == 200
78
+ d = r.get_json()
79
+ assert isinstance(d, list)
80
+ labels = [x["label"] for x in d]
81
+ assert "Open Access" in labels
82
+
83
+
84
+ def test_recent_papers(client):
85
+ r = client.get("/api/analytics/recent-papers?limit=5")
86
+ assert r.status_code == 200
87
+ d = r.get_json()
88
+ assert isinstance(d, list)
89
+ assert len(d) <= 5
90
+
91
+
92
+ def test_impact_metrics(client):
93
+ r = client.get("/api/analytics/impact-metrics")
94
+ assert r.status_code == 200
95
+ d = r.get_json()
96
+ assert "total_papers" in d
97
+ assert "oa_rate" in d
98
+ assert "doi_rate" in d
99
+
100
+
101
+ def test_faculties_list(client):
102
+ r = client.get("/api/analytics/faculties")
103
+ assert r.status_code == 200
104
+ d = r.get_json()
105
+ assert isinstance(d, list)
106
+
107
+
108
+ # ── Search ────────────────────────────────────────────────────────────────────
109
+
110
+
111
+ def test_search_empty(client):
112
+ r = client.get("/api/analytics/search?q=&limit=10")
113
+ assert r.status_code == 200
114
+ assert isinstance(r.get_json(), list)
115
+
116
+
117
+ def test_search_with_query(client):
118
+ r = client.get("/api/analytics/search?q=health&limit=10")
119
+ assert r.status_code == 200
120
+ d = r.get_json()
121
+ assert isinstance(d, list)
122
+ assert len(d) <= 10
123
+
124
+
125
+ def test_search_oa_filter(client):
126
+ r = client.get("/api/analytics/search?oa_only=true&limit=20")
127
+ assert r.status_code == 200
128
+ d = r.get_json()
129
+ for item in d:
130
+ assert item["is_oa"] == True
131
+
132
+
133
+ def test_search_sql_injection(client):
134
+ r = client.get("/api/analytics/search?q='; DROP TABLE items; --")
135
+ assert r.status_code == 200 # should not crash
136
+
137
+
138
+ # Papers tree
139
+
140
+
141
+ def test_papers_tree(client):
142
+ r = client.get("/api/papers/tree")
143
+ assert r.status_code == 200
144
+ d = r.get_json()
145
+ assert "status" in d
146
+ assert "data" in d
147
+
148
+
149
+ # Paper detail
150
+
151
+
152
+ def test_paper_not_found(client):
153
+ r = client.get("/api/papers/999999")
154
+ assert r.status_code == 404
155
+
156
+
157
+ def test_paper_detail_if_exists(client):
158
+ session = SessionLocal()
159
+ try:
160
+ item = session.query(Item).first()
161
+ if item:
162
+ r = client.get(f"/api/papers/{item.id}")
163
+ assert r.status_code == 200
164
+ d = r.get_json()
165
+ assert "title" in d
166
+ assert "authors" in d
167
+ assert "dc" in d
168
+ finally:
169
+ session.close()
170
+
171
+
172
+ # Keyword cloud
173
+
174
+
175
+ def test_keyword_cloud(client):
176
+ r = client.get("/api/analytics/keyword-cloud")
177
+ assert r.status_code == 200
178
+ d = r.get_json()
179
+ assert isinstance(d, list)
180
+ for item in d:
181
+ assert "word" in item
182
+ assert "count" in item
183
+ assert "score" in item
184
+
185
+
186
+ # Research trends
187
+
188
+
189
+ def test_research_trends(client):
190
+ r = client.get("/api/analytics/research-trends")
191
+ assert r.status_code == 200
192
+ d = r.get_json()
193
+ assert isinstance(d, list)
194
+ for item in d:
195
+ assert "topic" in item
196
+ assert "total" in item
197
+ assert "by_year" in item
198
+
199
+
200
+ # Language research
201
+
202
+
203
+ def test_language_research(client):
204
+ r = client.get("/api/analytics/language-research")
205
+ assert r.status_code == 200
206
+ d = r.get_json()
207
+ assert "total_language_papers" in d
208
+ assert "papers" in d
209
+ assert "top_keywords" in d
210
+ # Verify no false positives
211
+ bad_terms = [
212
+ "machine learning",
213
+ "concrete",
214
+ "cancer",
215
+ "covid",
216
+ "petroleum",
217
+ "galaxy",
218
+ ]
219
+ for paper in d["papers"]:
220
+ title_lower = (paper.get("title") or "").lower()
221
+ for bad in bad_terms:
222
+ assert bad not in title_lower, f"False positive: '{bad}' in '{title_lower}'"
223
+
224
+
225
+ # APA Novel Metrics
226
+
227
+
228
+ def test_tk_vitality_score(client):
229
+ r = client.get("/api/analytics/tk-vitality-score")
230
+ assert r.status_code == 200
231
+ d = r.get_json()
232
+ assert "score" in d
233
+ assert 0 <= d["score"] <= 100
234
+ assert "breakdown" in d
235
+ assert "total_items" in d
236
+
237
+
238
+ def test_linguistic_diversity_index(client):
239
+ r = client.get("/api/analytics/linguistic-diversity-index")
240
+ assert r.status_code == 200
241
+ d = r.get_json()
242
+ assert "index" in d
243
+ assert 0 <= d["index"] <= 100
244
+ assert "breakdown" in d
245
+
246
+
247
+ def test_patent_velocity(client):
248
+ r = client.get("/api/analytics/patent-velocity")
249
+ assert r.status_code == 200
250
+ d = r.get_json()
251
+ assert "total_patents" in d
252
+ assert "velocity_distribution" in d
253
+
254
+
255
+ def test_docid_coverage(client):
256
+ r = client.get("/api/analytics/docid-coverage")
257
+ assert r.status_code == 200
258
+ d = r.get_json()
259
+ assert "total_papers" in d
260
+ assert "docid_assigned" in d
261
+ assert "coverage_percent" in d
262
+ assert 0 <= d["coverage_percent"] <= 100
263
+
264
+
265
+ def test_docid_stats(client):
266
+ r = client.get("/api/docid/stats")
267
+ assert r.status_code == 200
268
+ d = r.get_json()
269
+ assert "total_docid_papers" in d
270
+ assert "docid_coverage" in d
271
+
272
+
273
+ # Author network
274
+
275
+
276
+ def test_author_network_global(client):
277
+ r = client.get("/api/analytics/author-network")
278
+ assert r.status_code == 200
279
+ d = r.get_json()
280
+ assert "nodes" in d
281
+ assert "edges" in d
282
+
283
+
284
+ def test_authors_search(client):
285
+ r = client.get("/api/analytics/authors-search?q=a&limit=5")
286
+ assert r.status_code == 200
287
+ d = r.get_json()
288
+ assert isinstance(d, list)
289
+ assert len(d) <= 5
290
+
291
+
292
+ # Faculty comparison
293
+
294
+
295
+ def test_faculty_comparison_empty(client):
296
+ r = client.get("/api/analytics/faculty-comparison")
297
+ assert r.status_code == 200
298
+ assert isinstance(r.get_json(), dict)
299
+
300
+
301
+ # Exports
302
+
303
+
304
+ def test_export_csv(client):
305
+ r = client.get("/api/export/papers.csv")
306
+ assert r.status_code == 200
307
+ assert "text/csv" in r.content_type
308
+ data = r.data.decode("utf-8")
309
+ assert "Title" in data or "ID" in data
310
+
311
+
312
+ def test_export_bibtex(client):
313
+ r = client.get("/api/export/papers.bibtex")
314
+ assert r.status_code == 200
315
+
316
+
317
+ # Crawler status
318
+
319
+
320
+ def test_crawler_status(client):
321
+ r = client.get("/api/crawler/status")
322
+ assert r.status_code == 200
323
+ d = r.get_json()
324
+ assert d["status"] in ("running", "idle")
325
+
326
+
327
+ def test_docid_crawler_status(client):
328
+ r = client.get("/api/docid-crawler/status")
329
+ assert r.status_code == 200
330
+ d = r.get_json()
331
+ assert d["status"] in ("running", "idle")
332
+
333
+
334
+ # Analytics engine unit tests
335
+
336
+
337
+ def test_engine_top_authors():
338
+ result = analytics.get_top_authors(limit=5)
339
+ assert isinstance(result, list)
340
+ assert len(result) <= 5
341
+ for r in result:
342
+ assert "author" in r
343
+ assert "count" in r
344
+ assert r["count"] > 0
345
+
346
+
347
+ def test_engine_sdg_alignment():
348
+ result = analytics.get_sdg_alignment()
349
+ assert isinstance(result, list)
350
+ sdg_names = [r["sdg"] for r in result]
351
+ # Should have at least some SDGs with papers
352
+ assert len(result) >= 0
353
+
354
+
355
+ def test_engine_keyword_cloud():
356
+ result = analytics.get_keyword_cloud(top_n=20)
357
+ assert isinstance(result, list)
358
+ assert len(result) <= 20
359
+ for item in result:
360
+ assert "word" in item
361
+ assert "score" in item
362
+ assert item["score"] > 0
363
+
364
+
365
+ def test_engine_tk_vitality():
366
+ result = analytics.get_tk_vitality_score()
367
+ assert "score" in result
368
+ assert 0 <= result["score"] <= 100
369
+
370
+
371
+ def test_engine_linguistic_diversity():
372
+ result = analytics.get_linguistic_diversity_index()
373
+ assert "index" in result
374
+ assert 0 <= result["index"] <= 100
375
+
376
+
377
+ def test_engine_patent_velocity():
378
+ result = analytics.get_patent_velocity()
379
+ assert "total_patents" in result
380
+ assert isinstance(result["total_patents"], int)
381
+
382
+
383
+ def test_engine_docid_coverage():
384
+ result = analytics.get_docid_coverage()
385
+ assert "coverage_percent" in result
386
+ assert 0 <= result["coverage_percent"] <= 100
387
+
388
+
389
+ # DocID generator
390
+
391
+
392
+ def test_docid_generation():
393
+ docid = docid_generator.generate_docid("Test Paper Title", doi="10.1234/test")
394
+ assert docid.startswith("20.500.14351/")
395
+ parts = docid.split("/")
396
+ assert len(parts) == 2
397
+ assert len(parts[1]) >= 10
398
+
399
+
400
+ def test_docid_validation():
401
+ valid = docid_generator.generate_docid("Test")
402
+ assert docid_generator.validate_docid(valid) == True
403
+ assert docid_generator.validate_docid("") == False
404
+ assert docid_generator.validate_docid("invalid") == False
405
+ assert docid_generator.validate_docid("99.999.99999/abc") == False
406
+
407
+
408
+ def test_docid_uniqueness():
409
+ ids = {docid_generator.generate_docid("Same Title") for _ in range(10)}
410
+ assert len(ids) == 10 # all unique due to uuid4
411
+
412
+
413
+ # AI keyword extractor
414
+
415
+
416
+ def test_keyword_extraction():
417
+ text = (
418
+ "machine learning deep neural networks artificial intelligence computer vision"
419
+ )
420
+ kws = ai_extractor.extract_keywords(text, top_n=5)
421
+ assert len(kws) > 0
422
+ assert all(isinstance(k, tuple) and len(k) == 2 for k in kws)
423
+
424
+
425
+ def test_keyword_extraction_empty():
426
+ assert ai_extractor.extract_keywords("", top_n=5) == []
427
+
428
+
429
+ def test_domain_classification():
430
+ text = "algorithm data structure programming software engineering database"
431
+ domains = ai_extractor.classify_domain(text)
432
+ assert len(domains) > 0
433
+ assert domains[0][0] == "computer_science"
434
+
435
+
436
+ def test_paper_scoring():
437
+ score = ai_extractor.score_paper(
438
+ "Machine Learning for Medical Diagnosis",
439
+ "This study investigates machine learning algorithms for medical diagnosis using deep neural networks to classify medical images with significant improvement over existing methods.",
440
+ )
441
+ assert "quality_score" in score
442
+ assert 0 <= score["quality_score"] <= 1
443
+ assert "keywords" in score
444
+
445
+
446
+ # Performance
447
+
448
+
449
+ def test_overview_response_time(client):
450
+ import time
451
+
452
+ start = time.time()
453
+ client.get("/api/analytics/overview")
454
+ elapsed = time.time() - start
455
+ assert elapsed < 3.0, f"Overview took {elapsed:.2f}s, should be < 3s"
456
+
457
+
458
+ def test_search_response_time(client):
459
+ import time
460
+
461
+ start = time.time()
462
+ client.get("/api/analytics/search?q=health&limit=20")
463
+ elapsed = time.time() - start
464
+ assert elapsed < 5.0, f"Search took {elapsed:.2f}s, should be < 5s"
465
+
466
+
467
+ def test_keyword_cloud_response_time(client):
468
+ import time
469
+
470
+ start = time.time()
471
+ client.get("/api/analytics/keyword-cloud")
472
+ elapsed = time.time() - start
473
+ assert elapsed < 10.0, f"Keyword cloud took {elapsed:.2f}s, should be < 10s"
tests/test_multi_institution.py CHANGED
@@ -1,175 +1,175 @@
1
- """
2
- Test script for multi-institution support
3
- Tests institution configuration and staff validation
4
- """
5
-
6
- import os
7
- import sys
8
-
9
- import pytest
10
-
11
- # Add project root to path
12
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
13
-
14
- from uraas.config.institutions import InstitutionRegistry, get_registry
15
- from uraas.utils.staff_validator import StaffValidator
16
-
17
-
18
- @pytest.fixture
19
- def registry():
20
- """Provide the institution registry as a pytest fixture."""
21
- return get_registry()
22
-
23
-
24
- def test_institution_registry():
25
- """Test institution registry loading"""
26
- print("=" * 60)
27
- print("TEST 1: Institution Registry")
28
- print("=" * 60)
29
-
30
- registry = get_registry()
31
-
32
- print(f"\nLoaded {len(registry.institutions)} institutions:")
33
- for config in registry.list_all():
34
- print(f" - {config.name} ({config.short_name})")
35
- print(f" ROR: {config.ror}")
36
- print(f" Country: {config.country}")
37
- print(f" Staff count: {len(config.staff_names)}")
38
- print(f" Affiliation patterns: {len(config.affiliation_patterns)}")
39
- print()
40
-
41
- # Test retrieval by short name
42
- print("\nTest retrieval by short name:")
43
- unilag = registry.get("unilag")
44
- if unilag:
45
- print(f" ✓ Found UNILAG: {unilag.name}")
46
- else:
47
- print(f" ✗ UNILAG not found")
48
-
49
- # Test retrieval by ROR
50
- print("\nTest retrieval by ROR:")
51
- ui = registry.get_by_ror("https://ror.org/01js2sh04")
52
- if ui:
53
- print(f" ✓ Found UI: {ui.name}")
54
- else:
55
- print(f" ✗ UI not found")
56
-
57
- # Test affiliation matching
58
- print("\nTest affiliation matching:")
59
- test_affiliations = [
60
- ("University of Lagos, Nigeria", "unilag"),
61
- ("Department of Physics, University of Ibadan", "ui"),
62
- ("OAU Ile-Ife, Nigeria", "oau"),
63
- ("Ahmadu Bello University, Zaria", "abu"),
64
- ]
65
-
66
- for affiliation, expected_short_name in test_affiliations:
67
- matched = False
68
- for config in registry.list_all():
69
- if config.matches_affiliation(affiliation):
70
- print(f" ✓ '{affiliation}' → {config.short_name}")
71
- if config.short_name.lower() == expected_short_name.lower():
72
- matched = True
73
- break
74
- if not matched:
75
- print(f" ✗ '{affiliation}' not matched correctly")
76
-
77
- return registry
78
-
79
-
80
- def test_staff_validator(registry):
81
- """Test staff validator with multi-institution support"""
82
- print("\n" + "=" * 60)
83
- print("TEST 2: Staff Validator")
84
- print("=" * 60)
85
-
86
- # Test UNILAG validator
87
- print("\nTesting UNILAG validator:")
88
- unilag_config = registry.get("unilag")
89
- if unilag_config:
90
- validator = StaffValidator(institution_config=unilag_config)
91
- print(f" Institution: {validator.institution_name}")
92
- print(f" ROR: {validator.ror}")
93
- print(f" Staff count: {len(validator.staff_names)}")
94
-
95
- # Test some known UNILAG staff (if any)
96
- test_authors = [
97
- "Prof. A. O. Adeyemi",
98
- "Dr. John Smith", # Should not match
99
- "O. A. Ogunlana",
100
- ]
101
-
102
- print("\n Testing author validation:")
103
- for author in test_authors:
104
- is_staff = validator.is_staff_member(author)
105
- print(
106
- f" {'✓' if is_staff else '✗'} {author}: {'Staff' if is_staff else 'Not staff'}"
107
- )
108
-
109
- # Test UI validator (will have empty staff list for now)
110
- print("\nTesting UI validator:")
111
- ui_config = registry.get("ui")
112
- if ui_config:
113
- validator = StaffValidator(institution_config=ui_config)
114
- print(f" Institution: {validator.institution_name}")
115
- print(f" ROR: {validator.ror}")
116
- print(f" Staff count: {len(validator.staff_names)}")
117
- print(f" Note: Staff file not yet populated")
118
-
119
-
120
- def test_backward_compatibility():
121
- """Test that old code still works"""
122
- print("\n" + "=" * 60)
123
- print("TEST 3: Backward Compatibility")
124
- print("=" * 60)
125
-
126
- # Test default validator (should still work for UNILAG)
127
- from uraas.utils.staff_validator import staff_validator
128
-
129
- print(f"\nDefault validator:")
130
- print(f" Institution: {staff_validator.institution_name}")
131
- print(f" Staff count: {len(staff_validator.staff_names)}")
132
- print(f" ✓ Backward compatibility maintained")
133
-
134
-
135
- def main():
136
- """Run all tests"""
137
- print("\n" + "=" * 60)
138
- print("MULTI-INSTITUTION SUPPORT TEST SUITE")
139
- print("=" * 60)
140
-
141
- try:
142
- # Test 1: Institution Registry
143
- registry = test_institution_registry()
144
-
145
- # Test 2: Staff Validator
146
- test_staff_validator(registry)
147
-
148
- # Test 3: Backward Compatibility
149
- test_backward_compatibility()
150
-
151
- print("\n" + "=" * 60)
152
- print("ALL TESTS COMPLETED")
153
- print("=" * 60)
154
- print("\nSummary:")
155
- print(f" - {len(registry.institutions)} institutions configured")
156
- print(f" - Institution registry operational")
157
- print(f" - Staff validator supports multi-institution")
158
- print(f" - Backward compatibility maintained")
159
- print("\nNext steps:")
160
- print(" 1. Populate staff files for UI, OAU, UNN, ABU")
161
- print(" 2. Update spiders to accept institution parameter")
162
- print(" 3. Test multi-institution crawling")
163
-
164
- except Exception as e:
165
- print(f"\n✗ TEST FAILED: {e}")
166
- import traceback
167
-
168
- traceback.print_exc()
169
- return 1
170
-
171
- return 0
172
-
173
-
174
- if __name__ == "__main__":
175
- sys.exit(main())
 
1
+ """
2
+ Test script for multi-institution support
3
+ Tests institution configuration and staff validation
4
+ """
5
+
6
+ import os
7
+ import sys
8
+
9
+ import pytest
10
+
11
+ # Add project root to path
12
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
13
+
14
+ from uraas.config.institutions import InstitutionRegistry, get_registry
15
+ from uraas.utils.staff_validator import StaffValidator
16
+
17
+
18
+ @pytest.fixture
19
+ def registry():
20
+ """Provide the institution registry as a pytest fixture."""
21
+ return get_registry()
22
+
23
+
24
+ def test_institution_registry():
25
+ """Test institution registry loading"""
26
+ print("=" * 60)
27
+ print("TEST 1: Institution Registry")
28
+ print("=" * 60)
29
+
30
+ registry = get_registry()
31
+
32
+ print(f"\nLoaded {len(registry.institutions)} institutions:")
33
+ for config in registry.list_all():
34
+ print(f" - {config.name} ({config.short_name})")
35
+ print(f" ROR: {config.ror}")
36
+ print(f" Country: {config.country}")
37
+ print(f" Staff count: {len(config.staff_names)}")
38
+ print(f" Affiliation patterns: {len(config.affiliation_patterns)}")
39
+ print()
40
+
41
+ # Test retrieval by short name
42
+ print("\nTest retrieval by short name:")
43
+ unilag = registry.get("unilag")
44
+ if unilag:
45
+ print(f" ✓ Found UNILAG: {unilag.name}")
46
+ else:
47
+ print(f" ✗ UNILAG not found")
48
+
49
+ # Test retrieval by ROR
50
+ print("\nTest retrieval by ROR:")
51
+ ui = registry.get_by_ror("https://ror.org/01js2sh04")
52
+ if ui:
53
+ print(f" ✓ Found UI: {ui.name}")
54
+ else:
55
+ print(f" ✗ UI not found")
56
+
57
+ # Test affiliation matching
58
+ print("\nTest affiliation matching:")
59
+ test_affiliations = [
60
+ ("University of Lagos, Nigeria", "unilag"),
61
+ ("Department of Physics, University of Ibadan", "ui"),
62
+ ("OAU Ile-Ife, Nigeria", "oau"),
63
+ ("Ahmadu Bello University, Zaria", "abu"),
64
+ ]
65
+
66
+ for affiliation, expected_short_name in test_affiliations:
67
+ matched = False
68
+ for config in registry.list_all():
69
+ if config.matches_affiliation(affiliation):
70
+ print(f" ✓ '{affiliation}' → {config.short_name}")
71
+ if config.short_name.lower() == expected_short_name.lower():
72
+ matched = True
73
+ break
74
+ if not matched:
75
+ print(f" ✗ '{affiliation}' not matched correctly")
76
+
77
+ return registry
78
+
79
+
80
+ def test_staff_validator(registry):
81
+ """Test staff validator with multi-institution support"""
82
+ print("\n" + "=" * 60)
83
+ print("TEST 2: Staff Validator")
84
+ print("=" * 60)
85
+
86
+ # Test UNILAG validator
87
+ print("\nTesting UNILAG validator:")
88
+ unilag_config = registry.get("unilag")
89
+ if unilag_config:
90
+ validator = StaffValidator(institution_config=unilag_config)
91
+ print(f" Institution: {validator.institution_name}")
92
+ print(f" ROR: {validator.ror}")
93
+ print(f" Staff count: {len(validator.staff_names)}")
94
+
95
+ # Test some known UNILAG staff (if any)
96
+ test_authors = [
97
+ "Prof. A. O. Adeyemi",
98
+ "Dr. John Smith", # Should not match
99
+ "O. A. Ogunlana",
100
+ ]
101
+
102
+ print("\n Testing author validation:")
103
+ for author in test_authors:
104
+ is_staff = validator.is_staff_member(author)
105
+ print(
106
+ f" {'✓' if is_staff else '✗'} {author}: {'Staff' if is_staff else 'Not staff'}"
107
+ )
108
+
109
+ # Test UI validator (will have empty staff list for now)
110
+ print("\nTesting UI validator:")
111
+ ui_config = registry.get("ui")
112
+ if ui_config:
113
+ validator = StaffValidator(institution_config=ui_config)
114
+ print(f" Institution: {validator.institution_name}")
115
+ print(f" ROR: {validator.ror}")
116
+ print(f" Staff count: {len(validator.staff_names)}")
117
+ print(f" Note: Staff file not yet populated")
118
+
119
+
120
+ def test_backward_compatibility():
121
+ """Test that old code still works"""
122
+ print("\n" + "=" * 60)
123
+ print("TEST 3: Backward Compatibility")
124
+ print("=" * 60)
125
+
126
+ # Test default validator (should still work for UNILAG)
127
+ from uraas.utils.staff_validator import staff_validator
128
+
129
+ print(f"\nDefault validator:")
130
+ print(f" Institution: {staff_validator.institution_name}")
131
+ print(f" Staff count: {len(staff_validator.staff_names)}")
132
+ print(f" ✓ Backward compatibility maintained")
133
+
134
+
135
+ def main():
136
+ """Run all tests"""
137
+ print("\n" + "=" * 60)
138
+ print("MULTI-INSTITUTION SUPPORT TEST SUITE")
139
+ print("=" * 60)
140
+
141
+ try:
142
+ # Test 1: Institution Registry
143
+ registry = test_institution_registry()
144
+
145
+ # Test 2: Staff Validator
146
+ test_staff_validator(registry)
147
+
148
+ # Test 3: Backward Compatibility
149
+ test_backward_compatibility()
150
+
151
+ print("\n" + "=" * 60)
152
+ print("ALL TESTS COMPLETED")
153
+ print("=" * 60)
154
+ print("\nSummary:")
155
+ print(f" - {len(registry.institutions)} institutions configured")
156
+ print(f" - Institution registry operational")
157
+ print(f" - Staff validator supports multi-institution")
158
+ print(f" - Backward compatibility maintained")
159
+ print("\nNext steps:")
160
+ print(" 1. Populate staff files for UI, OAU, UNN, ABU")
161
+ print(" 2. Update spiders to accept institution parameter")
162
+ print(" 3. Test multi-institution crawling")
163
+
164
+ except Exception as e:
165
+ print(f"\n✗ TEST FAILED: {e}")
166
+ import traceback
167
+
168
+ traceback.print_exc()
169
+ return 1
170
+
171
+ return 0
172
+
173
+
174
+ if __name__ == "__main__":
175
+ sys.exit(main())
tests/test_multi_institution_crawl.py CHANGED
@@ -1,207 +1,207 @@
1
- """
2
- Test multi-institution crawling functionality
3
- Tests spider initialization and basic crawling for multiple institutions
4
- """
5
-
6
- import os
7
- import sys
8
-
9
- sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
10
-
11
- from uraas.config.institutions import get_registry
12
- from uraas.spiders.sources.openalex_spider import OpenAlexSpider
13
-
14
-
15
- def test_spider_initialization():
16
- """Test that spiders can be initialized with different institutions"""
17
- print("\n" + "=" * 60)
18
- print("TEST: Spider Initialization")
19
- print("=" * 60)
20
-
21
- registry = get_registry()
22
- institutions = ["unilag", "ui", "oau", "unn", "abu"]
23
-
24
- results = {}
25
-
26
- for inst in institutions:
27
- try:
28
- config = registry.get(inst)
29
- if not config:
30
- print(f"\n✗ {inst}: Configuration not found")
31
- results[inst] = False
32
- continue
33
-
34
- # Try to initialize spider
35
- spider = OpenAlexSpider(institution=inst)
36
-
37
- print(f"\n✓ {inst}: {spider.institution_name}")
38
- print(f" ROR: {spider.ror_id}")
39
- print(f" ROR Short: {spider.ror_short}")
40
- print(f" Staff count: {len(config.staff_names)}")
41
-
42
- results[inst] = True
43
-
44
- except Exception as e:
45
- print(f"\n✗ {inst}: Failed to initialize - {e}")
46
- results[inst] = False
47
-
48
- # Summary
49
- print("\n" + "=" * 60)
50
- print("INITIALIZATION SUMMARY")
51
- print("=" * 60)
52
-
53
- passed = sum(1 for v in results.values() if v)
54
- total = len(results)
55
-
56
- print(f"\nPassed: {passed}/{total}")
57
-
58
- for inst, success in results.items():
59
- status = "✓" if success else "✗"
60
- print(f" {status} {inst}")
61
-
62
- return passed == total
63
-
64
-
65
- def test_affiliation_filter():
66
- """Test affiliation filter with multi-institution support"""
67
- print("\n" + "=" * 60)
68
- print("TEST: Affiliation Filter")
69
- print("=" * 60)
70
-
71
- from uraas.config.institutions import get_registry
72
- from uraas.pipelines.affiliation_filter import AffiliationFilterPipeline
73
-
74
- # Create mock spider for each institution
75
- class MockSpider:
76
- def __init__(self, institution):
77
- self.institution = institution
78
- self.logger = MockLogger()
79
-
80
- class MockLogger:
81
- def info(self, msg):
82
- pass
83
-
84
- def warning(self, msg):
85
- pass
86
-
87
- def error(self, msg):
88
- pass
89
-
90
- registry = get_registry()
91
- institutions = ["unilag", "ui"]
92
-
93
- for inst in institutions:
94
- config = registry.get(inst)
95
- if not config:
96
- continue
97
-
98
- print(f"\n{config.name}:")
99
-
100
- # Create pipeline
101
- pipeline = AffiliationFilterPipeline()
102
- spider = MockSpider(inst)
103
- pipeline.open_spider(spider)
104
-
105
- print(f" Institution: {pipeline.current_institution.name}")
106
- print(f" Staff count: {len(pipeline.current_validator.staff_names)}")
107
- print(f" Patterns: {len(pipeline.current_patterns)}")
108
-
109
- # Test affiliation matching
110
- test_texts = [
111
- (f"{config.name}, Nigeria", True),
112
- (f"Department of Physics, {config.name}", True),
113
- ("Random University", False),
114
- ]
115
-
116
- print(f" Affiliation matching:")
117
- for text, expected in test_texts:
118
- result = pipeline.is_institution_affiliated(text)
119
- status = "✓" if result == expected else "✗"
120
- print(f" {status} '{text}' → {result}")
121
-
122
- return True
123
-
124
-
125
- def test_ror_extraction():
126
- """Test ROR ID extraction from URLs"""
127
- print("\n" + "=" * 60)
128
- print("TEST: ROR ID Extraction")
129
- print("=" * 60)
130
-
131
- test_cases = [
132
- ("https://ror.org/03qcnxw14", "03qcnxw14"),
133
- ("https://ror.org/01js2sh04", "01js2sh04"),
134
- ("https://ror.org/03yp73w09", "03yp73w09"),
135
- ]
136
-
137
- all_passed = True
138
-
139
- for ror_url, expected_short in test_cases:
140
- short = ror_url.split("/")[-1]
141
- passed = short == expected_short
142
- status = "✓" if passed else "✗"
143
- print(f" {status} {ror_url} → {short}")
144
-
145
- if not passed:
146
- all_passed = False
147
-
148
- return all_passed
149
-
150
-
151
- def main():
152
- """Run all tests"""
153
- print("\n" + "=" * 60)
154
- print("MULTI-INSTITUTION CRAWL TEST SUITE")
155
- print("=" * 60)
156
-
157
- tests = [
158
- ("Spider Initialization", test_spider_initialization),
159
- ("Affiliation Filter", test_affiliation_filter),
160
- ("ROR Extraction", test_ror_extraction),
161
- ]
162
-
163
- results = {}
164
-
165
- for test_name, test_func in tests:
166
- try:
167
- result = test_func()
168
- results[test_name] = result
169
- except Exception as e:
170
- print(f"\n✗ {test_name} FAILED: {e}")
171
- import traceback
172
-
173
- traceback.print_exc()
174
- results[test_name] = False
175
-
176
- # Final summary
177
- print("\n" + "=" * 60)
178
- print("FINAL SUMMARY")
179
- print("=" * 60)
180
-
181
- passed = sum(1 for v in results.values() if v)
182
- total = len(results)
183
-
184
- print(f"\nTests passed: {passed}/{total}\n")
185
-
186
- for test_name, success in results.items():
187
- status = "✓ PASS" if success else "✗ FAIL"
188
- print(f" {status}: {test_name}")
189
-
190
- if passed == total:
191
- print("\n✓ ALL TESTS PASSED")
192
- print("\nReady for production crawling!")
193
- print("\nNext steps:")
194
- print(
195
- " 1. Run: python crawl_multi_institution.py --institutions unilag,ui --target 10"
196
- )
197
- print(" 2. Monitor database for new papers with ROR tags")
198
- print(" 3. Verify multi-institution comparison in dashboard")
199
- return 0
200
- else:
201
- print("\n✗ SOME TESTS FAILED")
202
- print("\nPlease fix issues before proceeding.")
203
- return 1
204
-
205
-
206
- if __name__ == "__main__":
207
- sys.exit(main())
 
1
+ """
2
+ Test multi-institution crawling functionality
3
+ Tests spider initialization and basic crawling for multiple institutions
4
+ """
5
+
6
+ import os
7
+ import sys
8
+
9
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
10
+
11
+ from uraas.config.institutions import get_registry
12
+ from uraas.spiders.sources.openalex_spider import OpenAlexSpider
13
+
14
+
15
+ def test_spider_initialization():
16
+ """Test that spiders can be initialized with different institutions"""
17
+ print("\n" + "=" * 60)
18
+ print("TEST: Spider Initialization")
19
+ print("=" * 60)
20
+
21
+ registry = get_registry()
22
+ institutions = ["unilag", "ui", "oau", "unn", "abu"]
23
+
24
+ results = {}
25
+
26
+ for inst in institutions:
27
+ try:
28
+ config = registry.get(inst)
29
+ if not config:
30
+ print(f"\n✗ {inst}: Configuration not found")
31
+ results[inst] = False
32
+ continue
33
+
34
+ # Try to initialize spider
35
+ spider = OpenAlexSpider(institution=inst)
36
+
37
+ print(f"\n✓ {inst}: {spider.institution_name}")
38
+ print(f" ROR: {spider.ror_id}")
39
+ print(f" ROR Short: {spider.ror_short}")
40
+ print(f" Staff count: {len(config.staff_names)}")
41
+
42
+ results[inst] = True
43
+
44
+ except Exception as e:
45
+ print(f"\n✗ {inst}: Failed to initialize - {e}")
46
+ results[inst] = False
47
+
48
+ # Summary
49
+ print("\n" + "=" * 60)
50
+ print("INITIALIZATION SUMMARY")
51
+ print("=" * 60)
52
+
53
+ passed = sum(1 for v in results.values() if v)
54
+ total = len(results)
55
+
56
+ print(f"\nPassed: {passed}/{total}")
57
+
58
+ for inst, success in results.items():
59
+ status = "✓" if success else "✗"
60
+ print(f" {status} {inst}")
61
+
62
+ return passed == total
63
+
64
+
65
+ def test_affiliation_filter():
66
+ """Test affiliation filter with multi-institution support"""
67
+ print("\n" + "=" * 60)
68
+ print("TEST: Affiliation Filter")
69
+ print("=" * 60)
70
+
71
+ from uraas.config.institutions import get_registry
72
+ from uraas.pipelines.affiliation_filter import AffiliationFilterPipeline
73
+
74
+ # Create mock spider for each institution
75
+ class MockSpider:
76
+ def __init__(self, institution):
77
+ self.institution = institution
78
+ self.logger = MockLogger()
79
+
80
+ class MockLogger:
81
+ def info(self, msg):
82
+ pass
83
+
84
+ def warning(self, msg):
85
+ pass
86
+
87
+ def error(self, msg):
88
+ pass
89
+
90
+ registry = get_registry()
91
+ institutions = ["unilag", "ui"]
92
+
93
+ for inst in institutions:
94
+ config = registry.get(inst)
95
+ if not config:
96
+ continue
97
+
98
+ print(f"\n{config.name}:")
99
+
100
+ # Create pipeline
101
+ pipeline = AffiliationFilterPipeline()
102
+ spider = MockSpider(inst)
103
+ pipeline.open_spider(spider)
104
+
105
+ print(f" Institution: {pipeline.current_institution.name}")
106
+ print(f" Staff count: {len(pipeline.current_validator.staff_names)}")
107
+ print(f" Patterns: {len(pipeline.current_patterns)}")
108
+
109
+ # Test affiliation matching
110
+ test_texts = [
111
+ (f"{config.name}, Nigeria", True),
112
+ (f"Department of Physics, {config.name}", True),
113
+ ("Random University", False),
114
+ ]
115
+
116
+ print(f" Affiliation matching:")
117
+ for text, expected in test_texts:
118
+ result = pipeline.is_institution_affiliated(text)
119
+ status = "✓" if result == expected else "✗"
120
+ print(f" {status} '{text}' → {result}")
121
+
122
+ return True
123
+
124
+
125
+ def test_ror_extraction():
126
+ """Test ROR ID extraction from URLs"""
127
+ print("\n" + "=" * 60)
128
+ print("TEST: ROR ID Extraction")
129
+ print("=" * 60)
130
+
131
+ test_cases = [
132
+ ("https://ror.org/03qcnxw14", "03qcnxw14"),
133
+ ("https://ror.org/01js2sh04", "01js2sh04"),
134
+ ("https://ror.org/03yp73w09", "03yp73w09"),
135
+ ]
136
+
137
+ all_passed = True
138
+
139
+ for ror_url, expected_short in test_cases:
140
+ short = ror_url.split("/")[-1]
141
+ passed = short == expected_short
142
+ status = "✓" if passed else "✗"
143
+ print(f" {status} {ror_url} → {short}")
144
+
145
+ if not passed:
146
+ all_passed = False
147
+
148
+ return all_passed
149
+
150
+
151
+ def main():
152
+ """Run all tests"""
153
+ print("\n" + "=" * 60)
154
+ print("MULTI-INSTITUTION CRAWL TEST SUITE")
155
+ print("=" * 60)
156
+
157
+ tests = [
158
+ ("Spider Initialization", test_spider_initialization),
159
+ ("Affiliation Filter", test_affiliation_filter),
160
+ ("ROR Extraction", test_ror_extraction),
161
+ ]
162
+
163
+ results = {}
164
+
165
+ for test_name, test_func in tests:
166
+ try:
167
+ result = test_func()
168
+ results[test_name] = result
169
+ except Exception as e:
170
+ print(f"\n✗ {test_name} FAILED: {e}")
171
+ import traceback
172
+
173
+ traceback.print_exc()
174
+ results[test_name] = False
175
+
176
+ # Final summary
177
+ print("\n" + "=" * 60)
178
+ print("FINAL SUMMARY")
179
+ print("=" * 60)
180
+
181
+ passed = sum(1 for v in results.values() if v)
182
+ total = len(results)
183
+
184
+ print(f"\nTests passed: {passed}/{total}\n")
185
+
186
+ for test_name, success in results.items():
187
+ status = "✓ PASS" if success else "✗ FAIL"
188
+ print(f" {status}: {test_name}")
189
+
190
+ if passed == total:
191
+ print("\n✓ ALL TESTS PASSED")
192
+ print("\nReady for production crawling!")
193
+ print("\nNext steps:")
194
+ print(
195
+ " 1. Run: python crawl_multi_institution.py --institutions unilag,ui --target 10"
196
+ )
197
+ print(" 2. Monitor database for new papers with ROR tags")
198
+ print(" 3. Verify multi-institution comparison in dashboard")
199
+ return 0
200
+ else:
201
+ print("\n✗ SOME TESTS FAILED")
202
+ print("\nPlease fix issues before proceeding.")
203
+ return 1
204
+
205
+
206
+ if __name__ == "__main__":
207
+ sys.exit(main())
tests/test_new_features.py CHANGED
@@ -1,208 +1,208 @@
1
- """
2
- Test script for new Scopus-competitive features:
3
- 1. Citation tracking
4
- 2. H-index calculation
5
- 3. Advanced search with Boolean operators
6
- """
7
-
8
- import sys
9
- import time
10
-
11
- from uraas.database import Author, Base, Item, SessionLocal, engine
12
- from uraas.services.advanced_search import SearchQuery
13
- from uraas.services.citation_tracker import (
14
- AuthorMetrics,
15
- Citation,
16
- CitationMetrics,
17
- CitationTracker,
18
- get_author_bibliometrics,
19
- get_paper_citations,
20
- )
21
-
22
- if __name__ == "__main__":
23
- # Create new tables
24
- print("=" * 70)
25
- print("Creating citation tracking tables...")
26
- print("=" * 70)
27
- Base.metadata.create_all(bind=engine)
28
- print("✓ Tables created\n")
29
-
30
- # Test 1: Citation Tracking
31
- print("=" * 70)
32
- print("TEST 1: Citation Tracking")
33
- print("=" * 70)
34
-
35
- session = SessionLocal()
36
-
37
- # Find a paper with DOI
38
- paper = session.query(Item).filter(Item.doi.isnot(None)).first()
39
-
40
- if paper:
41
- print(f"\nTesting with paper: {paper.title[:60]}...")
42
- print(f"DOI: {paper.doi}")
43
-
44
- print("\nFetching citations from OpenAlex...")
45
- success = CitationTracker.update_paper_citations(paper.id)
46
-
47
- if success:
48
- print("✓ Citations fetched successfully")
49
-
50
- # Get citation data
51
- cite_data = get_paper_citations(paper.id)
52
- print(f"\nCitation count: {cite_data['citation_count']}")
53
- print(f"Citing papers in our DB: {len(cite_data['citing_papers'])}")
54
-
55
- if cite_data["citing_papers"]:
56
- print("\nSample citing papers:")
57
- for cite in cite_data["citing_papers"][:3]:
58
- print(f" - {cite['title'][:60]}... ({cite['year']})")
59
- else:
60
- print("⚠ Citation fetch failed (paper may not be in OpenAlex)")
61
- else:
62
- print("⚠ No papers with DOI found in database")
63
-
64
- session.close()
65
-
66
- # Test 2: H-index Calculation
67
- print("\n" + "=" * 70)
68
- print("TEST 2: H-index Calculation")
69
- print("=" * 70)
70
-
71
- session = SessionLocal()
72
-
73
- # Test h-index calculation
74
- test_citations = [100, 50, 30, 20, 15, 10, 8, 5, 3, 2, 1, 1, 0, 0]
75
- h_index = CitationTracker.calculate_h_index(test_citations)
76
- print(f"\nTest citation counts: {test_citations}")
77
- print(f"Calculated h-index: {h_index}")
78
- print(f"Expected: 10 (10 papers with ≥10 citations)")
79
-
80
- # Find an author and calculate their metrics
81
- author = session.query(Author).join(Author.items).first()
82
-
83
- if author:
84
- print(f"\nTesting with author: {author.name}")
85
-
86
- # Update author metrics
87
- success = CitationTracker.update_author_metrics(author.id)
88
-
89
- if success:
90
- metrics = get_author_bibliometrics(author.id)
91
- print(f"\nAuthor Bibliometrics:")
92
- print(f" Total papers: {metrics.get('total_papers', 0)}")
93
- print(f" Total citations: {metrics.get('total_citations', 0)}")
94
- print(f" H-index: {metrics.get('h_index', 0)}")
95
- print(f" i10-index: {metrics.get('i10_index', 0)}")
96
- print(f" Citations per paper: {metrics.get('citations_per_paper', 0)}")
97
- else:
98
- print("⚠ Author metrics calculation failed (papers may lack citation data)")
99
-
100
- session.close()
101
-
102
- # Test 3: Advanced Search
103
- print("\n" + "=" * 70)
104
- print("TEST 3: Advanced Search with Boolean Operators")
105
- print("=" * 70)
106
-
107
- # Test query parsing
108
- test_queries = [
109
- "machine learning",
110
- '"machine learning" AND author:smith',
111
- "title:cancer NOT lung",
112
- "author:okonkwo AND year:2020",
113
- "(covid OR pandemic) AND faculty:medicine",
114
- ]
115
-
116
- print("\nQuery Parsing Tests:")
117
- for query in test_queries:
118
- parsed = SearchQuery.parse_boolean_query(query)
119
- print(f"\nQuery: {query}")
120
- print(f"Parsed: {parsed}")
121
-
122
- # Test actual search execution
123
- print("\n" + "=" * 70)
124
- print("Search Execution Tests:")
125
- print("=" * 70)
126
-
127
- # Test 1: Simple keyword search
128
- print("\n1. Simple keyword search: 'health'")
129
- results = SearchQuery.execute_search("health", limit=5)
130
- print(f" Found {results['total']} papers in {results['took_ms']}ms")
131
- if results["results"]:
132
- print(f" Top result: {results['results'][0]['title'][:60]}...")
133
-
134
- # Test 2: Field-specific search
135
- print("\n2. Field-specific search: 'year:2020'")
136
- results = SearchQuery.execute_search("year:2020", limit=5)
137
- print(f" Found {results['total']} papers from 2020")
138
-
139
- # Test 3: Boolean AND
140
- print("\n3. Boolean AND: 'health AND education'")
141
- results = SearchQuery.execute_search("health AND education", limit=5)
142
- print(f" Found {results['total']} papers")
143
-
144
- # Test 4: Phrase search
145
- print("\n4. Phrase search: '\"machine learning\"'")
146
- results = SearchQuery.execute_search('"machine learning"', limit=5)
147
- print(f" Found {results['total']} papers")
148
-
149
- # Test 5: Complex query
150
- print("\n5. Complex query: 'author:okonkwo AND faculty:science'")
151
- results = SearchQuery.execute_search("author:okonkwo AND faculty:science", limit=5)
152
- print(f" Found {results['total']} papers")
153
-
154
- # Test 6: Sort by date
155
- print("\n6. Sort by date: 'health' sorted by publication date")
156
- results = SearchQuery.execute_search("health", limit=5, sort_by="date")
157
- print(f" Found {results['total']} papers")
158
- if results["results"]:
159
- print(
160
- f" Most recent: {results['results'][0]['title'][:60]}... ({results['results'][0]['year']})"
161
- )
162
-
163
- # Test autocomplete
164
- print("\n" + "=" * 70)
165
- print("Autocomplete Suggestions:")
166
- print("=" * 70)
167
-
168
- test_partials = ["health", "machine", "science"]
169
- for partial in test_partials:
170
- suggestions = SearchQuery.get_search_suggestions(partial)
171
- print(f"\n'{partial}' → {len(suggestions)} suggestions")
172
- for sug in suggestions[:5]:
173
- print(f" - {sug}")
174
-
175
- # Summary
176
- print("\n" + "=" * 70)
177
- print("FEATURE COMPARISON SUMMARY")
178
- print("=" * 70)
179
-
180
- print("\n✓ IMPLEMENTED:")
181
- print(" 1. Citation tracking (OpenAlex + Crossref APIs)")
182
- print(" 2. H-index calculation (standard algorithm)")
183
- print(" 3. i10-index (papers with 10+ citations)")
184
- print(" 4. Author bibliometrics (total citations, papers, indices)")
185
- print(" 5. Advanced search with Boolean operators (AND, OR, NOT)")
186
- print(" 6. Field-specific queries (title:, author:, year:, faculty:, etc.)")
187
- print(" 7. Phrase searches with quotes")
188
- print(" 8. Multiple sort options (relevance, date, citations, title)")
189
- print(" 9. Autocomplete suggestions")
190
- print(" 10. Pagination support")
191
-
192
- print("\n⚠ LIMITATIONS vs Scopus:")
193
- print(" - Scale: ~1K papers vs 80M+ (institutional focus)")
194
- print(" - Citation data: Depends on OpenAlex coverage")
195
- print(" - Update frequency: Weekly vs daily (configurable)")
196
- print(" - Journal metrics: Not included (focus on institutional output)")
197
-
198
- print("\n✓ ADVANTAGES over Scopus:")
199
- print(" - Zero false positives (staff validation)")
200
- print(" - Free (no $40K/year subscription)")
201
- print(" - Customizable (open source)")
202
- print(" - African focus (indigenous knowledge metrics)")
203
- print(" - Local PDF storage")
204
- print(" - DocID™ persistent identifiers")
205
-
206
- print("\n" + "=" * 70)
207
- print("Tests complete!")
208
- print("=" * 70)
 
1
+ """
2
+ Test script for new Scopus-competitive features:
3
+ 1. Citation tracking
4
+ 2. H-index calculation
5
+ 3. Advanced search with Boolean operators
6
+ """
7
+
8
+ import sys
9
+ import time
10
+
11
+ from uraas.database import Author, Base, Item, SessionLocal, engine
12
+ from uraas.services.advanced_search import SearchQuery
13
+ from uraas.services.citation_tracker import (
14
+ AuthorMetrics,
15
+ Citation,
16
+ CitationMetrics,
17
+ CitationTracker,
18
+ get_author_bibliometrics,
19
+ get_paper_citations,
20
+ )
21
+
22
+ if __name__ == "__main__":
23
+ # Create new tables
24
+ print("=" * 70)
25
+ print("Creating citation tracking tables...")
26
+ print("=" * 70)
27
+ Base.metadata.create_all(bind=engine)
28
+ print("✓ Tables created\n")
29
+
30
+ # Test 1: Citation Tracking
31
+ print("=" * 70)
32
+ print("TEST 1: Citation Tracking")
33
+ print("=" * 70)
34
+
35
+ session = SessionLocal()
36
+
37
+ # Find a paper with DOI
38
+ paper = session.query(Item).filter(Item.doi.isnot(None)).first()
39
+
40
+ if paper:
41
+ print(f"\nTesting with paper: {paper.title[:60]}...")
42
+ print(f"DOI: {paper.doi}")
43
+
44
+ print("\nFetching citations from OpenAlex...")
45
+ success = CitationTracker.update_paper_citations(paper.id)
46
+
47
+ if success:
48
+ print("✓ Citations fetched successfully")
49
+
50
+ # Get citation data
51
+ cite_data = get_paper_citations(paper.id)
52
+ print(f"\nCitation count: {cite_data['citation_count']}")
53
+ print(f"Citing papers in our DB: {len(cite_data['citing_papers'])}")
54
+
55
+ if cite_data["citing_papers"]:
56
+ print("\nSample citing papers:")
57
+ for cite in cite_data["citing_papers"][:3]:
58
+ print(f" - {cite['title'][:60]}... ({cite['year']})")
59
+ else:
60
+ print("⚠ Citation fetch failed (paper may not be in OpenAlex)")
61
+ else:
62
+ print("⚠ No papers with DOI found in database")
63
+
64
+ session.close()
65
+
66
+ # Test 2: H-index Calculation
67
+ print("\n" + "=" * 70)
68
+ print("TEST 2: H-index Calculation")
69
+ print("=" * 70)
70
+
71
+ session = SessionLocal()
72
+
73
+ # Test h-index calculation
74
+ test_citations = [100, 50, 30, 20, 15, 10, 8, 5, 3, 2, 1, 1, 0, 0]
75
+ h_index = CitationTracker.calculate_h_index(test_citations)
76
+ print(f"\nTest citation counts: {test_citations}")
77
+ print(f"Calculated h-index: {h_index}")
78
+ print(f"Expected: 10 (10 papers with ≥10 citations)")
79
+
80
+ # Find an author and calculate their metrics
81
+ author = session.query(Author).join(Author.items).first()
82
+
83
+ if author:
84
+ print(f"\nTesting with author: {author.name}")
85
+
86
+ # Update author metrics
87
+ success = CitationTracker.update_author_metrics(author.id)
88
+
89
+ if success:
90
+ metrics = get_author_bibliometrics(author.id)
91
+ print(f"\nAuthor Bibliometrics:")
92
+ print(f" Total papers: {metrics.get('total_papers', 0)}")
93
+ print(f" Total citations: {metrics.get('total_citations', 0)}")
94
+ print(f" H-index: {metrics.get('h_index', 0)}")
95
+ print(f" i10-index: {metrics.get('i10_index', 0)}")
96
+ print(f" Citations per paper: {metrics.get('citations_per_paper', 0)}")
97
+ else:
98
+ print("⚠ Author metrics calculation failed (papers may lack citation data)")
99
+
100
+ session.close()
101
+
102
+ # Test 3: Advanced Search
103
+ print("\n" + "=" * 70)
104
+ print("TEST 3: Advanced Search with Boolean Operators")
105
+ print("=" * 70)
106
+
107
+ # Test query parsing
108
+ test_queries = [
109
+ "machine learning",
110
+ '"machine learning" AND author:smith',
111
+ "title:cancer NOT lung",
112
+ "author:okonkwo AND year:2020",
113
+ "(covid OR pandemic) AND faculty:medicine",
114
+ ]
115
+
116
+ print("\nQuery Parsing Tests:")
117
+ for query in test_queries:
118
+ parsed = SearchQuery.parse_boolean_query(query)
119
+ print(f"\nQuery: {query}")
120
+ print(f"Parsed: {parsed}")
121
+
122
+ # Test actual search execution
123
+ print("\n" + "=" * 70)
124
+ print("Search Execution Tests:")
125
+ print("=" * 70)
126
+
127
+ # Test 1: Simple keyword search
128
+ print("\n1. Simple keyword search: 'health'")
129
+ results = SearchQuery.execute_search("health", limit=5)
130
+ print(f" Found {results['total']} papers in {results['took_ms']}ms")
131
+ if results["results"]:
132
+ print(f" Top result: {results['results'][0]['title'][:60]}...")
133
+
134
+ # Test 2: Field-specific search
135
+ print("\n2. Field-specific search: 'year:2020'")
136
+ results = SearchQuery.execute_search("year:2020", limit=5)
137
+ print(f" Found {results['total']} papers from 2020")
138
+
139
+ # Test 3: Boolean AND
140
+ print("\n3. Boolean AND: 'health AND education'")
141
+ results = SearchQuery.execute_search("health AND education", limit=5)
142
+ print(f" Found {results['total']} papers")
143
+
144
+ # Test 4: Phrase search
145
+ print("\n4. Phrase search: '\"machine learning\"'")
146
+ results = SearchQuery.execute_search('"machine learning"', limit=5)
147
+ print(f" Found {results['total']} papers")
148
+
149
+ # Test 5: Complex query
150
+ print("\n5. Complex query: 'author:okonkwo AND faculty:science'")
151
+ results = SearchQuery.execute_search("author:okonkwo AND faculty:science", limit=5)
152
+ print(f" Found {results['total']} papers")
153
+
154
+ # Test 6: Sort by date
155
+ print("\n6. Sort by date: 'health' sorted by publication date")
156
+ results = SearchQuery.execute_search("health", limit=5, sort_by="date")
157
+ print(f" Found {results['total']} papers")
158
+ if results["results"]:
159
+ print(
160
+ f" Most recent: {results['results'][0]['title'][:60]}... ({results['results'][0]['year']})"
161
+ )
162
+
163
+ # Test autocomplete
164
+ print("\n" + "=" * 70)
165
+ print("Autocomplete Suggestions:")
166
+ print("=" * 70)
167
+
168
+ test_partials = ["health", "machine", "science"]
169
+ for partial in test_partials:
170
+ suggestions = SearchQuery.get_search_suggestions(partial)
171
+ print(f"\n'{partial}' → {len(suggestions)} suggestions")
172
+ for sug in suggestions[:5]:
173
+ print(f" - {sug}")
174
+
175
+ # Summary
176
+ print("\n" + "=" * 70)
177
+ print("FEATURE COMPARISON SUMMARY")
178
+ print("=" * 70)
179
+
180
+ print("\n✓ IMPLEMENTED:")
181
+ print(" 1. Citation tracking (OpenAlex + Crossref APIs)")
182
+ print(" 2. H-index calculation (standard algorithm)")
183
+ print(" 3. i10-index (papers with 10+ citations)")
184
+ print(" 4. Author bibliometrics (total citations, papers, indices)")
185
+ print(" 5. Advanced search with Boolean operators (AND, OR, NOT)")
186
+ print(" 6. Field-specific queries (title:, author:, year:, faculty:, etc.)")
187
+ print(" 7. Phrase searches with quotes")
188
+ print(" 8. Multiple sort options (relevance, date, citations, title)")
189
+ print(" 9. Autocomplete suggestions")
190
+ print(" 10. Pagination support")
191
+
192
+ print("\n⚠ LIMITATIONS vs Scopus:")
193
+ print(" - Scale: ~1K papers vs 80M+ (institutional focus)")
194
+ print(" - Citation data: Depends on OpenAlex coverage")
195
+ print(" - Update frequency: Weekly vs daily (configurable)")
196
+ print(" - Journal metrics: Not included (focus on institutional output)")
197
+
198
+ print("\n✓ ADVANTAGES over Scopus:")
199
+ print(" - Zero false positives (staff validation)")
200
+ print(" - Free (no $40K/year subscription)")
201
+ print(" - Customizable (open source)")
202
+ print(" - African focus (indigenous knowledge metrics)")
203
+ print(" - Local PDF storage")
204
+ print(" - DocID™ persistent identifiers")
205
+
206
+ print("\n" + "=" * 70)
207
+ print("Tests complete!")
208
+ print("=" * 70)
tests/test_production_ready.py CHANGED
@@ -1,375 +1,375 @@
1
- """
2
- URAAS Production Readiness Test Suite
3
- Comprehensive tests ensuring zero defects before deployment
4
- """
5
-
6
- import sys
7
- from pathlib import Path
8
-
9
- import pytest
10
-
11
- sys.path.insert(0, str(Path(__file__).parent.parent))
12
-
13
- from uraas.database import Author, Item, SessionLocal
14
- from uraas.utils.ai_keyword_extractor import ai_extractor
15
- from uraas.utils.staff_validator import staff_validator
16
-
17
-
18
- class TestAIKeywordExtractor:
19
- """Test AI keyword extraction system"""
20
-
21
- def test_keyword_extraction_basic(self):
22
- """Test basic keyword extraction"""
23
- text = "machine learning deep neural networks artificial intelligence"
24
- keywords = ai_extractor.extract_keywords(text, top_n=5)
25
-
26
- assert len(keywords) > 0, "Should extract keywords"
27
- assert all(
28
- isinstance(k, tuple) and len(k) == 2 for k in keywords
29
- ), "Keywords should be (word, score) tuples"
30
- assert all(
31
- 0 <= score <= 1 for _, score in keywords
32
- ), "Scores should be between 0 and 1"
33
-
34
- def test_keyword_extraction_empty(self):
35
- """Test with empty text"""
36
- keywords = ai_extractor.extract_keywords("", top_n=5)
37
- assert keywords == [], "Empty text should return no keywords"
38
-
39
- def test_domain_classification(self):
40
- """Test domain classification"""
41
- text = "algorithm data structure programming software engineering"
42
- domains = ai_extractor.classify_domain(text)
43
-
44
- assert len(domains) > 0, "Should classify domains"
45
- assert (
46
- domains[0][0] == "computer_science"
47
- ), "Should identify computer science domain"
48
-
49
- def test_entity_extraction(self):
50
- """Test entity extraction"""
51
- text = "The study was conducted at University of Lagos using 50 mg of compound"
52
- entities = ai_extractor.extract_entities(text)
53
-
54
- assert "organizations" in entities, "Should extract organizations"
55
- assert "measurements" in entities, "Should extract measurements"
56
-
57
- def test_paper_scoring(self):
58
- """Test paper quality scoring"""
59
- title = "Machine Learning Applications in Medical Diagnosis"
60
- abstract = (
61
- "This study investigates the application of machine learning "
62
- "algorithms for medical diagnosis. We developed a novel approach "
63
- "using deep neural networks to classify medical images. Our results "
64
- "show significant improvement over existing methods."
65
- )
66
-
67
- score = ai_extractor.score_paper(title, abstract)
68
-
69
- assert "quality_score" in score, "Should have quality score"
70
- assert 0 <= score["quality_score"] <= 1, "Quality score should be 0-1"
71
- assert "keywords" in score, "Should have keywords"
72
- assert "domains" in score, "Should have domains"
73
-
74
-
75
- class TestDashboardUI:
76
- """Test dashboard UI for emoji removal"""
77
-
78
- def test_dashboard_no_emojis(self):
79
- """Verify dashboard HTML has no emojis in critical UI text"""
80
- dashboard_path = Path("uraas/dashboard/templates/index.html")
81
- assert dashboard_path.exists(), "Dashboard file should exist"
82
-
83
- content = dashboard_path.read_text(encoding="utf-8")
84
-
85
- # Check for common emoji patterns in non-optgroup areas (optgroups intentionally use flag emojis)
86
- import re
87
-
88
- # Strip out optgroup labels before checking
89
- stripped = re.sub(r'<optgroup[^>]*label="[^"]*"[^>]*>', "", content)
90
- emoji_pattern = r"[\U0001F680-\U0001F9FF]" # Rockets, charts, etc.
91
- matches = re.findall(emoji_pattern, stripped)
92
- assert len(matches) == 0, f"Found unexpected emojis: {matches[:5]}"
93
-
94
- def test_dashboard_minimalist_design(self):
95
- """Verify dashboard uses consistent design tokens"""
96
- dashboard_path = Path("uraas/dashboard/templates/index.html")
97
- content = dashboard_path.read_text(encoding="utf-8")
98
-
99
- # Check for design system elements present in index.html
100
- assert "Inter" in content, "Should use Inter font"
101
- assert (
102
- "gradient-text" in content or "var(--accent)" in content
103
- ), "Should use CSS design tokens"
104
-
105
- def test_dashboard_accessibility(self):
106
- """Verify dashboard has accessibility features"""
107
- dashboard_path = Path("uraas/dashboard/templates/index.html")
108
- content = dashboard_path.read_text(encoding="utf-8")
109
-
110
- # Check for accessibility features
111
- assert (
112
- "aria-" in content or "role=" in content or "title=" in content
113
- ), "Should have ARIA attributes or title attributes"
114
- assert "lang=" in content, "HTML element should have lang attribute"
115
-
116
-
117
- class TestMetadataExtraction:
118
- """Test metadata extraction accuracy"""
119
-
120
- def test_paper_metadata_completeness(self):
121
- """Test that papers have complete metadata"""
122
- session = SessionLocal()
123
- try:
124
- papers = session.query(Item).limit(10).all()
125
-
126
- for paper in papers:
127
- assert paper.title, "Paper should have title"
128
- assert paper.dc_title, "Paper should have Dublin Core title"
129
-
130
- # dc_identifier_doi stores the repository handle (OAI/DocID),
131
- # while paper.doi stores the scholarly DOI — these are distinct fields.
132
- # Both can coexist; just verify dc_identifier_doi is non-empty when doi is set.
133
- if paper.doi and paper.dc_identifier_doi:
134
- assert (
135
- len(paper.dc_identifier_doi) > 0
136
- ), "dc_identifier_doi should be non-empty when present"
137
- finally:
138
- session.close()
139
-
140
- def test_author_extraction_accuracy(self):
141
- """Test author extraction"""
142
- session = SessionLocal()
143
- try:
144
- papers = session.query(Item).filter(Item.authors.any()).limit(5).all()
145
-
146
- for paper in papers:
147
- assert len(paper.authors) > 0, "Paper should have authors"
148
-
149
- for author in paper.authors:
150
- assert author.name, "Author should have name"
151
- assert author.normalized_name, "Author should have normalized name"
152
- assert (
153
- author.normalized_name == author.name.lower().strip()
154
- ), "Normalized name should be lowercase"
155
- finally:
156
- session.close()
157
-
158
-
159
- class TestStaffValidation:
160
- """Test staff validation accuracy"""
161
-
162
- def test_staff_cache_loaded(self):
163
- """Verify staff cache can be loaded from the data directory"""
164
- import os
165
-
166
- # The data file is always relative to the project root
167
- staff_path = os.path.join(
168
- os.path.dirname(__file__), "..", "data", "unilag_staff.json"
169
- )
170
- assert os.path.exists(
171
- staff_path
172
- ), f"Staff data file should exist at {staff_path}"
173
- # If staff_validator already loaded correctly, that is the best evidence
174
- if len(staff_validator.staff_names) == 0:
175
- # Reload using absolute path so tests pass regardless of working dir
176
- from uraas.utils.staff_validator import StaffValidator
177
-
178
- sv = StaffValidator(staff_cache_path=os.path.abspath(staff_path))
179
- assert (
180
- len(sv.staff_names) > 0
181
- ), "Staff cache should load from data/unilag_staff.json"
182
-
183
- def test_exact_staff_match(self):
184
- """Test exact staff matching"""
185
- if staff_validator.staff_names:
186
- test_name = list(staff_validator.staff_names)[0]
187
- assert staff_validator.is_staff_member(
188
- test_name, fuzzy_threshold=100
189
- ), "Exact match should work"
190
-
191
- def test_fuzzy_staff_match(self):
192
- """Test fuzzy staff matching"""
193
- if staff_validator.staff_names:
194
- test_name = list(staff_validator.staff_names)[0]
195
- # Slightly modify the name
196
- modified = test_name.replace("a", "e", 1) if "a" in test_name else test_name
197
- result = staff_validator.is_staff_member(modified, fuzzy_threshold=75)
198
- assert isinstance(result, bool), "Should return boolean"
199
-
200
-
201
- class TestAPIEndpoints:
202
- """Test API endpoints"""
203
-
204
- @pytest.fixture
205
- def client(self):
206
- """Create test client"""
207
- from uraas.dashboard.app import app
208
-
209
- app.config["TESTING"] = True
210
- with app.test_client() as client:
211
- yield client
212
-
213
- def test_dashboard_loads(self, client):
214
- """Test dashboard loads"""
215
- response = client.get("/")
216
- assert response.status_code == 200, "Dashboard should load"
217
-
218
- def test_analytics_overview(self, client):
219
- """Test analytics overview endpoint"""
220
- response = client.get("/api/analytics/overview")
221
- assert response.status_code == 200, "Analytics overview should work"
222
-
223
- data = response.get_json()
224
- assert "total_papers" in data, "Should have total papers"
225
- assert "total_authors" in data, "Should have total authors"
226
-
227
- def test_search_endpoint(self, client):
228
- """Test search endpoint"""
229
- response = client.get("/api/analytics/search?q=test&limit=10")
230
- assert response.status_code == 200, "Search should work"
231
-
232
- data = response.get_json()
233
- assert isinstance(data, list), "Search should return list"
234
-
235
-
236
- class TestPerformance:
237
- """Test performance requirements"""
238
-
239
- def test_keyword_extraction_speed(self):
240
- """Test keyword extraction is fast"""
241
- import time
242
-
243
- text = "machine learning deep neural networks artificial intelligence " * 10
244
-
245
- start = time.time()
246
- for _ in range(100):
247
- ai_extractor.extract_keywords(text, top_n=10)
248
- duration = time.time() - start
249
-
250
- avg_time = duration / 100
251
- assert (
252
- avg_time < 0.05
253
- ), f"Keyword extraction took {avg_time}s, should be < 0.05s"
254
-
255
- def test_domain_classification_speed(self):
256
- """Test domain classification is fast"""
257
- import time
258
-
259
- text = "algorithm data structure programming software engineering" * 5
260
-
261
- start = time.time()
262
- for _ in range(100):
263
- ai_extractor.classify_domain(text)
264
- duration = time.time() - start
265
-
266
- avg_time = duration / 100
267
- assert avg_time < 0.01, f"Classification took {avg_time}s, should be < 0.01s"
268
-
269
-
270
- class TestSecurity:
271
- """Test security measures"""
272
-
273
- def test_no_sql_injection_in_search(self):
274
- """Test SQL injection protection"""
275
- from uraas.dashboard.app import app
276
-
277
- app.config["TESTING"] = True
278
-
279
- with app.test_client() as client:
280
- malicious_query = "'; DROP TABLE items; --"
281
- response = client.get(f"/api/analytics/search?q={malicious_query}")
282
-
283
- # Should not crash
284
- assert response.status_code in [200, 400], "Should handle malicious input"
285
-
286
- def test_xss_protection(self):
287
- """Test XSS protection"""
288
- session = SessionLocal()
289
- try:
290
- malicious_title = "<script>alert('XSS')</script>"
291
- item = Item(
292
- title=malicious_title, dc_title=malicious_title, doi="10.test/xss.001"
293
- )
294
- session.add(item)
295
- session.commit()
296
-
297
- # Retrieve and verify
298
- retrieved = session.query(Item).filter_by(doi="10.test/xss.001").first()
299
- assert retrieved.title == malicious_title, "Should store as-is"
300
-
301
- # Cleanup
302
- session.delete(retrieved)
303
- session.commit()
304
- finally:
305
- session.close()
306
-
307
-
308
- class TestCodeQuality:
309
- """Test code quality"""
310
-
311
- def test_no_print_statements(self):
312
- """Verify no debug print statements in production code"""
313
- import os
314
- import re
315
-
316
- production_dirs = ["uraas/dashboard", "uraas/utils", "uraas/pipelines"]
317
-
318
- # Files that legitimately use print() for IPC/stdout protocols
319
- ALLOWED_FILES = {
320
- os.path.normpath(
321
- "uraas/pipelines/database.py"
322
- ), # URAAS_DOWNLOAD: stdout IPC
323
- }
324
-
325
- for dir_path in production_dirs:
326
- for root, dirs, files in os.walk(dir_path):
327
- for file in files:
328
- if file.endswith(".py"):
329
- filepath = os.path.join(root, file)
330
- if os.path.normpath(filepath) in ALLOWED_FILES:
331
- continue # Skip intentional stdout IPC files
332
- with open(
333
- filepath, "r", encoding="utf-8", errors="replace"
334
- ) as f:
335
- content = f.read()
336
- # Check for debug print statements (not logging)
337
- debug_prints = re.findall(
338
- r"^\s*print\(", content, re.MULTILINE
339
- )
340
- assert (
341
- len(debug_prints) == 0
342
- ), f"Found debug print statements in {filepath}"
343
-
344
- def test_imports_organized(self):
345
- """Verify imports are organized"""
346
- import os
347
-
348
- for root, dirs, files in os.walk("uraas"):
349
- for file in files:
350
- if file.endswith(".py"):
351
- filepath = os.path.join(root, file)
352
- with open(filepath, "r") as f:
353
- lines = f.readlines()
354
-
355
- # Check that imports are at the top
356
- import_section_ended = False
357
- for i, line in enumerate(lines[:50]):
358
- if line.strip() and not line.startswith(
359
- ("import", "from", "#", '"""', "'''")
360
- ):
361
- import_section_ended = True
362
- elif import_section_ended and line.startswith(
363
- ("import", "from")
364
- ):
365
- # Imports after code is bad
366
- pass # Allow for now
367
-
368
-
369
- def run_all_tests():
370
- """Run all tests"""
371
- pytest.main([__file__, "-v", "--tb=short", "--color=yes"])
372
-
373
-
374
- if __name__ == "__main__":
375
- run_all_tests()
 
1
+ """
2
+ URAAS Production Readiness Test Suite
3
+ Comprehensive tests ensuring zero defects before deployment
4
+ """
5
+
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ sys.path.insert(0, str(Path(__file__).parent.parent))
12
+
13
+ from uraas.database import Author, Item, SessionLocal
14
+ from uraas.utils.ai_keyword_extractor import ai_extractor
15
+ from uraas.utils.staff_validator import staff_validator
16
+
17
+
18
+ class TestAIKeywordExtractor:
19
+ """Test AI keyword extraction system"""
20
+
21
+ def test_keyword_extraction_basic(self):
22
+ """Test basic keyword extraction"""
23
+ text = "machine learning deep neural networks artificial intelligence"
24
+ keywords = ai_extractor.extract_keywords(text, top_n=5)
25
+
26
+ assert len(keywords) > 0, "Should extract keywords"
27
+ assert all(
28
+ isinstance(k, tuple) and len(k) == 2 for k in keywords
29
+ ), "Keywords should be (word, score) tuples"
30
+ assert all(
31
+ 0 <= score <= 1 for _, score in keywords
32
+ ), "Scores should be between 0 and 1"
33
+
34
+ def test_keyword_extraction_empty(self):
35
+ """Test with empty text"""
36
+ keywords = ai_extractor.extract_keywords("", top_n=5)
37
+ assert keywords == [], "Empty text should return no keywords"
38
+
39
+ def test_domain_classification(self):
40
+ """Test domain classification"""
41
+ text = "algorithm data structure programming software engineering"
42
+ domains = ai_extractor.classify_domain(text)
43
+
44
+ assert len(domains) > 0, "Should classify domains"
45
+ assert (
46
+ domains[0][0] == "computer_science"
47
+ ), "Should identify computer science domain"
48
+
49
+ def test_entity_extraction(self):
50
+ """Test entity extraction"""
51
+ text = "The study was conducted at University of Lagos using 50 mg of compound"
52
+ entities = ai_extractor.extract_entities(text)
53
+
54
+ assert "organizations" in entities, "Should extract organizations"
55
+ assert "measurements" in entities, "Should extract measurements"
56
+
57
+ def test_paper_scoring(self):
58
+ """Test paper quality scoring"""
59
+ title = "Machine Learning Applications in Medical Diagnosis"
60
+ abstract = (
61
+ "This study investigates the application of machine learning "
62
+ "algorithms for medical diagnosis. We developed a novel approach "
63
+ "using deep neural networks to classify medical images. Our results "
64
+ "show significant improvement over existing methods."
65
+ )
66
+
67
+ score = ai_extractor.score_paper(title, abstract)
68
+
69
+ assert "quality_score" in score, "Should have quality score"
70
+ assert 0 <= score["quality_score"] <= 1, "Quality score should be 0-1"
71
+ assert "keywords" in score, "Should have keywords"
72
+ assert "domains" in score, "Should have domains"
73
+
74
+
75
+ class TestDashboardUI:
76
+ """Test dashboard UI for emoji removal"""
77
+
78
+ def test_dashboard_no_emojis(self):
79
+ """Verify dashboard HTML has no emojis in critical UI text"""
80
+ dashboard_path = Path("uraas/dashboard/templates/index.html")
81
+ assert dashboard_path.exists(), "Dashboard file should exist"
82
+
83
+ content = dashboard_path.read_text(encoding="utf-8")
84
+
85
+ # Check for common emoji patterns in non-optgroup areas (optgroups intentionally use flag emojis)
86
+ import re
87
+
88
+ # Strip out optgroup labels before checking
89
+ stripped = re.sub(r'<optgroup[^>]*label="[^"]*"[^>]*>', "", content)
90
+ emoji_pattern = r"[\U0001F680-\U0001F9FF]" # Rockets, charts, etc.
91
+ matches = re.findall(emoji_pattern, stripped)
92
+ assert len(matches) == 0, f"Found unexpected emojis: {matches[:5]}"
93
+
94
+ def test_dashboard_minimalist_design(self):
95
+ """Verify dashboard uses consistent design tokens"""
96
+ dashboard_path = Path("uraas/dashboard/templates/index.html")
97
+ content = dashboard_path.read_text(encoding="utf-8")
98
+
99
+ # Check for design system elements present in index.html
100
+ assert "Inter" in content, "Should use Inter font"
101
+ assert (
102
+ "gradient-text" in content or "var(--accent)" in content
103
+ ), "Should use CSS design tokens"
104
+
105
+ def test_dashboard_accessibility(self):
106
+ """Verify dashboard has accessibility features"""
107
+ dashboard_path = Path("uraas/dashboard/templates/index.html")
108
+ content = dashboard_path.read_text(encoding="utf-8")
109
+
110
+ # Check for accessibility features
111
+ assert (
112
+ "aria-" in content or "role=" in content or "title=" in content
113
+ ), "Should have ARIA attributes or title attributes"
114
+ assert "lang=" in content, "HTML element should have lang attribute"
115
+
116
+
117
+ class TestMetadataExtraction:
118
+ """Test metadata extraction accuracy"""
119
+
120
+ def test_paper_metadata_completeness(self):
121
+ """Test that papers have complete metadata"""
122
+ session = SessionLocal()
123
+ try:
124
+ papers = session.query(Item).limit(10).all()
125
+
126
+ for paper in papers:
127
+ assert paper.title, "Paper should have title"
128
+ assert paper.dc_title, "Paper should have Dublin Core title"
129
+
130
+ # dc_identifier_doi stores the repository handle (OAI/DocID),
131
+ # while paper.doi stores the scholarly DOI — these are distinct fields.
132
+ # Both can coexist; just verify dc_identifier_doi is non-empty when doi is set.
133
+ if paper.doi and paper.dc_identifier_doi:
134
+ assert (
135
+ len(paper.dc_identifier_doi) > 0
136
+ ), "dc_identifier_doi should be non-empty when present"
137
+ finally:
138
+ session.close()
139
+
140
+ def test_author_extraction_accuracy(self):
141
+ """Test author extraction"""
142
+ session = SessionLocal()
143
+ try:
144
+ papers = session.query(Item).filter(Item.authors.any()).limit(5).all()
145
+
146
+ for paper in papers:
147
+ assert len(paper.authors) > 0, "Paper should have authors"
148
+
149
+ for author in paper.authors:
150
+ assert author.name, "Author should have name"
151
+ assert author.normalized_name, "Author should have normalized name"
152
+ assert (
153
+ author.normalized_name == author.name.lower().strip()
154
+ ), "Normalized name should be lowercase"
155
+ finally:
156
+ session.close()
157
+
158
+
159
+ class TestStaffValidation:
160
+ """Test staff validation accuracy"""
161
+
162
+ def test_staff_cache_loaded(self):
163
+ """Verify staff cache can be loaded from the data directory"""
164
+ import os
165
+
166
+ # The data file is always relative to the project root
167
+ staff_path = os.path.join(
168
+ os.path.dirname(__file__), "..", "data", "unilag_staff.json"
169
+ )
170
+ assert os.path.exists(
171
+ staff_path
172
+ ), f"Staff data file should exist at {staff_path}"
173
+ # If staff_validator already loaded correctly, that is the best evidence
174
+ if len(staff_validator.staff_names) == 0:
175
+ # Reload using absolute path so tests pass regardless of working dir
176
+ from uraas.utils.staff_validator import StaffValidator
177
+
178
+ sv = StaffValidator(staff_cache_path=os.path.abspath(staff_path))
179
+ assert (
180
+ len(sv.staff_names) > 0
181
+ ), "Staff cache should load from data/unilag_staff.json"
182
+
183
+ def test_exact_staff_match(self):
184
+ """Test exact staff matching"""
185
+ if staff_validator.staff_names:
186
+ test_name = list(staff_validator.staff_names)[0]
187
+ assert staff_validator.is_staff_member(
188
+ test_name, fuzzy_threshold=100
189
+ ), "Exact match should work"
190
+
191
+ def test_fuzzy_staff_match(self):
192
+ """Test fuzzy staff matching"""
193
+ if staff_validator.staff_names:
194
+ test_name = list(staff_validator.staff_names)[0]
195
+ # Slightly modify the name
196
+ modified = test_name.replace("a", "e", 1) if "a" in test_name else test_name
197
+ result = staff_validator.is_staff_member(modified, fuzzy_threshold=75)
198
+ assert isinstance(result, bool), "Should return boolean"
199
+
200
+
201
+ class TestAPIEndpoints:
202
+ """Test API endpoints"""
203
+
204
+ @pytest.fixture
205
+ def client(self):
206
+ """Create test client"""
207
+ from uraas.dashboard.app import app
208
+
209
+ app.config["TESTING"] = True
210
+ with app.test_client() as client:
211
+ yield client
212
+
213
+ def test_dashboard_loads(self, client):
214
+ """Test dashboard loads"""
215
+ response = client.get("/")
216
+ assert response.status_code == 200, "Dashboard should load"
217
+
218
+ def test_analytics_overview(self, client):
219
+ """Test analytics overview endpoint"""
220
+ response = client.get("/api/analytics/overview")
221
+ assert response.status_code == 200, "Analytics overview should work"
222
+
223
+ data = response.get_json()
224
+ assert "total_papers" in data, "Should have total papers"
225
+ assert "total_authors" in data, "Should have total authors"
226
+
227
+ def test_search_endpoint(self, client):
228
+ """Test search endpoint"""
229
+ response = client.get("/api/analytics/search?q=test&limit=10")
230
+ assert response.status_code == 200, "Search should work"
231
+
232
+ data = response.get_json()
233
+ assert isinstance(data, list), "Search should return list"
234
+
235
+
236
+ class TestPerformance:
237
+ """Test performance requirements"""
238
+
239
+ def test_keyword_extraction_speed(self):
240
+ """Test keyword extraction is fast"""
241
+ import time
242
+
243
+ text = "machine learning deep neural networks artificial intelligence " * 10
244
+
245
+ start = time.time()
246
+ for _ in range(100):
247
+ ai_extractor.extract_keywords(text, top_n=10)
248
+ duration = time.time() - start
249
+
250
+ avg_time = duration / 100
251
+ assert (
252
+ avg_time < 0.05
253
+ ), f"Keyword extraction took {avg_time}s, should be < 0.05s"
254
+
255
+ def test_domain_classification_speed(self):
256
+ """Test domain classification is fast"""
257
+ import time
258
+
259
+ text = "algorithm data structure programming software engineering" * 5
260
+
261
+ start = time.time()
262
+ for _ in range(100):
263
+ ai_extractor.classify_domain(text)
264
+ duration = time.time() - start
265
+
266
+ avg_time = duration / 100
267
+ assert avg_time < 0.01, f"Classification took {avg_time}s, should be < 0.01s"
268
+
269
+
270
+ class TestSecurity:
271
+ """Test security measures"""
272
+
273
+ def test_no_sql_injection_in_search(self):
274
+ """Test SQL injection protection"""
275
+ from uraas.dashboard.app import app
276
+
277
+ app.config["TESTING"] = True
278
+
279
+ with app.test_client() as client:
280
+ malicious_query = "'; DROP TABLE items; --"
281
+ response = client.get(f"/api/analytics/search?q={malicious_query}")
282
+
283
+ # Should not crash
284
+ assert response.status_code in [200, 400], "Should handle malicious input"
285
+
286
+ def test_xss_protection(self):
287
+ """Test XSS protection"""
288
+ session = SessionLocal()
289
+ try:
290
+ malicious_title = "<script>alert('XSS')</script>"
291
+ item = Item(
292
+ title=malicious_title, dc_title=malicious_title, doi="10.test/xss.001"
293
+ )
294
+ session.add(item)
295
+ session.commit()
296
+
297
+ # Retrieve and verify
298
+ retrieved = session.query(Item).filter_by(doi="10.test/xss.001").first()
299
+ assert retrieved.title == malicious_title, "Should store as-is"
300
+
301
+ # Cleanup
302
+ session.delete(retrieved)
303
+ session.commit()
304
+ finally:
305
+ session.close()
306
+
307
+
308
+ class TestCodeQuality:
309
+ """Test code quality"""
310
+
311
+ def test_no_print_statements(self):
312
+ """Verify no debug print statements in production code"""
313
+ import os
314
+ import re
315
+
316
+ production_dirs = ["uraas/dashboard", "uraas/utils", "uraas/pipelines"]
317
+
318
+ # Files that legitimately use print() for IPC/stdout protocols
319
+ ALLOWED_FILES = {
320
+ os.path.normpath(
321
+ "uraas/pipelines/database.py"
322
+ ), # URAAS_DOWNLOAD: stdout IPC
323
+ }
324
+
325
+ for dir_path in production_dirs:
326
+ for root, dirs, files in os.walk(dir_path):
327
+ for file in files:
328
+ if file.endswith(".py"):
329
+ filepath = os.path.join(root, file)
330
+ if os.path.normpath(filepath) in ALLOWED_FILES:
331
+ continue # Skip intentional stdout IPC files
332
+ with open(
333
+ filepath, "r", encoding="utf-8", errors="replace"
334
+ ) as f:
335
+ content = f.read()
336
+ # Check for debug print statements (not logging)
337
+ debug_prints = re.findall(
338
+ r"^\s*print\(", content, re.MULTILINE
339
+ )
340
+ assert (
341
+ len(debug_prints) == 0
342
+ ), f"Found debug print statements in {filepath}"
343
+
344
+ def test_imports_organized(self):
345
+ """Verify imports are organized"""
346
+ import os
347
+
348
+ for root, dirs, files in os.walk("uraas"):
349
+ for file in files:
350
+ if file.endswith(".py"):
351
+ filepath = os.path.join(root, file)
352
+ with open(filepath, "r") as f:
353
+ lines = f.readlines()
354
+
355
+ # Check that imports are at the top
356
+ import_section_ended = False
357
+ for i, line in enumerate(lines[:50]):
358
+ if line.strip() and not line.startswith(
359
+ ("import", "from", "#", '"""', "'''")
360
+ ):
361
+ import_section_ended = True
362
+ elif import_section_ended and line.startswith(
363
+ ("import", "from")
364
+ ):
365
+ # Imports after code is bad
366
+ pass # Allow for now
367
+
368
+
369
+ def run_all_tests():
370
+ """Run all tests"""
371
+ pytest.main([__file__, "-v", "--tb=short", "--color=yes"])
372
+
373
+
374
+ if __name__ == "__main__":
375
+ run_all_tests()
tests/test_staff_res.py CHANGED
@@ -1,12 +1,12 @@
1
- import os
2
-
3
- from uraas.config.institutions import get_registry
4
-
5
- if __name__ == "__main__":
6
- registry = get_registry()
7
- for inst in registry.list_all():
8
- print(
9
- f"{inst.short_name}: {len(inst.staff_names)} names, resolved path: {inst._resolve_staff_file()}"
10
- )
11
- if not os.path.exists(inst._resolve_staff_file()):
12
- print(f" ERROR: Path does not exist!")
 
1
+ import os
2
+
3
+ from uraas.config.institutions import get_registry
4
+
5
+ if __name__ == "__main__":
6
+ registry = get_registry()
7
+ for inst in registry.list_all():
8
+ print(
9
+ f"{inst.short_name}: {len(inst.staff_names)} names, resolved path: {inst._resolve_staff_file()}"
10
+ )
11
+ if not os.path.exists(inst._resolve_staff_file()):
12
+ print(f" ERROR: Path does not exist!")