데이터 분석/웹 스크래핑

[Web Scrapping] 정규 표현식

eunnys 2023. 10. 30. 17:17

정규 표현식 (Regular Expression)

- 정규 표현식은 복잡한 문자열을 처리할 때 사용하는 기법

 

[사례]
주민등록번호를 포함하고 있는 텍스트에서 주민등록번호 뒷자리를 * 문자로 변경
- 주민등록번호 문자열은 주민등록번호 형식인지 조사한다.

 
- 일반 파이썬 문법을 이용한 코드

data = """
park 800904-1234567
kim 841204-1034562
"""

result = []
for line in data.split('\n'):
    word_result = []
    for word in line.split():
        if len(word) == 14 and word[:6].isdigit() and word[7:].isdigit: # 숫자인지 체크
            word = word[:6] + '-' + '*******'
        word_result.append(word)
    result.append(' '.join(word_result)) # 이름 한칸 띄고 주민번호 출력
print('\n'.join(result))

 
- 정규 표현식을 이용한 코드

import re

data = """
park 800904-1234567
kim 841204-1034562
"""

pat = re.compile('(\d{6})[-]\d{7}') 
# compile(): 패턴을 만드는 함수, \d{6}: 숫자 6자리와 일치, (괄호)는 그룹을 의미
print(pat.sub(r'\1-*******', data).strip()) 
# sub(): replace함수와 비슷, 1번 그룹-뒤에는 *으로 대체, strip()은 공백 제거

 
 

■ 메타 문자
 

- 정규 표현식에 사용되어 지는 특별한 의미를 갖는 문자

.  ^  $  *  +  ?  { }  [ ]  \  |  ( )

 
 

■ 문자 클래스 [ ]

 
- [ ] 안에 포함된 문자들 중 하나와 매치 여부를 확인한다.
 

정규식 문자열 매치 여부
[abc] a Yes
before Yes
dude No

 
- 문자 사이에 하이픈(-)을 사용하면 두 문자 사이의 범위(from-to)를 의미한다.
    [a-c] → [abc], [0-5] → [012345]와 동일
- 문자 클래스 안에 ^ 메타 문자가 사용되면 not의 의미를 갖는다. [^0-9]는 숫자가 아닌 문자만 매치한다.
-  ^ 메타 문자가 [] 밖에 사용되면 특정 문자로 시작하는지 판단
 
 

■ 자주 사용하는 문자 클래스

 
- 자주 사용하는 정규식은 별도의 표기법으로 표현할 수 있다.
 

정규 표현식 설명
\ 정규 표현식에서 사용하는 문자 그대로 표현하려면 앞에 \를 붙인다 (예 : \+, \*)
\d 숫자와 매치, [0-9]와 동일한 표현식
\D 숫자가 아닌 것과 매치, [^0-9]와 동일한 표현식
\s 공백문자(space, tab)와 매치, [\t\n\r\f\v]와 동일한 표현식
\S 공백문잘가 아닌 것과 매치, [^ \t\n\r\f\v]와 동일한 표현식
\w 문자+숫자와 매치, [a-zA-Z0-9]와 동일한 표현식
\W 문자+숫자가 아닌 문자와 매치, [^a-zA-Z0-9]와 동일한 표현식

 
 

■ Dot(.)

 
- Dot(.) 메타 문자는 줄바꿈 문자인 \n을 제외한 모든 문자와 매치됨을 의미한다.
- [] 사이에 . 을 사용할 경우 문자 원래의 의미인 마침표가 된다.
 

정규식 문자열 매치 여부
a.b aab Yes
a0b Yes
abc No

 
 

반복(*)
 

- *은 * 바로 앞에 있는 문자가 0부터 무한대로 반복될 수 있다는 의미이다.
 

정규식 문자열 매치 여부
ca*t ct Yes
cat Yes
caaat Yes

 
 

■ 반복(+)

 
- +는 최소 1번 이상 반복될 때 사용한다.
 

정규식 문자열 매치 여부
ca+t ct No
cat Yes
caaat Yes

 
 

