Spaces:
Sleeping
Sleeping
Download src/modules/scraper/scraper.controller.ts from mahrooshishaq/SmartfolioBackend: direct link, hf CLI and curl.
- Browser
- Download file 14.2 kB
-
https://huggingface.co/spaces/mahrooshishaq/SmartfolioBackend/resolve/main/src/modules/scraper/scraper.controller.ts
- Command line
-
hf download hf://spaces/mahrooshishaq/SmartfolioBackend/src/modules/scraper/scraper.controller.ts
-
curl -L -o scraper.controller.ts https://huggingface.co/spaces/mahrooshishaq/SmartfolioBackend/resolve/main/src/modules/scraper/scraper.controller.ts
14.2 kB
| import { Controller, Post, Get, Param, Body, UseGuards, Req, Query } from '@nestjs/common'; | |
| import { ScraperService } from './scraper.service'; | |
| import { MaintenanceService } from './maintenance.service'; | |
| import { LinkVerifierService } from './link-verifier.service'; | |
| import { JobsService } from '../jobs/jobs.service'; | |
| import { CoursesService } from '../courses/courses.service'; | |
| import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery, ApiSecurity } from '@nestjs/swagger'; | |
| import { JwtAuthGuard } from '../auth/jwt-auth.guard'; | |
| import { AdminKeyGuard } from '../auth/admin-key.guard'; | |
| import { CustomSearchDto } from './dto/custom-search.dto'; | |
| import { UsersService } from '../users/users.service'; | |
| ('Scraper') | |
| ('scraper') | |
| export class ScraperController { | |
| constructor( | |
| private readonly scraperService: ScraperService, | |
| private readonly jobsService: JobsService, | |
| private readonly coursesService: CoursesService, | |
| private readonly maintenanceService: MaintenanceService, | |
| private readonly usersService: UsersService, | |
| private readonly linkVerifier: LinkVerifierService, | |
| ) {} | |
| // βββ POST /scraper/maintenance/expire β manually run job expiry/cleanup βββββ | |
| ('maintenance/expire') | |
| (AdminKeyGuard) | |
| ('admin-key') | |
| ({ summary: 'Admin: run job expiry + cleanup now (normally daily 02:00 cron)' }) | |
| async runExpiry() { | |
| await this.maintenanceService.expireAndCleanJobs(); | |
| return { status: 'done' }; | |
| } | |
| // βββ POST /scraper/maintenance/refresh β manually run nightly refresh βββββββ | |
| ('maintenance/refresh') | |
| (AdminKeyGuard) | |
| ('admin-key') | |
| ({ summary: 'Admin: run scheduled refresh for active users now (normally nightly 03:00 cron)' }) | |
| async runRefresh() { | |
| await this.maintenanceService.refreshActiveUsers(); | |
| return { status: 'done' }; | |
| } | |
| // βββ POST /scraper/maintenance/purge-jobs β wipe the feed for a fresh start ββ | |
| ('maintenance/purge-jobs') | |
| (AdminKeyGuard) | |
| ('admin-key') | |
| ({ | |
| summary: 'Admin: delete ALL scraped jobs + link_health + job cache (fresh start)', | |
| description: | |
| 'DESTRUCTIVE. Deletes every scraped_jobs row, all link_health verdicts, and the job-query ' + | |
| 'cache so the next scrape re-fetches fresh. Preserves job_applications (saved tracker), courses, ' + | |
| 'and scrape history. Requires ?confirm=DELETE to actually run.', | |
| }) | |
| ({ name: 'confirm', required: false }) | |
| async purgeJobs(('confirm') confirm?: string) { | |
| if (confirm !== 'DELETE') { | |
| return { | |
| error: 'No-op. Pass ?confirm=DELETE to actually purge.', | |
| wouldDelete: 'ALL scraped_jobs + link_health + job cache', | |
| preserves: 'job_applications, courses, scrape history', | |
| }; | |
| } | |
| return this.maintenanceService.purgeAllJobs(); | |
| } | |
| // βββ POST /scraper/maintenance/verify-links βββββββββββββββββββββββββββββββββ | |
| ('maintenance/verify-links') | |
| (AdminKeyGuard) | |
| ('admin-key') | |
| ({ | |
| summary: 'Admin: verify apply-link liveness across all currently-visible jobs', | |
| description: | |
| 'Fetches every distinct apply URL of non-expired jobs, follows redirects, and records ' + | |
| 'live/dead/unknown in link_health. Non-destructive. It never edits job rows; dead links ' + | |
| 'are hidden by the feed JOIN and can be un-hidden by clearing link_health. Fail-open: only ' + | |
| 'a positive death signal (404/410/removed-page) marks a link dead; blocked/timeout stays ' + | |
| 'shown. Run from the deployed backend (its IP is not bot-blocked like a laptop/CI IP). ' + | |
| 'Query: ?limit=N caps URLs checked; ?force=true re-checks even fresh ones.', | |
| }) | |
| ({ name: 'limit', required: false, type: Number }) | |
| ({ name: 'force', required: false, type: Boolean }) | |
| async verifyLinks(('limit') limit?: number, ('force') force?: string) { | |
| return this.maintenanceService.verifyExistingJobLinks({ | |
| limit: limit ? Number(limit) : undefined, | |
| force: force === 'true' || force === '1', | |
| }); | |
| } | |
| // βββ GET /scraper/maintenance/link-health β audit verdicts + evidence βββββββ | |
| ('maintenance/link-health') | |
| (AdminKeyGuard) | |
| ('admin-key') | |
| ({ | |
| summary: 'Admin: review link-liveness verdicts with the evidence behind each', | |
| description: | |
| 'The audit view for the filter. Lists link_health rows of a given status (default "dead") ' + | |
| 'with the exact reason, HTTP status, final resolved URL, and evidence snippet, plus a ' + | |
| 'representative job. Use it to confirm every discarded job was rightly discarded before ' + | |
| 'trusting the pass. ?status=dead|live|unknown|pending|all, ?limit=N.', | |
| }) | |
| ({ name: 'status', required: false, enum: ['dead', 'live', 'unknown', 'pending', 'all'] }) | |
| ({ name: 'limit', required: false, type: Number }) | |
| async linkHealth(('status') status?: string, ('limit') limit?: number) { | |
| const allowed = ['dead', 'live', 'unknown', 'pending', 'all']; | |
| const s = (allowed.includes(status as string) ? status : 'dead') as any; | |
| return this.maintenanceService.listLinkHealth(s, limit ? Number(limit) : 100); | |
| } | |
| // βββ POST /scraper/maintenance/verify-url β re-check ONE url live (audit) ββββ | |
| ('maintenance/verify-url') | |
| (AdminKeyGuard) | |
| ('admin-key') | |
| ({ | |
| summary: 'Admin: verify a single apply URL live and return the full verdict + evidence', | |
| description: | |
| 'On-demand re-check of one URL (escalating to a browser on bot-hostile boards), for auditing ' + | |
| 'a specific discard. Returns { status, finalUrl, httpStatus, reason, evidence }. Body: { "url": "..." }.', | |
| }) | |
| async verifyUrl(() body: { url?: string }) { | |
| if (!body?.url || !/^https?:\/\//i.test(body.url)) { | |
| return { error: 'Provide a valid http(s) url in the body: { "url": "..." }' }; | |
| } | |
| const row = await this.linkVerifier.verifyOne(body.url, true); | |
| return { | |
| url: row.url, status: row.status, finalUrl: row.finalUrl, | |
| httpStatus: row.httpStatus, reason: row.reason, evidence: row.evidence, | |
| escalated: row.escalated, lastCheckedAt: row.lastCheckedAt, | |
| }; | |
| } | |
| // βββ POST /scraper/maintenance/backfill-salaries ββββββββββββββββββββββββββββ | |
| ('maintenance/backfill-salaries') | |
| (AdminKeyGuard) | |
| ('admin-key') | |
| ({ | |
| summary: 'Admin: fill numeric salary columns from the original strings (one-time)', | |
| description: | |
| 'Jobs written before the numeric salary columns existed have NULL mirrors, and the ' + | |
| 'salary filter requires NOT NULL, so filtering by salary returns nothing for them. ' + | |
| 'Run once after deploying. Safe to repeat: it only touches rows still NULL.', | |
| }) | |
| async backfillSalaries() { | |
| return this.maintenanceService.backfillSalaryValues(); | |
| } | |
| // βββ POST /scraper/run βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ('run') | |
| (JwtAuthGuard) | |
| () | |
| ({ | |
| summary: 'Run personalized scraper for logged-in user', | |
| description: 'Reads user onboarding profile, generates targeted queries via AI, scrapes relevant jobs and courses, stores results in the database', | |
| }) | |
| async runScraper(() req: any) { | |
| const userId = req.user.id; | |
| return this.scraperService.runForUser(userId); | |
| } | |
| // βββ POST /scraper/search β Custom query search (async) βββββββββββββββββββββ | |
| ('search') | |
| (JwtAuthGuard) | |
| () | |
| ({ | |
| summary: 'Start a custom search (async)', | |
| description: 'Kicks off scraping for a user-typed query in the background and returns a jobId immediately. ' + | |
| 'Poll GET /scraper/search/:jobId for status/result instead of blocking for 30sβ5min. ' + | |
| 'Any `filters` are pushed down into the job-board APIs, so results come back already narrowed ' + | |
| 'rather than being filtered out of a broad pool afterwards.', | |
| }) | |
| searchScraper(() req: any, () body: CustomSearchDto) { | |
| // Typing a search is deliberate intent β it keeps the nightly refresh alive | |
| // for this user. Not awaited; the search itself must not wait on bookkeeping. | |
| void this.usersService.markActive(req.user.id).catch(() => undefined); | |
| const job = this.scraperService.startCustomSearch( | |
| req.user.id, body.query || '', body.type || 'both', body.filters, | |
| ); | |
| return { jobId: job.jobId, status: job.status, query: job.query, filters: job.filters }; | |
| } | |
| // βββ GET /scraper/search/:jobId β poll a custom search ββββββββββββββββββββββ | |
| ('search/:jobId') | |
| (JwtAuthGuard) | |
| () | |
| ({ summary: 'Poll the status/result of a custom search started via POST /scraper/search' }) | |
| getSearchStatus(() req: any, ('jobId') jobId: string) { | |
| // Scoped to the caller: another user's jobId reads as not_found. | |
| const job = this.scraperService.getCustomSearchStatus(jobId, req.user.id); | |
| if (!job) return { status: 'not_found', message: 'Unknown or expired jobId. Start a new search.' }; | |
| return job; | |
| } | |
| // βββ POST /scraper/jobs/:jobId/resolve β JIT liveness check at click time ββββ | |
| ('jobs/:jobId/resolve') | |
| (JwtAuthGuard) | |
| () | |
| ({ | |
| summary: 'Verify a job apply-link at click time and return where it actually lands', | |
| description: | |
| 'Checks the posting live (HTTP, escalating to a browser on bot-hostile boards), follows the ' + | |
| 'redirect chain, and returns { status, finalUrl }. The frontend opens finalUrl for live/unknown, ' + | |
| 'or warns when dead. Persists to link_health so the verdict also removes a dead job from the feed. ' + | |
| 'Fail-open: only a positive death signal yields status "dead".', | |
| }) | |
| async resolveJobLink(() req: any, ('jobId') jobId: string) { | |
| const applyUrl = await this.jobsService.getApplyUrl(req.user.id, jobId); | |
| if (!applyUrl) return { status: 'not_found', finalUrl: '', applyUrl: '' }; | |
| const health = await this.linkVerifier.verifyOne(applyUrl, true); | |
| return { | |
| status: health.status, | |
| finalUrl: health.finalUrl || applyUrl, | |
| applyUrl, | |
| reason: health.reason, | |
| }; | |
| } | |
| // βββ GET /scraper/runs β scrape history for logged-in user ββββββββββββββββββ | |
| ('runs') | |
| (JwtAuthGuard) | |
| () | |
| ({ | |
| summary: 'Get scrape run history for logged-in user', | |
| description: 'Shows when scrapes ran, which platforms succeeded/failed, and how many items each found', | |
| }) | |
| ({ name: 'limit', required: false, type: Number }) | |
| async getMyRuns(() req: any, ('limit') limit?: number) { | |
| return this.scraperService.getRunHistory(req.user.id, Number(limit) || 20); | |
| } | |
| // βββ POST /scraper/migrate-legacy ββββββββββββββββββββββββββββββββββββββββββββ | |
| ('migrate-legacy') | |
| (AdminKeyGuard) | |
| ('admin-key') | |
| ({ | |
| summary: 'Admin: one-time import of old per-user JSON files into the database', | |
| description: 'Reads src/data/users/*.json and upserts all jobs/courses into the DB. Safe to run multiple times (deduped by URL).', | |
| }) | |
| async migrateLegacy() { | |
| return this.scraperService.migrateLegacyFiles(); | |
| } | |
| // βββ POST /scraper/run/:userId βββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Admin-only: this spends Adzuna/JSearch/Groq quota, so it must never be | |
| // reachable without the operator key. | |
| ('run/:userId') | |
| (AdminKeyGuard) | |
| ('admin-key') | |
| ({ summary: 'Admin: run scraper for a specific user by ID' }) | |
| async runScraperForUser(('userId') userId: string) { | |
| return this.scraperService.runForUser(userId); | |
| } | |
| // βββ GET /scraper/runs/:userId βββββββββββββββββββββββββββββββββββββββββββββββ | |
| ('runs/:userId') | |
| (AdminKeyGuard) | |
| ('admin-key') | |
| ({ summary: 'Admin: get scrape run history for a specific user' }) | |
| async getUserRuns(('userId') userId: string, ('limit') limit?: number) { | |
| return this.scraperService.getRunHistory(userId, Number(limit) || 20); | |
| } | |
| // βββ GET /scraper/results β scraped results for the logged-in user βββββββββββ | |
| // Was GET /scraper/results/:userId, which let any authenticated caller read | |
| // any other user's jobs and courses. The id now comes from the token. | |
| ('results') | |
| (JwtAuthGuard) | |
| () | |
| ({ summary: 'Get scraped results for the logged-in user.', | |
| description: 'Returned fields for courses: title, instructor, platform, category, level, duration, price, rating, language, description, course_url, thumbnail, scraped_at, source. Returned fields for jobs: title, company, location, salary_min, salary_max, job_type, experience_level, category, country, source, apply_url, scraped_at' }) | |
| async getResults(() req: any) { | |
| const userId = req.user.id; | |
| const [jobs, courses] = await Promise.all([ | |
| this.jobsService.getUserJobs(userId), | |
| this.coursesService.getUserCourses(userId), | |
| ]); | |
| if (jobs.length === 0 && courses.length === 0) { | |
| return { message: 'No results yet. Please run POST /scraper/run first.' }; | |
| } | |
| return { jobs, courses }; | |
| } | |
| } | |