create-file.ts 8.3 KB

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