반복 횟수 지정({m, n})

 
- { } 메타 문자는 반복 횟수를 지정할 수 있다. {m, n} 정규식을 사용하면 반복 횟수가 m부터 n까지 매치할 수 있다. m 또는 n을 생략하거나 정수 한 개만 쓸 수 있다. {m, }이면 반복 횟수가 m이상인 경우이고, { ,n}이면 반복 횟수가 n이하를 의미한다.
 

정규식 문자열 매치 여부
ca{2}t cat No
caat Yes

 

정규식 문자열 매치 여부
ca{2,5}t cat No
caat Yes
caaaaat Yes

 
 

?

 
- ? 앞에 문자가 하나 있거나 없을 때 매치된다.
 

정규식 문자열 매치 여부
go?d gd Yes
god Yes
good No

 
 

$

 
- $ 앞에 있는 문자로 끝나면 매치된다. 여러 줄의 문자열일 경우 마지막 줄만 적용된다.
 

정규식 문자열 매치 여부
a$ a Yes
baa Yes
aabb No

 
 

^

 
- ^ 다음에 있는 문자로 시작하면 매치된다.
 

정규식 문자열 매치 여부
^a aaa Yes
abc Yes
good No

 
 

파이썬에서 정규 표현식 사용

 
- re 모듈 사용
   > re 모듈의 compile 함수를 이용하여 정규 표현식을 컴파일 한다.

import re
p = re.compile('ab*')

 
 
- 정규식을 사용한 문자열 검색
   - 컴파일 된 객체를 이용하여 다음의 함수를 이용하여 문자열 검색을 할 수 있다.
 

함수 설명
match() 문자열의 처음 시작부터 검색하여 일치하지 않는 부분이 나올 때까지 찾는다
search() 문자열 전체를 검색하여 처음으로 매치되는 문자열을 찾는다
findall() 정규식과 매치되는 모든 문자열을 찾아 리스트로 반환한다
finditer() 정규식과 매치되는 모든 문자열을 반복 가능한 객체로 반환한다
# 컴파일과 매치의 동시 수행
m = re.match('[a-z]+', 'python')

 

# match() : 문자열의 처음부터 정규식과 매치되는지 조사
m1 = p.match('python')
print(m1) # <re.Match object; span=(0, 6), match='python'>

m2 = p.match('pYthon')
print(m2) # <re.Match object; span=(0, 1), match='p'>

m3 = p.match('3 python')
print(m3) # None : 처음부터 숫자가 나왔기 때문에

 

# search() : 문자열 전체를 검색하여 **처음으로** 매치되는 문자열을 찾는다
m = p.search('3 python')
print(m.group()) # group() : 매치된 것만 추출 python

 
 


[문제] 전화번호 추출하기
다음의 전화번호 데이터에서 전화번호만 추출하는 정규 표현식을 작성하세요.
 

import re
phone = ['홍길동:010-1234-5678', '우리집:02-555-3333']

pat = re.compile('\d{2,3}-\d{3,4}-\d{4}') # 전화번호 패턴
for p in phone:
    s = pat.search(p)
    print(s.group())

 

# findall() : 정규식과 매치되는 모든 문자열을 찾아 리스트로 반환한다
p = re.compile('[a-z]+')
result = p.findall('life is too short')
print(result) # ['life', 'is', 'too', 'short']

 

# finditer() : 정규식과 매치되는 모든 문자열을 반복 가능한 객체로 반환한다
result = p.finditer('life is too short')
for m in result:
    print(m)

# <re.Match object; span=(0, 4), match='life'>
# <re.Match object; span=(5, 7), match='is'>
# <re.Match object; span=(8, 11), match='too'>
# <re.Match object; span=(12, 17), match='short'>

 
 

■ Match 객체 함수

 

함수 설명
group() 매치된 문자열을 반환한다
start() 매치된 문자열의 시작 위치를 반환한다
end() 매치된 문자열의 끝 위치를 반환한다
span() 매치된 문자열의 시작과 끝 값을 튜플로 반환한다

 

