dsh-scrape 0.0.1 → 0.1.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.
package/lib/robots.mjs ADDED
@@ -0,0 +1,171 @@
1
+ /**
2
+ * robots.txt:解析 + 「我这个爬虫能不能抓这条路径」的判定。
3
+ *
4
+ * 判定口径照 RFC 9309(与 Google 的公开实现一致):
5
+ * 1) 有精确匹配我这条 UA 的组时只用精确组,忽略 `*`;
6
+ * 2) 组内按**最长路径匹配**胜出;长度相同时 `Allow` 胜;
7
+ * 3) 没有任何规则命中 = 允许;
8
+ * 4) `Crawl-delay` 非标准但常见,读出来当同域最低间隔。
9
+ *
10
+ * 只解析、不推测:看不懂的行进 warnings,不当成结论。
11
+ */
12
+
13
+ /** 本工具的 UA 产品令牌:robots.txt 里写 `User-agent: dsh-scrape` 就能精确指名它。 */
14
+ export const BOT_TOKEN = 'dsh-scrape'
15
+
16
+ /**
17
+ * 解析 robots.txt。
18
+ * @param {string} text robots.txt 原文
19
+ * @returns {{groups:Array<object>,sitemaps:string[],warnings:string[],lineCount:number}} 解析结果
20
+ */
21
+ export function parseRobots(text) {
22
+ const lines = String(text ?? '').replace(/\r\n?/g, '\n').split('\n')
23
+ const groups = []
24
+ const sitemaps = []
25
+ const warnings = []
26
+ let current = null
27
+ let lastWasAgent = false
28
+
29
+ for (let i = 0; i < lines.length; i++) {
30
+ const line = lines[i].replace(/#.*$/, '').trim()
31
+ if (line.length === 0) continue
32
+ const colon = line.indexOf(':')
33
+ if (colon === -1) {
34
+ warnings.push(`第 ${i + 1} 行不是 key:value,已跳过`)
35
+ continue
36
+ }
37
+ const key = line.slice(0, colon).trim().toLowerCase()
38
+ const value = line.slice(colon + 1).trim()
39
+
40
+ if (key === 'user-agent' || key === 'useragent') {
41
+ if (current === null || !lastWasAgent) {
42
+ current = { agents: [], rules: [], crawlDelay: null, line: i + 1 }
43
+ groups.push(current)
44
+ }
45
+ if (value.length > 0) current.agents.push(value.toLowerCase())
46
+ lastWasAgent = true
47
+ continue
48
+ }
49
+
50
+ lastWasAgent = false
51
+ if (key === 'sitemap') { sitemaps.push(value); continue }
52
+ if (key === 'crawl-delay') {
53
+ const seconds = Number(value)
54
+ if (current && Number.isFinite(seconds) && seconds >= 0) current.crawlDelay = seconds
55
+ else warnings.push(`第 ${i + 1} 行的 Crawl-delay 不是数字,已忽略`)
56
+ continue
57
+ }
58
+ if (key === 'allow' || key === 'disallow') {
59
+ if (current === null) {
60
+ warnings.push(`第 ${i + 1} 行的 ${key} 出现在任何 User-agent 之前,已跳过`)
61
+ continue
62
+ }
63
+ if (value.length === 0) continue // `Disallow:` 空值 = 不限制
64
+ current.rules.push({ type: key, path: value, line: i + 1 })
65
+ continue
66
+ }
67
+ if (key === 'host' || key === 'clean-param') continue
68
+ warnings.push(`第 ${i + 1} 行的指令「${key}」不受支持,已忽略`)
69
+ }
70
+
71
+ return { groups, sitemaps, warnings, lineCount: lines.length }
72
+ }
73
+
74
+ /**
75
+ * 一条 robots 路径规则是否命中目标路径(支持 `*` 与结尾 `$`)。
76
+ * @param {string} pattern 规则路径
77
+ * @param {string} target 目标路径(以 / 开头)
78
+ * @returns {boolean} 是否命中
79
+ */
80
+ export function pathMatches(pattern, target) {
81
+ if (!pattern) return false
82
+ let body = pattern
83
+ let anchored = false
84
+ if (body.endsWith('$')) { anchored = true; body = body.slice(0, -1) }
85
+ const escaped = body.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
86
+ try {
87
+ return new RegExp(`^${escaped}${anchored ? '$' : ''}`).test(target)
88
+ } catch {
89
+ return false
90
+ }
91
+ }
92
+
93
+ /**
94
+ * 找出适用于我这个 UA 的组(精确优先,否则 `*`)。
95
+ * @param {object} robots parseRobots 结果
96
+ * @param {string} [userAgent] 完整 UA 或产品令牌
97
+ * @returns {{groups:Array<object>,usedGroup:'exact'|'*'|'none',declared:boolean}} 命中的组
98
+ */
99
+ export function matchingGroups(robots, userAgent = BOT_TOKEN) {
100
+ const ua = String(userAgent).toLowerCase()
101
+ const exact = robots.groups.filter((group) => group.agents.some((agent) => agent !== '*' && ua.includes(agent)))
102
+ if (exact.length > 0) return { groups: exact, usedGroup: 'exact', declared: true }
103
+ const wildcard = robots.groups.filter((group) => group.agents.includes('*'))
104
+ return { groups: wildcard, usedGroup: wildcard.length > 0 ? '*' : 'none', declared: false }
105
+ }
106
+
107
+ /**
108
+ * 判定我这个爬虫对某条路径的结论。
109
+ * @param {object} robots parseRobots 结果
110
+ * @param {string} [target='/'] 目标路径(含查询串也无妨,规则只匹配路径)
111
+ * @param {{userAgent?:string}} [options] 选项
112
+ * @returns {{allowed:boolean,usedGroup:string,declared:boolean,matchedRule:object|null,crawlDelayMs:number|null}} 结论
113
+ */
114
+ export function checkAllowance(robots, target = '/', options = {}) {
115
+ const matched = matchingGroups(robots, options.userAgent ?? BOT_TOKEN)
116
+ const rules = matched.groups.flatMap((group) => group.rules).filter((rule) => pathMatches(rule.path, target))
117
+ const delays = matched.groups.map((group) => group.crawlDelay).filter((value) => typeof value === 'number')
118
+ const crawlDelayMs = delays.length > 0 ? Math.round(Math.max(...delays) * 1000) : null
119
+
120
+ let best = null
121
+ for (const rule of rules) {
122
+ if (best === null || rule.path.length > best.path.length || (rule.path.length === best.path.length && rule.type === 'allow')) {
123
+ best = rule
124
+ }
125
+ }
126
+
127
+ return {
128
+ allowed: best === null ? true : best.type === 'allow',
129
+ usedGroup: matched.usedGroup,
130
+ declared: matched.declared,
131
+ matchedRule: best,
132
+ crawlDelayMs,
133
+ }
134
+ }
135
+
136
+ /**
137
+ * 取 URL 的路径(给 robots 匹配用)。
138
+ * @param {string} url 页面 URL
139
+ * @returns {string} 以 / 开头的路径
140
+ */
141
+ export function pathOf(url) {
142
+ try {
143
+ return new URL(url).pathname || '/'
144
+ } catch {
145
+ return '/'
146
+ }
147
+ }
148
+
149
+ /**
150
+ * 取 URL 的 origin(拼 robots.txt 地址用)。
151
+ * @param {string} url 页面 URL
152
+ * @returns {string} 协议 + 主机 + 端口
153
+ */
154
+ export function originOf(url) {
155
+ return new URL(url).origin
156
+ }
157
+
158
+ /**
159
+ * 一行结论文案(报告与 warnings 共用,避免两处口径不一致)。
160
+ * @param {object} result checkAllowance 结果
161
+ * @param {string} target 路径
162
+ * @returns {string} 中文说明
163
+ */
164
+ export function describeAllowance(result, target) {
165
+ if (result.allowed) {
166
+ return result.matchedRule
167
+ ? `robots.txt 允许抓 ${target}(命中 ${result.matchedRule.type}: ${result.matchedRule.path},第 ${result.matchedRule.line} 行)`
168
+ : `robots.txt 没有禁止抓 ${target}(${result.usedGroup === 'exact' ? '按 dsh-scrape 组' : result.usedGroup === '*' ? '按 * 组' : '无相关组'},无命中规则 = 允许)`
169
+ }
170
+ return `robots.txt 禁止抓 ${target}(命中 ${result.matchedRule.type}: ${result.matchedRule.path},第 ${result.matchedRule.line} 行)`
171
+ }