shirayuki-anime-scraper-api 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/Dockerfile +14 -0
  2. package/LICENSE +24 -0
  3. package/README.md +539 -0
  4. package/config/database.js +37 -0
  5. package/index.js +63 -0
  6. package/models/Episode.js +49 -0
  7. package/models/Schedule.js +50 -0
  8. package/package.json +46 -0
  9. package/routes/anime-list.js +67 -0
  10. package/routes/episodeStream.js +64 -0
  11. package/routes/genre.js +67 -0
  12. package/routes/home.js +30 -0
  13. package/routes/monthly.js +37 -0
  14. package/routes/schedule.js +174 -0
  15. package/routes/search.js +79 -0
  16. package/routes/top10.js +37 -0
  17. package/routes/weekly.js +37 -0
  18. package/save.txt +431 -0
  19. package/scrapeanime/A-Z/AnimeList/filter.js +43 -0
  20. package/scrapeanime/A-Z/Genre/genre.js +42 -0
  21. package/scrapeanime/AnimeDetails/animedetails.js +73 -0
  22. package/scrapeanime/Browse/Search/search.js +119 -0
  23. package/scrapeanime/Browse/Suggestion/suggestion.js +50 -0
  24. package/scrapeanime/Leaderboard/Monthly/scrapeHiAnimeMonthlyTop10.js +137 -0
  25. package/scrapeanime/Leaderboard/Top/scrapeHiAnimeTop10.js +125 -0
  26. package/scrapeanime/Leaderboard/Weekly/scrapeHiAnimeWeeklyTop10.js +188 -0
  27. package/scrapeanime/Schedule/schedule.js +174 -0
  28. package/scrapeanime/SingleEpisode/scrapeSingleEpisode.js +496 -0
  29. package/scrapeanime/homepage/latest/latest.js +118 -0
  30. package/scrapeanime/homepage/most_favorite/mostFavorite.js +55 -0
  31. package/scrapeanime/homepage/most_popular/mostPopular.js +55 -0
  32. package/scrapeanime/homepage/recently_updated/recentlyUpdated.js +56 -0
  33. package/scrapeanime/homepage/scrapeAnimeDetails.js +128 -0
  34. package/scrapeanime/homepage/scrapehomepage.js +2 -0
  35. package/scrapeanime/homepage/scrapeservice.js +158 -0
  36. package/scrapeanime/homepage/slider/slider.js +151 -0
  37. package/scrapeanime/homepage/top_airing/topAiring.js +55 -0
  38. package/scrapeanime/homepage/trending/trending.js +59 -0
  39. package/service/scraperService.js +38 -0