import re

p = re.compile('[a-z]+')
m = p.search('python')

print(m.group())
print(m.start()) # 시작 위치
print(m.end()) # 끝 위치
print(m.span()) # 시작과 끝

 
 

■ 컴파일 옵션

 

옵션 약어 설명
DOTALL S dot(.) 메타 문자가 줄바꿈 문자('\n')를 포함하여 모든 문자와 일치한다
IGNORECASE I 대소문자에 관계 없이 일치한다
MULTILINE M 여러 줄의 문자열에 대해 ^, $ 메타문자를 적용할 수 있다
※ ^는 문자열의 처음을, $는 문자열의 마지막을 의미
VERBOSE X 정규식을 보기 편하게 만들고 주석 등을 사용할 수 있게 한다

 
 
▶ DOTALL
- dot(.) 메타 문자가 줄바꿈 문자(\n)을 포함하여 모든 문자와 일치한다.
 

m = re.match('a.b', 'a\nb') # \n은 해당 사항 없음
print(m) # 매치 결과 없음

p = re.compile('a.b', re.DOTALL) # \n까지 포함
m = p.match('a\nb')
print(m) # <re.Match object; span=(0, 3), match='a\nb'>
s = '''hello
python'''
p = re.compile('hello.python')
m = p.match(s)
print(m) # 개행 문자가 있기 때문에 결과 없음
s = '''hello
python'''
p = re.compile('hello.python', re.DOTALL)
m = p.match(s)
print(m) # <re.Match object; span=(0, 12), match='hello\npython'>

 
 
▶ IGNORECASE
- 대소문자에 관계 없이 매치한다.
 

p = re.compile('[a-z]+', re.IGNORECASE)
m = p.match('pYthon')
print(m) # <re.Match object; span=(0, 6), match='pYthon'>

 
 
▶ MULTILINE
- 여러 줄의 문자열에 대해 ^, $ 메타 문자를 적용할 수 있다.
 

# python으로 시작하면서 공백 문자 하나, 숫자 또는 문자가 나와야 함
p = re.compile('^python\s\w+')
text = '''python one
life too short
python two
you need python
python three'''

m = p.findall(text)
print(m) # 첫줄만 반환 : ['python one']
# python으로 시작하면서 공백 문자 하나, 숫자 또는 문자가 나와야 함
p = re.compile('^python\s\w+', re.MULTILINE) 
text = '''python one
life too short
python two
you need python
python three'''

m = p.findall(text)
print(m) # ['python one', 'python two', 'python three']
p = re.compile('python\s\w+') 
text = '''python one
life too short
python two
you need python
python three'''

m = p.findall(text)
print(m) # ^를 빼면 : ['python one', 'python two', 'python\npython']

 
 

■ 백슬래시

 
[가정] "\section" 문자열을 찾기 위한 정규식을 만들어야 하는 상황
 
→ re.compile("\section")
 
- 이 정규식은 \s 문자가 공백으로 해석되어 의도한 대로 매치가 이뤄지지 않음
- 위쪽의 정규식은 [\t\n\r\f\v]ection과 같은 의미로 해석 됨
- 결국 정규식에서 사용한 \ 문자가 문자열 자체임을 알려 주기 위해 \를 2개 사용하여 이스케이프 처리를 해야 한다
>>> re.compile("\\section")
- 하지만 파이썬 정규식 엔진에는 파이썬 문자열 리터럴 규칙에 따라 \\  →  \로 변경되어 결국 \section이 전달된다
- 결국 파이썬 정규식 엔진에 \\ 문자를 전달하기 위해서는 백슬래시 4개 \\\\를 사용해야 한다
 
- 이러한 문제로 인해 파이썬 정규식에는 Raw String 규칙이 생기게 됐다.
- 따라서 다음과 같이 정규식 문자열 앞에 r 문자를 삽입하면 Raw String 규칙에 의해 \를 1개만 써도 동일한 의미를 갖게 된다. >>> re.compile(r'\\section')
 

import re

