sensitive-word 基于 DFA ç®—æ³•å®žçŽ°çš„é«˜æ€§èƒ½æ•æ„Ÿè¯å·¥å…·ã€‚
å®žçŽ°ä¸€æ¬¾å¥½ç”¨æ•æ„Ÿè¯å·¥å…·ã€‚
基于 DFA ç®—æ³•å®žçŽ°ï¼Œç›®å‰æ•感è¯åº“内容收录 6W+ï¼ˆæºæ–‡ä»¶ 18W+,ç»è¿‡ä¸€æ¬¡åˆ å‡ï¼‰ã€‚
åŽæœŸå°†è¿›è¡ŒæŒç»ä¼˜åŒ–å’Œè¡¥å……æ•æ„Ÿè¯åº“ï¼Œå¹¶è¿›ä¸€æ¥æå‡ç®—法的性能。
希望å¯ä»¥ç»†åŒ–æ•æ„Ÿè¯çš„åˆ†ç±»ï¼Œæ„Ÿè§‰å·¥ä½œé‡æ¯”较大,暂时没有进行。
-
6W+ è¯åº“ï¼Œä¸”ä¸æ–优化更新
-
基于 DFA 算法,性能较好
-
基于 fluent-api 实现,使用优雅简æ´
-
æ”¯æŒæ•感è¯çš„判æ–ã€è¿”回ã€è„±æ•ç‰å¸¸è§æ“作
-
支æŒå…¨è§’åŠè§’互æ¢
-
支æŒè‹±æ–‡å¤§å°å†™äº’æ¢
-
æ”¯æŒæ•°å—常è§å½¢å¼çš„互æ¢
-
支æŒä¸æ–‡ç¹ç®€ä½“互æ¢
-
支æŒè‹±æ–‡å¸¸è§å½¢å¼çš„互æ¢
-
支æŒç”¨æˆ·è‡ªå®šä¹‰æ•感è¯å’Œç™½åå•
-
æ”¯æŒæ•°æ®çš„æ•°æ®åŠ¨æ€æ›´æ–°ï¼Œå®žæ—¶ç”Ÿæ•ˆ
v0.2.0 å˜æ›´ï¼š
- æ 8000 ”¯æŒç”¨æˆ·è‡ªå®šä¹‰æ›¿æ¢ç–ç•¥
-
JDK1.7+
-
Maven 3.x+
<dependency>
<groupId>com.github.houbb</groupId>
<artifactId>sensitive-word</artifactId>
<version>0.2.0</version>
</dependency>
SensitiveWordHelper
ä½œä¸ºæ•æ„Ÿè¯çš„å·¥å…·ç±»ï¼Œæ ¸å¿ƒæ–¹æ³•å¦‚ä¸‹ï¼š
方法 | 傿•° | 返回值 | 说明 |
---|---|---|---|
contains(String) | 待验è¯çš„å—符串 | 布尔值 | 验è¯å—符串是å¦åŒ…嫿•æ„Ÿè¯ |
replace(String, ISensitiveWordReplace) | 使用指定的替æ¢ç–ç•¥æ›¿æ¢æ•æ„Ÿè¯ | å—符串 | 返回脱æ•åŽçš„å—符串 |
replace(String, char) | 使用指定的 char æ›¿æ¢æ•æ„Ÿè¯ | å—符串 | 返回脱æ•åŽçš„å—符串 |
replace(String) | 使用 * æ›¿æ¢æ•æ„Ÿè¯ |
å—符串 | 返回脱æ•åŽçš„å—符串 |
findAll(String) | 待验è¯çš„å—符串 | å—符串列表 | 返回å—ç¬¦ä¸²ä¸æ‰€æœ‰æ•æ„Ÿè¯ |
findFirst(String) | 待验è¯çš„å—符串 | å—符串 | 返回å—符串ä¸ç¬¬ä¸€ä¸ªæ•æ„Ÿè¯ |
findAll(String, IWordResultHandler) | IWordResultHandler 结果处ç†ç±» | å—符串列表 | 返回å—ç¬¦ä¸²ä¸æ‰€æœ‰æ•æ„Ÿè¯ |
findFirst(String, IWordResultHandler) | IWordResultHandler 结果处ç†ç±» | å—符串 | 返回å—符串ä¸ç¬¬ä¸€ä¸ªæ•æ„Ÿè¯ |
IWordResultHandler å¯ä»¥å¯¹æ•感è¯çš„结果进行处ç†ï¼Œå…许用户自定义。
å†…ç½®å®žçŽ°è§ WordResultHandlers
工具类:
- WordResultHandlers.word()
åªä¿ç•™æ•感è¯å•è¯æœ¬èº«ã€‚
- WordResultHandlers.raw()
ä¿ç•™æ•感è¯ç›¸å…³ä¿¡æ¯ï¼ŒåŒ…嫿•感è¯ï¼Œå¼€å§‹å’Œç»“æŸä¸‹æ ‡ã€‚
所有测试案例å‚è§ SensitiveWordHelperTest
final String text = "五星红旗迎风飘扬,毛主å¸çš„ç”»åƒå±¹ç«‹åœ¨å¤©å®‰é—¨å‰ã€‚";
Assert.assertTrue(SensitiveWordHelper.contains(text));
final String text = "五星红旗迎风飘扬,毛主å¸çš„ç”»åƒå±¹ç«‹åœ¨å¤©å®‰é—¨å‰ã€‚";
String word = SensitiveWordHelper.findFirst(text);
Assert.assertEquals("五星红旗", word);
SensitiveWordHelper.findFirst(text) ç‰ä»·äºŽï¼š
String word = SensitiveWordHelper.findFirst(text, WordResultHandlers.word());
WordResultHandlers.raw() å¯ä»¥ä¿ç•™å¯¹åº”çš„ä¸‹æ ‡ä¿¡æ¯ï¼š
final String text = "五星红旗迎风飘扬,毛主å¸çš„ç”»åƒå±¹ç«‹åœ¨å¤©å®‰é—¨å‰ã€‚";
IWordResult word = SensitiveWordHelper.findFirst(text, WordResultHandlers.raw());
Assert.assertEquals("WordResult{word='五星红旗', startIndex=0, endIndex=4}", word.toString());
final String text = "五星红旗迎风飘扬,毛主å¸çš„ç”»åƒå±¹ç«‹åœ¨å¤©å®‰é—¨å‰ã€‚";
List<String> wordList = SensitiveWordHelper.findAll(text);
Assert.assertEquals("[五星红旗, 毛主å¸, 天安门]", wordList.toString());
è¿”å›žæ‰€æœ‰æ•æ„Ÿè¯ç”¨æ³•上类似于 SensitiveWordHelper.findFirst()ï¼ŒåŒæ ·ä¹Ÿæ”¯æŒæŒ‡å®šç»“果处ç†ç±»ã€‚
SensitiveWordHelper.findAll(text) ç‰ä»·äºŽï¼š
List<String> wordList = SensitiveWordHelper.findAll(text, WordResultHandlers.word());
WordResultHandlers.raw() å¯ä»¥ä¿ç•™å¯¹åº”çš„ä¸‹æ ‡ä¿¡æ¯ï¼š
final String text = "五星红旗迎风飘扬,毛主å¸çš„ç”»åƒå±¹ç«‹åœ¨å¤©å®‰é—¨å‰ã€‚";
List<IWordResult> wordList = SensitiveWordHelper.findAll(text, WordResultHandlers.raw());
Assert.assertEquals("[WordResult{word='五星红旗', startIndex=0, endIndex=4}, WordResult{word='毛主å¸', startIndex=9, endIndex=12}, WordResult{word='天安门', startIndex=18, endIndex=21}]", wordList.toString());
final String text = "五星红旗迎风飘扬,毛主å¸çš„ç”»åƒå±¹ç«‹åœ¨å¤©å®‰é—¨å‰ã€‚";
String result = SensitiveWordHelper.replace(text);
Assert.assertEquals("****迎风飘扬,***的画åƒå±¹ç«‹åœ¨***å‰ã€‚", result);
final String text = "五星红旗迎风飘扬,毛主å¸çš„ç”»åƒå±¹ç«‹åœ¨å¤©å®‰é—¨å‰ã€‚";
String result = SensitiveWordHelper.replace(text, '0');
Assert.assertEquals("0000迎风飘扬,000的画åƒå±¹ç«‹åœ¨000å‰ã€‚", result);
V0.2.0 支æŒè¯¥ç‰¹æ€§ã€‚
场景说明:有时候我们希望ä¸åŒçš„æ•æ„Ÿè¯æœ‰ä¸åŒçš„æ›¿æ¢ç»“æžœã€‚æ¯”å¦‚ã€æ¸¸æˆã€‘替æ¢ä¸ºã€ç”µå竞技】,ã€å¤±ä¸šã€‘替æ¢ä¸ºã€çµæ´»å°±ä¸šã€‘。
诚然,æå‰ä½¿ç”¨å—符串的æ£åˆ™æ›¿æ¢ä¹Ÿå¯ä»¥ï¼Œä¸è¿‡æ€§èƒ½ä¸€èˆ¬ã€‚
使用例å:
/**
* 自定替æ¢ç–ç•¥
* @since 0.2.0
*/
@Test
public void defineReplaceTest() {
final String text = "五星红旗迎风飘扬,毛主å¸çš„ç”»åƒå±¹ç«‹åœ¨å¤©å®‰é—¨å‰ã€‚";
ISensitiveWordReplace replace = new MySensitiveWordReplace();
String result = SensitiveWordHelper.replace(text, replace);
Assert.assertEquals("国家旗帜迎风飘扬,教员的画åƒå±¹ç«‹åœ¨***å‰ã€‚", result);
}
å…¶ä¸ MySensitiveWordReplace
是我们自定义的替æ¢ç–略,实现如下:
public class MySensitiveWordReplace implements ISensitiveWordReplace {
@Override
public String replace(ISensitiveWordReplaceContext context) {
String sensitiveWord = context.sensitiveWord();
// 自定义ä¸åŒçš„æ•æ„Ÿè¯æ›¿æ¢ç–略,å¯ä»¥ä»Žæ•°æ®åº“ç‰åœ°æ–¹è¯»å–
if("五星红旗".equals(sensitiveWord)) {
return "国家旗帜";
}
if("毛主å¸".equals(sensitiveWord)) {
return "教员";
}
// 其他默认使用 * 代替
int wordLength = context.wordLength();
return CharUtil.repeat('*', wordLength);
}
}
我们针对其ä¸çš„部分è¯åšå›ºå®šæ˜ 射处ç†ï¼Œå…¶ä»–的默认转æ¢ä¸º *
。
åŽç»çš„è¯¸å¤šç‰¹æ€§ï¼Œä¸»è¦æ˜¯é’ˆå¯¹å„ç§é’ˆå¯¹å„ç§æƒ…况的处ç†ï¼Œå°½å¯èƒ½çš„æå‡æ•感è¯å‘½ä¸çŽ‡ã€‚
这是一场漫长的攻防之战。
final String text = "fuCK the bad words.";
String word = SensitiveWordHelper.findFirst(text);
Assert.assertEquals("fuCK", word);
final String text = "fuck the bad words.";
String word = SensitiveWordHelper.findFirst(text);
Assert.assertEquals("fuck", word);
这里实现了数å—常è§å½¢å¼çš„转æ¢ã€‚
final String text = "这个是我的微信:9⓿二肆â¹â‚ˆâ‘¢â‘¸â’‹âžƒãˆ¤ãŠ„";
List<String> wordList = SensitiveWordHelper.findAll(text);
Assert.assertEquals("[9⓿二肆â¹â‚ˆâ‘¢â‘¸â’‹âžƒãˆ¤ãŠ„]", wordList.toString());
final String text = "我爱我的祖国和五星紅旗。";
List<String> wordList = SensitiveWordHelper.findAll(text);
Assert.assertEquals("[五星紅旗]", wordList.toString());
final String text = "Ⓕⓤc⒦ the bad words";
List<String> wordList = SensitiveWordHelper.findAll(text);
Assert.assertEquals("[Ⓕⓤc⒦]", wordList.toString());
final String text = "ⒻⒻⒻfⓤuⓤ⒰cⓒ⒦ the bad words";
List<String> wordList = SensitiveWordBs.newInstance()
.ignoreRepeat(true)
.findAll(text);
Assert.assertEquals("[ⒻⒻⒻfⓤuⓤ⒰cⓒ⒦]", wordList.toString());
final String text = "楼主好人,邮箱 sensitiveword@xx.com";
List<String> wordList = SensitiveWordHelper.findAll(text);
Assert.assertEquals("[sensitiveword@xx.com]", wordList.toString());
上é¢çš„特性默认都是开å¯çš„,有时业务需è¦çµæ´»å®šä¹‰ç›¸å…³çš„é…置特性。
所以 v0.0.14 开放了属性é…置。
ä¸ºäº†è®©ä½¿ç”¨æ›´åŠ ä¼˜é›…ï¼Œç»Ÿä¸€ä½¿ç”¨ fluent-api 的方å¼å®šä¹‰ã€‚
用户å¯ä»¥ä½¿ç”¨ SensitiveWordBs
进行如下定义:
SensitiveWordBs wordBs = SensitiveWordBs.newInstance()
.ignoreCase(true)
.ignoreWidth(true)
.ignoreNumStyle(true)
.ignoreChineseStyle(true)
.ignoreEnglishStyle(true)
.ignoreRepeat(true)
.enableNumCheck(true)
.enableEmailCheck(true)
.enableUrlCheck(true)
.init();
final String text = "五星红旗迎风飘扬,毛主å¸çš„ç”»åƒå±¹ç«‹åœ¨å¤©å®‰é—¨å‰ã€‚";
Assert.assertTrue(wordBs.contains(text));
å…¶ä¸å„项é…置的说明如下:
åºå· | 方法 | 说明 |
---|---|---|
1 | ignoreCase | 忽略大å°å†™ |
2 | ignoreWidth | 忽略åŠè§’圆角 |
3 | ignoreNumStyle | 忽略数å—的写法 |
4 | ignoreChineseStyle | å¿½ç•¥ä¸æ–‡çš„ä¹¦å†™æ ¼å¼ |
5 | ignoreEnglishStyle | å¿½ç•¥è‹±æ–‡çš„ä¹¦å†™æ ¼å¼ |
6 | ignoreRepeat | 忽略é‡å¤è¯ |
7 | enableNumCheck | 是å¦å¯ç”¨æ•°å—æ£€æµ‹ã€‚é»˜è®¤è¿žç» 8 使•°å—è®¤ä¸ºæ˜¯æ•æ„Ÿè¯ |
8 | enableEmailCheck | 是有å¯ç”¨é‚®ç®±æ£€æµ‹ |
9 | enableUrlCheck | 是å¦å¯ç”¨é“¾æŽ¥æ£€æµ‹ |
æœ‰æ—¶å€™æˆ‘ä»¬å¸Œæœ›å°†æ•æ„Ÿè¯çš„åŠ è½½è®¾è®¡æˆåЍæ€çš„,比如控å°ä¿®æ”¹ï¼Œç„¶åŽå¯ä»¥å®žæ—¶ç”Ÿæ•ˆã€‚
v0.0.13 支æŒäº†è¿™ç§ç‰¹æ€§ã€‚
为了实现这个特性,并且兼容以å‰çš„功能,我们定义了两个接å£ã€‚
接å£å¦‚下,å¯ä»¥è‡ªå®šä¹‰è‡ªå·±çš„实现。
è¿”å›žçš„åˆ—è¡¨ï¼Œè¡¨ç¤ºè¿™ä¸ªè¯æ˜¯ä¸€ä¸ªæ•感è¯ã€‚
/**
* æ‹’ç»å‡ºçŽ°çš„æ•°æ®-è¿”å›žçš„å†…å®¹è¢«å½“åšæ˜¯æ•感è¯
* @author binbin.hou
* @since 0.0.13
*/
public interface IWordDeny {
/**
* 获å–结果
* @return 结果
* @since 0.0.13
*/
List<String> deny();
}
比如:
public class MyWordDeny implements IWordDeny {
@Override
public List<String> deny() {
return Arrays.asList("æˆ‘çš„è‡ªå®šä¹‰æ•æ„Ÿè¯");
}
}
接å£å¦‚下,å¯ä»¥è‡ªå®šä¹‰è‡ªå·±çš„实现。
返回的列表,表示这个è¯ä¸æ˜¯ä¸€ä¸ªæ•感è¯ã€‚
/**
* å…许的内容-返回的内容ä¸è¢«å½“åšæ•感è¯
* @author binbin.hou
* @since 0.0.13
*/
public interface IWordAllow {
/**
* 获å–结果
* @return 结果
* @since 0.0.13
*/
List<String> allow();
}
如:
public class MyWordAllow implements IWordAllow {
@Override
public List<String> allow() {
return Arrays.asList("五星红旗");
}
}
接å£è‡ªå®šä¹‰ä¹‹åŽï¼Œå½“ç„¶éœ€è¦æŒ‡å®šæ‰èƒ½ç”Ÿæ•ˆã€‚
ä¸ºäº†è®©ä½¿ç”¨æ›´åŠ ä¼˜é›…ï¼Œæˆ‘ä»¬è®¾è®¡äº†å¼•å¯¼ç±» SensitiveWordBs
。
å¯ä»¥é€šè¿‡ wordDeny() æŒ‡å®šæ•æ„Ÿè¯ï¼ŒwordAllow() æŒ‡å®šéžæ•感è¯ï¼Œé€šè¿‡ init() åˆå§‹åŒ–æ•æ„Ÿè¯å—典。
SensitiveWordBs wordBs = SensitiveWordBs.newInstance()
.wordDeny(WordDenys.system())
.wordAllow(WordAllows.system())
.init();
final String text = "五星红旗迎风飘扬,毛主å¸çš„ç”»åƒå±¹ç«‹åœ¨å¤©å®‰é—¨å‰ã€‚";
Assert.assertTrue(wordBs.contains(text));
备注:init() å¯¹äºŽæ•æ„Ÿè¯ DFA 的构建是比较耗时的,一般建议在应用åˆå§‹åŒ–的时候åªåˆå§‹åŒ–ä¸€æ¬¡ã€‚è€Œä¸æ˜¯é‡å¤åˆå§‹åŒ–ï¼
我们å¯ä»¥æµ‹è¯•一下自定义的实现,如下:
String text = "è¿™æ˜¯ä¸€ä¸ªæµ‹è¯•ï¼Œæˆ‘çš„è‡ªå®šä¹‰æ•æ„Ÿè¯ã€‚";
SensitiveWordBs wordBs = SensitiveWordBs.newInstance()
.wordDeny(new MyWordDeny())
.wordAllow(new MyWordAllow())
.init();
Assert.assertEquals("[æˆ‘çš„è‡ªå®šä¹‰æ•æ„Ÿè¯]", wordBs.findAll(text).toString());
è¿™é‡Œåªæœ‰ æˆ‘çš„è‡ªå®šä¹‰æ•æ„Ÿè¯
æ˜¯æ•æ„Ÿè¯ï¼Œè€Œ 测试
䏿˜¯æ•感è¯ã€‚
当然,这里是全部使用我们自定义的实现,一般建议使用系统的默认é…ç½®+自定义é…置。
å¯ä»¥ä½¿ç”¨ä¸‹é¢çš„æ–¹å¼ã€‚
- å¤šä¸ªæ•æ„Ÿè¯
WordDenys.chains()
方法,将多个实现åˆå¹¶ä¸ºåŒä¸€ä¸ª IWordDeny。
- 多个白åå•
WordAllows.chains()
方法,将多个实现åˆå¹¶ä¸ºåŒä¸€ä¸ª IWordAllow。
例å:
String text = "è¿™æ˜¯ä¸€ä¸ªæµ‹è¯•ã€‚æˆ‘çš„è‡ªå®šä¹‰æ•æ„Ÿè¯ã€‚";
IWordDeny wordDeny = WordDenys.chains(WordDenys.system(), new MyWordDeny());
IWordAllow wordAllow = WordAllows.chains(WordAllows.system(), new MyWordAllow());
SensitiveWordBs wordBs = SensitiveWordBs.newInstance()
.wordDeny(wordDeny)
.wordAllow(wordAllow)
.init();
Assert.assertEquals("[æˆ‘çš„è‡ªå®šä¹‰æ•æ„Ÿè¯]", wordBs.findAll(text).toString());
è¿™é‡Œéƒ½æ˜¯åŒæ—¶ä½¿ç”¨äº†ç³»ç»Ÿé»˜è®¤é…置,和自定义的é…置。
实际使用ä¸ï¼Œæ¯”如å¯ä»¥åœ¨é¡µé¢é…置修改,然åŽå®žæ—¶ç”Ÿæ•ˆã€‚
æ•°æ®å˜å‚¨åœ¨æ•°æ®åº“ä¸ï¼Œä¸‹é¢æ˜¯ä¸€ä¸ªä¼ªä»£ç 的例å,å¯ä»¥å‚考 SpringSensitiveWordConfig.java
è¦æ±‚,版本 v0.0.15 åŠå…¶ä»¥ä¸Šã€‚
简化伪代ç 如下,数æ®çš„æºå¤´ä¸ºæ•°æ®åº“。
MyDdWordAllow å’Œ MyDdWordDeny 是基于数æ®åº“为æºå¤´çš„自定义实现类。
@Configuration
public class SpringSensitiveWordConfig {
@Autowired
private MyDdWordAllow myDdWordAllow;
@Autowired
private MyDdWordDeny myDdWordDeny;
/**
* åˆå§‹åŒ–引导类
* @return åˆå§‹åŒ–引导类
* @since 1.0.0
*/
@Bean
public SensitiveWordBs sensitiveWordBs() {
SensitiveWordBs sensitiveWordBs = SensitiveWordBs.newInstance()
.wordAllow(WordAllows.chains(WordAllows.system(), myDdWordAllow))
.wordDeny(myDdWordDeny)
// å„ç§å…¶ä»–é…ç½®
.init();
return sensitiveWordBs;
}
}
æ•æ„Ÿè¯åº“çš„åˆå§‹åŒ–较为耗时,建议程åºå¯åŠ¨æ—¶åšä¸€æ¬¡ init åˆå§‹åŒ–。
为了ä¿è¯æ•感è¯ä¿®æ”¹å¯ä»¥å®žæ—¶ç”Ÿæ•ˆä¸”ä¿è¯æŽ¥å£çš„å°½å¯èƒ½ç®€åŒ–,æ¤å¤„没有新增 add/remove 的方法。
而是在调用 sensitiveWordBs.init()
çš„æ—¶å€™ï¼Œæ ¹æ® IWordDeny+IWordAllow 釿–°æž„å»ºæ•æ„Ÿè¯åº“。
å› ä¸ºåˆå§‹åŒ–å¯èƒ½è€—时较长(秒级别),所有优化为 init æœªå®Œæˆæ—¶ä¸å½±å“æ—§çš„è¯åº“功能,完æˆåŽä»¥æ–°çš„为准。
@Component
public class SensitiveWordService {
@Autowired
private SensitiveWordBs sensitiveWordBs;
/**
* æ›´æ–°è¯åº“
*
* æ¯æ¬¡æ•°æ®åº“的信æ¯å‘生å˜åŒ–之åŽï¼Œé¦–先调用更新数æ®åº“æ•æ„Ÿè¯åº“的方法。
* 如果需è¦ç”Ÿæ•ˆï¼Œåˆ™è°ƒç”¨è¿™ä¸ªæ–¹æ³•。
*
* è¯´æ˜Žï¼šé‡æ–°åˆå§‹åŒ–ä¸å½±å“旧的方法使用。åˆå§‹åŒ–完æˆåŽï¼Œä¼šä»¥æ–°çš„为准。
*/
public void refresh() {
// æ¯æ¬¡æ•°æ®åº“的信æ¯å‘生å˜åŒ–之åŽï¼Œé¦–先调用更新数æ®åº“æ•æ„Ÿè¯åº“的方法,然åŽè°ƒç”¨è¿™ä¸ªæ–¹æ³•。
sensitiveWordBs.init();
}
}
å¦‚ä¸Šï¼Œä½ å¯ä»¥åœ¨æ•°æ®åº“è¯åº“å‘ç”Ÿå˜æ›´æ—¶ï¼Œéœ€è¦è¯åº“生效,主动触å‘一次åˆå§‹åŒ– sensitiveWordBs.init();
。
å…¶ä»–ä½¿ç”¨ä¿æŒä¸å˜ï¼Œæ— 需é‡å¯åº”用。
-
åŒéŸ³å—处ç†
-
形近å—处ç†
-
æ–‡å—镜åƒç¿»è½¬
-
æ–‡å—é™å™ªå¤„ç†
-
æ•æ„Ÿè¯æ ‡ç¾æ”¯æŒ
-
DFA æ•°æ®ç»“构的å¦ä¸€ç§å®žçް
java 如何实现开箱å³ç”¨çš„æ•æ„Ÿè¯æŽ§å°æœåŠ¡ï¼Ÿ