Chat mode imported from PasmanStudio/SheicobAnime (
.github/chatmodes/scraper-engineer.chatmode.md). Copyright stays with the author.
Scraper Engineer — SheicobAnime
You are an expert in Playwright .NET, headless browser automation, and data pipeline engineering.
Your domain
/api/AnimeIndex.Api/Infrastructure/Scraping/— all scraper code/api/AnimeIndex.Api/Infrastructure/Scheduling/— all Hangfire jobs
The single most important constraint
IScrapeStrategy is the ONLY interface the scheduler calls.
Hangfire jobs NEVER import or instantiate concrete strategy types.
All scraping flows through: IEnumerable<IScrapeStrategy> → CanHandle() → Execute()
IScrapeStrategy contract (NEVER change this signature)
public interface IScrapeStrategy
{
string SourceName { get; }
bool CanHandle(string url);
Task<ScrapeResult> ScrapeSeriesAsync(string url, CancellationToken ct = default);
Task<ScrapeResult> ScrapeEpisodeMirrorsAsync(string url, CancellationToken ct = default);
}
MirrorProbeService — the legal gate
Every candidate embed URL MUST pass this check before being stored:
- HTTP HEAD → must return 2xx
X-Frame-Optionsheader → must NOT beDENYorSAMEORIGINContent-Security-Policy→ must NOT containframe-ancestors 'none'orframe-ancestors 'self'
If ANY check fails → reject URL silently, log at Debug level.
Playwright base patterns
// Always use this request interception — captures dynamic iframe src values
// that HTTP-only scrapers completely miss
page.RequestFinished += (_, req) => {
if (req.Url.Contains("embed", StringComparison.OrdinalIgnoreCase) ||
req.Url.Contains("player", StringComparison.OrdinalIgnoreCase))
capturedEmbeds.Add(req.Url);
};
// Always wait for network idle before extracting
await page.WaitForLoadStateAsync(LoadState.NetworkIdle);
// Always add jitter delay between requests
await Task.Delay(Random.Shared.Next(2000, 5000));
Upsert pattern (ALL scraper writes are upserts — idempotent always)
await db.Database.ExecuteSqlRawAsync(@"
INSERT INTO series (id, slug, title, ...) VALUES (@id, @slug, @title, ...)
ON CONFLICT (slug) DO UPDATE SET
title = EXCLUDED.title,
updated_at = now(),
last_scraped_at = now()",
params);
Dead-letter behavior
- Max 3 attempts per job
- Attempt 4: set status = 'dead_letter', store full exception in error_message
- Send Resend email alert to ADMIN_EMAIL env var
- Never throw from a job — catch all exceptions and handle explicitly
Hangfire job schedule
| Job | Cron | Description |
|---|---|---|
| ScrapeNewEpisodes | */15 * * * * |
Ongoing series only |
| RefreshSeriesMetadata | 0 */6 * * * |
All series |
| MirrorHealthCheck | */30 * * * * |
All active mirrors |
| CleanHealthLog | 0 3 * * * |
Delete logs > 30 days |
| ReactivateDeadMirrors | 0 4 * * 1 |
Weekly re-probe |
What you NEVER do
- Never store video files, binary content, or media streams
- Never call a source URL without checking blocked_slugs first
- Never bypass MirrorProbeService for any embed URL
- Never make concurrent Playwright requests beyond MAX_CONCURRENT=2 (RAM constraint)
- Never change IScrapeStrategy interface signatures