p = re.compile('\\section') # '\section'으로 해석됨, [\t\n\r\f\v]ection 문자열과 일치됨을 찾음
m = p.search('What is \section and example?')
print(m) # \section -> ection으로 수정하면 검색 됨

p = re.compile(r'\\section') # Raw String 사용
m = p.search('What is \section and example?')
print(m) # <re.Match object; span=(8, 16), match='\\section'>

 
 

■ 메타 문자

 

메타 문자 설명
| or의 의미
^ 문자열의 처음과 일치함을 의미
$ 문자열의 끝과 일치함을 의미
\A 문자열의 처음과 일치함을 의미. ^은 MILTILINE 옵션을 사용할 경우 각 줄의 문자열의 처음과 매치되지만 \A는 줄과 상관 없이 전체 문자열의 처음하고만 일치된다
\Z 문자열의 끝과 일치함을 의미. 여러 줄로 작성된 문자열에서 줄과 상관없이 전체 문자열의 끝과 일치된다
\b 단어 경계를 나타낸다. 단어 경계는 단어 문자와 비단어 문자 사이의 위치를 의미한다. 여기서 단어 문자는 보통 알파벳, 숫자, 밑줄 문자를 포함하며, 비단어 문자는 그 외의 문자이다.
\b는 백스페이스를 의미하므로 단어 구분자로 사용되기 위해서는 Raw String임을 알려주는 r을 반드시 붙여줘야 한다
\B \b와 반대로 단어 앞뒤가 공백으로 구분된 단어가 아닌 경우에만 일치된다

 
 
|
 

p = re.compile('Crow|Servo') # 둘중에 하나만 일치하면 일치
m = p.match('ServoHello')
print(m) # <re.Match object; span=(0, 5), match='Servo'>

 
 
$
 

print(re.search('short$', 'Life is too short')) # short으로 끝나는지 여부
print(re.search('short$', 'Life is too short, you need python'))

 
 
\A
 

p = re.compile('\Apython\s\w+', re.MULTILINE) 
text = '''python one
life too short
python two
you need python
python three'''

m = p.findall(text)
print(m)

 
 
\b
 

p = re.compile(r'\bclass\b')       # 공백을 포함하지 않고 매치 결과 반환
print(p.search('no class at all')) # <re.Match object; span=(3, 8), match='class'>
p = re.compile(r'\sclass\s')       # 공백을 포함해서 매치 결과 반환
print(p.search('no class at all')) # <re.Match object; span=(2, 9), match=' class '>
p = re.compile(r'\bclass\b')
print(p.search('class at all')) # <re.Match object; span=(0, 5), match='class'>
print(p.search('one subclass')) # None

 
 
\
- 정규 표현식에서 사용하는 문자 그대로 표현하려면 앞에 \를 붙여준다.
- 즉, 문자열 안에 포함된 메타 문자(. ? $ 등)를 원래 문자로 사용
 

# '안녕하세요?'는 ?앞에 문자가 하나 있거나 없으면 매치
# 즉, '안녕하세요', '안녕하세' 문자열과 일치
m = re.search('안녕하세요?', '여러분 안녕하세요?')
print(m) # <re.Match object; span=(4, 9), match='안녕하세요'> 물음표 없음
m = re.search('안녕하세요\?', '여러분 안녕하세요?') # ?는 메타 문자가 아닌 문자 자체로 인식
print(m) # <re.Match object; span=(4, 10), match='안녕하세요?'>

 
 

■ 그룹핑

 
- () 메타 문자는 그룹을 만든다
- 그룹을 만들면 group() 함수를 사용하여 그룹핑된 부분의 문자열만 뽑아낼 수 있다
 

group(인덱스) 설명
group(0) 일치된 전체 문자열, group()와 동일
group(1) 첫 번쩨 그룹에 해당하는 문자열
group(n) n 번째 그룹에 해당하는 문자열

 
- '\번호'를 이용하면 번호에 해당하는 그룹을 재참조 한다. 표현식은 Raw String으로 선언해야 한다.
- 그룹핑에 이름 붙이기 : (?p<그룹 이름>)
 

