import path from 'node:path'; import type { Span } from '../trace'; import { surgeDomainsetToClashDomainset } from './clash'; import { compareAndWriteFile, withBannerArray } from './create-file'; import { ipCidrListToSingbox, surgeDomainsetToSingbox } from './singbox'; import { sortDomains } from './stable-sort-domain'; import { createTrie } from './trie'; import { invariant } from 'foxact/invariant'; import { OUTPUT_CLASH_DIR, OUTPUT_SINGBOX_DIR, OUTPUT_SURGE_DIR } from '../constants/dir'; import stringify from 'json-stringify-pretty-compact'; import { appendArrayInPlace } from './append-array-in-place'; abstract class RuleOutput { protected domainTrie = createTrie(null, true); protected domainKeywords = new Set(); protected domainWildcard = new Set(); protected ipcidr = new Set(); protected ipcidrNoResolve = new Set(); protected ipcidr6 = new Set(); protected ipcidr6NoResolve = new Set(); protected otherRules = new Set(); protected abstract type: 'domainset' | 'non_ip' | 'ip'; protected pendingPromise = Promise.resolve(); static jsonToLines(this: void, json: unknown): string[] { return stringify(json).split('\n'); } constructor( protected readonly span: Span, protected readonly id: string ) {} protected title: string | null = null; withTitle(title: string) { this.title = title; return this; } protected description: string[] | readonly string[] | null = null; withDescription(description: string[] | readonly string[]) { this.description = description; return this; } protected date = new Date(); withDate(date: Date) { this.date = date; return this; } protected apexDomainMap: Map | null = null; protected subDomainMap: Map | null = null; withDomainMap(apexDomainMap: Map, subDomainMap: Map) { this.apexDomainMap = apexDomainMap; this.subDomainMap = subDomainMap; return this; } addDomain(domain: string) { this.domainTrie.add(domain); return this; } addDomainSuffix(domain: string) { this.domainTrie.add(domain[0] === '.' ? domain : '.' + domain); return this; } bulkAddDomainSuffix(domains: string[]) { for (let i = 0, len = domains.length; i < len; i++) { this.addDomainSuffix(domains[i]); } return this; } addDomainKeyword(keyword: string) { this.domainKeywords.add(keyword); return this; } addDomainWildcard(wildcard: string) { this.domainWildcard.add(wildcard); return this; } private async addFromDomainsetPromise(source: AsyncIterable | Iterable | string[]) { for await (const line of source) { if (line[0] === '.') { this.addDomainSuffix(line); } else { this.addDomain(line); } } } addFromDomainset(source: AsyncIterable | Iterable | string[]) { this.pendingPromise = this.pendingPromise.then(() => this.addFromDomainsetPromise(source)); return this; } async addFromRuleset(source: AsyncIterable | Iterable) { for await (const line of source) { const [type, value, arg] = line.split(','); switch (type) { case 'DOMAIN': this.addDomain(value); break; case 'DOMAIN-SUFFIX': this.addDomainSuffix(value); break; case 'DOMAIN-KEYWORD': this.addDomainKeyword(value); break; case 'DOMAIN-WILDCARD': this.addDomainWildcard(value); break; case 'IP-CIDR': (arg === 'no-resolve' ? this.ipcidrNoResolve : this.ipcidr).add(value); break; case 'IP-CIDR6': (arg === 'no-resolve' ? this.ipcidr6NoResolve : this.ipcidr6).add(value); break; default: this.otherRules.add(line); break; } } return this; } bulkAddCIDR4(cidr: string[]) { for (let i = 0, len = cidr.length; i < len; i++) { this.ipcidr.add(cidr[i]); } return this; } bulkAddCIDR6(cidr: string[]) { for (let i = 0, len = cidr.length; i < len; i++) { this.ipcidr6.add(cidr[i]); } return this; } abstract write(): Promise; } export class DomainsetOutput extends RuleOutput { protected type = 'domainset' as const; async write() { await this.pendingPromise; invariant(this.title, 'Missing title'); invariant(this.description, 'Missing description'); const sorted = sortDomains(this.domainTrie.dump(), this.apexDomainMap, this.subDomainMap); sorted.push('this_ruleset_is_made_by_sukkaw.ruleset.skk.moe'); const surge = sorted; const clash = surgeDomainsetToClashDomainset(sorted); const singbox = RuleOutput.jsonToLines(surgeDomainsetToSingbox(sorted)); await Promise.all([ compareAndWriteFile( this.span, withBannerArray( this.title, this.description, this.date, surge ), path.join(OUTPUT_SURGE_DIR, this.type, this.id + '.conf') ), compareAndWriteFile( this.span, withBannerArray( this.title, this.description, this.date, clash ), path.join(OUTPUT_CLASH_DIR, this.type, this.id + '.txt') ), compareAndWriteFile( this.span, singbox, path.join(OUTPUT_SINGBOX_DIR, this.type, this.id + '.json') ) ]); } } export class IPListOutput extends RuleOutput { protected type = 'ip' as const; constructor(span: Span, id: string, private readonly clashUseRule = true) { super(span, id); } async write() { await this.pendingPromise; invariant(this.title, 'Missing title'); invariant(this.description, 'Missing description'); const sorted4 = Array.from(this.ipcidr); const sorted6 = Array.from(this.ipcidr6); const merged = appendArrayInPlace(appendArrayInPlace([], sorted4), sorted6); const surge = sorted4.map(i => `IP-CIDR,${i}`); appendArrayInPlace(surge, sorted6.map(i => `IP-CIDR6,${i}`)); surge.push('DOMAIN,this_ruleset_is_made_by_sukkaw.ruleset.skk.moe'); const clash = this.clashUseRule ? surge : merged; const singbox = RuleOutput.jsonToLines(ipCidrListToSingbox(merged)); await Promise.all([ compareAndWriteFile( this.span, withBannerArray( this.title, this.description, this.date, surge ), path.join(OUTPUT_SURGE_DIR, this.type, this.id + '.conf') ), compareAndWriteFile( this.span, withBannerArray( this.title, this.description, this.date, clash ), path.join(OUTPUT_CLASH_DIR, this.type, this.id + '.txt') ), compareAndWriteFile( this.span, singbox, path.join(OUTPUT_SINGBOX_DIR, this.type, this.id + '.json') ) ]); } }