scraply 1.0.13 → 1.0.15
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.
- package/.github/workflows/npm-publish.yml +2 -2
- package/package.json +1 -1
- package/readme.md +4 -12
- package/src/defaultConfig.js +4 -12
- package/src/scraply.js +4 -26
- package/src/utils/crawl/fileOperations.js +1 -1
- package/src/utils/crawl/url/fetch.js +26 -9
- package/src/utils/crawl/url/handlers.js +31 -11
- package/src/utils/crawl/url/processor.js +6 -7
- package/src/utils/format/formatData.js +0 -11
|
@@ -16,11 +16,11 @@ jobs:
|
|
|
16
16
|
- name: Set up Node.js
|
|
17
17
|
uses: actions/setup-node@v3
|
|
18
18
|
with:
|
|
19
|
-
node-version: '
|
|
19
|
+
node-version: '22'
|
|
20
20
|
registry-url: 'https://registry.npmjs.org/'
|
|
21
21
|
|
|
22
22
|
- name: Install dependencies
|
|
23
|
-
run: npm
|
|
23
|
+
run: npm ci
|
|
24
24
|
|
|
25
25
|
- name: Publish to npm
|
|
26
26
|
run: npm publish
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -76,6 +76,9 @@ CRAWLER: {
|
|
|
76
76
|
MAX_RETRIES: 2,
|
|
77
77
|
CRAWL_DELAY_MS: 200,
|
|
78
78
|
CRAWL_ERROR_RETRY_DELAY_MS: 800,
|
|
79
|
+
CRAWL_RATE_LIMIT_FALLBACK_DELAY_MS: 60000,
|
|
80
|
+
EXIT_CODE_RATE_LIMIT: 10,
|
|
81
|
+
EXIT_ON_RATE_LIMIT: true
|
|
79
82
|
},
|
|
80
83
|
|
|
81
84
|
DATA_FORMATTER: {
|
|
@@ -85,17 +88,6 @@ DATA_FORMATTER: {
|
|
|
85
88
|
'mobile': 'mobile.json',
|
|
86
89
|
'*': 'general.json'
|
|
87
90
|
},
|
|
88
|
-
}
|
|
89
|
-
HARD_CODED_LINKS: [
|
|
90
|
-
{
|
|
91
|
-
file_name: 'hc-links.json',
|
|
92
|
-
data: [
|
|
93
|
-
{
|
|
94
|
-
"url": "https://custom-link.com",
|
|
95
|
-
"content": "That's a custom link content, you can add as many as you want."
|
|
96
|
-
},
|
|
97
|
-
]
|
|
98
|
-
}
|
|
99
|
-
]
|
|
91
|
+
}
|
|
100
92
|
}
|
|
101
93
|
```
|
package/src/defaultConfig.js
CHANGED
|
@@ -41,6 +41,9 @@ export const DEFAULT_CONFIG = {
|
|
|
41
41
|
MAX_RETRIES: 2,
|
|
42
42
|
CRAWL_DELAY_MS: 200,
|
|
43
43
|
CRAWL_ERROR_RETRY_DELAY_MS: 800,
|
|
44
|
+
CRAWL_RATE_LIMIT_FALLBACK_DELAY_MS: 60000,
|
|
45
|
+
EXIT_CODE_RATE_LIMIT: 10,
|
|
46
|
+
EXIT_ON_RATE_LIMIT: true
|
|
44
47
|
},
|
|
45
48
|
|
|
46
49
|
DATA_FORMATTER: {
|
|
@@ -50,17 +53,6 @@ export const DEFAULT_CONFIG = {
|
|
|
50
53
|
'mobile': 'mobile.json',
|
|
51
54
|
'*': 'general.json'
|
|
52
55
|
},
|
|
53
|
-
}
|
|
54
|
-
HARD_CODED_LINKS: [
|
|
55
|
-
// {
|
|
56
|
-
// file_name: 'hc-links.json',
|
|
57
|
-
// data: [
|
|
58
|
-
// {
|
|
59
|
-
// "url": "https://custom-link.com",
|
|
60
|
-
// "content": "That's a custom link content, you can add as many as you want."
|
|
61
|
-
// },
|
|
62
|
-
// ]
|
|
63
|
-
// }
|
|
64
|
-
]
|
|
56
|
+
}
|
|
65
57
|
}
|
|
66
58
|
};
|
package/src/scraply.js
CHANGED
|
@@ -2,11 +2,9 @@ import { loadConfig } from './loadConfig.js';
|
|
|
2
2
|
import { normalizeURL } from './utils/crawl/url/normalize.js';
|
|
3
3
|
import { loadJSON, saveQueue, deleteDataFiles, deleteUntrackedFiles } from './utils/crawl/fileOperations.js';
|
|
4
4
|
import { processURL } from './utils/crawl/url/processor.js';
|
|
5
|
-
import { formatData, saveSortedFormattedJSON
|
|
6
|
-
import path from 'node:path';
|
|
5
|
+
import { formatData, saveSortedFormattedJSON } from './utils/format/formatData.js';
|
|
7
6
|
|
|
8
7
|
let urlData = [];
|
|
9
|
-
let urlMetadata = {};
|
|
10
8
|
let CONFIG = {};
|
|
11
9
|
let generatedFiles = new Set(); // Track files generated in the current crawl session.
|
|
12
10
|
|
|
@@ -18,7 +16,7 @@ const init = () => {
|
|
|
18
16
|
|
|
19
17
|
CONFIG.CRAWLER.INITIAL_URLS.forEach(url => {
|
|
20
18
|
const normalizedURL = normalizeURL(url);
|
|
21
|
-
urlData.push({ url: normalizedURL, file: null, status: null, error: null });
|
|
19
|
+
urlData.push({ url: normalizedURL, file: null, status: null, error: null, referrerUrl: null, depth: 0 });
|
|
22
20
|
});
|
|
23
21
|
saveQueue(urlData);
|
|
24
22
|
} else { // If the queue is not empty
|
|
@@ -28,12 +26,10 @@ const init = () => {
|
|
|
28
26
|
|
|
29
27
|
// Reset data for a fresh crawl.
|
|
30
28
|
urlData = [];
|
|
31
|
-
urlMetadata = {};
|
|
32
29
|
|
|
33
30
|
// Delete everything except CONFIG.DATA_FORMATTER.FORMATTED_PATH, so that the formatted data is always preserved until the crawler really finalizes the data.
|
|
34
31
|
deleteDataFiles(CONFIG.CRAWLER.QUEUE_PATH);
|
|
35
32
|
deleteDataFiles(CONFIG.CRAWLER.CRAWLED_PATH);
|
|
36
|
-
deleteDataFiles(CONFIG.DATA_FORMATTER.ERROR_REPORT_PATH);
|
|
37
33
|
|
|
38
34
|
init();
|
|
39
35
|
} else { // If there are URLs that haven't been processed yet, resume from the queue.
|
|
@@ -59,7 +55,7 @@ const start = async () => {
|
|
|
59
55
|
let fileNumber = urlData.filter(entry => entry.file).length + 1;
|
|
60
56
|
for await (const entry of urlData) {
|
|
61
57
|
if (!entry.file) {
|
|
62
|
-
const processedFile = await processURL(entry, fileNumber, urlData
|
|
58
|
+
const processedFile = await processURL(entry, fileNumber, urlData);
|
|
63
59
|
if (processedFile) {
|
|
64
60
|
generatedFiles.add(processedFile); // Track the file generated
|
|
65
61
|
}
|
|
@@ -72,7 +68,7 @@ const start = async () => {
|
|
|
72
68
|
const notCrawledUrls = totalUrls - crawledUrls;
|
|
73
69
|
const errorUrls = urlData.filter(entry => entry.error !== null);
|
|
74
70
|
|
|
75
|
-
console.log(`\nCRAWLING COMPLETED! ${crawledUrls} of ${totalUrls} (${notCrawledUrls} not crawled)`);
|
|
71
|
+
console.log(`\nCRAWLING COMPLETED! ${crawledUrls} of ${totalUrls} (${notCrawledUrls} not crawled, ${errorUrls.length} errors)`);
|
|
76
72
|
|
|
77
73
|
// Iterate over all the urlData and save all the url & content to files, categorized by CONFIG.DATA_FORMATTER.CATEGORISED_PATHS. Exclude the URLs that match the patterns in CONFIG.DATA_FORMATTER.EXCLUDED_PATTERNS. Save in CONFIG.DATA_FORMATTER.FORMATTED_PATH.
|
|
78
74
|
console.log(`\nFORMATTING DATA...`);
|
|
@@ -104,24 +100,6 @@ const start = async () => {
|
|
|
104
100
|
};
|
|
105
101
|
console.log(`${totalSavedURLs} total saved URLs to ${CONFIG.DATA_FORMATTER.FORMATTED_PATH}`);
|
|
106
102
|
|
|
107
|
-
// Save hardcoded extra links to files.
|
|
108
|
-
const totalHardcodedLinks = await saveHardcodedExtraLinks();
|
|
109
|
-
console.log(`${totalHardcodedLinks} Hardcoded extra links saved to ${CONFIG.DATA_FORMATTER.FORMATTED_PATH}`);
|
|
110
|
-
|
|
111
|
-
// Track the files generated for hardcoded links with full paths.
|
|
112
|
-
CONFIG.DATA_FORMATTER.HARD_CODED_LINKS.forEach(link => {
|
|
113
|
-
const hardcodedFilePath = path.join(CONFIG.DATA_FORMATTER.FORMATTED_PATH, link.file_name);
|
|
114
|
-
generatedFiles.add(hardcodedFilePath); // Add the full path to the set
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
// Error reporting: Save into CONFIG.DATA_FORMATTER.ERROR_REPORT_PATH the URLs that had any error: Save the url, the referrer, status code and error!
|
|
118
|
-
const errorData = errorUrls.map(entry => {
|
|
119
|
-
return { url: entry.url, status: entry.status, error: entry.error };
|
|
120
|
-
});
|
|
121
|
-
saveSortedFormattedJSON(CONFIG.DATA_FORMATTER.ERROR_REPORT_PATH, errorData);
|
|
122
|
-
|
|
123
|
-
console.log(`Errors: ${errorData.length} -> ${CONFIG.DATA_FORMATTER.ERROR_REPORT_PATH}.`);
|
|
124
|
-
|
|
125
103
|
// After formatting data, delete untracked files
|
|
126
104
|
console.log(`\nCLEANING UP UNTRACKED FILES...`);
|
|
127
105
|
deleteUntrackedFiles(CONFIG.DATA_FORMATTER.FORMATTED_PATH, generatedFiles); // Delete files not generated during this crawl
|
|
@@ -11,7 +11,7 @@ export const saveJSON = (filePath, data) => {
|
|
|
11
11
|
|
|
12
12
|
export const saveDataset = (data, fileNumber) => {
|
|
13
13
|
if (!fs.existsSync(CONFIG.CRAWLER.CRAWLED_PATH)) fs.mkdirSync(CONFIG.CRAWLER.CRAWLED_PATH, { recursive: true });
|
|
14
|
-
const filename =
|
|
14
|
+
const filename = path.join(CONFIG.CRAWLER.CRAWLED_PATH, `${fileNumber}.json`);
|
|
15
15
|
fs.writeFileSync(filename, JSON.stringify(data, null, 2), 'utf8');
|
|
16
16
|
return filename;
|
|
17
17
|
};
|
|
@@ -2,24 +2,41 @@ import axios from 'axios';
|
|
|
2
2
|
import { delay } from '../delay.js';
|
|
3
3
|
import { shouldRetry } from './handlers.js';
|
|
4
4
|
|
|
5
|
-
export
|
|
5
|
+
export async function fetchURL(url, retries = 2) {
|
|
6
6
|
try {
|
|
7
|
-
const response = await axios.get(url, {
|
|
8
|
-
|
|
7
|
+
const response = await axios.get(url, {
|
|
8
|
+
timeout: CONFIG.CRAWLER.REQUEST_TIMEOUT,
|
|
9
|
+
maxRedirects: CONFIG.CRAWLER.MAX_REDIRECTS
|
|
10
|
+
});
|
|
9
11
|
|
|
12
|
+
const { 'content-type': contentType } = response.headers;
|
|
13
|
+
|
|
14
|
+
// Validate content type
|
|
10
15
|
if (!CONFIG.CRAWLER.ALLOWED_CONTENT_TYPES.some(type => contentType.includes(type))) {
|
|
11
|
-
return {
|
|
16
|
+
return {
|
|
17
|
+
error: `Content-Type ${contentType} is not allowed.`,
|
|
18
|
+
status: response.status
|
|
19
|
+
};
|
|
12
20
|
};
|
|
13
21
|
|
|
14
|
-
return {
|
|
22
|
+
return {
|
|
23
|
+
data: response.data,
|
|
24
|
+
status: response.status
|
|
25
|
+
};
|
|
15
26
|
} catch (error) {
|
|
16
27
|
if (retries > 0 && shouldRetry(error)) {
|
|
17
|
-
|
|
28
|
+
const retryCount = CONFIG.CRAWLER.MAX_RETRIES - retries + 1;
|
|
29
|
+
console.log(`Retrying (${retryCount}/${CONFIG.CRAWLER.MAX_RETRIES}) -> ${url}`);
|
|
30
|
+
|
|
18
31
|
if (CONFIG.CRAWLER.CRAWL_ERROR_RETRY_DELAY_MS > 0) await delay(CONFIG.CRAWLER.CRAWL_ERROR_RETRY_DELAY_MS);
|
|
32
|
+
|
|
19
33
|
return fetchURL(url, retries - 1);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
console.error(`Failed to fetch ${url} -> ${error.message}`);
|
|
37
|
+
return {
|
|
38
|
+
error: error.message,
|
|
39
|
+
status: error.response?.status
|
|
23
40
|
};
|
|
24
41
|
};
|
|
25
42
|
};
|
|
@@ -1,19 +1,40 @@
|
|
|
1
1
|
import { URL } from 'node:url';
|
|
2
2
|
import { normalizeURL } from './normalize.js';
|
|
3
|
+
import { delay } from '../delay.js';
|
|
3
4
|
|
|
4
5
|
// Handle HTML Status Codes HERE!
|
|
5
|
-
export const shouldRetry = (error) => {
|
|
6
|
+
export const shouldRetry = async (error) => {
|
|
6
7
|
if (!error.response) return true;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
|
|
9
|
+
const { status, headers } = error.response;
|
|
10
|
+
const retryAfter = headers?.['retry-after'];
|
|
11
|
+
const rateLimitReset = headers?.['x-ratelimit-reset'];
|
|
12
|
+
|
|
13
|
+
if (status === 429) {
|
|
14
|
+
let waitTime = null;
|
|
15
|
+
|
|
16
|
+
if (retryAfter) {
|
|
17
|
+
waitTime = isNaN(retryAfter)
|
|
18
|
+
? Math.ceil((new Date(retryAfter).getTime() - Date.now()) / 1000) // HTTP date
|
|
19
|
+
: parseInt(retryAfter, 10); // Seconds
|
|
20
|
+
console.log(`Rate limited. Retrying after ${waitTime} seconds...`);
|
|
21
|
+
} else if (rateLimitReset) {
|
|
22
|
+
waitTime = Math.max(parseInt(rateLimitReset, 10) - Math.floow(Date.now() / 1000), 0);
|
|
23
|
+
console.log(`Rate limited. Retrying after ${waitTime} seconds...`);
|
|
11
24
|
} else {
|
|
12
|
-
|
|
25
|
+
waitTime = CONFIG.CRAWLER.CRAWL_RATE_LIMIT_FALLBACK_DELAY_MS / 1000;
|
|
26
|
+
console.log(`Rate limited. No 'retry-after' or 'x-ratelimit-reset' headers found. Falling back to ${waitTime} seconds...`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (CONFIG.CRAWLER.EXIT_ON_RATE_LIMIT) {
|
|
30
|
+
console.log(`Exiting due to rate limit.`);
|
|
31
|
+
process.exit(CONFIG.CRAWLER.EXIT_CODE_RATE_LIMIT); // GitHub Actions Docker uses values ranged from 0 to 255, so any bigger value will be % 256!
|
|
32
|
+
} else {
|
|
33
|
+
await delay(waitTime * 1000);
|
|
13
34
|
}
|
|
14
|
-
process.exit(10); // GitHub Actions Docker uses values ranged from 0 to 255, so any bigger value will be % 256!
|
|
15
35
|
}
|
|
16
|
-
|
|
36
|
+
|
|
37
|
+
return CONFIG.CRAWLER.RETRY_STATUS_CODES.includes(status); // Retry on the specified status codes
|
|
17
38
|
};
|
|
18
39
|
|
|
19
40
|
const shouldIncludeURL = (url) => {
|
|
@@ -40,7 +61,7 @@ const shouldIncludeURL = (url) => {
|
|
|
40
61
|
}
|
|
41
62
|
};
|
|
42
63
|
|
|
43
|
-
export const enqueueURLs = (urlData,
|
|
64
|
+
export const enqueueURLs = (urlData, $, baseURL, depth) => {
|
|
44
65
|
$('a[href]').each((_, element) => {
|
|
45
66
|
const href = $(element).attr('href');
|
|
46
67
|
if (!href) return;
|
|
@@ -49,8 +70,7 @@ export const enqueueURLs = (urlData, urlMetadata, $, baseURL, referrer, depth) =
|
|
|
49
70
|
const newURL = new URL(href, baseURL).toString();
|
|
50
71
|
const normalizedURL = normalizeURL(newURL);
|
|
51
72
|
if (shouldIncludeURL(normalizedURL) && !urlData.some(entry => entry.url === normalizedURL)) {
|
|
52
|
-
urlData.push({ url: normalizedURL, file: null, status: null, error: null });
|
|
53
|
-
urlMetadata[normalizedURL] = { referrer, depth };
|
|
73
|
+
urlData.push({ url: normalizedURL, file: null, status: null, error: null, referrerUrl: baseURL, depth });
|
|
54
74
|
}
|
|
55
75
|
} catch (error) {
|
|
56
76
|
console.error(`Failed to enqueue URL: ${href} from ${baseURL}: ${error.message}`);
|
|
@@ -5,21 +5,20 @@ import { shouldRetry, enqueueURLs } from './handlers.js';
|
|
|
5
5
|
import { fetchURL } from './fetch.js';
|
|
6
6
|
import { saveDataset, saveQueue } from '../fileOperations.js';
|
|
7
7
|
|
|
8
|
-
export const processURL = async (entry, fileNumber, urlData
|
|
9
|
-
|
|
8
|
+
export const processURL = async (entry, fileNumber, urlData) => {
|
|
9
|
+
const startTime = new Date().getTime();
|
|
10
|
+
const { url, referrer, depth } = entry;
|
|
10
11
|
|
|
12
|
+
if (entry.file || (entry.error && !(await shouldRetry({ response: { status: entry.status } })))) return;
|
|
13
|
+
|
|
11
14
|
console.log(`- ${fileNumber}/${urlData.length} -> ${entry.url}`);
|
|
12
15
|
|
|
13
|
-
const { url } = entry;
|
|
14
|
-
const { referrer, depth } = urlMetadata[url] || { referrer: null, depth: 0 }; // Default depth is 0.
|
|
15
|
-
|
|
16
|
-
const startTime = new Date().getTime();
|
|
17
16
|
try {
|
|
18
17
|
const result = await fetchURL(url, CONFIG.CRAWLER.MAX_RETRIES);
|
|
19
18
|
if (result && result.data) {
|
|
20
19
|
const { data: html, status } = result;
|
|
21
20
|
const $ = cheerio.load(html);
|
|
22
|
-
enqueueURLs(urlData,
|
|
21
|
+
enqueueURLs(urlData, $, url, depth + 1);
|
|
23
22
|
|
|
24
23
|
const content = cleanHTML($);
|
|
25
24
|
const filename = saveDataset({ url, referrerURL: referrer, statusCode: status, depth, content }, fileNumber);
|
|
@@ -50,14 +50,3 @@ export const saveSortedFormattedJSON = (filePath, data) => {
|
|
|
50
50
|
const sortedData = sortData(data, 'url'); // ensure data is sorted before saving
|
|
51
51
|
return fs.writeFileSync(filePath, JSON.stringify(sortedData, null, 2), 'utf8');
|
|
52
52
|
};
|
|
53
|
-
|
|
54
|
-
export const saveHardcodedExtraLinks = async () => {
|
|
55
|
-
const hardcodedLinks = CONFIG.DATA_FORMATTER.HARD_CODED_LINKS;
|
|
56
|
-
|
|
57
|
-
for (const link of hardcodedLinks) {
|
|
58
|
-
const filePath = path.join(CONFIG.DATA_FORMATTER.FORMATTED_PATH, link.file_name);
|
|
59
|
-
saveSortedFormattedJSON(filePath, link.data);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
return hardcodedLinks.reduce((acc, link) => acc + link.data.length, 0); // Total number of links saved
|
|
63
|
-
};
|