create-file.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. import path from 'node:path';
  2. import type { Span } from '../trace';
  3. import { surgeDomainsetToClashDomainset, surgeRulesetToClashClassicalTextRuleset } from './clash';
  4. import { ipCidrListToSingbox, surgeDomainsetToSingbox, surgeRulesetToSingbox } from './singbox';
  5. import { buildParseDomainMap, sortDomains } from './stable-sort-domain';
  6. import { createTrie } from './trie';
  7. import { invariant } from 'foxact/invariant';
  8. import { OUTPUT_CLASH_DIR, OUTPUT_SINGBOX_DIR, OUTPUT_SURGE_DIR } from '../constants/dir';
  9. import stringify from 'json-stringify-pretty-compact';
  10. import { appendArrayInPlace } from './append-array-in-place';
  11. import { nullthrow } from 'foxact/nullthrow';
  12. import createKeywordFilter from './aho-corasick';
  13. import picocolors from 'picocolors';
  14. import fs from 'node:fs';
  15. import { fastStringArrayJoin, writeFile } from './misc';
  16. import { readFileByLine } from './fetch-text-by-line';
  17. import { asyncWriteToStream } from './async-write-to-stream';
  18. const defaultSortTypeOrder = Symbol('defaultSortTypeOrder');
  19. const sortTypeOrder: Record<string | typeof defaultSortTypeOrder, number> = {
  20. DOMAIN: 1,
  21. 'DOMAIN-SUFFIX': 2,
  22. 'DOMAIN-KEYWORD': 10,
  23. // experimental domain wildcard support
  24. 'DOMAIN-WILDCARD': 20,
  25. 'DOMAIN-REGEX': 21,
  26. 'USER-AGENT': 30,
  27. 'PROCESS-NAME': 40,
  28. [defaultSortTypeOrder]: 50, // default sort order for unknown type
  29. 'URL-REGEX': 100,
  30. AND: 300,
  31. OR: 300,
  32. GEOIP: 400,
  33. 'IP-CIDR': 400,
  34. 'IP-CIDR6': 400
  35. };
  36. abstract class RuleOutput {
  37. protected domainTrie = createTrie<unknown>(null, true);
  38. protected domainKeywords = new Set<string>();
  39. protected domainWildcard = new Set<string>();
  40. protected ipcidr = new Set<string>();
  41. protected ipcidrNoResolve = new Set<string>();
  42. protected ipcidr6 = new Set<string>();
  43. protected ipcidr6NoResolve = new Set<string>();
  44. // TODO: add sourceIpcidr
  45. // TODO: add sourcePort
  46. // TODO: add port
  47. // TODO: processName
  48. // TODO: processPath
  49. // TODO: userAgent
  50. // TODO: urlRegex
  51. protected otherRules: Array<[raw: string, orderWeight: number]> = [];
  52. protected abstract type: 'domainset' | 'non_ip' | 'ip';
  53. protected pendingPromise = Promise.resolve();
  54. static jsonToLines(this: void, json: unknown): string[] {
  55. return stringify(json).split('\n');
  56. }
  57. constructor(
  58. protected readonly span: Span,
  59. protected readonly id: string
  60. ) {}
  61. protected title: string | null = null;
  62. withTitle(title: string) {
  63. this.title = title;
  64. return this;
  65. }
  66. protected description: string[] | readonly string[] | null = null;
  67. withDescription(description: string[] | readonly string[]) {
  68. this.description = description;
  69. return this;
  70. }
  71. protected date = new Date();
  72. withDate(date: Date) {
  73. this.date = date;
  74. return this;
  75. }
  76. protected apexDomainMap: Map<string, string> | null = null;
  77. protected subDomainMap: Map<string, string> | null = null;
  78. withDomainMap(apexDomainMap: Map<string, string>, subDomainMap: Map<string, string>) {
  79. this.apexDomainMap = apexDomainMap;
  80. this.subDomainMap = subDomainMap;
  81. return this;
  82. }
  83. addDomain(domain: string) {
  84. this.domainTrie.add(domain);
  85. return this;
  86. }
  87. addDomainSuffix(domain: string) {
  88. this.domainTrie.add(domain[0] === '.' ? domain : '.' + domain);
  89. return this;
  90. }
  91. bulkAddDomainSuffix(domains: string[]) {
  92. for (let i = 0, len = domains.length; i < len; i++) {
  93. this.addDomainSuffix(domains[i]);
  94. }
  95. return this;
  96. }
  97. addDomainKeyword(keyword: string) {
  98. this.domainKeywords.add(keyword);
  99. return this;
  100. }
  101. addDomainWildcard(wildcard: string) {
  102. this.domainWildcard.add(wildcard);
  103. return this;
  104. }
  105. private async addFromDomainsetPromise(source: AsyncIterable<string> | Iterable<string> | string[]) {
  106. for await (const line of source) {
  107. if (line[0] === '.') {
  108. this.addDomainSuffix(line);
  109. } else {
  110. this.addDomain(line);
  111. }
  112. }
  113. }
  114. addFromDomainset(source: AsyncIterable<string> | Iterable<string> | string[]) {
  115. this.pendingPromise = this.pendingPromise.then(() => this.addFromDomainsetPromise(source));
  116. return this;
  117. }
  118. private async addFromRulesetPromise(source: AsyncIterable<string> | Iterable<string>) {
  119. for await (const line of source) {
  120. const splitted = line.split(',');
  121. const type = splitted[0];
  122. const value = splitted[1];
  123. const arg = splitted[2];
  124. switch (type) {
  125. case 'DOMAIN':
  126. this.addDomain(value);
  127. break;
  128. case 'DOMAIN-SUFFIX':
  129. this.addDomainSuffix(value);
  130. break;
  131. case 'DOMAIN-KEYWORD':
  132. this.addDomainKeyword(value);
  133. break;
  134. case 'DOMAIN-WILDCARD':
  135. this.addDomainWildcard(value);
  136. break;
  137. case 'IP-CIDR':
  138. (arg === 'no-resolve' ? this.ipcidrNoResolve : this.ipcidr).add(value);
  139. break;
  140. case 'IP-CIDR6':
  141. (arg === 'no-resolve' ? this.ipcidr6NoResolve : this.ipcidr6).add(value);
  142. break;
  143. default:
  144. this.otherRules.push([line, type in sortTypeOrder ? sortTypeOrder[type] : sortTypeOrder[defaultSortTypeOrder]]);
  145. break;
  146. }
  147. }
  148. }
  149. addFromRuleset(source: AsyncIterable<string> | Iterable<string>) {
  150. this.pendingPromise = this.pendingPromise.then(() => this.addFromRulesetPromise(source));
  151. return this;
  152. }
  153. bulkAddCIDR4(cidr: string[]) {
  154. for (let i = 0, len = cidr.length; i < len; i++) {
  155. this.ipcidr.add(cidr[i]);
  156. }
  157. return this;
  158. }
  159. bulkAddCIDR4NoResolve(cidr: string[]) {
  160. for (let i = 0, len = cidr.length; i < len; i++) {
  161. this.ipcidrNoResolve.add(cidr[i]);
  162. }
  163. return this;
  164. }
  165. bulkAddCIDR6(cidr: string[]) {
  166. for (let i = 0, len = cidr.length; i < len; i++) {
  167. this.ipcidr6.add(cidr[i]);
  168. }
  169. return this;
  170. }
  171. bulkAddCIDR6NoResolve(cidr: string[]) {
  172. for (let i = 0, len = cidr.length; i < len; i++) {
  173. this.ipcidr6NoResolve.add(cidr[i]);
  174. }
  175. return this;
  176. }
  177. abstract write(): Promise<void>;
  178. }
  179. export class DomainsetOutput extends RuleOutput {
  180. protected type = 'domainset' as const;
  181. private $dumped: string[] | null = null;
  182. get dumped() {
  183. if (!this.$dumped) {
  184. const kwfilter = createKeywordFilter(this.domainKeywords);
  185. const results: string[] = [];
  186. const dumped = this.domainTrie.dump();
  187. for (let i = 0, len = dumped.length; i < len; i++) {
  188. const domain = dumped[i];
  189. if (!kwfilter(domain)) {
  190. results.push(domain);
  191. }
  192. }
  193. this.$dumped = results;
  194. }
  195. return this.$dumped;
  196. }
  197. calcDomainMap() {
  198. if (!this.apexDomainMap || !this.subDomainMap) {
  199. const { domainMap, subdomainMap } = buildParseDomainMap(this.dumped);
  200. this.apexDomainMap = domainMap;
  201. this.subDomainMap = subdomainMap;
  202. }
  203. }
  204. async write() {
  205. await this.pendingPromise;
  206. invariant(this.title, 'Missing title');
  207. invariant(this.description, 'Missing description');
  208. const sorted = sortDomains(this.dumped, this.apexDomainMap, this.subDomainMap);
  209. sorted.push('this_ruleset_is_made_by_sukkaw.ruleset.skk.moe');
  210. const surge = sorted;
  211. const clash = surgeDomainsetToClashDomainset(sorted);
  212. // TODO: Implement singbox directly using data
  213. const singbox = RuleOutput.jsonToLines(surgeDomainsetToSingbox(sorted));
  214. await Promise.all([
  215. compareAndWriteFile(
  216. this.span,
  217. withBannerArray(
  218. this.title,
  219. this.description,
  220. this.date,
  221. surge
  222. ),
  223. path.join(OUTPUT_SURGE_DIR, this.type, this.id + '.conf')
  224. ),
  225. compareAndWriteFile(
  226. this.span,
  227. withBannerArray(
  228. this.title,
  229. this.description,
  230. this.date,
  231. clash
  232. ),
  233. path.join(OUTPUT_CLASH_DIR, this.type, this.id + '.txt')
  234. ),
  235. compareAndWriteFile(
  236. this.span,
  237. singbox,
  238. path.join(OUTPUT_SINGBOX_DIR, this.type, this.id + '.json')
  239. )
  240. ]);
  241. }
  242. getStatMap() {
  243. invariant(this.dumped, 'Non dumped yet');
  244. invariant(this.apexDomainMap, 'Missing apex domain map');
  245. return Array.from(
  246. (
  247. nullthrow(this.dumped, 'Non dumped yet').reduce<Map<string, number>>((acc, cur) => {
  248. const suffix = this.apexDomainMap!.get(cur);
  249. if (suffix) {
  250. acc.set(suffix, (acc.get(suffix) ?? 0) + 1);
  251. }
  252. return acc;
  253. }, new Map())
  254. ).entries()
  255. )
  256. .filter(a => a[1] > 9)
  257. .sort(
  258. (a, b) => (b[1] - a[1]) || a[0].localeCompare(b[0])
  259. )
  260. .map(([domain, count]) => `${domain}${' '.repeat(100 - domain.length)}${count}`);
  261. }
  262. }
  263. export class IPListOutput extends RuleOutput {
  264. protected type = 'ip' as const;
  265. constructor(span: Span, id: string, private readonly clashUseRule = true) {
  266. super(span, id);
  267. }
  268. async write() {
  269. await this.pendingPromise;
  270. invariant(this.title, 'Missing title');
  271. invariant(this.description, 'Missing description');
  272. const sorted4 = Array.from(this.ipcidr);
  273. const sorted6 = Array.from(this.ipcidr6);
  274. const merged = appendArrayInPlace(appendArrayInPlace([], sorted4), sorted6);
  275. const surge = sorted4.map(i => `IP-CIDR,${i}`);
  276. appendArrayInPlace(surge, sorted6.map(i => `IP-CIDR6,${i}`));
  277. surge.push('DOMAIN,this_ruleset_is_made_by_sukkaw.ruleset.skk.moe');
  278. const clash = this.clashUseRule ? surge : merged;
  279. // TODO: Implement singbox directly using data
  280. const singbox = RuleOutput.jsonToLines(ipCidrListToSingbox(merged));
  281. await Promise.all([
  282. compareAndWriteFile(
  283. this.span,
  284. withBannerArray(
  285. this.title,
  286. this.description,
  287. this.date,
  288. surge
  289. ),
  290. path.join(OUTPUT_SURGE_DIR, this.type, this.id + '.conf')
  291. ),
  292. compareAndWriteFile(
  293. this.span,
  294. withBannerArray(
  295. this.title,
  296. this.description,
  297. this.date,
  298. clash
  299. ),
  300. path.join(OUTPUT_CLASH_DIR, this.type, this.id + '.txt')
  301. ),
  302. compareAndWriteFile(
  303. this.span,
  304. singbox,
  305. path.join(OUTPUT_SINGBOX_DIR, this.type, this.id + '.json')
  306. )
  307. ]);
  308. }
  309. }
  310. export class RulesetOutput extends RuleOutput {
  311. constructor(span: Span, id: string, protected type: 'non_ip' | 'ip') {
  312. super(span, id);
  313. }
  314. async write() {
  315. await this.pendingPromise;
  316. invariant(this.title, 'Missing title');
  317. invariant(this.description, 'Missing description');
  318. const results: string[] = [
  319. 'DOMAIN,this_ruleset_is_made_by_sukkaw.ruleset.skk.moe'
  320. ];
  321. const kwfilter = createKeywordFilter(this.domainKeywords);
  322. const sortedDomains = sortDomains(this.domainTrie.dump(), this.apexDomainMap, this.subDomainMap);
  323. for (let i = 0, len = sortedDomains.length; i < len; i++) {
  324. const domain = sortedDomains[i];
  325. if (kwfilter(domain)) {
  326. continue;
  327. }
  328. if (domain[0] === '.') {
  329. results.push(`DOMAIN-SUFFIX,${domain.slice(1)}`);
  330. } else {
  331. results.push(`DOMAIN,${domain}`);
  332. }
  333. }
  334. for (const keyword of this.domainKeywords) {
  335. results.push(`DOMAIN-KEYWORD,${keyword}`);
  336. }
  337. for (const wildcard of this.domainWildcard) {
  338. results.push(`DOMAIN-WILDCARD,${wildcard}`);
  339. }
  340. const sortedRules = this.otherRules.sort((a, b) => a[1] - b[1]);
  341. for (let i = 0, len = sortedRules.length; i < len; i++) {
  342. results.push(sortedRules[i][0]);
  343. }
  344. this.ipcidr.forEach(cidr => results.push(`IP-CIDR,${cidr}`));
  345. this.ipcidrNoResolve.forEach(cidr => results.push(`IP-CIDR,${cidr},no-resolve`));
  346. this.ipcidr6.forEach(cidr => results.push(`IP-CIDR6,${cidr}`));
  347. this.ipcidr6NoResolve.forEach(cidr => results.push(`IP-CIDR6,${cidr},no-resolve`));
  348. const surge = results;
  349. const clash = surgeRulesetToClashClassicalTextRuleset(results);
  350. // TODO: Implement singbox directly using data
  351. const singbox = RuleOutput.jsonToLines(surgeRulesetToSingbox(results));
  352. await Promise.all([
  353. compareAndWriteFile(
  354. this.span,
  355. withBannerArray(
  356. this.title,
  357. this.description,
  358. this.date,
  359. surge
  360. ),
  361. path.join(OUTPUT_SURGE_DIR, this.type, this.id + '.conf')
  362. ),
  363. compareAndWriteFile(
  364. this.span,
  365. withBannerArray(
  366. this.title,
  367. this.description,
  368. this.date,
  369. clash
  370. ),
  371. path.join(OUTPUT_CLASH_DIR, this.type, this.id + '.txt')
  372. ),
  373. compareAndWriteFile(
  374. this.span,
  375. singbox,
  376. path.join(OUTPUT_SINGBOX_DIR, this.type, this.id + '.json')
  377. )
  378. ]);
  379. }
  380. }
  381. function withBannerArray(title: string, description: string[] | readonly string[], date: Date, content: string[]) {
  382. return [
  383. '#########################################',
  384. `# ${title}`,
  385. `# Last Updated: ${date.toISOString()}`,
  386. `# Size: ${content.length}`,
  387. ...description.map(line => (line ? `# ${line}` : '#')),
  388. '#########################################',
  389. ...content,
  390. '################## EOF ##################'
  391. ];
  392. };
  393. export const fileEqual = async (linesA: string[], source: AsyncIterable<string>): Promise<boolean> => {
  394. if (linesA.length === 0) {
  395. return false;
  396. }
  397. let index = -1;
  398. for await (const lineB of source) {
  399. index++;
  400. if (index > linesA.length - 1) {
  401. if (index === linesA.length && lineB === '') {
  402. return true;
  403. }
  404. // The file becomes smaller
  405. return false;
  406. }
  407. const lineA = linesA[index];
  408. if (lineA[0] === '#' && lineB[0] === '#') {
  409. continue;
  410. }
  411. if (
  412. lineA[0] === '/'
  413. && lineA[1] === '/'
  414. && lineB[0] === '/'
  415. && lineB[1] === '/'
  416. && lineA[3] === '#'
  417. && lineB[3] === '#'
  418. ) {
  419. continue;
  420. }
  421. if (lineA !== lineB) {
  422. return false;
  423. }
  424. }
  425. if (index < linesA.length - 1) {
  426. // The file becomes larger
  427. return false;
  428. }
  429. return true;
  430. };
  431. export async function compareAndWriteFile(span: Span, linesA: string[], filePath: string) {
  432. let isEqual = true;
  433. const linesALen = linesA.length;
  434. if (fs.existsSync(filePath)) {
  435. isEqual = await fileEqual(linesA, readFileByLine(filePath));
  436. } else {
  437. console.log(`${filePath} does not exists, writing...`);
  438. isEqual = false;
  439. }
  440. if (isEqual) {
  441. console.log(picocolors.gray(picocolors.dim(`same content, bail out writing: ${filePath}`)));
  442. return;
  443. }
  444. await span.traceChildAsync(`writing ${filePath}`, async () => {
  445. // The default highwater mark is normally 16384,
  446. // So we make sure direct write to file if the content is
  447. // most likely less than 500 lines
  448. if (linesALen < 500) {
  449. return writeFile(filePath, fastStringArrayJoin(linesA, '\n') + '\n');
  450. }
  451. const writeStream = fs.createWriteStream(filePath);
  452. for (let i = 0; i < linesALen; i++) {
  453. const p = asyncWriteToStream(writeStream, linesA[i] + '\n');
  454. // eslint-disable-next-line no-await-in-loop -- stream high water mark
  455. if (p) await p;
  456. }
  457. await asyncWriteToStream(writeStream, '\n');
  458. writeStream.end();
  459. });
  460. }