22Scrapy pipelines — ordered by priority:
33
44 1. DedupePipeline (100) — drops items whose link is already in the sheet
5- 2. GeminiPipeline (150) — scores with Gemini; drops score < 5; adds AI blurb
6- (imported from scoutbot/gemini_scoring.py)
7- 3. SheetsPipeline (200) — writes to Nigeria or International tab
5+ 2. SheetsPipeline (200) — writes to Nigeria or International tab
86
9- Sheet columns (6 total):
10- Title | Category | Application Link | Deadline | Date Added | AI Blurb
7+ Sheet columns (5 total):
8+ Title | Category | Application Link | Deadline | Date Added
119"""
1210
1311import logging
1715from dotenv import load_dotenv
1816from scrapy .exceptions import DropItem
1917
20- # GeminiPipeline lives in its own file for visibility — import it here
21- from scoutbot .gemini_scoring import GeminiPipeline # noqa: F401 (re-exported for settings.py)
22-
2318load_dotenv ()
2419
2520logger = logging .getLogger (__name__ )
2621
2722SPREADSHEET_ID = os .getenv ("SPREADSHEET_ID" , "1pLCEvDI1btjtOe1H3VgzCqpC6R0nRsEtnTwQhY6BqmU" )
2823SERVICE_ACCOUNT_JSON = os .getenv ("GOOGLE_SERVICE_ACCOUNT_JSON" , "service_account.json" )
2924
30- # ── Clean 6-column sheet schema ───────────────────────────────────────────────
3125SHEET_HEADERS = [
32- "Title" , # A (col 0)
33- "Category" , # B (col 1)
34- "Application Link" , # C (col 2) ← direct apply URL
35- "Deadline" , # D (col 3)
36- "Date Added" , # E (col 4)
37- "AI Blurb" , # F (col 5) ← Gemini-generated, empty if key not set
26+ "Title" , # A
27+ "Category" , # B
28+ "Application Link" , # C ← direct org apply URL
29+ "Deadline" , # D
30+ "Date Added" , # E
3831]
39- LINK_COL_INDEX = 2 # "Application Link" is column C (0-based)
32+ LINK_COL_INDEX = 2
4033
4134TAB_NIGERIA = "Nigeria"
4235TAB_INTERNATIONAL = "International"
@@ -66,50 +59,30 @@ def _get_sheet_client():
6659
6760
6861def _ensure_tab (spreadsheet , name ):
69- """Return the worksheet, creating it with correct headers if it does not exist.
70- If the tab exists with the old 13-column schema, migrate it: clear data rows
71- and set new headers so all future rows use the clean 6-column format.
72- Old rows with wrong links are removed as part of this migration.
73- """
7462 try :
7563 ws = spreadsheet .worksheet (name )
7664 except Exception :
7765 ws = spreadsheet .add_worksheet (title = name , rows = 2000 , cols = len (SHEET_HEADERS ))
7866 ws .append_row (SHEET_HEADERS )
79- logger .info ("SheetsPipeline: Created tab '%s' with new 6-column schema ." , name )
67+ logger .info ("SheetsPipeline: Created tab '%s'." , name )
8068 return ws
8169
82- # Check if headers already match the new schema
83- existing_headers = ws .row_values (1 )
84- if existing_headers == SHEET_HEADERS :
85- return ws # already migrated
70+ existing = ws .row_values (1 )
71+ if existing [:len (SHEET_HEADERS )] == SHEET_HEADERS :
72+ return ws
8673
87- # Old schema detected — migrate: clear all rows and set new headers
88- logger .info (
89- "SheetsPipeline: Tab '%s' has old schema (%d cols) — migrating to 6-column schema. "
90- "Existing rows cleared (they had wrong application links anyway)." ,
91- name , len (existing_headers ),
92- )
93- ws .clear ()
94- ws .append_row (SHEET_HEADERS )
74+ # Headers mismatch — update header row only (preserve data rows)
75+ ws .update ("A1" , [SHEET_HEADERS ])
76+ logger .info ("SheetsPipeline: Updated headers on tab '%s'." , name )
9577 return ws
9678
9779
98- # ---------------------------------------------------------------------------
99- # 1. DedupePipeline
100- # ---------------------------------------------------------------------------
101-
10280class DedupePipeline :
103- """Drops duplicate items before Gemini scoring.
104-
105- Pre-loads existing sheet links so items already in the sheet never
106- reach GeminiPipeline — avoids burning free-tier Gemini quota on
107- items that will be discarded anyway.
108- """
81+ """Drops items whose application_link already exists in the sheet."""
10982
11083 def __init__ (self ):
111- self .seen = set () # within-run dedup
112- self .existing = set () # pre-loaded from live sheet
84+ self .seen = set ()
85+ self .existing = set ()
11386
11487 @classmethod
11588 def from_crawler (cls , crawler ):
@@ -127,9 +100,7 @@ def open_spider(self, spider=None):
127100 self .existing .add (row [LINK_COL_INDEX ].strip ())
128101 except Exception :
129102 pass
130- logger .info (
131- "DedupePipeline: %d existing links pre-loaded." , len (self .existing ),
132- )
103+ logger .info ("DedupePipeline: %d existing links pre-loaded." , len (self .existing ))
133104 except Exception as exc :
134105 logger .warning ("DedupePipeline: Could not pre-load sheet links — %s" , exc )
135106
@@ -141,18 +112,8 @@ def process_item(self, item, spider=None):
141112 return item
142113
143114
144- # ---------------------------------------------------------------------------
145- # 3. SheetsPipeline (GeminiPipeline is #2, imported from gemini_scoring.py)
146- # ---------------------------------------------------------------------------
147-
148115class SheetsPipeline :
149- """Writes opportunities to Nigeria or International tab.
150-
151- Uses the clean 6-column schema: Title | Category | Application Link |
152- Deadline | Date Added | AI Blurb.
153-
154- Migrates old 13-column tabs automatically on first run.
155- """
116+ """Writes opportunities to Nigeria or International tab."""
156117
157118 def __init__ (self ):
158119 self .nigeria_ws = None
@@ -171,15 +132,11 @@ def open_spider(self, spider=None):
171132 ss = client .open_by_key (SPREADSHEET_ID )
172133 self .nigeria_ws = _ensure_tab (ss , TAB_NIGERIA )
173134 self .international_ws = _ensure_tab (ss , TAB_INTERNATIONAL )
174-
175135 for ws in (self .nigeria_ws , self .international_ws ):
176136 for row in ws .get_all_values ()[1 :]:
177137 if len (row ) > LINK_COL_INDEX and row [LINK_COL_INDEX ].strip ():
178138 self .existing_links .add (row [LINK_COL_INDEX ].strip ())
179-
180- logger .info (
181- "SheetsPipeline: %d existing entries loaded." , len (self .existing_links ),
182- )
139+ logger .info ("SheetsPipeline: %d existing entries loaded." , len (self .existing_links ))
183140 except Exception as exc :
184141 logger .error ("SheetsPipeline: Failed to connect — %s" , exc )
185142
@@ -190,12 +147,11 @@ def process_item(self, item, spider=None):
190147
191148 today = date .today ().isoformat ()
192149 row = [
193- (item .get ("title" ) or "" ).strip (), # A: Title
194- (item .get ("category" ) or "Opportunity" ).strip (), # B: Category
195- link , # C: Application Link
196- (item .get ("deadline" ) or "" ).strip (), # D: Deadline
197- today , # E: Date Added
198- (item .get ("ai_blurb" ) or "" ).strip (), # F: AI Blurb
150+ (item .get ("title" ) or "" ).strip (),
151+ (item .get ("category" ) or "Opportunity" ).strip (),
152+ link ,
153+ (item .get ("deadline" ) or "" ).strip (),
154+ today ,
199155 ]
200156
201157 if (item .get ("range" ) or "" ).strip () == "International" :
0 commit comments