教程文档【免费下载链接】30-seconds-of-codeCoding articles to level up your development skills项目地址https://gitcode.com/gh_mirrors/30/30-seconds-of-code点击查看免费下载本篇技术指南围绕 30 seconds of code 仓库中经典的 Porter 词干提取算法文章 展开完整讲解 Martin F. Porter 于 1980 年提出的这一套 5 步词干提取stemming算法先建立辅音/元音序列与 measure 度量等术语体系再逐步骤给出规则表、正则表达式与可运行的 JavaScript 代码最后结合仓库中实际驱动站点搜索的生产级实现 porterStemmer.js 与 documentSearch.js说明如何把词干提取接入分词、停用词过滤、TF-IDF 与倒排索引组成的完整搜索管线。读完你将能够独立实现并理解一个最小可用的英文词干提取器并清楚它在自然语言处理NLP与文本搜索中的定位与局限。算法概述为什么需要词干提取Porter 词干提取算法由 Martin F. Porter 于 1980 年提出距今已 45 年。他在论文中描述了一组步骤用于去除英语单词中携带极少语义信息的常见后缀将其归约为基本形式这一过程被称为词干提取stemming。例如CARESSES→CAress去除复数后缀PLASTERED→PLASTER去除过去时后缀HAPPY→HAPPI把-y后缀改写为-iRELATIONAL→RELATE去除常见派生后缀算法的完整流程由5 个步骤组成其中第 1 步和第 5 步又拆分为多个子步骤。尽管该算法只适用于英语单词、且在不少情况下无法还原出真正的词根但由于其相对简单、可读性好、无需词典至今仍被广泛使用。它是进入**自然语言处理NLP**领域、或构建一个简单的基于文本的搜索引擎的绝佳起点。在 30 seconds of code 仓库中这篇文档正是 JavaScript Search Engine 集合 的第一篇后续依次衔接 TF-IDF 与倒排索引搜索、部分匹配搜索 与 n-gram 模糊匹配共同构成一套完整的迷你搜索引擎教程。[!IMPORTANT]本实现主要作为学习资源部分代码可能未经优化或存在瑕疵。作者在尽力测试的前提下以可读性优先、不过度牺牲性能为原则编写。由于该算法非常流行若用于生产环境完全可以查找更优化的实现版本。术语体系辅音、元音、measure 与规则形式为便于理解规则先回顾原论文中的术语约定。在单词中辅音consonant指除 A、E、I、O、U 之外的字母以及前一个字母为辅音时的 Y若非辅音则该字母为元音vowel。两者分别用c和v表示其非零连续序列分别记为C和V。任何单词或单词的一部分都可以用一个统一模式匹配C{m}[V]其中方括号表示内容可有可无(VC){m}表示VC模式重复m次。m被称为该单词或单词部分的measure度量。去除后缀的规则以下列形式给出( condition ) S1 - S2含义是若单词以S1结尾且S1之前的词干stem满足condition则将S1替换为S2。条件通常用m表达但也可能包含以下特殊标记*S—— 词干以S结尾其他字母同理*v*—— 词干包含元音*d—— 词干以双辅音结尾如tt、ss*o—— 词干以「辅音-元音-辅音」序列结尾且第二个辅音不是w、x或y如wil、hop。条件之间还可以用and、or、not组合。针对单个步骤给出的一组规则中只有一条生效且总是移除最长的后缀。通用正则模式把术语翻译成 JavaScript实现的第一步是定义一组通用正则表达式对应上述术语。它们会在后续所有步骤中被反复引用// c - consonant const consonant [^aeiou]; // v - vowel const vowel [aeiouy]; // C - consonant sequence const consonants ( consonant [^aeiouy]*); // V - vowel sequence const vowels ( vowel [aeiou]*); // m 0 const mGreaterThanZero new RegExp( ^ consonants ? vowels consonants ); // m 1 const mEqualsOne new RegExp( ^ consonants ? vowels consonants vowels ?$ ); // m 1 const mGreaterThanOne new RegExp( ^ consonants ?( vowels consonants ){2,} ); // *v* - stem contains a vowel const stemContainsVowel new RegExp( ^ consonants ? vowel ); // *o - stem ends with a consonant-vowel-consonant sequence const stemEndsWithConsonantVowelConsonant new RegExp( ^ consonants ? consonant vowel [^aeiouwxy]$ );这里的关键在于用正则表达式编码字符串的结构consonant匹配单个辅音字符consonants匹配辅音序列vowels匹配元音序列mGreaterThanZero/mEqualsOne/mGreaterThanOne分别编码 measure 的m 0、m 1、m 1三种取值stemContainsVowel编码条件*v*stemEndsWithConsonantVowelConsonant编码条件*o注意末尾的字符类[^aeiouwxy]排除了w、x、y。Step 1a去除复数后缀Step 1a 负责处理复数形式后缀规则示例SSES - SSCARESSES - CARESSIES - IPONIES - PONI、TIES - TISS - SSCARESS - CARESSS -CATS - CATconst step1a word { if (word.endsWith(sses)) return word.slice(0, -2); if (word.endsWith(ies)) return word.slice(0, -2); if (word.endsWith(ss)) return word; if (word.endsWith(s)) return word.slice(0, -1); return word; }; step1a(caresses); // caress step1a(ponies); // poni step1a(ties); // ti step1a(caress); // caress step1a(cats); // cat实现上直接用String.prototype.endsWith()依次判断并按后缀长度用slice()截断。注意sses - ss与ies - i都是去掉末尾两个字符而ss - ss不变——这正是规则集中只挑最长匹配后缀生效的体现以ss结尾的单词不应被最后的s -规则误伤。Step 1b去除过去时与动名词后缀Step 1b 处理过去时和动名词-ing后缀并在去除后缀后恢复词干应有的形态规则示例(m0) EED - EEFEED - FEED、AGREED - AGREE(m0) ED -PLASTERED - PLASTER、BLED - BLED(m0) ING -MOTORING - MOTOR、SING - SING若第二或第三条规则成功命中还需继续应用以下规则规则示例AT - ATECONFLATED- CONFLATEBL - BLETROUBLED- TROUBLEIZ - IZESIZED- SIZE(*d and not (*L or *S or *Z))- single letterHOPPING- HOP、TANNED- TAN、FALLING- FALL、HISSING- HISS、FIZZED- FIZZ(m1 and *o) - EFAILING- FAIL、FILING- FILEconst step1b word { if (word.endsWith(eed) mGreaterThanZero.test(word.slice(0, -3))) return word.slice(0, -1); let matched null; if (word.endsWith(ed) mGreaterThanZero.test(word.slice(0, -2))) matched word.slice(0, -2); if (word.endsWith(ing) mGreaterThanZero.test(word.slice(0, -3))) matched word.slice(0, -3); if (matched) { if (/(at|bl|iz)$/.test(matched)) return matched e; if (/([^aeiouylsz])\1$/g.test(matched)) return matched.slice(0, -1); if ( mEqualsOne.test(matched) stemEndsWithConsonantVowelConsonant.test(matched) ) return matched e; return matched; } return word; }; step1b(feed); // feed step1b(agreed); // agree step1b(plastered); // plaster step1b(bled); // bled step1b(motoring); // motor step1b(sing); // sing step1b(conflated); // conflate step1b(troubled); // trouble step1b(sized); // size step1b(hopping); // hop step1b(tanned); // tan step1b(falling); // fall step1b(hissing); // hiss step1b(fizzed); // fizz step1b(failing); // fail step1b(filing); // file三个关键设计measure 门槛eed - ee、ed、ing的去除都以mGreaterThanZero.test(词干)为前提避免误伤feed词干f的 measure 为 0这类词——这正是BLED - BLED、SING - SING保持不变的原因。双辅音回退/([^aeiouylsz])\1$/g用反向引用检测词干是否以双辅音结尾且该辅音不是l、s、z若命中则删除末尾一个字符HOPP - HOP。l、s、z被排除是因为FALL - FALL、HISS - HISS、FIZZ - FIZZ需保持双字母。*o补e当m 1且词干满足「辅-元-辅」结尾时补回e如FIL - FILE。Step 1c处理-y后缀Step 1c 处理-y后缀的改写规则示例(*v*) Y - IHAPPY - HAPPI、SKY - SKIconst step1c word { if (word.endsWith(y) stemContainsVowel.test(word.slice(0, -1))) return word.slice(0, -1) i; return word; }; step1c(happy); // happi step1c(sky); // ski条件*v*词干含元音由stemContainsVowel正则判定。HAPPY去掉y后的词干happ含元音a故改写为happi若词干不含元音则保持原样。Step 2去除常见派生后缀Step 2 处理一批常见后缀多为拉丁/法语来源的派生后缀规则集中体现了(m0)门槛规则示例(m0) ATIONAL - ATERELATIONAL - RELATE(m0) TIONAL - TIONCONDITIONAL - CONDITION、RATIONAL - RATIONAL(m0) ENCI - ENCEVALENCI - VALENCE(m0) ANCI - ANCEHESITANCI - HESITANCE(m0) IZER - IZEDIGITIZER - DIGITIZE(m0) ABLI - ABLECONFORMABLI - CONFORMABLE(m0) ALLI - ALRADICALLI - RADICAL(m0) ENTLI - ENTDIFFERENTLI - DIFFERENT(m0) ELI - EVILELI - VILE(m0) OUSLI - OUSANALOGOUSLI - ANALOGOUS(m0) IZATION - IZEVIETNAMIZATION - VIETNAMIZE(m0) ATION - ATEPREDICATION - PREDICATE(m0) ATOR - ATEOPERATOR - OPERATE(m0) ALISM - ALFEUDALISM - FEUDAL(m0) IVENESS - IVEDECISIVENESS - DECISIVE(m0) FULNESS - FULHOPEFULNESS - HOPEFUL(m0) OUSNESS - OUSCALLOUSNESS - CALLOUS(m0) ALITI - ALFORMALITI - FORMAL(m0) IVITI - IVESENSITIVITI - SENSITIVE(m0) BILITI - BLESENSIBILITI - SENSIBLEconst step2 word { const rules { ational: ate, tional: tion, enci: ence, anci: ance, izer: ize, abli: able, alli: al, entli: ent, eli: e, ousli: ous, ization: ize, ation: ate, ator: ate, alism: al, iveness: ive, fulness: ful, ousness: ous, aliti: al, iviti: ive, biliti: ble, }; for (const [suffix, replacement] of Object.entries(rules)) { if ( word.endsWith(suffix) mGreaterThanZero.test(word.slice(0, -suffix.length)) ) return word.slice(0, -suffix.length) replacement; } return word; }; step2(relational); // relate step2(conditional); // condition step2(rational); // rational step2(valenci); // valence step2(hesitanci); // hesitance step2(digitizer); // digitize step2(conformabli); // conformable step2(radicalli); // radical step2(differentli); // different step2(vileli); // vile step2(analogousli); // analogous step2(vietnamization); // vietnamize step2(predication); // predicate step2(operator); // operate step2(feudalism); // feudal step2(decisiveness); // decisive step2(hopefulness); // hopeful step2(callousness); // callous step2(formaliti); // formal step2(sensitiviti); // sensitive step2(sensibiliti); // sensible实现上把「后缀 → 替换」映射放进一个对象用Object.entries()遍历注意更长的后缀排在前面如ization先于ation保证最长后缀优先匹配的语义——这与一组规则中只挑最长匹配后缀的原则一致。RATIONAL - RATIONAL保持不变的例子说明去除tional后词干ra的 measure 为 0不满足m0。Step 3另一组常见后缀Step 3 继续处理常见后缀规则同样受(m0)约束规则示例(m0) ICATE - ICTRIPLICATE - TRIPLIC(m0) ATIVE -FORMATIVE - FORM(m0) ALIZE - ALFORMALIZE - FORMAL(m0) ICITI - ICELECTRICITI - ELECTRIC(m0) ICAL - ICELECTRICAL - ELECTRIC(m0) FUL -HOPEFUL - HOPE(m0) NESS -GOODNESS - GOODconst step3 word { const rules { icate: ic, ative: , alize: al, iciti: ic, ical: ic, ful: , ness: , }; for (const [suffix, replacement] of Object.entries(rules)) { if ( word.endsWith(suffix) mGreaterThanZero.test(word.slice(0, -suffix.length)) ) return word.slice(0, -suffix.length) replacement; } return word; }; step3(triplicate); // triplic step3(formative); // form step3(formalize); // formal step3(electriciti); // electric step3(electrical); // electric step3(hopeful); // hope step3(goodness); // good注意部分规则ATIVE、FUL、NESS的替换为空字符串即直接删除后缀。Step 4更长后缀的删除Step 4 的规则要求(m1)且是纯删除替换均为空。唯一的例外是ION规则它还要求词干以*S或*T结尾规则示例(m1) AL -REVIVAL - REVIV(m1) ANCE -ALLOWANCE - ALLOW(m1) ENCE -INFERENCE - INFER(m1) ER -AIRLINER - AIRLIN(m1) IC -GYROSCOPIC - GYROSCOP(m1) ABLE -ADJUSTABLE - ADJUST(m1) IBLE -DEFENSIBLE - DEFENS(m1) ANT -IRRITANT - IRRIT(m1) EMENT -REPLACEMENT - REPLAC(m1) MENT -ADJUSTMENT - ADJUST(m1) ENT -DEPENDENT - DEPEND(m1 and (*S or *T)) ION -ADOPTION - ADOPT(m1) OU -HOMOLOGOU - HOMOLOG(m1) ISM -COMMUNISM - COMMUN(m1) ATE -ACTIVATE - ACTIV(m1) ITI -ANGULARITI - ANGULAR(m1) OUS -HOMOLOGOUS - HOMOLOG(m1) IVE -EFFECTIVE - EFFECT(m1) IZE -BOWDLERIZE - BOWDLERconst step4 word { const rules { al: , ance: , ence: , er: , ic: , able: , ible: , ant: , ement: , ment: , ent: , ion: , ou: , ism: , ate: , iti: , ous: , ive: , ize: , }; for (const [suffix, replacement] of Object.entries(rules)) { if ( word.endsWith(suffix) mGreaterThanOne.test(word.slice(0, -suffix.length)) ) if (suffix ion /[^st]$/.test(word.slice(0, -3))) continue; else return word.slice(0, -suffix.length) replacement; } return word; }; step4(revival); // reviv step4(allowance); // allow step4(inference); // infer step4(airliner); // airlin step4(gyroscopic); // gyroscop step4(adjustable); // adjust step4(defensible); // defens step4(irritant); // irrit step4(replacement); // replac step4(adjustment); // adjust step4(dependent); // depend step4(adoption); // adopt step4(homologou); // homolog step4(communism); // commun step4(activate); // activ step4(angulariti); // angular step4(homologous); // homolog step4(effective); // effect step4(bowdlerize); // bowdlerion的例外用/[^st]$/检查词干末尾字符若词干不以s或t结尾如adopt满足、某些以其他字母结尾的词不满足则continue跳过本次匹配避免把nation之类不应删除ion的词误伤。Step 5a 与 Step 5b收尾的形态调整Step 5a 负责常见的末尾调整即删除或保留词尾e规则示例(m1) E -PROBATE - PROBAT、RATE - RATE(m1 and not *o) E -CEASE - CEASconst step5a word { if ( word.endsWith(e) mGreaterThanOne.test(word.slice(0, -1)) ) return word.slice(0, -1); if ( word.endsWith(e) mEqualsOne.test(word.slice(0, -1)) !stemEndsWithConsonantVowelConsonant.test(word.slice(0, -1)) ) return word.slice(0, -1); return word; }; step5a(probate); // probat step5a(rate); // rate step5a(cease); // ceasRATE - RATE保持不变是因为去掉e后词干rat的 measure 为 1等于 1 时仅当不满足*o才删除CEASE - CEAS则满足「m1 且非 *o」条件。Step 5b 处理-ll后缀的收敛规则示例(m1 and *d and *L)- single letterCONTROLL - CONTROL、ROLL - ROLLconst step5b word { if ( word.endsWith(ll) mGreaterThanOne.test(word) ) return word.slice(0, -1); return word; }; step5b(controll); // control step5b(roll); // roll生产级实现对比仓库源码中的优化与差异教学版代码以可读性为第一目标而 30 seconds of code 站点的实际搜索功能使用了位于 src/lib/search/porterStemmer.js 的生产级stem函数。对比两者可以学到不少工程细节1. 以「捕获组」代替手写slice()生产版用带捕获组的正则一次性拆出词干部分如sfxE /^(.?)e$/再用match[1]取回词干。这样每个步骤的表达更紧凑也更贴近 Porter 论文的(condition) S1 - S2记法。2. 额外的边界处理生产版在函数开头就对长度小于 3 的字符串直接返回if (str.length 3) return str;避免对过短输入做无意义的处理同时会检测词首小写y临时替换为大写Y以防其在*v*判断中误当作元音处理完再还原。3. 规则表的细微差异教学版的 Step 2 写的是abli - able而生产版是bli - ble且额外增加了一条logi - log如analogi - analog用于处理-logy/-logist一类派生词Step 4 的ion例外在生产版中由专门的正则sfxIon /^(.?(s|t))(ion)$/表达。这些差异说明同一个算法在工程落地时可以根据实际语料做针对性微调。4. 在搜索管线中的真实位置生产版stem被 documentSearch.js 的tokenizePlainText调用const tokenizePlainText str splitTokens(str).map(tkn cleanTokenPunctuation(stem(tkn)));即搜索时先做小写化与分词splitTokens见 utils.js再对每个 token 执行词干提取随后进入倒排索引与 TF-IDF 评分阶段。而 server.js 在写入索引前还叠加了多达 15 条的tokenFilter过滤规则排除数字、十六进制串、变量名、URL 片段、过短/过长 token 等配合 settings.js 中数百个英语停用词构成完整的文档预处理流水线。与后续搜索系列文章的衔接词干提取本身解决的是让不同词形的同一语义词匹配到同一词条这一检索核心问题。在 JavaScript Search Engine 集合 中这篇算法实现是第一块基石后续内容按以下顺序递进TF-IDF 与倒排索引搜索先分词、去停用词再用porterStemmer处理 query 与文档进而用 TF-IDF 计算相关性、以倒排索引加速检索。文中明确写道Assuming theporterStemmerfunction is available并直接import { porterStemmer }复用本算法的实现部分匹配搜索在倒排索引命中失败时对查询最后一个词做前缀匹配并修正 TF-IDF 计分n-gram 模糊匹配引入三元组trigram倒排索引与fuzziness加权参数容忍用户拼写错误。可见Porter 词干提取是整个迷你搜索引擎的预处理层核心组件没有它search(jumping)与文档中的jumps将永远无法互相匹配。完整实现汇总全部步骤将上述所有步骤串联起来就得到完整的词干提取器。以下是教学版的完整代码汇总各步函数与上文一一对应主函数porterStemmer依次调用 1a → 1b → 1c → 2 → 3 → 4 → 5a → 5b// c - consonant const consonant [^aeiou]; // v - vowel const vowel [aeiouy]; // C - consonant sequence const consonants ( consonant [^aeiouy]*); // V - vowel sequence const vowels ( vowel [aeiou]*); // m 0 const mGreaterThanZero new RegExp( ^ consonants ? vowels consonants ); // m 1 const mEqualsOne new RegExp( ^ consonants ? vowels consonants vowels ?$ ); // m 1 const mGreaterThanOne new RegExp( ^ consonants ?( vowels consonants ){2,} ); // *v* - stem contains a vowel const stemContainsVowel new RegExp( ^ consonants ? vowel ); // *o - stem ends with a consonant-vowel-consonant sequence const stemEndsWithConsonantVowelConsonant new RegExp( ^ consonants ? consonant vowel [^aeiouwxy]$ ); // Step 1a - common plural forms const step1a word { if (word.endsWith(sses)) return word.slice(0, -2); if (word.endsWith(ies)) return word.slice(0, -2); if (word.endsWith(ss)) return word; if (word.endsWith(s)) return word.slice(0, -1); return word; }; // Step 1b - past tenses and gerunds const step1b word { if (word.endsWith(eed) mGreaterThanZero.test(word.slice(0, -3))) return word.slice(0, -1); let matched null; if (word.endsWith(ed) mGreaterThanZero.test(word.slice(0, -2))) matched word.slice(0, -2); if (word.endsWith(ing) mGreaterThanZero.test(word.slice(0, -3))) matched word.slice(0, -3); if (matched) { if (/(at|bl|iz)$/.test(matched)) return matched e; if (/([^aeiouylsz])\1$/g.test(matched)) return matched.slice(0, -1); if ( mEqualsOne.test(matched) stemEndsWithConsonantVowelConsonant.test(matched) ) return matched e; return matched; } return word; }; // Step 1c - -y suffixes const step1c word { if (word.endsWith(y) stemContainsVowel.test(word.slice(0, -1))) return word.slice(0, -1) i; return word; }; // Step 2 - common suffixes const step2 word { const rules { ational: ate, tional: tion, enci: ence, anci: ance, izer: ize, abli: able, alli: al, entli: ent, eli: e, ousli: ous, ization: ize, ation: ate, ator: ate, alism: al, iveness: ive, fulness: ful, ousness: ous, aliti: al, iviti: ive, biliti: ble, }; for (const [suffix, replacement] of Object.entries(rules)) { if ( word.endsWith(suffix) mGreaterThanZero.test(word.slice(0, -suffix.length)) ) return word.slice(0, -suffix.length) replacement; } return word; }; // Step 3 - common suffixes const step3 word { const rules { icate: ic, ative: , alize: al, iciti: ic, ical: ic, ful: , ness: , }; for (const [suffix, replacement] of Object.entries(rules)) { if ( word.endsWith(suffix) mGreaterThanZero.test(word.slice(0, -suffix.length)) ) return word.slice(0, -suffix.length) replacement; } return word; }; // Step 4 - common suffixes const step4 word { const rules { al: , ance: , ence: , er: , ic: , able: , ible: , ant: , ement: , ment: , ent: , ion: , ou: , ism: , ate: , iti: , ous: , ive: , ize: , }; for (const [suffix, replacement] of Object.entries(rules)) { if ( word.endsWith(suffix) mGreaterThanOne.test(word.slice(0, -suffix.length)) ) if (suffix ion /[^st]$/.test(word.slice(0, -3))) continue; else return word.slice(0, -suffix.length) replacement; } return word; }; // Step 5a - common suffixes const step5a word { if ( word.endsWith(e) mGreaterThanOne.test(word.slice(0, -1)) ) return word.slice(0, -1); if ( word.endsWith(e) mEqualsOne.test(word.slice(0, -1)) !stemEndsWithConsonantVowelConsonant.test(word.slice(0, -1)) ) return word.slice(0, -1); return word; }; // Step 5b - -ll suffixes const step5b word { if ( word.endsWith(ll) mGreaterThanOne.test(word) ) return word.slice(0, -1); return word; }; const porterStemmer word { let stemmed step1a(word); stemmed step1b(stemmed); stemmed step1c(stemmed); stemmed step2(stemmed); stemmed step3(stemmed); stemmed step4(stemmed); stemmed step5a(stemmed); stemmed step5b(stemmed); return stemmed; };结语从 1980 年的论文到 2025 年的 JavaScript 实现Porter 词干提取算法用极简的 5 步规则集将CARESSES、PLASTERED、RELATIONAL这类形态各异的单词归约到统一词干为后续的 TF-IDF 相关性计算与倒排索引检索打下基础。即便它的输出并非严格意义上的语言学词根、也仅适用于英语但作为 NLP 入门与轻量级搜索引擎的预处理层它依然是性价比极高的选择。如果你想继续深入仓库中的 TF-IDF 与倒排索引文章 会展示如何把porterStemmer直接接入分词与检索流程而 documentSearch.js、server.js 则展示了生产环境中的完整预处理管线。把这一步跑通你就已经握住了搜索引擎词法归一化的核心钥匙。赞分享教程文档【免费下载链接】30-seconds-of-codeCoding articles to level up your development skills项目地址https://gitcode.com/gh_mirrors/30/30-seconds-of-code点击查看免费下载相关推荐Phabricator 内置的 Porter StemmerPHP 词干提取算法原理与全文搜索集成实战Phabricator 内置的 Porter StemmerPHP 词干提取算法原理与全文搜索集成实战 本文以 Phabricator 仓库内置的第三方词干提后端开发工具研发协作Civitai 事件引擎 Redis 缓存排查指南redis-inspect 技能全解析Civitai 事件引擎 Redis 缓存排查指南redis inspect 技能全解析 本文围绕 apps/event engine/.claude/ski教程文档30-seconds-of-code 实战在 JavaScript 中实现 K-means 聚类算法30 seconds of code 实战在 JavaScript 中实现 K means 聚类算法 导读 K means 聚类是最经典的无监督机器学习算法之教程文档上一篇在 Cursor 中用 Automation 落地 Issue Triage 循环只读报告、人工闸门与渐进式放权下一篇空文件查找器项目实战Go 命令行参数排序、目录扫描与文件写入练习精解创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考