Spaces:
Sleeping
Sleeping
File size: 14,211 Bytes
6fb7573 53dc52c 6fb7573 175af2a 53dc52c 6fb7573 6cd3ae1 53dc52c 6cd3ae1 a030856 a0eafe5 53dc52c 6fb7573 a0eafe5 175af2a 53dc52c 6fb7573 6cd3ae1 6fb7573 6cd3ae1 6fb7573 fb074b5 175af2a 2f666f7 175af2a 88800df 2f666f7 88800df 53dc52c 6fb7573 53dc52c a1056d7 9ffaa46 a1056d7 a030856 9ffaa46 a030856 a0eafe5 a030856 766c348 a030856 a1056d7 6cd3ae1 a1056d7 9ffaa46 175af2a 6fb7573 6cd3ae1 6fb7573 53dc52c 6cd3ae1 53dc52c 6cd3ae1 53dc52c 6fb7573 6cd3ae1 6fb7573 6cd3ae1 53dc52c 6cd3ae1 6fb7573 6cd3ae1 6fb7573 53dc52c 6fb7573 53dc52c 6fb7573 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 | 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';
@ApiTags('Scraper')
@Controller('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 βββββ
@Post('maintenance/expire')
@UseGuards(AdminKeyGuard)
@ApiSecurity('admin-key')
@ApiOperation({ 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 βββββββ
@Post('maintenance/refresh')
@UseGuards(AdminKeyGuard)
@ApiSecurity('admin-key')
@ApiOperation({ 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 ββ
@Post('maintenance/purge-jobs')
@UseGuards(AdminKeyGuard)
@ApiSecurity('admin-key')
@ApiOperation({
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.',
})
@ApiQuery({ name: 'confirm', required: false })
async purgeJobs(@Query('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 βββββββββββββββββββββββββββββββββ
@Post('maintenance/verify-links')
@UseGuards(AdminKeyGuard)
@ApiSecurity('admin-key')
@ApiOperation({
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.',
})
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'force', required: false, type: Boolean })
async verifyLinks(@Query('limit') limit?: number, @Query('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 βββββββ
@Get('maintenance/link-health')
@UseGuards(AdminKeyGuard)
@ApiSecurity('admin-key')
@ApiOperation({
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.',
})
@ApiQuery({ name: 'status', required: false, enum: ['dead', 'live', 'unknown', 'pending', 'all'] })
@ApiQuery({ name: 'limit', required: false, type: Number })
async linkHealth(@Query('status') status?: string, @Query('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) ββββ
@Post('maintenance/verify-url')
@UseGuards(AdminKeyGuard)
@ApiSecurity('admin-key')
@ApiOperation({
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() 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 ββββββββββββββββββββββββββββ
@Post('maintenance/backfill-salaries')
@UseGuards(AdminKeyGuard)
@ApiSecurity('admin-key')
@ApiOperation({
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 βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
@Post('run')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
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() req: any) {
const userId = req.user.id;
return this.scraperService.runForUser(userId);
}
// βββ POST /scraper/search β Custom query search (async) βββββββββββββββββββββ
@Post('search')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
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() req: any, @Body() 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 ββββββββββββββββββββββ
@Get('search/:jobId')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Poll the status/result of a custom search started via POST /scraper/search' })
getSearchStatus(@Req() req: any, @Param('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 ββββ
@Post('jobs/:jobId/resolve')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
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() req: any, @Param('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 ββββββββββββββββββ
@Get('runs')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({
summary: 'Get scrape run history for logged-in user',
description: 'Shows when scrapes ran, which platforms succeeded/failed, and how many items each found',
})
@ApiQuery({ name: 'limit', required: false, type: Number })
async getMyRuns(@Req() req: any, @Query('limit') limit?: number) {
return this.scraperService.getRunHistory(req.user.id, Number(limit) || 20);
}
// βββ POST /scraper/migrate-legacy ββββββββββββββββββββββββββββββββββββββββββββ
@Post('migrate-legacy')
@UseGuards(AdminKeyGuard)
@ApiSecurity('admin-key')
@ApiOperation({
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.
@Post('run/:userId')
@UseGuards(AdminKeyGuard)
@ApiSecurity('admin-key')
@ApiOperation({ summary: 'Admin: run scraper for a specific user by ID' })
async runScraperForUser(@Param('userId') userId: string) {
return this.scraperService.runForUser(userId);
}
// βββ GET /scraper/runs/:userId βββββββββββββββββββββββββββββββββββββββββββββββ
@Get('runs/:userId')
@UseGuards(AdminKeyGuard)
@ApiSecurity('admin-key')
@ApiOperation({ summary: 'Admin: get scrape run history for a specific user' })
async getUserRuns(@Param('userId') userId: string, @Query('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.
@Get('results')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ 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() 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 };
}
}
|