import re

p = re.compile('(ABC)+') # ABC가 한번 이상 반복
m = p.search('ABCABCABC OK?')
print(m)          # <re.Match object; span=(0, 9), match='ABCABCABC'>
print(m.group())  # or group(0), 일치된 전체 문자열 : ABCABCABC
print(m.group(1)) # 첫 번쨰 그룹에 해당하는 문자열 : ABC
# 이름과 전화번호를 그룹으로 지정하여 분리
s = 'park 010-1234-5678'
# 문자나 숫자 하나 이상, 공백 하나 이상, 숫자 하나 이상
p = re.compile('(\w+)\s+(\d+[-]\d+[-]\d+)') 
m = p.search(s)
print(m.group())  # 전체 출력
print(m.group(1)) # 이름만 출력
print(m.group(2)) # 전화번호만 출력
# 국번만 추출하고자 할 경우
# 그룹이 중첩되어 있는 경우는 바깥쪽부터 시작하여 안쪽으로 들어갈수록 인덱스가 증가
s = 'park 010-1234-5678'
p = re.compile('(\w+)\s+((\d+)[-]\d+[-]\d+)') # 그룹 : 이름이 1번, 전체 전화번호가 2번, 국번이 3번
m = p.search(s)
print(m.group(3))

 
 
- '\번호'를 이용한 재참조
- Raw String을 이용해야 함
 

re.match(r'(a)(b)\1\2', 'abab') # abab와 일치하는지

 

# 동일한 단어가 연속적으로 사용된 문자열 찾기

# \b(\w+)\b : 단어 경계를 기준으로 문자와 숫자로 이루어진 한 단어를 찾는다. 그리고 그 단어를 그룹으로 지정
# \s+ : 하나 이상의 공백 문자를 찾는다.
# \b\1\b : 단어 경계를 기준으로 한 단어를 찾으며, 그룹 참조를 통해 이전에 그룹화된 단어와 동일한 단어를 찾는다.
p = re.compile(r'\b(\w+)\b\s+\b\1\b')
m = p.search('I have a dog  dog in my house')
print(m.group()) # dog  dog

 

# 그룹에 이름 붙이기
# (?P<그룹 이름>)
p = re.compile('(?P<name>\w+)\s+(\d+[-]\d+[-]\d+)')
m = p.search('park 010-1111-2222')
print(m.group('name'))

 
 

■ 전방 탐색

 

정규식 종류 설명
표현식1(?=표현식2) 긍정 전방 탐색 표현식 1 뒤의 문자열이 표현식2와 매치되면 표현식1 일치.
표현식2의 문자열은 결과로 반환하지 않는다
표현식1(?!표현식2) 부정 전방 탐색 표현식1 뒤의 문자열이 표현식2와 매치되지 않으면 표현식1 일치.
표현식2의 문자열은 결과로 반환하지 않는다

 
 
- 긍정 전방 탐색

import re

# URL에서 프로토콜 이름만 검색
p = re.compile('.+(?=:)') # .+ 어떤 문자든 한 글자 이상, 앞에서부터 : 전까지 찾음
m = p.search('http://www.google.com')
print(m.group())  # http

 
 
- 부정 전방 탐색

# 파일 이름의 확장자 중 bat 파일만 제외하고 추출

file_names = ['autoexe.bat', 'python.exe', 'sysinfo.cf']
p = re.compile('[a-zA-Z]+\w*[.](?!bat)[a-zA-Z]+')
for file in file_names:
    m = p.search(file)
    if m: # 만약 검색이 되면
        print(m.group())

 
 

■ 후방 탐색

 
- 텍스트를 반환하기 전에 뒤쪽을 탐색하는 것
 

정규식 종류 설명
(?<=표현식2) 표현식1 긍정 후방 탐색 표현식 1 앞의 문자열이 표현식2와 매치되면 표현식1 일치.
표현식2의 문자열은 결과로 반환하지 않는다
(?<!표현식2) 표현식1 부정 후방 탐색 표현식1 앞의 문자열이 표현식2와 매치되지 않으면 표현식1 일치.
표현식2의 문자열은 결과로 반환하지 않는다

 
 
