1+ import * as functions from "firebase-functions"
12import { RuntimeOptions , runWith } from "firebase-functions"
23import { DateTime } from "luxon"
34import { JSDOM } from "jsdom"
45import { AssemblyAI } from "assemblyai"
5- import { logFetchError } from "../common"
6+ import { checkAuth , checkAdmin , logFetchError } from "../common"
67import { db , storage , Timestamp } from "../firebase"
78import * as api from "../malegislature"
89import {
@@ -287,7 +288,10 @@ export const getHearingVideoUrl = async (EventId: number) => {
287288 return null
288289}
289290
290- const shouldScrapeVideo = async ( EventId : number ) => {
291+ const shouldScrapeVideo = async (
292+ EventId : number ,
293+ ignoreCutoff : boolean = false
294+ ) => {
291295 const eventInDb = await db
292296 . collection ( "events" )
293297 . doc ( `hearing-${ String ( EventId ) } ` )
@@ -300,7 +304,10 @@ const shouldScrapeVideo = async (EventId: number) => {
300304 return false
301305 }
302306 if ( ! eventData . videoURL ) {
303- return withinCutoff ( new Date ( Hearing . check ( eventData ) . startsAt . toDate ( ) ) )
307+ return (
308+ ignoreCutoff ||
309+ withinCutoff ( new Date ( Hearing . check ( eventData ) . startsAt . toDate ( ) ) )
310+ )
304311 }
305312 return false
306313}
@@ -346,7 +353,10 @@ class HearingScraper extends EventScraper<HearingListItem, Hearing> {
346353 return events . filter ( HearingListItem . guard )
347354 }
348355
349- async getEvent ( { EventId } : HearingListItem /* e.g. 4962 */ ) {
356+ async getEvent (
357+ { EventId } : HearingListItem /* e.g. 4962 */ ,
358+ { ignoreCutoff = false } : { ignoreCutoff ?: boolean } = { }
359+ ) {
350360 const data = await api . getHearing ( EventId )
351361 const content = HearingContent . check ( data )
352362
@@ -359,9 +369,9 @@ class HearingScraper extends EventScraper<HearingListItem, Hearing> {
359369 host . GeneralCourtNumber ,
360370 host . CommitteeCode
361371 )
362- : undefined
372+ : [ ]
363373
364- if ( await shouldScrapeVideo ( EventId ) ) {
374+ if ( await shouldScrapeVideo ( EventId , ignoreCutoff ) ) {
365375 try {
366376 const maybeVideoUrl = await getHearingVideoUrl ( EventId )
367377 if ( maybeVideoUrl ) {
@@ -411,6 +421,61 @@ class HearingScraper extends EventScraper<HearingListItem, Hearing> {
411421 }
412422}
413423
424+ /**
425+ * Callable cloud function to scrape a single hearing by EventId.
426+ * Requires authentication to prevent abuse of API call limits.
427+ *
428+ * @param data - Object containing the EventId (e.g., 1234)
429+ * @param context - Firebase callable context with auth information
430+ */
431+ export const scrapeSingleHearing = functions
432+ . runWith ( {
433+ timeoutSeconds : 480 ,
434+ secrets : [ "ASSEMBLY_API_KEY" ] ,
435+ memory : "4GB"
436+ } )
437+ . https . onCall ( async ( data : { eventId : number } , context ) => {
438+ // Require admin authentication
439+ checkAuth ( context , false )
440+ checkAdmin ( context )
441+
442+ const { eventId } = data
443+
444+ if ( ! eventId || typeof eventId !== "number" ) {
445+ throw new functions . https . HttpsError (
446+ "invalid-argument" ,
447+ "The function must be called with a valid eventId (number)."
448+ )
449+ }
450+
451+ try {
452+ // Create a temporary scraper instance to reuse the existing logic
453+ const scraper = new HearingScraper ( )
454+ const hearing = await scraper . getEvent (
455+ { EventId : eventId } ,
456+ { ignoreCutoff : true }
457+ )
458+
459+ // Save the hearing to Firestore
460+ await db . doc ( `/events/${ hearing . id } ` ) . set ( hearing , { merge : true } )
461+
462+ console . log ( `Successfully scraped hearing ${ eventId } ` , hearing )
463+
464+ return {
465+ status : "success" ,
466+ message : `Successfully scraped hearing ${ eventId } ` ,
467+ hearingId : hearing . id
468+ }
469+ } catch ( error : any ) {
470+ console . error ( `Failed to scrape hearing ${ eventId } :` , error )
471+ throw new functions . https . HttpsError (
472+ "internal" ,
473+ `Failed to scrape hearing ${ eventId } ` ,
474+ { details : error . message }
475+ )
476+ }
477+ } )
478+
414479export const scrapeSpecialEvents = new SpecialEventsScraper ( ) . function
415480export const scrapeSessions = new SessionScraper ( ) . function
416481export const scrapeHearings = new HearingScraper ( ) . function
0 commit comments