@first-ch/tools-mcp
Advanced tools
+503
| /* robots.txt ジェネレーター(robotstxt_generate) | ||
| * 生成コア(AI_CRAWLERS / buildRobotsTxt とその補助関数)は | ||
| * FirstCHTools の site/robots-txt/app.js と同一 | ||
| * (2箇所ルール: site側が正本・片方を直したらもう片方も同じ内容で反映する)。 | ||
| * 下のコアは site 側からそのまま持ってきたもので、両ファイルから | ||
| * コアの開始/終了コメントに挟まれた範囲を sed で切り出し(site側は字下げ2文字を落とす)、 | ||
| * diff が空になることで同期を機械的に確認できる。 | ||
| * | ||
| * クロールの許可/禁止・サイトマップ宣言・AIクローラー(GPTBot / ClaudeBot 等)の | ||
| * 許可プリセットから robots.txt を組み立て、書き間違いの指摘も返す。 | ||
| * 完全ローカル処理・ネットワーク送信なし。 */ | ||
| /* ==================== ここから生成コア(site / MCP で同一) ==================== */ | ||
| export const AI_PRESETS = ['allow', 'block', 'training', 'none', 'custom']; | ||
| export const AI_DECISIONS = ['allow', 'block', 'omit']; | ||
| // AIクローラーの一覧。purpose は収集の目的で、robots.txt の書き分けの単位になる: | ||
| // training = モデルの学習データを集める / search = AI検索の索引を作る | ||
| // user = 利用者がURLを貼った・質問したときだけ取りに来る | ||
| // 「学習は断りたいが、AI検索には出したい」が典型的な要望なので purpose で分けられるようにしてある。 | ||
| export const AI_CRAWLERS = [ | ||
| { ua: 'GPTBot', vendor: 'OpenAI', purpose: 'training', | ||
| ja: 'ChatGPTのモデル学習に使うデータを集める', en: 'Collects data used to train OpenAI models' }, | ||
| { ua: 'OAI-SearchBot', vendor: 'OpenAI', purpose: 'search', | ||
| ja: 'ChatGPTの検索結果に出すための索引を作る(学習には使われない)', en: 'Indexes pages so they can appear in ChatGPT search (not used for training)' }, | ||
| { ua: 'ChatGPT-User', vendor: 'OpenAI', purpose: 'user', | ||
| ja: '利用者が貼ったURLをその場で読みに来る', en: 'Fetches a URL a user pasted into ChatGPT' }, | ||
| { ua: 'ClaudeBot', vendor: 'Anthropic', purpose: 'training', | ||
| ja: 'Claudeのモデル学習に使うデータを集める', en: 'Collects data used to train Claude' }, | ||
| { ua: 'Claude-SearchBot', vendor: 'Anthropic', purpose: 'search', | ||
| ja: 'Claudeの検索結果に出すための索引を作る', en: 'Indexes pages for Claude search results' }, | ||
| { ua: 'Claude-User', vendor: 'Anthropic', purpose: 'user', | ||
| ja: '利用者の指示でそのページを読みに来る', en: 'Fetches a page when a user asks for it' }, | ||
| { ua: 'Google-Extended', vendor: 'Google', purpose: 'training', | ||
| ja: 'Geminiの学習・回答の根拠づけに使う(Google検索の順位には影響しない)', en: 'Gemini training and grounding (does not affect Google Search ranking)' }, | ||
| { ua: 'Applebot-Extended', vendor: 'Apple', purpose: 'training', | ||
| ja: 'Apple Intelligenceの学習に使う(検索用のApplebot本体とは別)', en: 'Apple Intelligence training (separate from Applebot, which is for search)' }, | ||
| { ua: 'PerplexityBot', vendor: 'Perplexity', purpose: 'search', | ||
| ja: 'Perplexityの検索索引を作る', en: 'Indexes pages for Perplexity search' }, | ||
| { ua: 'Perplexity-User', vendor: 'Perplexity', purpose: 'user', | ||
| ja: '利用者の質問に答えるためにそのページを読みに来る', en: 'Fetches a page to answer a user question' }, | ||
| { ua: 'Meta-ExternalAgent', vendor: 'Meta', purpose: 'training', | ||
| ja: 'Meta AIの学習に使うデータを集める', en: 'Collects data used to train Meta AI' }, | ||
| { ua: 'meta-externalfetcher', vendor: 'Meta', purpose: 'user', | ||
| ja: '利用者の指示でそのページを読みに来る', en: 'Fetches a page when a user asks for it' }, | ||
| { ua: 'Amazonbot', vendor: 'Amazon', purpose: 'search', | ||
| ja: 'Alexaなどの回答に使うために読みに来る', en: 'Fetches pages to answer questions in Alexa and other services' }, | ||
| { ua: 'Bytespider', vendor: 'ByteDance', purpose: 'training', | ||
| ja: 'ByteDance(TikTok運営)のモデル学習に使うデータを集める', en: 'Collects training data for ByteDance (TikTok) models' }, | ||
| { ua: 'CCBot', vendor: 'Common Crawl', purpose: 'training', | ||
| ja: '公開データセットを作る(多くのLLMの学習元になっている)', en: 'Builds a public dataset that many LLMs are trained on' }, | ||
| { ua: 'cohere-ai', vendor: 'Cohere', purpose: 'training', | ||
| ja: 'Cohereのモデル学習に使うデータを集める', en: 'Collects data used to train Cohere models' }, | ||
| { ua: 'MistralAI-User', vendor: 'Mistral AI', purpose: 'user', | ||
| ja: 'Le Chatの利用者の指示で読みに来る', en: 'Fetches a page when a Le Chat user asks for it' }, | ||
| { ua: 'DuckAssistBot', vendor: 'DuckDuckGo', purpose: 'search', | ||
| ja: 'DuckAssistの回答を作るために読みに来る', en: 'Fetches pages to build DuckAssist answers' }, | ||
| { ua: 'YouBot', vendor: 'You.com', purpose: 'search', | ||
| ja: 'You.comの検索索引を作る', en: 'Indexes pages for You.com search' }, | ||
| { ua: 'Diffbot', vendor: 'Diffbot', purpose: 'training', | ||
| ja: 'ナレッジグラフと学習用データセットを作る', en: 'Builds a knowledge graph and training datasets' }, | ||
| { ua: 'ImagesiftBot', vendor: 'ImageSift', purpose: 'training', | ||
| ja: '画像のデータセットを作る', en: 'Builds image datasets' }, | ||
| { ua: 'Omgilibot', vendor: 'Webz.io', purpose: 'training', | ||
| ja: '学習用データセットとして販売するために集める', en: 'Collects data that is sold as training datasets' }, | ||
| { ua: 'Timpibot', vendor: 'Timpi', purpose: 'training', | ||
| ja: '分散型検索・データセット向けに集める', en: 'Collects data for a decentralised search index and datasets' }, | ||
| { ua: 'anthropic-ai', vendor: 'Anthropic', purpose: 'training', | ||
| ja: '旧称(現在はClaudeBot。古い設定を残しているサイト向け)', en: 'Legacy name (now ClaudeBot; kept for older configurations)' }, | ||
| ]; | ||
| export const PURPOSE_LABEL = { | ||
| training: { ja: '学習', en: 'Training' }, | ||
| search: { ja: 'AI検索', en: 'AI search' }, | ||
| user: { ja: '利用者の指示', en: 'User-triggered' }, | ||
| }; | ||
| const ROBOTS_MSG = { | ||
| header: { | ||
| ja: () => '# robots.txt — First CH Tools (https://tools.first-ch.com/robots-txt/) で生成', | ||
| en: () => '# robots.txt — generated with First CH Tools (https://tools.first-ch.com/robots-txt/)', | ||
| }, | ||
| headerSite: { | ||
| ja: (a) => `# 対象サイト: ${a.url}`, | ||
| en: (a) => `# Site: ${a.url}`, | ||
| }, | ||
| sitemapComment: { | ||
| ja: () => 'サイトマップの場所(クロールの起点になる)', | ||
| en: () => 'Sitemap location (where crawlers start from)', | ||
| }, | ||
| aiBlockComment: { | ||
| ja: (a) => `AIクローラー: 拒否(${a.n}件)`, | ||
| en: (a) => `AI crawlers: blocked (${a.n})`, | ||
| }, | ||
| aiAllowComment: { | ||
| ja: (a) => `AIクローラー: 許可(${a.n}件)`, | ||
| en: (a) => `AI crawlers: allowed (${a.n})`, | ||
| }, | ||
| aiAllowBlockedComment: { | ||
| ja: (a) => `AIクローラー: 許可の指定(${a.n}件)だが共通ルールでサイト全体を拒否`, | ||
| en: (a) => `AI crawlers: marked as allowed (${a.n}) but the shared rule blocks the whole site`, | ||
| }, | ||
| pathFromUrl: { | ||
| ja: (a) => `絶対URLからパスだけを取り出しました(${a.from} → ${a.to})。robots.txt に書けるのは「/」で始まるパスだけです。`, | ||
| en: (a) => `Took just the path out of a full URL (${a.from} → ${a.to}). robots.txt only accepts paths that start with "/".`, | ||
| }, | ||
| pathFixed: { | ||
| ja: (a) => `パスの先頭に「/」を補いました(${a.from} → ${a.to})。パスはサイトのルートからの絶対パスで書きます。`, | ||
| en: (a) => `Added the leading slash (${a.from} → ${a.to}). Paths are written from the site root.`, | ||
| }, | ||
| pathSpace: { | ||
| ja: (a) => `「${a.value}」に空白が含まれています。robots.txt は空白でパスが切れるため、%20 に置き換えてください。`, | ||
| en: (a) => `"${a.value}" contains a space. A space ends the path in robots.txt, so write it as %20.`, | ||
| }, | ||
| pathNonAscii: { | ||
| ja: (a) => `「${a.value}」にASCII以外の文字が含まれています。日本語のパスはパーセントエンコード(%E3%81%82 の形)で書くのが確実です。`, | ||
| en: (a) => `"${a.value}" contains non-ASCII characters. Percent-encode them (the %E3%81%82 form) to be safe.`, | ||
| }, | ||
| pathWildcard: { | ||
| ja: (a) => `「${a.value}」はワイルドカード(* と $)を使っています。Google・Bing・OpenAIなど主要なクローラーは解釈しますが、対応していないクローラーは文字どおりに読みます。`, | ||
| en: (a) => `"${a.value}" uses wildcards (* and $). Google, Bing and OpenAI understand them, but crawlers that do not will read them literally.`, | ||
| }, | ||
| crawlDelayIgnored: { | ||
| ja: () => 'Crawl-delay はGooglebotが解釈しません(Googleのクロール頻度はSearch Consoleで調整します)。Bing・Yandexなどには効きます。', | ||
| en: () => 'Googlebot ignores Crawl-delay (adjust Google crawl rate in Search Console). Bing and Yandex do honour it.', | ||
| }, | ||
| crawlDelayInvalid: { | ||
| ja: (a) => `Crawl-delay の値「${a.value}」は0以上の数値ではないため出力しませんでした。`, | ||
| en: (a) => `Crawl-delay "${a.value}" is not a number of zero or more, so it was left out.`, | ||
| }, | ||
| sitemapNotAbsolute: { | ||
| ja: (a) => `Sitemap は絶対URLで書く必要があります(「${a.value}」は http:// または https:// で始まっていません)。`, | ||
| en: (a) => `Sitemap must be an absolute URL ("${a.value}" does not start with http:// or https://).`, | ||
| }, | ||
| noSitemap: { | ||
| ja: () => 'サイトマップが宣言されていません。「Sitemap: https://example.com/sitemap.xml」を入れておくと、クローラーがページの一覧を見つけやすくなります。', | ||
| en: () => 'No sitemap is declared. Adding "Sitemap: https://example.com/sitemap.xml" helps crawlers find every page.', | ||
| }, | ||
| blockAll: { | ||
| ja: () => 'すべてのクローラーにサイト全体を拒否しています(User-agent: * に Disallow: /)。このまま公開すると検索結果に出なくなります。', | ||
| en: () => 'Every crawler is blocked from the whole site (Disallow: / under User-agent: *). Published as-is, the site will drop out of search results.', | ||
| }, | ||
| blockAllSitemap: { | ||
| ja: () => 'サイト全体を拒否しているため、宣言したサイトマップもクローラーには読まれません。', | ||
| en: () => 'Because the whole site is blocked, crawlers will not read the sitemap you declared either.', | ||
| }, | ||
| notNoindex: { | ||
| ja: () => 'Disallow はクロールを止めるだけで、すでに検索結果に載っているページを消すものではありません(他サイトからリンクされていればURLだけ残ることがあります)。消したいページには noindex を使います。', | ||
| en: () => 'Disallow only stops crawling; it does not remove pages already in the index (a URL linked from elsewhere can still be listed). Use noindex to drop a page.', | ||
| }, | ||
| duplicateAgent: { | ||
| ja: (a) => `User-agent「${a.name}」が複数のグループに出てきます。クローラーは自分に一致するグループを1つだけ使うため、意図しない方が採用されることがあります。`, | ||
| en: (a) => `User-agent "${a.name}" appears in more than one group. A crawler obeys only one matching group, so the wrong one may win.`, | ||
| }, | ||
| aiOwnGroup: { | ||
| ja: () => 'AIクローラーに専用のグループを作ると、そのクローラーは User-agent: * のルールを読まなくなります(一致するグループを1つだけ使う仕様のため)。共通の禁止パスはAI側のグループにも書き写す必要があります。', | ||
| en: () => 'Once an AI crawler has its own group it stops reading the User-agent: * rules (a crawler obeys only one matching group). Shared disallow paths must be repeated in the AI group.', | ||
| }, | ||
| aiInherited: { | ||
| ja: (a) => `共通の禁止パス(${a.n}件)を、許可するAIクローラーのグループにも書き写しました。`, | ||
| en: (a) => `Copied the shared disallow paths (${a.n}) into the group for the AI crawlers you allow.`, | ||
| }, | ||
| aiAllowButBlocked: { | ||
| ja: () => 'サイト全体を拒否しているため、許可としたAIクローラーにも Disallow: / が引き継がれています(結果としてどのAIも読めません)。', | ||
| en: () => 'The whole site is blocked, so the AI crawlers you allowed inherit Disallow: / as well — none of them can read anything.', | ||
| }, | ||
| unlistedAllowed: { | ||
| ja: () => 'robots.txt は「書いていないものは許可」の仕組みです。一覧に無いクローラーや新しく登場したクローラーは、拒否したことになりません。', | ||
| en: () => 'robots.txt allows anything it does not mention. Crawlers missing from this list — including new ones — are not blocked.', | ||
| }, | ||
| tooLarge: { | ||
| ja: (a) => `${a.kb} KB あります。Googleが読むのは先頭500KiBまでで、それ以降は無視されます。`, | ||
| en: (a) => `The file is ${a.kb} KB. Google only reads the first 500 KiB and ignores the rest.`, | ||
| }, | ||
| ok: { | ||
| ja: () => '気になる点はありません。サイトのルート(https://example.com/robots.txt)に置いてください。', | ||
| en: () => 'Nothing to flag. Upload it to the site root (https://example.com/robots.txt).', | ||
| }, | ||
| }; | ||
| // 改行(オプションでカンマ)区切りの入力を、空行・コメント行・重複を落とした配列にする | ||
| export function robotsList(value, opts) { | ||
| const options = opts || {}; | ||
| const raw = Array.isArray(value) | ||
| ? value.map((v) => String(v == null ? '' : v)) | ||
| : String(value == null ? '' : value).split(/\r?\n/); | ||
| const parts = options.comma ? raw.reduce((acc, line) => acc.concat(line.split(',')), []) : raw; | ||
| const out = []; | ||
| for (const part of parts) { | ||
| const s = part.trim(); | ||
| if (!s || s.charAt(0) === '#') continue; | ||
| if (out.indexOf(s) === -1) out.push(s); | ||
| } | ||
| return out; | ||
| } | ||
| // 同じ指摘は1回だけ積む(同じパスを2度書いても2行にしない) | ||
| function robotsWarn(warnings, level, code, lang, args) { | ||
| const entry = ROBOTS_MSG[code]; | ||
| const message = entry ? entry[lang](args || {}) : code; | ||
| for (const w of warnings) if (w.code === code && w.message === message) return; | ||
| warnings.push({ level, code, message }); | ||
| } | ||
| function robotsPath(value, warnings, lang) { | ||
| let s = String(value == null ? '' : value).trim(); | ||
| if (!s) return ''; | ||
| if (/^https?:\/\//i.test(s)) { | ||
| const m = s.match(/^https?:\/\/[^/]*(\/.*)?$/i); | ||
| const path = (m && m[1]) || '/'; | ||
| robotsWarn(warnings, 'info', 'pathFromUrl', lang, { from: s, to: path }); | ||
| s = path; | ||
| } else if (s.charAt(0) !== '/' && s.charAt(0) !== '*') { | ||
| const fixed = '/' + s; | ||
| robotsWarn(warnings, 'info', 'pathFixed', lang, { from: s, to: fixed }); | ||
| s = fixed; | ||
| } | ||
| if (/\s/.test(s)) robotsWarn(warnings, 'warn', 'pathSpace', lang, { value: s }); | ||
| if (/[^\x20-\x7E]/.test(s)) robotsWarn(warnings, 'info', 'pathNonAscii', lang, { value: s }); | ||
| if (s.indexOf('*') !== -1 || s.indexOf('$') !== -1) robotsWarn(warnings, 'info', 'pathWildcard', lang, { value: s }); | ||
| return s; | ||
| } | ||
| // AIクローラー1件ごとの扱い(allow / block / omit)を決める。overrides が個別指定 | ||
| function robotsAiDecisions(ai) { | ||
| const conf = ai || {}; | ||
| const preset = AI_PRESETS.indexOf(conf.preset) === -1 ? 'training' : conf.preset; | ||
| const overrides = conf.overrides || {}; | ||
| const decisions = {}; | ||
| for (const bot of AI_CRAWLERS) { | ||
| let d; | ||
| if (preset === 'allow') d = 'allow'; | ||
| else if (preset === 'block') d = 'block'; | ||
| else if (preset === 'training') d = bot.purpose === 'training' ? 'block' : 'allow'; | ||
| else d = 'omit'; // none / custom は既定で書かない | ||
| const o = overrides[bot.ua]; | ||
| if (AI_DECISIONS.indexOf(o) !== -1) d = o; | ||
| decisions[bot.ua] = d; | ||
| } | ||
| return { preset, decisions }; | ||
| } | ||
| function robotsRenderGroup(group, opts) { | ||
| const lines = []; | ||
| const comments = opts.comments; | ||
| if (comments && group.comment) lines.push('# ' + group.comment); | ||
| const agents = group.userAgents.map((a) => (typeof a === 'string' ? { name: a, note: '' } : a)); | ||
| const heads = agents.map((a) => 'User-agent: ' + a.name); | ||
| // 注釈を付ける行だけを見て桁を揃える(ボット名はASCIIなので文字数=表示幅) | ||
| let width = 0; | ||
| if (comments) heads.forEach((h, i) => { if (agents[i].note) width = Math.max(width, h.length); }); | ||
| heads.forEach((h, i) => { | ||
| const note = comments ? agents[i].note : ''; | ||
| lines.push(note ? h + new Array(Math.max(1, width - h.length + 2) + 1).join(' ') + '# ' + note : h); | ||
| }); | ||
| const disallow = group.disallow || []; | ||
| const allow = group.allow || []; | ||
| if (!disallow.length && !allow.length) { | ||
| // 空の Disallow は「すべて許可」を意味する(1994年の原案から現在のRFC 9309まで共通) | ||
| lines.push(opts.allowStyle === 'allow-slash' ? 'Allow: /' : 'Disallow:'); | ||
| } else { | ||
| for (const p of disallow) lines.push('Disallow: ' + p); | ||
| for (const p of allow) lines.push('Allow: ' + p); | ||
| } | ||
| if (group.crawlDelay != null) lines.push('Crawl-delay: ' + group.crawlDelay); | ||
| return lines.join('\n'); | ||
| } | ||
| /* robots.txt を組み立てる。 | ||
| * config = { | ||
| * groups: [{ userAgents, disallow, allow, crawlDelay, comment }], // 省略時は User-agent: * ひとつ | ||
| * ai: { preset: 'allow'|'block'|'training'|'none'|'custom', overrides: {UA: 'allow'|'block'|'omit'}, inherit }, | ||
| * sitemaps, siteUrl, comments, allowStyle: 'disallow-empty'|'allow-slash', lang: 'ja'|'en' | ||
| * } | ||
| * 返り値 = { text, warnings, stats, ai } */ | ||
| export function buildRobotsTxt(config) { | ||
| const conf = config || {}; | ||
| const lang = conf.lang === 'en' ? 'en' : 'ja'; | ||
| const comments = conf.comments !== false; | ||
| const allowStyle = conf.allowStyle === 'allow-slash' ? 'allow-slash' : 'disallow-empty'; | ||
| const warnings = []; | ||
| const siteUrl = String(conf.siteUrl == null ? '' : conf.siteUrl).trim(); | ||
| const inputGroups = Array.isArray(conf.groups) && conf.groups.length ? conf.groups : [{ userAgents: ['*'] }]; | ||
| const groups = []; | ||
| for (const g of inputGroups) { | ||
| const source = g || {}; | ||
| const agents = robotsList(source.userAgents == null ? '*' : source.userAgents, { comma: true }); | ||
| const disallow = robotsList(source.disallow).map((p) => robotsPath(p, warnings, lang)).filter(Boolean); | ||
| const allow = robotsList(source.allow).map((p) => robotsPath(p, warnings, lang)).filter(Boolean); | ||
| let crawlDelay = null; | ||
| const rawDelay = source.crawlDelay; | ||
| if (rawDelay != null && String(rawDelay).trim() !== '') { | ||
| const n = Number(rawDelay); | ||
| if (!isFinite(n) || n < 0) { | ||
| robotsWarn(warnings, 'warn', 'crawlDelayInvalid', lang, { value: String(rawDelay) }); | ||
| } else { | ||
| crawlDelay = n; | ||
| robotsWarn(warnings, 'info', 'crawlDelayIgnored', lang, {}); | ||
| } | ||
| } | ||
| groups.push({ | ||
| comment: source.comment || '', | ||
| userAgents: agents.length ? agents : ['*'], | ||
| disallow, | ||
| allow, | ||
| crawlDelay, | ||
| }); | ||
| } | ||
| // 「*」のグループ=共通ルール。AIクローラーへ書き写す元になる | ||
| let base = null; | ||
| for (const g of groups) if (g.userAgents.indexOf('*') !== -1) { base = g; break; } | ||
| const { preset, decisions } = robotsAiDecisions(conf.ai); | ||
| const inherit = !(conf.ai && conf.ai.inherit === false); | ||
| const blockedBots = []; | ||
| const allowedBots = []; | ||
| for (const bot of AI_CRAWLERS) { | ||
| const d = decisions[bot.ua]; | ||
| const entry = { name: bot.ua, note: bot.vendor + ' / ' + PURPOSE_LABEL[bot.purpose][lang] }; | ||
| if (d === 'block') blockedBots.push(entry); | ||
| else if (d === 'allow') allowedBots.push(entry); | ||
| } | ||
| const aiGroups = []; | ||
| if (blockedBots.length) { | ||
| aiGroups.push({ | ||
| comment: ROBOTS_MSG.aiBlockComment[lang]({ n: blockedBots.length }), | ||
| userAgents: blockedBots, | ||
| disallow: ['/'], | ||
| allow: [], | ||
| crawlDelay: null, | ||
| }); | ||
| } | ||
| if (allowedBots.length) { | ||
| const inheritedDisallow = inherit && base ? base.disallow.slice() : []; | ||
| const inheritedAllow = inherit && base ? base.allow.slice() : []; | ||
| // 共通ルールが全体拒否のときは「許可」と書くと出力と食い違うので見出しを変える | ||
| const shutOut = inheritedDisallow.indexOf('/') !== -1; | ||
| const commentKey = shutOut ? 'aiAllowBlockedComment' : 'aiAllowComment'; | ||
| aiGroups.push({ | ||
| comment: ROBOTS_MSG[commentKey][lang]({ n: allowedBots.length }), | ||
| userAgents: allowedBots, | ||
| disallow: inheritedDisallow, | ||
| allow: inheritedAllow, | ||
| crawlDelay: null, | ||
| }); | ||
| if (inheritedDisallow.length) robotsWarn(warnings, 'info', 'aiInherited', lang, { n: inheritedDisallow.length }); | ||
| if (shutOut) robotsWarn(warnings, 'warn', 'aiAllowButBlocked', lang, {}); | ||
| } | ||
| if (aiGroups.length) robotsWarn(warnings, 'info', 'aiOwnGroup', lang, {}); | ||
| if (blockedBots.length) robotsWarn(warnings, 'info', 'unlistedAllowed', lang, {}); | ||
| const allGroups = groups.concat(aiGroups); | ||
| // 同じUser-agentが2つのグループに出ていないか(クローラーは1グループしか読まない) | ||
| const seen = {}; | ||
| for (const g of allGroups) { | ||
| for (const a of g.userAgents) { | ||
| const name = typeof a === 'string' ? a : a.name; | ||
| const key = name.toLowerCase(); | ||
| if (seen[key]) robotsWarn(warnings, 'warn', 'duplicateAgent', lang, { name }); | ||
| seen[key] = true; | ||
| } | ||
| } | ||
| const sitemaps = robotsList(conf.sitemaps).map((s) => { | ||
| if (!/^https?:\/\//i.test(s)) robotsWarn(warnings, 'warn', 'sitemapNotAbsolute', lang, { value: s }); | ||
| return s; | ||
| }); | ||
| const blocksAll = !!base && base.disallow.indexOf('/') !== -1; | ||
| if (blocksAll) { | ||
| robotsWarn(warnings, 'warn', 'blockAll', lang, {}); | ||
| if (sitemaps.length) robotsWarn(warnings, 'info', 'blockAllSitemap', lang, {}); | ||
| } | ||
| let ruleCount = 0; | ||
| for (const g of allGroups) ruleCount += g.disallow.length + g.allow.length; | ||
| if (ruleCount) robotsWarn(warnings, 'info', 'notNoindex', lang, {}); | ||
| if (!sitemaps.length) robotsWarn(warnings, 'info', 'noSitemap', lang, {}); | ||
| const blocks = []; | ||
| if (comments) { | ||
| const head = [ROBOTS_MSG.header[lang]({})]; | ||
| if (siteUrl) head.push(ROBOTS_MSG.headerSite[lang]({ url: siteUrl })); | ||
| blocks.push(head.join('\n')); | ||
| } | ||
| for (const g of allGroups) blocks.push(robotsRenderGroup(g, { comments, allowStyle })); | ||
| if (sitemaps.length) { | ||
| const sm = []; | ||
| if (comments) sm.push('# ' + ROBOTS_MSG.sitemapComment[lang]({})); | ||
| for (const s of sitemaps) sm.push('Sitemap: ' + s); | ||
| blocks.push(sm.join('\n')); | ||
| } | ||
| const text = blocks.join('\n\n') + '\n'; | ||
| const bytes = new TextEncoder().encode(text).length; | ||
| if (bytes > 512000) robotsWarn(warnings, 'warn', 'tooLarge', lang, { kb: Math.round(bytes / 102.4) / 10 }); | ||
| return { | ||
| text, | ||
| warnings, | ||
| stats: { | ||
| groups: allGroups.length, | ||
| rules: ruleCount, | ||
| sitemaps: sitemaps.length, | ||
| lines: text.replace(/\n$/, '').split('\n').length, | ||
| bytes, | ||
| aiAllowed: allowedBots.length, | ||
| aiBlocked: blockedBots.length, | ||
| }, | ||
| ai: { preset, decisions, allowed: allowedBots.map((b) => b.name), blocked: blockedBots.map((b) => b.name) }, | ||
| }; | ||
| } | ||
| /* ==================== ここまで生成コア ==================== */ | ||
| /* ==================== MCPツールの入口(site側には無い) ==================== */ | ||
| import { writeFile } from 'node:fs/promises'; | ||
| import path from 'node:path'; | ||
| const AI_PURPOSES = ['training', 'search', 'user']; | ||
| function assertEnum(value, allowed, label) { | ||
| if (value != null && allowed.indexOf(value) === -1) { | ||
| throw new Error(`${label} は ${allowed.join(' / ')} のいずれかを指定してください(受け取った値: ${JSON.stringify(value)})`); | ||
| } | ||
| } | ||
| /** AIクローラーの一覧を返す(生成せずに相手を確かめたいとき用) */ | ||
| export function listAiCrawlers(lang = 'ja') { | ||
| const l = lang === 'en' ? 'en' : 'ja'; | ||
| return { | ||
| count: AI_CRAWLERS.length, | ||
| purposes: AI_PURPOSES.map((p) => ({ | ||
| purpose: p, | ||
| label: PURPOSE_LABEL[p][l], | ||
| crawlers: AI_CRAWLERS.filter((b) => b.purpose === p).map((b) => b.ua), | ||
| })), | ||
| crawlers: AI_CRAWLERS.map((b) => ({ | ||
| userAgent: b.ua, | ||
| vendor: b.vendor, | ||
| purpose: b.purpose, | ||
| description: b[l], | ||
| })), | ||
| }; | ||
| } | ||
| /** MCPツール robotstxt_generate の本体 */ | ||
| export async function robotsTxtGenerate(opts = {}) { | ||
| const lang = opts.lang === 'en' ? 'en' : 'ja'; | ||
| if (opts.listCrawlers) return listAiCrawlers(lang); | ||
| assertEnum(opts.allowStyle, ['disallow-empty', 'allow-slash'], 'allowStyle'); | ||
| const ai = opts.ai ? { ...opts.ai } : {}; | ||
| assertEnum(ai.preset, AI_PRESETS, 'ai.preset'); | ||
| if (ai.overrides) { | ||
| for (const [ua, decision] of Object.entries(ai.overrides)) { | ||
| if (!AI_CRAWLERS.some((b) => b.ua === ua)) { | ||
| throw new Error(`ai.overrides の "${ua}" は一覧にないクローラーです(listCrawlers=true で一覧を取得できます)`); | ||
| } | ||
| assertEnum(decision, AI_DECISIONS, `ai.overrides["${ua}"]`); | ||
| } | ||
| } | ||
| let groups; | ||
| if (Array.isArray(opts.groups) && opts.groups.length) { | ||
| groups = opts.groups; | ||
| } else { | ||
| groups = [{ | ||
| userAgents: opts.userAgents == null ? '*' : opts.userAgents, | ||
| disallow: opts.disallow, | ||
| allow: opts.allow, | ||
| crawlDelay: opts.crawlDelay, | ||
| }]; | ||
| } | ||
| const others = robotsList(opts.blockCrawlers, { comma: true }); | ||
| if (others.length) { | ||
| groups = groups.concat([{ | ||
| comment: lang === 'en' ? 'Other crawlers you asked to stay out' : '個別に拒否したクローラー', | ||
| userAgents: others, | ||
| disallow: ['/'], | ||
| }]); | ||
| } | ||
| const result = buildRobotsTxt({ | ||
| groups, | ||
| ai, | ||
| sitemaps: opts.sitemaps, | ||
| siteUrl: opts.siteUrl, | ||
| comments: opts.comments, | ||
| allowStyle: opts.allowStyle, | ||
| lang, | ||
| }); | ||
| if (opts.outputPath) { | ||
| if (!path.isAbsolute(opts.outputPath)) throw new Error('outputPath は絶対パスで指定してください'); | ||
| await writeFile(opts.outputPath, result.text, 'utf8'); | ||
| return { written: opts.outputPath, warnings: result.warnings, stats: result.stats, ai: result.ai }; | ||
| } | ||
| return result; | ||
| } |
+11
-3
| { | ||
| "name": "@first-ch/tools-mcp", | ||
| "version": "0.24.1", | ||
| "description": "First CH Tools の計算ロジック(WCAGコントラスト比・文字数/Xウェイト・WebP変換・JSON-LD生成・llms.txt生成・文字コード/改行コード変換・Marp Markdown→スライド レンダリング・テストデータ生成・テキスト/コード差分・Cron式の解説・Base64/Data URI変換・URLパラメータの分解/再構築・HTML特殊文字のエスケープ/エンティティのデコード・JSON⇄YAMLの相互変換・px⇄rem/emの単位換算とCSSの一括変換・カラーコードの相互変換とアルファ透過の合成・MD5/SHA-1/SHA-256/SHA-384/SHA-512のハッシュ生成と照合・JWTのデコードと有効期限/署名の検証・User-Agent文字列の解析とデバイス判定・UUID v4/ULIDの一括生成・アスペクト比の計算とレスポンシブサイズの算出・TSV/CSVからMarkdownテーブルへの整形と相互変換・SQLクエリの整形と予約語の大文字化・URL/テキストからのQRコード生成・UNIXタイムスタンプと日時表記の相互変換)をMCPツールとして提供するサーバー", | ||
| "version": "0.25.0", | ||
| "description": "First CH Tools の計算ロジック(WCAGコントラスト比・文字数/Xウェイト・WebP変換・JSON-LD生成・llms.txt生成・文字コード/改行コード変換・Marp Markdown→スライド レンダリング・テストデータ生成・テキスト/コード差分・Cron式の解説・Base64/Data URI変換・URLパラメータの分解/再構築・HTML特殊文字のエスケープ/エンティティのデコード・JSON⇄YAMLの相互変換・px⇄rem/emの単位換算とCSSの一括変換・カラーコードの相互変換とアルファ透過の合成・MD5/SHA-1/SHA-256/SHA-384/SHA-512のハッシュ生成と照合・JWTのデコードと有効期限/署名の検証・User-Agent文字列の解析とデバイス判定・UUID v4/ULIDの一括生成・アスペクト比の計算とレスポンシブサイズの算出・TSV/CSVからMarkdownテーブルへの整形と相互変換・SQLクエリの整形と予約語の大文字化・URL/テキストからのQRコード生成・UNIXタイムスタンプと日時表記の相互変換・robots.txtの生成(AIクローラーの許可プリセット付き))をMCPツールとして提供するサーバー", | ||
| "type": "module", | ||
@@ -137,3 +137,10 @@ "bin": { | ||
| "datetime", | ||
| "timezone" | ||
| "timezone", | ||
| "robots-txt", | ||
| "robots.txt", | ||
| "seo", | ||
| "gptbot", | ||
| "claudebot", | ||
| "ai-crawler", | ||
| "web-crawler" | ||
| ], | ||
@@ -179,2 +186,3 @@ "author": "First CH LLC (https://first-ch.com)", | ||
| "unixtime.mjs", | ||
| "robots-txt.mjs", | ||
| "vendor/", | ||
@@ -181,0 +189,0 @@ "README.md", |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1071512
3.46%35
2.94%12138
4.8%224
0.9%19
5.56%