- 긍정 후방 탐색

p = re.compile('(?<=\$)[0-9]+[.][0-9]+') # 뒤에서부터 $가 나오기 전까지
m = p.search('ABC01: $23.45')
print(m.group())  # 23.45
# 금액에 소수점이 없는 숫자인 경우를 고려한 패턴
p = re.compile('(?<=\$)[0-9]+[.]?[0-9]*')
m = p.search('ABC01: $23')
print(m.group())

 
 
- 전후방 동시 탐색

# <p> ~ </p> 태그 사이의 문자열 추출하기
# 뒤에서 탐색했을 시 <p>까지 일치가 되어야 하고, 앞에서 탐색했을 시 </p>까지 일치가 되어야 함.

p = re.compile('(?<=<p>)\w+(?=</p>)')
m = p.search('kakao <p>ryan</p> keep a straight face.')
print(m.group()) # ryan

 
 

■ 문자열 바꾸기

# Pattern 객체.sub(바꿀 문자열, 대상 문자열, 바꿀 횟수)

p = re.compile('(blue|white|red)')
# count = 0 이면 전체를 다 바꾼다
p.sub('color', 'blue socks and red shoes', count=0)
# 'color socks and color shoes'

 
 
[문제] 이메일 형식 검증
- 사용자로부터 입력 받은 임의의 이메일 주소에 대해 유효한 형식인지를 검증하는 함수를 작성하세요.
    def valid_email(email):
        return '이메일 검증 결과'

    일반적인 이메일은 '알파벳 및 숫자@도메인 이름'으로 되어 있다.
    위 함수는 다음의 이메일을 올바른 이메일 형식으로 결과 값을 반환한다.
    - mike@korea.co.kr
    - mike@daum.net
    - mike.kim@gmail.com

    위 함수는 다음의 이메일을 올바른 이메일 형식이 아님을 결과 겂으로 반환한다.
    - mysite.com
    - mike@good
 

import re

def valid_email(email):
    regex = '[a-zA-Z]+\w*[.]?\w*[@]\w+[.]\w+[.]?\w{2,3}'
    valid = re.match(regex, email)

    if valid:
        return 'valid email'
    else:
        return 'invalid email'

input_email = input('이메일 입력:')
print(input_email, end=': ')
print(valid_email(input_email))

 
 
[실습] 한글 찾기 / 한글 제거

s = '한글이에요. good morning. 안녕하세요'
m = re.findall('[ㄱ-힣]+', s) # 한글만 찾음
print(m) # ['한글이에요', '안녕하세요']
s = '한글이에요. good morning. 안녕하세요'
m = re.findall('[a-zA-Z]+', s) # 한글만 제거
print(m) # ['good', 'morning']

 
 
[문제] 주민번호의 유효성 검증
사용자로부터 입력 받은 임의의 주민등록번호가 올바른 형식인지를 검증하는 정규 표현식을 작성하세요.
주민번호 형식: 생년월일(6자리)-성별(1자리)나머지 6자리 숫자
    - 년도는 숫자 2자리
    - 월은 앞자리가 0일 경우 뒷자리는 1~9까지 허용 또는 앞자리가 1일 경우 뒷자리는 0~2까지 허용
    - 일은 앞자리가 0일 경우 뒷자리는 1~9까지 허용 또는 앞자리가 1 또는 2일 경우 뒷자리는 0~9까지 허용, 앞자리가 3일 경우 뒷자리는 0~1만 하용
    - 성별 숫자는 1~4까지 허용
    - 나머지 6자리는 숫자
 

jumin = input('주민등록번호 입력:')
p = re.compile('\d{2}(0[1-9]|1[0-2])(0[1-9]|1[0-9]|2[0-9]|3[0-1])[-][1-4]\d{6}')
m = p.match(jumin)

if m:
    print('유효한 주민번호 형식입니다.')
else:
    print('유효하지 않은 주민번호 형식입니다.')