@@ -0,0 +1,118 @@
1
+ export default function scrapeLatest($, resolveUrl, source) {
2
+ const items = [];
3
+
4
+ const selectors = ['.flw-item', '.film-list .item', '.block_area-content .flw-item', '.film_list-wrap .flw-item'];
5
+
6
+ for (const sel of selectors) {
7
+ const found = $(sel);
8
+ if (!found || !found.length) continue;
9
+
10
+ found.slice(0, 15).each((i, el) => {
11
+ const el$ = $(el);
12
+
13
+ let title = el$.find('.film-name a').attr('title') ||
14
+ el$.find('.film-name a').text() ||
15
+ el$.find('.dynamic-name').attr('data-jname') ||
16
+ el$.find('h3 a').text() ||
17
+ el$.find('.film-detail .film-name a').text() ||
18
+ el$.find('a').attr('title') || null;
19
+ if (title) title = title.trim();
20
+
21
+ let href = el$.find('.film-name a').attr('href') ||
22
+ el$.find('.film-detail .film-name a').attr('href') ||
23
+ el$.find('a').first().attr('href') || '';
24
+ href = href ? resolveUrl(href) : null;
25
+
26
+ let image = null;
27
+ const imgEl = el$.find('.film-poster img, img').first();
28
+ if (imgEl && imgEl.length) {
29
+ image = imgEl.attr('data-src') ||
30
+ imgEl.attr('data-lazy') ||
31
+ imgEl.attr('src') ||
32
+ imgEl.attr('data-original') || null;
33
+ }
34
+ if (image) image = resolveUrl(image);
35
+
36
+ let subtitles = null;
37
+ const subEl = el$.find('.tick-item.tick-sub').first();
38
+ if (subEl.length) {
39
+ const subText = subEl.text().trim();
40
+ const subMatch = subText.match(/(\d+)/);
41
+ subtitles = subMatch ? subMatch[1] : null;
42
+ }
43
+
44
+ let dubbed = null;
45
+ const dubEl = el$.find('.tick-item.tick-dub').first();
46
+ if (dubEl.length) {
47
+ const dubText = dubEl.text().trim();
48
+ const dubMatch = dubText.match(/(\d+)/);
49
+ dubbed = dubMatch ? dubMatch[1] : null;
50
+ }
51
+
52
+ let episodes = null;
53
+
54
+ let episodeEl = el$.find('.fdb-type').first();
55
+
56
+ if (!episodeEl.length) {
57
+ episodeEl = el$.find('.fd-bar .fdb-type').first();
58
+ }
59
+ if (!episodeEl.length) {
60
+ episodeEl = el$.find('.film-detail .fd-bar .fdb-type').first();
61
+ }
62
+ if (!episodeEl.length) {
63
+ episodeEl = el$.find('*').filter((i, elem) => {
64
+ const text = $(elem).text().trim().toLowerCase();
65
+ return text.match(/ep\s*\d+/i) && $(elem).children().length === 0;
66
+ }).first();
67
+ }
68
+
69
+ if (episodeEl.length) {
70
+ const episodeText = episodeEl.text().trim();
71
+ const epMatch = episodeText.match(/EP\s*(\d+)/i);
72
+
73
+ if (epMatch) {
74
+ episodes = epMatch[1];
75
+ } else if (episodeText.toLowerCase().includes('movie')) {
76
+ episodes = "Movie";
77
+ } else if (episodeText.toLowerCase().includes('ova')) {
78
+ episodes = "OVA";
79
+ } else if (episodeText.toLowerCase().includes('special')) {
80
+ episodes = "Special";
81
+ } else {
82
+ const numMatch = episodeText.match(/(\d+)/);
83
+ if (numMatch) {
84
+ episodes = numMatch[1];
85
+ }
86
+ }
87
+ }
88
+
89
+ if (!episodes && (subtitles || dubbed)) {
90
+ if (subtitles && dubbed && subtitles === dubbed) {
91
+ episodes = subtitles;
92
+ } else if (subtitles) {
93
+ episodes = subtitles;
94
+ } else if (dubbed) {
95
+ episodes = dubbed;
96
+ }
97
+ }
98
+
99
+ if (title || href) {
100
+ const item = {
101
+ title: title || null,
102
+ href: href || null,
103
+ image: image || null,
104
+ subtitles: subtitles || null,
105
+ dubbed: dubbed || null,
106
+ episodes: episodes || null,
107
+ source,
108
+ section: 'latest'
109
+ };
110
+ items.push(item);
111
+ }
112
+ });
113
+
114
+ if (items.length) break;
115
+ }
116
+
117
+ return items;
118
+ }
@@ -0,0 +1,55 @@
1
+ export default function scrapeMostFavorite($, resolveUrl, source) {
2
+ const items = [];
3
+
4
+ $('div.anif-block').each((i, block) => {
5
+ const block$ = $(block);
6
+ const header = block$.find('.anif-block-header').text() || '';
7
+ if (!/most\s*favorite/i.test(header)) return;
8
+
9
+ block$.find('.anif-block-ul ul.ulclear > li').slice(0, 6).each((j, li) => {
10
+ const el$ = $(li);
11
+ const a = el$.find('h3.film-name a').first();
12
+ let href = a.attr('href') || el$.find('a').first().attr('href') || '';
13
+ href = href ? resolveUrl(href) : null;
14
+
15
+ let title = a.attr('title') || a.attr('data-jname') || a.text() || null;
16
+ if (title) title = title.trim();
17
+
18
+ let img = null;
19
+ const poster = el$.find('.film-poster').first();
20
+ if (poster && poster.length) {
21
+ const imgEl = poster.find('img').first();
22
+ if (imgEl && imgEl.length) {
23
+ img = imgEl.attr('data-src') || imgEl.attr('data-lazy') || imgEl.attr('src') || imgEl.attr('data-original') || null;
24
+ }
25
+ if (!img) {
26
+ const style = poster.attr('style') || poster.find('a').attr('style') || '';
27
+ const m = /url\(['"]?(.*?)['"]?\)/.exec(style);
28
+ if (m && m[1]) img = m[1];
29
+ }
30
+ }
31
+ if (img) img = resolveUrl(img);
32
+
33
+ const dubText = el$.find('.tick .tick-item.tick-dub').text() || el$.find('.tick-item.tick-dub').text() || '';
34
+ const subText = el$.find('.tick .tick-item.tick-sub').text() || el$.find('.tick-item.tick-sub').text() || '';
35
+ const dub = (dubText || '').toString().replace(/[,\s"']/g, '').match(/(\d+)/);
36
+ const sub = (subText || '').toString().replace(/[,\s"']/g, '').match(/(\d+)/);
37
+
38
+ const fdi = el$.find('.fdi-item').text() || el$.find('.fd-infor .fdi-item').text() || '';
39
+ const tv = /\bTV\b/i.test(fdi);
40
+
41
+ items.push({
42
+ title: title || null,
43
+ href: href || null,
44
+ image: img || null,
45
+ dub: dub ? parseInt(dub[1], 10) : null,
46
+ sub: sub ? parseInt(sub[1], 10) : null,
47
+ tv: !!tv,
48
+ source,
49
+ section: 'most_favorite',
50
+ });
51
+ });
52
+ });
53
+
54
+ return items;
55
+ }
@@ -0,0 +1,55 @@
1
+ export default function scrapeMostPopular($, resolveUrl, source) {
2
+ const items = [];
3
+
4
+ $('div.anif-block').each((i, block) => {
5
+ const block$ = $(block);
6
+ const header = block$.find('.anif-block-header').text() || '';
7
+ if (!/most\s*popular/i.test(header)) return;
8
+
9
+ block$.find('.anif-block-ul ul.ulclear > li').slice(0, 6).each((j, li) => {
10
+ const el$ = $(li);
11
+ const a = el$.find('h3.film-name a').first();
12
+ let href = a.attr('href') || el$.find('a').first().attr('href') || '';
13
+ href = href ? resolveUrl(href) : null;
14
+
15
+ let title = a.attr('title') || a.attr('data-jname') || a.text() || null;
16
+ if (title) title = title.trim();
17
+
18
+ let img = null;
19
+ const poster = el$.find('.film-poster').first();
20
+ if (poster && poster.length) {
21
+ const imgEl = poster.find('img').first();
22
+ if (imgEl && imgEl.length) {
23
+ img = imgEl.attr('data-src') || imgEl.attr('data-lazy') || imgEl.attr('src') || imgEl.attr('data-original') || null;
24
+ }
25
+ if (!img) {
26
+ const style = poster.attr('style') || poster.find('a').attr('style') || '';
27
+ const m = /url\(['"]?(.*?)['"]?\)/.exec(style);
28
+ if (m && m[1]) img = m[1];
29
+ }
30
+ }
31
+ if (img) img = resolveUrl(img);
32
+
33
+ const dubText = el$.find('.tick .tick-item.tick-dub').text() || el$.find('.tick-item.tick-dub').text() || '';
34
+ const subText = el$.find('.tick .tick-item.tick-sub').text() || el$.find('.tick-item.tick-sub').text() || '';
35
+ const dub = (dubText || '').toString().replace(/[,\s"']/g, '').match(/(\d+)/);
36
+ const sub = (subText || '').toString().replace(/[,\s"']/g, '').match(/(\d+)/);
37
+
38
+ const fdi = el$.find('.fdi-item').text() || el$.find('.fd-infor .fdi-item').text() || '';
39
+ const tv = /\bTV\b/i.test(fdi);
40
+
41
+ items.push({
42
+ title: title || null,
43
+ href: href || null,
44
+ image: img || null,
45
+ dub: dub ? parseInt(dub[1], 10) : null,
46
+ sub: sub ? parseInt(sub[1], 10) : null,
47
+ tv: !!tv,
48
+ source,
49
+ section: 'most_popular',
50
+ });
51
+ });
52
+ });
53
+
54
+ return items;
55
+ }
@@ -0,0 +1,56 @@
1
+ export default function scrapeRecentlyUpdated($, resolveUrl, source) {
2
+ const items = [];
3
+
4
+ $('div.widget').each((i, widget) => {
5
+ const w$ = $(widget);
6
+ const title = w$.find('.widget-title .title, .widget-title h1.title').text() || w$.find('.widget-title').text() || '';
7
+ if (!/recently\s*updated/i.test(title)) return;
8
+
9
+ w$.find('.film-list .item').slice(0, 15).each((j, item) => {
10
+ const el$ = $(item);
11
+ const posterA = el$.find('a.poster').first();
12
+ const nameA = el$.find('a.name').first();
13
+
14
+ let href = posterA.attr('href') || nameA.attr('href') || '';
15
+ href = href ? resolveUrl(href) : null;
16
+
17
+ let titleText = nameA.attr('data-title') || nameA.attr('data-jtitle') || nameA.text() || posterA.attr('data-title') || null;
18
+ if (titleText) titleText = titleText.trim();
19
+
20
+ let img = null;
21
+ const imgEl = posterA.find('img').first();
22
+ if (imgEl && imgEl.length) img = imgEl.attr('data-src') || imgEl.attr('src') || imgEl.attr('data-lazy') || null;
23
+ if (!img) {
24
+ const style = posterA.attr('style') || posterA.find('div').attr('style') || '';
25
+ const m = /url\(['"]?(.*?)['"]?\)/.exec(style);
26
+ if (m && m[1]) img = m[1];
27
+ }
28
+ if (img) img = resolveUrl(img);
29
+
30
+ let episode = null;
31
+ let audio = null;
32
+ const status = el$.find('.status').first();
33
+ if (status && status.length) {
34
+ const epText = status.find('.ep').text() || status.find('.epi').text() || '';
35
+ const epMatch = (epText || '').toString().match(/(\d+)/);
36
+ if (epMatch) episode = parseInt(epMatch[1], 10);
37
+
38
+ const subEl = status.find('.sub').first();
39
+ const dubEl = status.find('.dub').first();
40
+ if (subEl && subEl.length) audio = 'sub';
41
+ else if (dubEl && dubEl.length) audio = 'dub';
42
+ else {
43
+ const sText = status.text() || '';
44
+ if (/\bSUB\b/i.test(sText)) audio = 'sub';
45
+ else if (/\bDUB\b/i.test(sText)) audio = 'dub';
46
+ }
47
+ }
48
+
49
+ if (href || titleText) {
50
+ items.push({ title: titleText || null, href: href || null, image: img || null, episode: episode, source, section: 'recently_updated' });
51
+ }
52
+ });
53
+ });
54
+
55
+ return items;
56
+ }
@@ -0,0 +1,128 @@
1
+ import { fetchAndLoad } from '../../service/scraperService.js';
2
+
3
+ export async function scrapeAnimeDetails(animeUrl) {
4
+ if (!animeUrl) return null;
5
+
6
+ try {
7
+ const $ = await fetchAndLoad(animeUrl);
8
+
9
+ const details = {
10
+ description: null,
11
+ synonyms: null,
12
+ aired: null,
13
+ premiered: null,
14
+ duration: null,
15
+ status: null,
16
+ malScore: null,
17
+ genres: [],
18
+ studios: [],
19
+ producers: []
20
+ };
21
+
22
+ $('.anisc-info .item').each((i, item) => {
23
+ const $item = $(item);
24
+ const label = $item.find('.item-head').text().trim().toLowerCase();
25
+ const content = $item.find('.name, .text').text().trim();
26
+
27
+ switch (label) {
28
+ case 'synonyms:':
29
+ case 'japanese:':
30
+ if (content && content !== 'N/A') {
31
+ details.synonyms = content;
32
+ }
33
+ break;
34
+ case 'aired:':
35
+ if (content && content !== 'N/A') {
36
+ details.aired = content;
37
+ }
38
+ break;
39
+ case 'premiered:':
40
+ if (content && content !== 'N/A') {
41
+ details.premiered = content;
42
+ }
43
+ break;
44
+ case 'duration:':
45
+ if (content && content !== 'N/A') {
46
+ details.duration = content;
47
+ }
48
+ break;
49
+ case 'status:':
50
+ if (content && content !== 'N/A') {
51
+ details.status = content;
52
+ }
53
+ break;
54
+ case 'mal score:':
55
+ case 'score:':
56
+ if (content && content !== 'N/A' && !isNaN(parseFloat(content))) {
57
+ details.malScore = parseFloat(content);
58
+ }
59
+ break;
60
+ case 'genres:':
61
+ $item.find('a').each((j, genreLink) => {
62
+ const genre = $(genreLink).text().trim();
63
+ if (genre) details.genres.push(genre);
64
+ });
65
+ break;
66
+ case 'studios:':
67
+ $item.find('a').each((j, studioLink) => {
68
+ const studio = $(studioLink).text().trim();
69
+ if (studio) details.studios.push(studio);
70
+ });
71
+ break;
72
+ case 'producers:':
73
+ $item.find('a').each((j, producerLink) => {
74
+ const producer = $(producerLink).text().trim();
75
+ if (producer) details.producers.push(producer);
76
+ });
77
+ break;
78
+ }
79
+ });
80
+
81
+ const descriptionSelectors = [
82
+ '.film-description .text',
83
+ '.anisc-detail .film-description .text',
84
+ '.anime-synopsis .text',
85
+ '.description .text',
86
+ '.overview .text',
87
+ '[data-content="description"]',
88
+ '.anisc-info .item .text'
89
+ ];
90
+
91
+ for (const selector of descriptionSelectors) {
92
+ const descElement = $(selector);
93
+ if (descElement.length && descElement.text().trim()) {
94
+ let desc = descElement.text().trim();
95
+ // Clean up common unwanted text
96
+ desc = desc.replace(/^(Description|Synopsis|Overview):\s*/i, '');
97
+ if (desc && desc.length > 50) {
98
+ details.description = desc;
99
+ break;
100
+ }
101
+ }
102
+ }
103
+
104
+ if (!details.malScore) {
105
+ const scoreText = $('.film-stats .tick .tick-pg, .score').text();
106
+ const scoreMatch = scoreText.match(/(\d+\.?\d*)/);
107
+ if (scoreMatch) {
108
+ details.malScore = parseFloat(scoreMatch[1]);
109
+ }
110
+ }
111
+
112
+ if (details.genres.length === 0) {
113
+ $('.film-stats .item .name a, .genres a').each((i, genreEl) => {
114
+ const genre = $(genreEl).text().trim();
115
+ if (genre && !details.genres.includes(genre)) {
116
+ details.genres.push(genre);
117
+ }
118
+ });
119
+ }
120
+
121
+ return details;
122
+ } catch (error) {
123
+ console.error(`Error scraping anime details from ${animeUrl}:`, error.message);
124
+ return null;
125
+ }
126
+ }
127
+
128
+ export default { scrapeAnimeDetails };
@@ -0,0 +1,2 @@
1
+ export { default } from './scrapeservice.js';
2
+ export { scrapeHomepage } from './scrapeservice.js';
@@ -0,0 +1,158 @@
1
+ import scrapeTopAiring from './top_airing/topAiring.js';
2
+ import scrapeMostPopular from './most_popular/mostPopular.js';
3
+ import scrapeMostFavorite from './most_favorite/mostFavorite.js';
4
+ import scrapeRecentlyUpdated from './recently_updated/recentlyUpdated.js';
5
+ import scrapeSlider from './slider/slider.js';
6
+ import scrapeLatest from './latest/latest.js';
7
+ import scrapeTrending from './trending/trending.js';
8
+ import { fetchAndLoad, resolveUrlFactory } from '../../service/scraperService.js';
9
+
10
+ const cache = new Map();
11
+ const CACHE_TTL = 2 * 60 * 1000;
12
+
13
+ function getCacheKey(url, includeDetails) {
14
+ return `${url}_${includeDetails}`;
15
+ }
16
+
17
+ function getFromCache(key) {
18
+ const cached = cache.get(key);
19
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
20
+ return cached.data;
21
+ }
22
+ return null;
23
+ }
24
+
25
+ function setCache(key, data) {
26
+ cache.set(key, {
27
+ data,
28
+ timestamp: Date.now()
29
+ });
30
+
31
+ if (cache.size > 10) {
32
+ const oldestKeys = Array.from(cache.keys()).slice(0, 5);
33
+ oldestKeys.forEach(key => cache.delete(key));
34
+ }
35
+ }
36
+
37
+ async function scrapeSite(url, base, source, includeDetails = false) {
38
+ const cacheKey = getCacheKey(url, includeDetails);
39
+ const cached = getFromCache(cacheKey);
40
+
41
+ if (cached) {
42
+ return cached;
43
+ }
44
+
45
+ const $ = await fetchAndLoad(url);
46
+ const resolveUrl = resolveUrlFactory(base);
47
+
48
+ const items = [];
49
+
50
+ try {
51
+ const top = await scrapeTopAiring($, resolveUrl, source, includeDetails);
52
+ if (top && top.length) items.push(...top);
53
+ } catch (e) {
54
+ }
55
+
56
+ try {
57
+ const popular = await scrapeMostPopular($, resolveUrl, source, includeDetails);
58
+ if (popular && popular.length) items.push(...popular);
59
+ } catch (e) {
60
+ }
61
+
62
+ try {
63
+ const fav = await scrapeMostFavorite($, resolveUrl, source, includeDetails);
64
+ if (fav && fav.length) items.push(...fav);
65
+ } catch (e) {
66
+ }
67
+
68
+ try {
69
+ if (source === '123animehub') {
70
+ const recent = scrapeRecentlyUpdated($, resolveUrl, source);
71
+ if (recent && recent.length) items.push(...recent);
72
+ }
73
+ } catch (e) {
74
+ }
75
+
76
+ try {
77
+ if (source !== '123animehub') {
78
+ const slider = scrapeSlider($, resolveUrl, source);
79
+ if (slider && slider.length) items.push(...slider);
80
+ }
81
+ } catch (e) {
82
+ }
83
+
84
+ try {
85
+ const latest = scrapeLatest($, resolveUrl, source);
86
+ if (latest && latest.length) items.push(...latest);
87
+ } catch (e) {
88
+ }
89
+
90
+ try {
91
+ const trending = await scrapeTrending($, resolveUrl, source, includeDetails);
92
+ if (trending && trending.length) items.push(...trending);
93
+ } catch (e) {
94
+ }
95
+
96
+ const seen = new Set();
97
+ const deduped = [];
98
+ for (const it of items) {
99
+ const key = (it.href || it.title || it.image || '').toString().toLowerCase();
100
+ if (!key) continue;
101
+ if (!seen.has(key)) {
102
+ seen.add(key);
103
+ deduped.push(it);
104
+ }
105
+ }
106
+
107
+ setCache(cacheKey, deduped);
108
+
109
+ return deduped;
110
+ }
111
+
112
+ export async function scrapeHomepage(includeDetails = false) {
113
+ const tasks = [
114
+ scrapeSite('https://hianime.to/home', 'https://hianime.to', 'hianime', includeDetails),
115
+ scrapeSite('https://123animehub.cc/home', 'https://123animehub.cc', '123animehub', includeDetails),
116
+ ];
117
+
118
+ const results = await Promise.allSettled(tasks);
119
+
120
+ const combined = [];
121
+ const errors = [];
122
+
123
+ if (results[0].status === 'fulfilled') combined.push(...results[0].value);
124
+ else errors.push({ source: 'hianime', error: String(results[0].reason) });
125
+
126
+ if (results[1].status === 'fulfilled') combined.push(...results[1].value);
127
+ else errors.push({ source: '123animehub', error: String(results[1].reason) });
128
+
129
+ const seen = new Set();
130
+ const deduped = [];
131
+ for (const it of combined) {
132
+ const key = (it.href || it.title || it.image || '').toString().toLowerCase();
133
+ if (!key) continue;
134
+ if (!seen.has(key)) {
135
+ seen.add(key);
136
+ deduped.push(it);
137
+ }
138
+ }
139
+
140
+ const sectionTotals = {};
141
+ for (const it of deduped) {
142
+ const sec = it.section || 'unknown';
143
+ sectionTotals[sec] = (sectionTotals[sec] || 0) + 1;
144
+ }
145
+
146
+ const sectionCounters = {};
147
+ const indexed = deduped.map((item) => {
148
+ const sec = item.section || 'unknown';
149
+ sectionCounters[sec] = (sectionCounters[sec] || 0) + 1;
150
+ return { index: sectionCounters[sec], ...item };
151
+ });
152
+
153
+ const result = { success: true, data: indexed, total: indexed.length, sectionTotals };
154
+ if (errors.length) result.errors = errors;
155
+ return result;
156
+ }
157
+
158
+ export default scrapeHomepage;