create-file.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268
  1. // @ts-check
  2. import { surgeDomainsetToClashDomainset, surgeRulesetToClashClassicalTextRuleset } from './clash';
  3. import picocolors from 'picocolors';
  4. import type { Span } from '../trace';
  5. import path from 'node:path';
  6. import fs from 'node:fs';
  7. import { fastStringArrayJoin, writeFile } from './misc';
  8. import { readFileByLine } from './fetch-text-by-line';
  9. import stringify from 'json-stringify-pretty-compact';
  10. import { ipCidrListToSingbox, surgeDomainsetToSingbox, surgeRulesetToSingbox } from './singbox';
  11. import { createTrie } from './trie';
  12. import { pack, unpackFirst, unpackSecond } from './bitwise';
  13. import { asyncWriteToStream } from './async-write-to-stream';
  14. export async function compareAndWriteFile(span: Span, linesA: string[], filePath: string) {
  15. let isEqual = true;
  16. const linesALen = linesA.length;
  17. if (!fs.existsSync(filePath)) {
  18. console.log(`${filePath} does not exists, writing...`);
  19. isEqual = false;
  20. } else if (linesALen === 0) {
  21. console.log(`Nothing to write to ${filePath}...`);
  22. isEqual = false;
  23. } else {
  24. isEqual = await span.traceChildAsync(`comparing ${filePath}`, async () => {
  25. let index = 0;
  26. for await (const lineB of readFileByLine(filePath)) {
  27. const lineA = linesA[index] as string | undefined;
  28. index++;
  29. if (lineA == null) {
  30. // The file becomes smaller
  31. return false;
  32. }
  33. if (lineA[0] === '#' && lineB[0] === '#') {
  34. continue;
  35. }
  36. if (
  37. lineA[0] === '/'
  38. && lineA[1] === '/'
  39. && lineB[0] === '/'
  40. && lineB[1] === '/'
  41. && lineA[3] === '#'
  42. && lineB[3] === '#'
  43. ) {
  44. continue;
  45. }
  46. if (lineA !== lineB) {
  47. return false;
  48. }
  49. }
  50. if (index !== linesALen) {
  51. // The file becomes larger
  52. return false;
  53. }
  54. return true;
  55. });
  56. }
  57. if (isEqual) {
  58. console.log(picocolors.gray(picocolors.dim(`same content, bail out writing: ${filePath}`)));
  59. return;
  60. }
  61. await span.traceChildAsync(`writing ${filePath}`, async () => {
  62. // The default highwater mark is normally 16384,
  63. // So we make sure direct write to file if the content is
  64. // most likely less than 500 lines
  65. if (linesALen < 500) {
  66. return writeFile(filePath, fastStringArrayJoin(linesA, '\n') + '\n');
  67. }
  68. const writeStream = fs.createWriteStream(filePath);
  69. for (let i = 0; i < linesALen; i++) {
  70. const p = asyncWriteToStream(writeStream, linesA[i] + '\n');
  71. // eslint-disable-next-line no-await-in-loop -- stream high water mark
  72. if (p) await p;
  73. }
  74. await asyncWriteToStream(writeStream, '\n');
  75. writeStream.end();
  76. });
  77. }
  78. const withBannerArray = (title: string, description: string[] | readonly string[], date: Date, content: string[]) => {
  79. return [
  80. '#########################################',
  81. `# ${title}`,
  82. `# Last Updated: ${date.toISOString()}`,
  83. `# Size: ${content.length}`,
  84. ...description.map(line => (line ? `# ${line}` : '#')),
  85. '#########################################',
  86. ...content,
  87. '################## EOF ##################'
  88. ];
  89. };
  90. const defaultSortTypeOrder = Symbol('defaultSortTypeOrder');
  91. const sortTypeOrder: Record<string | typeof defaultSortTypeOrder, number> = {
  92. DOMAIN: 1,
  93. 'DOMAIN-SUFFIX': 2,
  94. 'DOMAIN-KEYWORD': 10,
  95. // experimental domain wildcard support
  96. 'DOMAIN-WILDCARD': 20,
  97. 'DOMAIN-REGEX': 21,
  98. 'USER-AGENT': 30,
  99. 'PROCESS-NAME': 40,
  100. [defaultSortTypeOrder]: 50, // default sort order for unknown type
  101. 'URL-REGEX': 100,
  102. AND: 300,
  103. OR: 300,
  104. 'IP-CIDR': 400,
  105. 'IP-CIDR6': 400
  106. };
  107. const flagDomain = 1 << 2;
  108. const flagDomainSuffix = 1 << 3;
  109. // dedupe and sort based on rule type
  110. const processRuleSet = (ruleSet: string[]) => {
  111. const trie = createTrie<number>(null, true);
  112. /** Packed Array<[valueIndex: number, weight: number]> */
  113. const sortMap: number[] = [];
  114. for (let i = 0, len = ruleSet.length; i < len; i++) {
  115. const line = ruleSet[i];
  116. const [type, value] = line.split(',');
  117. let extraWeight = 0;
  118. switch (type) {
  119. case 'DOMAIN':
  120. trie.add(value, pack(i, flagDomain));
  121. break;
  122. case 'DOMAIN-SUFFIX':
  123. trie.add('.' + value, pack(i, flagDomainSuffix));
  124. break;
  125. case 'URL-REGEX':
  126. if (value.includes('.+') || value.includes('.*')) {
  127. extraWeight += 10;
  128. }
  129. if (value.includes('|')) {
  130. extraWeight += 1;
  131. }
  132. sortMap.push(pack(i, sortTypeOrder[type] + extraWeight));
  133. break;
  134. case null:
  135. sortMap.push(pack(i, 10));
  136. break;
  137. default:
  138. if (type in sortTypeOrder) {
  139. sortMap.push(pack(i, sortTypeOrder[type]));
  140. } else {
  141. sortMap.push(pack(i, sortTypeOrder[defaultSortTypeOrder]));
  142. }
  143. }
  144. }
  145. const dumped = trie.dumpWithMeta();
  146. for (let i = 0, len = dumped.length; i < len; i++) {
  147. const originalIndex = unpackFirst(dumped[i][1]);
  148. const flag = unpackSecond(dumped[i][1]);
  149. const type = flag === flagDomain ? 'DOMAIN' : 'DOMAIN-SUFFIX';
  150. sortMap.push(pack(originalIndex, sortTypeOrder[type]));
  151. }
  152. return sortMap
  153. .sort((a, b) => unpackSecond(a) - unpackSecond(b))
  154. .map(c => ruleSet[unpackFirst(c)]);
  155. };
  156. const MARK = 'this_ruleset_is_made_by_sukkaw.ruleset.skk.moe';
  157. export const createRuleset = (
  158. parentSpan: Span,
  159. title: string, description: string[] | readonly string[], date: Date, content: string[],
  160. type: 'ruleset' | 'domainset' | 'ipcidr' | 'ipcidr6',
  161. [surgePath, clashPath, singBoxPath, _clashMrsPath]: readonly [
  162. surgePath: string,
  163. clashPath: string,
  164. singBoxPath: string,
  165. _clashMrsPath?: string
  166. ]
  167. ) => parentSpan.traceChild(`create ruleset: ${path.basename(surgePath, path.extname(surgePath))}`).traceAsyncFn(async (childSpan) => {
  168. content = processRuleSet(content);
  169. const surgeContent = childSpan.traceChildSync('process surge ruleset', () => {
  170. let _surgeContent;
  171. switch (type) {
  172. case 'domainset':
  173. _surgeContent = [MARK, ...content];
  174. break;
  175. case 'ruleset':
  176. _surgeContent = [`DOMAIN,${MARK}`, ...content];
  177. break;
  178. case 'ipcidr':
  179. _surgeContent = [`DOMAIN,${MARK}`, ...content.map(i => `IP-CIDR,${i}`)];
  180. break;
  181. case 'ipcidr6':
  182. _surgeContent = [`DOMAIN,${MARK}`, ...content.map(i => `IP-CIDR6,${i}`)];
  183. break;
  184. default:
  185. throw new TypeError(`Unknown type: ${type}`);
  186. }
  187. return withBannerArray(title, description, date, _surgeContent);
  188. });
  189. const clashContent = childSpan.traceChildSync('convert incoming ruleset to clash', () => {
  190. let _clashContent;
  191. switch (type) {
  192. case 'domainset':
  193. _clashContent = [MARK, ...surgeDomainsetToClashDomainset(content)];
  194. break;
  195. case 'ruleset':
  196. _clashContent = [`DOMAIN,${MARK}`, ...surgeRulesetToClashClassicalTextRuleset(content)];
  197. break;
  198. case 'ipcidr':
  199. case 'ipcidr6':
  200. _clashContent = content;
  201. break;
  202. default:
  203. throw new TypeError(`Unknown type: ${type}`);
  204. }
  205. return withBannerArray(title, description, date, _clashContent);
  206. });
  207. const singboxContent = childSpan.traceChildSync('convert incoming ruleset to singbox', () => {
  208. let _singBoxContent;
  209. switch (type) {
  210. case 'domainset':
  211. _singBoxContent = surgeDomainsetToSingbox([MARK, ...content]);
  212. break;
  213. case 'ruleset':
  214. _singBoxContent = surgeRulesetToSingbox([`DOMAIN,${MARK}`, ...content]);
  215. break;
  216. case 'ipcidr':
  217. case 'ipcidr6':
  218. _singBoxContent = ipCidrListToSingbox(content);
  219. break;
  220. default:
  221. throw new TypeError(`Unknown type: ${type}`);
  222. }
  223. return stringify(_singBoxContent).split('\n');
  224. });
  225. await Promise.all([
  226. compareAndWriteFile(childSpan, surgeContent, surgePath),
  227. compareAndWriteFile(childSpan, clashContent, clashPath),
  228. compareAndWriteFile(childSpan, singboxContent, singBoxPath)
  229. ]);
  230. // if (clashMrsPath) {
  231. // if (type === 'domainset') {
  232. // await childSpan.traceChildAsync('clash meta mrs domain ' + clashMrsPath, async () => {
  233. // await fs.promises.mkdir(path.dirname(clashMrsPath), { recursive: true });
  234. // await convertClashMetaMrs(
  235. // 'domain', 'text', clashPath, clashMrsPath
  236. // );
  237. // });
  238. // }
  239. // }
  240. });