-
Notifications
You must be signed in to change notification settings - Fork 0
/
index.json
1 lines (1 loc) · 356 KB
/
index.json
1
[{"content":"코드는 Github Repo에 공개되어있습니다. 직접 받아서 실행하실 수 있습니다.\n24년 3월 15일 업데이트 실행 결과\n24년 3월 15일 버전 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 import certifi import ssl import asyncio import websockets import requests from api import get_player_live # 유니코드 및 기타 상수 F = \u0026#34;\\x0c\u0026#34; ESC = \u0026#34;\\x1b\\t\u0026#34; SEPARATOR = \u0026#34;+\u0026#34; + \u0026#34;-\u0026#34; * 70 + \u0026#34;+\u0026#34; # 아프리카TV에서 제공하는 API로 채팅 정보를 받습니다. def get_player_live(bno, bid): url = \u0026#39;https://live.afreecatv.com/afreeca/player_live_api.php\u0026#39; data = { \u0026#39;bid\u0026#39;: bid, \u0026#39;bno\u0026#39;: bno, \u0026#39;type\u0026#39;: \u0026#39;live\u0026#39;, \u0026#39;confirm_adult\u0026#39;: \u0026#39;false\u0026#39;, \u0026#39;player_type\u0026#39;: \u0026#39;html5\u0026#39;, \u0026#39;mode\u0026#39;: \u0026#39;landing\u0026#39;, \u0026#39;from_api\u0026#39;: \u0026#39;0\u0026#39;, \u0026#39;pwd\u0026#39;: \u0026#39;\u0026#39;, \u0026#39;stream_type\u0026#39;: \u0026#39;common\u0026#39;, \u0026#39;quality\u0026#39;: \u0026#39;HD\u0026#39; } try: response = requests.post(f\u0026#39;{url}?bjid={bid}\u0026#39;, data=data) response.raise_for_status() # HTTP 요청 에러를 확인하고, 에러가 있을 경우 예외를 발생시킵니다. res = response.json() CHDOMAIN = res[\u0026#34;CHANNEL\u0026#34;][\u0026#34;CHDOMAIN\u0026#34;].lower() CHATNO = res[\u0026#34;CHANNEL\u0026#34;][\u0026#34;CHATNO\u0026#34;] FTK = res[\u0026#34;CHANNEL\u0026#34;][\u0026#34;FTK\u0026#34;] TITLE = res[\u0026#34;CHANNEL\u0026#34;][\u0026#34;TITLE\u0026#34;] BJID = res[\u0026#34;CHANNEL\u0026#34;][\u0026#34;BJID\u0026#34;] CHPT = str(int(res[\u0026#34;CHANNEL\u0026#34;][\u0026#34;CHPT\u0026#34;]) + 1) return CHDOMAIN, CHATNO, FTK, TITLE, BJID, CHPT except requests.RequestException as e: print(f\u0026#34; ERROR: API 요청 중 오류 발생: {e}\u0026#34;) return None except KeyError as e: print(f\u0026#34; ERROR: 응답에서 필요한 데이터를 찾을 수 없습니다: {e}\u0026#34;) return None # SSL 컨텍스트 생성 def create_ssl_context(): ssl_context = ssl.create_default_context() ssl_context.load_verify_locations(certifi.where()) ssl_context.check_hostname = False ssl_context.verify_mode = ssl.CERT_NONE return ssl_context # 메시지 디코드 및 출력 def decode_message(bytes): parts = bytes.split(b\u0026#39;\\x0c\u0026#39;) messages = [part.decode(\u0026#39;utf-8\u0026#39;) for part in parts] if len(messages) \u0026gt; 5 and messages[1] not in [\u0026#39;-1\u0026#39;, \u0026#39;1\u0026#39;] and \u0026#39;|\u0026#39; not in messages[1]: user_id, comment, user_nickname = messages[2], messages[1], messages[6] print(SEPARATOR) print(f\u0026#34;| {user_nickname}[{user_id}] - {comment}\u0026#34;) else: # 채팅 뿐만 아니라 다른 메세지도 동시에 내려옵니다. pass # 바이트 크기 계산 def calculate_byte_size(string): return len(string.encode(\u0026#39;utf-8\u0026#39;)) + 6 # 채팅에 연결 async def connect_to_chat(url, ssl_context): try: BNO, BID = url.split(\u0026#39;/\u0026#39;)[-1], url.split(\u0026#39;/\u0026#39;)[-2] CHDOMAIN, CHATNO, FTK, TITLE, BJID, CHPT = get_player_live(BNO, BID) print(f\u0026#34;{SEPARATOR}\\n\u0026#34; f\u0026#34; CHDOMAIN: {CHDOMAIN}\\n CHATNO: {CHATNO}\\n FTK: {FTK}\\n\u0026#34; f\u0026#34; TITLE: {TITLE}\\n BJID: {BJID}\\n CHPT: {CHPT}\\n\u0026#34; f\u0026#34;{SEPARATOR}\u0026#34;) except Exception as e: print(f\u0026#34; ERROR: API 호출 실패 - {e}\u0026#34;) return try: async with websockets.connect( f\u0026#34;wss://{CHDOMAIN}:{CHPT}/Websocket/{BID}\u0026#34;, subprotocols=[\u0026#39;chat\u0026#39;], ssl=ssl_context, ping_interval=None ) as websocket: # 최초 연결시 전달하는 패킷 CONNECT_PACKET = f\u0026#39;{ESC}000100000600{F*3}16{F}\u0026#39; # 메세지를 내려받기 위해 보내는 패킷 JOIN_PACKET = f\u0026#39;{ESC}0002{calculate_byte_size(CHATNO):06}00{F}{CHATNO}{F*5}\u0026#39; # 주기적으로 핑을 보내서 메세지를 계속 수신하는 패킷 PING_PACKET = f\u0026#39;{ESC}000000000100{F}\u0026#39; await websocket.send(CONNECT_PACKET) print(f\u0026#34; 연결 성공, 채팅방 정보 수신 대기중...\u0026#34;) await asyncio.sleep(2) await websocket.send(JOIN_PACKET) async def ping(): while True: # 5분동안 핑이 보내지지 않으면 소켓은 끊어집니다. await asyncio.sleep(60) # 1분 = 60초 await websocket.send(PING_PACKET) async def receive_messages(): while True: data = await websocket.recv() decode_message(data) await asyncio.gather( receive_messages(), ping(), ) except Exception as e: print(f\u0026#34; ERROR: 웹소켓 연결 오류 - {e}\u0026#34;) async def main(): url = input(\u0026#34;아프리카TV URL을 입력해주세요: \u0026#34;) ssl_context = create_ssl_context() await connect_to_chat(url, ssl_context) if __name__ == \u0026#34;__main__\u0026#34;: asyncio.run(main()) 중요한 업데이트된 내용은 2가지가 있습니다. 첫번째로는 5분마다 끊어지는 것을 개선하고, 두번째로는 이전에 찾지못했던 bjid 길이에 따른 legth를 찾는 것이었습니다. DOCHIS(헛삯)님께서 댓글로 남겨주신 내용을 통해서 수정되었습니다. 감사합니다. 😀\n우선 아프리카TV 채팅 웹소켓에 규칙이 있었습니다. 앞의 12자리는 어떤 종류를 수행하고 패킷의 바디 길이는 몇인지 등을 정하는 데이터를 담고 있습니다.\n1 2 3 0000 \u0026gt; 4자리 : 어떤것을 실행할지 000000 \u0026gt; 6자리 : 패킷(바디)의 데이터 길이 (바이트) 00 \u0026gt; 2자리 : 어떤 역할인진 모르지만 12비트를 맞추기 위해 사용되는 걸로 추측됩니다. 위 규칙을 토대로 예를 들어보면 채팅 서버에 연결이 끊어지지 않게 확인하는 패킷은 다음과 같습니다.\n1 2 3 0000 \u0026gt; 핑을 보내는 코드 000001 \u0026gt; 패킷(바디)의 데이터길이는 1 00 \u0026gt; 12비트 맞추기 메세지를 받겠다는 send 역할을 하는 패킷을 보낼때는 패킷은 같습니다.\n1 2 3 0002 \u0026gt; 메세지를 받겠다는 코드 000123 \u0026gt; 패킷(바디)의 데이터길이 123은 예시입니다. 00 \u0026gt; 12비트 맞추기 위와같은 규칙을 코드로 구현하면 다음과 같습니다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # 유니코드 F = \u0026#34;\\x0c\u0026#34; ESC = \u0026#34;\\x1b\\t\u0026#34; # 최초 연결시 전달하는 패킷 # 0001 - connect / 000006 - 바디 길이가 {F}{F}{F}16{F} 로 6 / 00 - 12비트 만들기 / {F*3}16{F} - 바디 CONNECT_PACKET = f\u0026#39;{ESC}000100000600{F*3}16{F}\u0026#39; # 메세지를 내려받기 위해 보내는 패킷 # 0002 - JOIN / {calculate_byte_size(CHATNO):06} - CHATNO 길이 + {F} x 6 / 00 - 12비트 / {F}{CHATNO}{F*5} - 바디 # 즉 아프리카 API로 부터 받아온 채팅번호 + 6을 해준게 패킷 바디 사이즈가 됩니다. JOIN_PACKET = f\u0026#39;{ESC}0002{calculate_byte_size(CHATNO):06}00{F}{CHATNO}{F*5}\u0026#39; # 주기적으로 핑을 보내서 메세지를 계속 수신하는 패킷 # 0000 - PING / 000001 - 바디 길이는 {F} 로 1 / 00 - 12비트 만들기 / {F} - 바디 PING_PACKET = f\u0026#39;{ESC}000000000100{F}\u0026#39; 어떠한 패킷으로 메세지를 주고받는지 정확하게 알게되어 전체적인 코드를 개선시킬 수 있었습니다.\n전체적인 진행과정은 아래 글을 확인해주시면 됩니다. 궁금하신 내용이 있으시면 언제든지 댓글을 남겨주시기 바랍니다. 😀\n들어가며 방송프로그램으로 OBS를 사용하고 있는데, 최근 M1맥 업데이트로 동시송출 플러그인 사용이 가능해졌습니다. 유트브에서 유튜브 + 아프리카TV로 넘어가는 계획중인데 추첨방송을 계속 이어서 진행하기 위해 유튜브, 아프리카tv 두개 채팅을 실시간으로 크롤링하려 합니다.\n아프리카TV의 경우 참고할만한 레퍼런스가 매우 매우 매우 없는 (누가하겠냐고..) 맨땅에 해딩이었습니다 🥲 분석해보았지만 글에 설명되어 있듯이 예외케이스가 존재하니 참고 부탁드립니다.\n아프리카TV 채팅방 분석 탭이동 하면 소켓연결이 끊어지고 다시 돌아오면 새로 연결된다\n크롬의 개발자도구 네트워크탭을 분석해보면 아프리카TV의 채팅방의 경우 대략적으로 다음과 같이 웹소켓 서버와 연결됨을 알 수 있습니다.\n사람이 동영상을 보고있는지 계속 확인하는 웹소켓 1개 일정시간마다 핸드쉐이크를 꼐속 진행한다\n사람이 동영상을 보고있다고 판단되면 채팅방 연결하는 웹소켓 1개 맨 처음에만 핸드쉐이크\n사람이 동영상을 안보고있다고 판단되면 모든 소켓 연결을 끊어버립니다. 소리는 들려도 채팅은 보여지지 않습니다. 다시 동영상에 입장하면 당연히 처음부터 소켓연결을 재시작하기 떄문에 이전 채팅 내용들이 사라지게 됩니다 입장, 퇴장 여부도 전부 소켓에 찍힙니다.\n우선 가장 중요한 부분은 실제 채팅이 오고 가는 것이 찍히는 위 2번 웹소켓입니다. 해당 웹소켓에 연결하기 위해서는 ⭐️다음 값들을⭐️ 알아야합니다.\n웹소켓 주소 채팅방 입장시 받아오는 API 혹은 1번 웹소켓에서 불러와질 수 있습니다.\n핸드쉐이크때 필요한 값 위의 웹소켓 주소를 받아올때 같이 넘겨받는 값이 특정 bytes array 구조로 보내져야합니다.\n우선은 1번 2번은 재껴두고 소켓에 연결해서 채팅을 넘겨받을 수 있는지 확인해보겠습니다.\n어떻게든 채팅을 불러올 수 있을까? (소켓 분석) 총 2번의 핸드쉐이크 혹은 정보를 전달하는 것을 알 수 있습니다. 각각의 핸드쉐이크때 어떠한 정보를 보내야 연결이 성공되어 나머지 정보도 전달받는지 분석이 필요합니다.\n우선은 채팅방을 어떻게든 연결하여 정보를 출력하기 위해 받은 키값 그대로 연결해보겠습니다.\n첫번째 핸드쉐이크 첫번째 핸드쉐이크의 값중 유의미한 값은 A32.로 시작합니다. 네트워크탭을 분석해본 결과 아프리카TV API로 요청을 보낼때 쿠키로 함께 보내지는 PdboxTicket 값임을 확인했습니다.\n두번째 핸드쉐이크 두번째 핸드쉐이크는 _ 로 나누어지거나 \u0026amp;으로 값이 나누어 져있었습니다. \u0026amp;으로 나누어진 것을 보면 뭔가 파라미터 값을 전송하는 것으로 예측 할 수 있습니다. 전달되는 값들은 API의 리스폰스나 JS파일을 분석하면 나옵니다. 우선은 파이썬으로 핸드쉐이크떄 보내지는 값을 그대로 전송했을때 연결이 되는지 확인해봅니다 Test Code 해당값을 UTF-8즉 string 형태로 그대로 복사할 경우 깨져서 보여지지 않기 떄문에 base64 값으로 복사한다음에 코드에서 복호화 하는 형태로 진행합니다.\n핸드쉐이크 테스트 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 import certifi import ssl import base64 import asyncio import websockets # WSS 연결 위한 SSL 설정 ssl_context = ssl.create_default_context() ssl_context.load_verify_locations(certifi.where()) ssl_context.check_hostname = False ssl_context.verify_mode = ssl.CERT_NONE # 아래 3개 변수값은 어딘가의 API로 부터 받은 값들의 조합으로 우선은 그대로 사용 Base64PdboxTicket = \u0026#39;GwkwMDAxMDAwNTg3MDAMLkEzMi43YmJUNTZ2eUhNOWZLWmsuQ1V0N0dxNXdzSW95ZGdUejRvT0JkUGJEVTRPTEJjbDNEUkFUenJUYkhrQzlYRmtiZnl1YXMwUzE3SFlyZDFjbFU1VzV4U3hpTUJYS2lmV25fVXFDQThWWUYxczVidE04QzJuenhWN3NEVzlTcHFPM2V4VHNHQUMxU3ZmNTlCNUE1bDVrUFJsZHpleHB4OGxKbkJFYXE5UXNLbW1KQ21TTExQRm1JNkc3Q0FHZ1R0UDFWcGhzaTYzTDZXUHRDdkRPalZDNTg1LXVJQV9hRGRxcTlWX1pOWUlBQ0N3d3Yxb0NDckRLeE9icldKS0xpQlNrbGs3bW1TMkkwWVdtNnhiMDB2TG53OGJuQXVyZzRyNkpHeWVma0ZDWmZaM0V6c29LTEFwRU9xeFlkX0JXMVNxRWVNM1FuNkoyc0E5Y0d5WGFZLWhySm5wblJGNmN4RzFPTWJBSi1KVGVXc2JTLTNaNUNULS1fUW1vTWJCb2stSmJwUmt1Y1oxMURJSkFpY25NZmwxaWtzU1Y4aHh6YUVqWVExb19pamI1OXVCWUNYMlNsMFdLSEwydjk4WkhSLXZGNjNRRDY5VEtqSEpjaHBIaDh0RFNzUUxJekc3WUFZbmpKYjl3cDlvV2JfVi0wSFgyRFRYVnVUSEtKRTFPMWNtbHE1bC1qaXZ6bW1HdnJ0WnVmQVVfRG1NQzA1bUpPelNxZTl0bzNKVFZmMjBJWldfWXFpW...==\u0026#39; Base64ChannelInfo = \u0026#39;GwkwMDAyMDAwMjk3MDAMOTYxOAw0NTY1MzFjMDg0YjUxMGJkOTAyYWViZDcyMjFiMjUyNl92bGZ2bGY3ODlfMjQ1NTkwMzY1X2lvcwwwDAxsb2cRBiYGc2V0X2JwcwY9BjgwMDAGJgZ2aWV3X2JwcwY9BjEwMDAGJgZxdWFsaXR5Bj0Gbm9ybWFsBiYGdXVpZAY9BjFlNDNjZjZkMzc5MTNjMzZiMzVkNTgwZTBiNTY1NmVjBiYGZ2VvX2NjBj0GS1IGJgZnZW9fcmMGPQYxMQYmBmFjcHRfbGFuZwY9BmtvX0tSBiYGc3ZjX2xhbmcGPQZrb19LUgYmBmpvaW5fY2MGPQY0MTAScHdkERJhdXRoX2luZm8RTlVMTBJwdmVyETESYWNjZXNzX3N5c3R...=\u0026#39; WSSUrl = \u0026#39;wss://chat-76dbfccf.afreecatv.com:8001/Websocket/vlfvlf789\u0026#39; async def connect(): # 웹 소켓에 접속을 합니다. async with websockets.connect(WSSUrl, subprotocols=[\u0026#39;chat\u0026#39;], ssl=ssl_context, ping_interval=None) as websocket: # 핸드쉐이크 await websocket.send(base64.b64decode(Base64PdboxTicket)) await websocket.recv() await websocket.send(base64.b64decode(Base64ChannelInfo)) # 이후부터 채팅내용 받아와짐 while True: try: data = await websocket.recv() print(data) except Exception as e: print(\u0026#34;ERROR:\u0026#34;, e) asyncio.get_event_loop().run_until_complete(connect()) 코드 실행시 데이터를 성공적으로 잘 받아오는 것을 확인 할 수 있었습니다.\n단, 바이트 코드로 되어있으면서 hex 값이랑 평문이랑 섞여있기 때문에 해당 데이터를 복호화 해줍니다. \\x0c 값이 계속 보이는 것으로 보아 해당 값으로 split 해주고 utf-8로 변환하였습니다.\n1 2 3 4 5 6 def decode(bytes): test = bytes.split(b\u0026#39;\\x0c\u0026#39;) res = [] for i in test: res.append(str(i, \u0026#39;utf-8\u0026#39;)) print(res) 해당 코드로 복호화 하였을 경우에 유의미한 값이 보여지는 것을 확인했습니다. res[1] 값에 따라서 배열의 크기가 변경되는 것을 보아 해당 값을 기준으로 어떤 데이터인지 유추해볼 수 있습니다. 여러번 테스트해본 결과 다음과 같습니다.\nres[1] = 1 일때 res[2]=id res[3]=닉네임가 채팅방에 입장 하였음 res[1] = -1 일때 res[2]=id res[3]=닉네임가 채팅방에 퇴장 하였음 res[1] = '문자열' 일때 res[1]=닉네임 res[2]=id res[6]=채팅내용 보여지는 채팅입니다. 다른 경우에는 매니저 이거나 팬 일때 아이디가 색상으로 표시되는 경우 등이 있었지만 위 3가지만 으로도 충분히 목표를 달성하여 더이상 분석을 진행하진 않았습니다.\n최종 복호화 코드로 채팅 내용만 불러오게 하였습니다.\n1 2 3 4 5 6 7 8 9 10 11 def decode(bytes): test = bytes.split(b\u0026#39;\\x0c\u0026#39;) res = [] for i in test: res.append(str(i, \u0026#39;utf-8\u0026#39;)) if(res[1] != \u0026#39;-1\u0026#39; and res[1] != \u0026#39;1\u0026#39; and \u0026#39;|\u0026#39; not in res[1]): if(len(res) \u0026gt; 5): print(res[1], res[2], res[6]) else: # print(res) pass 결과 채팅 받아오기 성공!\n🚨 5분이 지나면 ERROR: no close frame received or sent 메세지와 함께 소켓 연결이 끊어집니다.\n[심화] URL만으로 채팅을 자동으로 불러올 수 있을까? 아프리카TV 채팅창 내용을 불러오는 것 까지는 성공했습니다 ! 다만 네트워크탭을 켜서 필요한 값들을 복사할 수는 없기에 해당 값들까지 분석하여 URL만 제공해도 모든것이 자동으로 돌아가져야 합니다. 위에서 연결하고 추출까지 성공했기 때문에 이제부터 필요한 값은 딱 3개 입니다.\n👉 채팅방 url 👉 첫번째 핸드쉐이크 👉 두번째 핸드쉐이크 채팅방 URL 찾기 채팅에 연결되면서 두번 핸드쉐이크할때 변화되는 값들을 확인해보고 해당 값들이 어떤 api에서 왔는지 분석해보았습니다.\nAPI 주소 : https://live.afreecatv.com/afreeca/player_live_api.php?bjid={bid}\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 def player_live_api(bno, bid): # type=aid 일 경우 aid(.A32~~~)불러옴 data = {\u0026#39;bid\u0026#39;: bid, \u0026#39;bno\u0026#39;:bno, \u0026#39;type\u0026#39;:\u0026#39;live\u0026#39;, \u0026#39;confirm_adult\u0026#39;:\u0026#39;false\u0026#39;, \u0026#39;player_type\u0026#39;:\u0026#39;html5\u0026#39;, \u0026#39;mode\u0026#39;:\u0026#39;landing\u0026#39;, \u0026#39;from_api\u0026#39;:\u0026#39;0\u0026#39;, \u0026#39;pwd\u0026#39;:\u0026#39;\u0026#39;, \u0026#39;stream_type\u0026#39;:\u0026#39;common\u0026#39;, \u0026#39;quality\u0026#39;:\u0026#39;HD\u0026#39;} res = requests.post(f\u0026#39;https://live.afreecatv.com/afreeca/player_live_api.php?bjid={bid}\u0026#39;, data=data, headers=headers).json() # wss연결할 채팅 Url CHDOMAIN = res[\u0026#34;CHANNEL\u0026#34;][\u0026#34;CHDOMAIN\u0026#34;].lower() # 채팅방 번호 CHATNO = res[\u0026#34;CHANNEL\u0026#34;][\u0026#34;CHATNO\u0026#34;] # 채팅방 포트 번호 인데 1을 더해주어야함 CHPT = str(int(res[\u0026#34;CHANNEL\u0026#34;][\u0026#34;CHPT\u0026#34;]) + 1) # 첫번재 핸드쉐이크때 사용할 티켓 TK = res[\u0026#34;CHANNEL\u0026#34;][\u0026#34;BJID\u0026#34;] # 두번째 핸드쉐이크때 사용할 티켓 FTK = res[\u0026#34;CHANNEL\u0026#34;][\u0026#34;FTK\u0026#34;] # bj 명 BJID = res[\u0026#34;CHANNEL\u0026#34;][\u0026#34;BJID\u0026#34;] # 제목 TITLE = res[\u0026#34;CHANNEL\u0026#34;][\u0026#34;TITLE\u0026#34;] return CHDOMAIN, CHATNO, FTK, TITLE, BJID, TK, CHPT 네트워크 탭을 보면 해당 API에 2번 POST요청을 보냅니다. 헤더값에 따라 받아오는 채팅 연결 도메인값이 변경되므로 헤더 설정을 꼭 잘해주어야합니다.\n헤더 참고\n헤더는 네트워크탭에서 확인할 수 있으며 그대로 진행하였습니다.. 단, 중간에 PdboxSaveTicket의 경우 로그인 혹은 일정 시간 이후 변경되는 것으로 확인하였습니다. 따라서 테스트 하다가 채팅 내용을 불러 올 수 없을 경우에 헤더를 일단 변경해주면 채팅방 URL을 확인할 수 있습니다 (헤더 없어도 채팅방 url 받을 수 있습니다)\n첫번째 핸드쉐이크 키값 찾기 시력이 약 0.1 정도 감소된듯 하다\n핸드쉐이크 값을 비교해보니 변경되는부분, 고정인부분, API로 불러오는부분이 있었습니다.\n1 2 secret_1 = f\u0026#39;\u001b\t000100058200\f.{tk}\f16\f\u0026#39; secret_2 = f\u0026#39;\u001b\t000200030000\f{chat_no}\f{ftk}\f0\flog\u0011\u0006\u0026amp;\u0006set_bps\u0006=\u00068000\u0006\u0026amp;\u0006view_bps\u0006=\u00061000\u0006\u0026amp;\u0006quality\u0006=\u0006normal\u0006\u0026amp;\u0006uuid\u0006=\u00061e43cf6d37913c36b35d580e0b5656ec\u0006\u0026amp;\u0006geo_cc\u0006=\u0006KR\u0006\u0026amp;\u0006geo_rc\u0006=\u000611\u0006\u0026amp;\u0006acpt_lang\u0006=\u0006ko_KR\u0006\u0026amp;\u0006svc_lang\u0006=\u0006ko_KR\u0006\u0026amp;\u0006join_cc\u0006=\u0006410\u0012pwd\u0011\u0012auth_info\u0011NULL\u0012pver\u00111\u0012access_system\u0011html5\u0012\f\u0026#39; secret_1 의 경우에 000100058200 (5820 부분)\nsecret_2 의 경우에 000200030000 (3000 부분)\n일단 방송중인 방을 셀레늄으로 받아와서 아까 찾은 API에 날려서 어떠한 공통점들이 있는지 확인해보려합니다.\n테스트 코드\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager import requests # DRIVER = webdriver.Chrome(service=Service(ChromeDriverManager().install())) DRIVER = webdriver.Chrome(\u0026#39;./chromedriver\u0026#39;) url = \u0026#39;https://www.afreecatv.com/\u0026#39; DRIVER.get(url) DRIVER.implicitly_wait(3) href = [] cBox_info = DRIVER.find_elements(By.CLASS_NAME, \u0026#39;cBox-info\u0026#39;) for c in cBox_info: url = c.find_element(By.CLASS_NAME, \u0026#39;title\u0026#39;).get_attribute(\u0026#39;href\u0026#39;) print(url) href.append(url) for h in href: bid = h.split(\u0026#39;/\u0026#39;)[-2] bno = h.split(\u0026#39;/\u0026#39;)[-1] res = requests.post(f\u0026#39;https://live.afreecatv.com/afreeca/player_live_api.php?bjid={bid}\u0026#39;, data={\u0026#39;bid\u0026#39;: bid, \u0026#39;bno\u0026#39;:bno, \u0026#39;type\u0026#39;:\u0026#39;live\u0026#39;, \u0026#39;confirm_adult\u0026#39;:\u0026#39;false\u0026#39;, \u0026#39;player_type\u0026#39;:\u0026#39;html5\u0026#39;, \u0026#39;mode\u0026#39;:\u0026#39;landing\u0026#39;, \u0026#39;from_api\u0026#39;:\u0026#39;0\u0026#39;,\u0026#39;pwd\u0026#39;:\u0026#39;\u0026#39;,\u0026#39;stream_type\u0026#39;:\u0026#39;common\u0026#39;,\u0026#39;quality\u0026#39;:\u0026#39;HD\u0026#39;}, headers=headers).json() CHDOMAIN = res[\u0026#34;CHANNEL\u0026#34;][\u0026#34;CHDOMAIN\u0026#34;].lower() CHATNO = res[\u0026#34;CHANNEL\u0026#34;][\u0026#34;CHATNO\u0026#34;] FTK = res[\u0026#34;CHANNEL\u0026#34;][\u0026#34;FTK\u0026#34;] TITLE = res[\u0026#34;CHANNEL\u0026#34;][\u0026#34;TITLE\u0026#34;] BJID = res[\u0026#34;CHANNEL\u0026#34;][\u0026#34;BJID\u0026#34;] TK = res[\u0026#34;CHANNEL\u0026#34;][\u0026#34;BJID\u0026#34;] print(CHDOMAIN, CHATNO, FTK, TITLE, BJID) 테스트 결과\n셀레늄\u0026hellip; 다시 보니 선녀 같다\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 chat-dee93642.afreecatv.com 1832 b730cac99de4c43e485636cd95a95171_ahrasmall_245682917_html5 신입여캠 예쁜음방 찾는사람♥신청곡 브금 라이브 뽑기 스쿼트 ahrasmall chat-6e0a4c42.afreecatv.com 401 ee461619bb3d078951729641e140f043_wkdtnvndeod2_245680218_html5 쉬고온 폼 어떨란감(맞방 맞즐 맞팬 미션 환영 시참 가능) wkdtnvndeod2 chat-dee93642.afreecatv.com 8068 db550a2aa702133b90053db49559fc32_0100151_245682923_html5 [신입]롤 3일차 칼바람 시참방송 ෆ.̮ෆ 0100151 chat-dee93642.afreecatv.com 8041 f771fc0888b0b5a27b6f40700c3b6d3a_truelight89_245682906_html5 매일 9am 아줌마 영이랑 벚꽃 구경 갈 사람? truelight89 chat-dee93642.afreecatv.com 8007 fabe810c6f46dcea461c4510eadf8dca_wkdrn0405_245677828_html5 실버 4의 랭겜에는 어떤 사람들이 있을까? wkdrn0405 chat-dee93642.afreecatv.com 1617 82b9432c8b887e2e53b60771e3fcf919_pmw1131_245683267_html5 신입남캠 58일차💋오늘은 레몬치즈의 날🧀 pmw1131 chat-6e0a4c42.afreecatv.com 5137 c4f178bf7263e2bd265bd76efd824556_louloumomo_245682407_html5 미국여자의 일본여행 비행기놓침 louloumomo chat-dee93642.afreecatv.com 6986 2ecd8e2f6a77e283736425eac8eec0f9_namnice1_245682825_html5 업사부 투자수업!! 환상적 수익행진~!#주식#ELW#선물옵션#해외선물!!#주린이#업사부#황금알#김성남#불개미#코인! namnice1 chat-dee93642.afreecatv.com 1832 4026375bc10725e389daf8da51889779_ahrasmall_245682917_html5 신입여캠 예쁜음방 찾는사람♥신청곡 브금 라이브 뽑기 스쿼트 ahrasmall chat-6e0a4c42.afreecatv.com 401 57d27877f877ef1e80f62e31fb814884_wkdtnvndeod2_245680218_html5 쉬고온 폼 어떨란감(맞방 맞즐 맞팬 미션 환영 시참 가능) wkdtnvndeod2 chat-dee93642.afreecatv.com 8068 c629e4348ac69be8f452569494d7a869_0100151_245682923_html5 [신입]롤 3일차 칼바람 시참방송 ෆ.̮ෆ 0100151 chat-dee93642.afreecatv.com 8041 904c6dc021fa1ae3fabff201d15101d2_truelight89_245682906_html5 매일 9am 아줌마 영이랑 벚꽃 구경 갈 사람? truelight89 chat-dee93642.afreecatv.com 8007 5b21a6ddb7a1729111762b5b39dcb56c_wkdrn0405_245677828_html5 실버 4의 랭겜에는 어떤 사람들이 있을까? wkdrn0405 chat-dee93642.afreecatv.com 1617 1931b0cc33b8ecf29a8569007f6f815b_pmw1131_245683267_html5 신입남캠 58일차💋오늘은 레몬치즈의 날🧀 pmw1131 chat-6e0a4c42.afreecatv.com 5137 4a71361598af0d481297fc7439aa7a76_louloumomo_245682407_html5 미국여자의 일본여행 비행기놓침 louloumomo chat-dee93642.afreecatv.com 6986 f8967d0187c2169670e9da82005928d0_namnice1_245682825_html5 업사부 투자수업!! 환상적 수익행진~!#주식#ELW#선물옵션#해외선물!!#주린이#업사부#황금알#김성남#불개미#코인! namnice1 chat-dee93642.afreecatv.com 703 74a4437c1f784a3c32e1ce14c3fb80a0_lovely5959_245674550_html5 수피 수힛 스맵 민교 vs 나닝 사장 밧드 교용 킬내기 사비빵 lovely5959 chat-6e0a4c42.afreecatv.com 9996 6aef02482a9b6446d4736b8ab4416d5e_townboy_245672930_html5 스맵임니다 배그의신 ^^ townboy chat-6e0a4c42.afreecatv.com 3761 a5be977fac8f11d27de3f7c62b8261ff_drumkyn_245683107_html5 주식왕용느★정신차리고 왔습니다 drumkyn chat-dee93642.afreecatv.com 4320 bd63b62701fdc62dd804baae84987e4c_wnstn0905_245674053_html5 박사장 킬내기 ^^ wnstn0905 chat-6e0a4c42.afreecatv.com 59 070c9c5fdcc4fa5c73ba74ed03534fa5_since821_245144841_html5 [생]음악방송 멜론 인기가요 슬픈발라드 24시간 듣기 좋은 노래 명곡 100 히트곡 차트음방최신가요팝송힙합댄스곡뮤직라디오퀵뷰신입BJ since821 chat-dee93642.afreecatv.com 7213 7eca7d68afba49201955e48b2114c1e6_na2un_245679460_html5 사비빵 ^ㅛ^ na2un chat-6e0a4c42.afreecatv.com 1979 30c8f01aed8e85431af29005ebdba03e_giltae1124_245682339_html5 마스터 승급전 1승1패 giltae1124 chat-6e0a4c42.afreecatv.com 5065 d70b5ea61501657e88a5000944275e2b_wwe1_245682996_html5 [생방송] WWE RAW LIVE 1557회 wwe1 chat-dee93642.afreecatv.com 1595 b9fdf6637769710dcc39155c194583b0_dldmssk_245683163_html5 늦었습니다 dldmssk chat-6e0a4c42.afreecatv.com 6667 55f36f872db59d3c3db4f2e992987db0_tsoul7_245664669_html5 대한민국 어죽1등 대흥식당 tsoul7 chat-dee93642.afreecatv.com 8486 a7a7b4c0417d781f8c0a9771479b7b29_suhee0051_245677530_html5 수힛 수피 스맵 민교 vs 나닝 사장 밧드 교용 킬내기 야미 suhee0051 chat-dee93642.afreecatv.com 4542 16a1bc62a4d357a422e9aa2f63f4fac0_wnddnjs3124_245682295_html5 오늘 태국 출국합니다 저는 비행기값 게이지 안차면 안갑니다 ......부천 정중만 설영욱 와이퍼 이수혁 나루토 품바 박공 하르 wnddnjs3124 chat-6e0a4c42.afreecatv.com 1779 3dd7b7d32ebe399045c639d71874d558_sunwo2534_245683271_html5 JUP 뉴스 분량전쟁 특별게스트 함께 X야옹민지 sunwo2534 chat-6e0a4c42.afreecatv.com 2010 58c69912e19990fa9cc3fcf7bd3d5dd3_gjgj3274_245683016_html5 스타 소룡이 래더 강의방송 욕하러오지마세요. gjgj3274 chat-6e0a4c42.afreecatv.com 9853 acf465cffcb8a267607f0fcf47507432_todakman1151_245682511_html5 드릴말씀이 있습니다 (어그로임) 범프리카 todakman1151 chat-6e0a4c42.afreecatv.com 7662 ccd30ea7baa9e681d74244b058b72810_dlrnf_245683294_html5 긴급회의 시작 합니다_세쌍컴퍼니 x 세찬 dlrnf chat-dee93642.afreecatv.com 7871 872ef2c55774dfcac9278408bdb2881d_o31511_245682721_html5 [무파] 다이아 77개 캐기 [로나월드:서수길형님] o31511 ... 채팅 URL의 경우 두가지로 어떻게 분류하는진 확인할 수 없으나 url에서 채팅번호를 기준으로 방입장이 되는 것을 확인할 수 있었습니다. 테스트 크롤링으로 핸드쉐이크에 대한 유의미한 결과를 얻진 못했습니다.\n💡 그러던중 PdTicket 값이 API에서 불러와질때 쿠키가 없으면 불러와지질 않는다는 것을 보고 비회원 즉 쿠키에 티켓값이 없는 상태로 진행하기로 하였습니다. 비회원일 경우에 첫번째 핸드쉐이크때 해당 티켓값 (로그인 정보)를 받지 않기 떄문에 티켓값이 갱신되거나 헤더가 변경되는 경우를 제외해도 된다는 이점이 있습니다.\n비회원 테스트 결과\n첫번째 키 값 secret_1\n전: 0001000XX200.A32.XXX..\n후: 000100000600.16 (고정)\n두번째 키 값 secret_2\n전: 00020003XX0 (로그인 쿠키값이 있을 경우에)\n후: 00020002XX0 (비회원)\n첫번째 핸드쉐이크는 고정이므로 필요한 3개 값 중 2개는 해결 되었습니다.\n두번째 핸드쉐이크 키값 찾기 이제 남은건 두번째 핸드쉐이크 인데 위 처럼 2자리 숫자가 감이 오질 않아서 무작정 정리해보기 시작했습니다. 네트워크탭 두번째 핸드쉐이크 키값, bj닉네임, 닉네임 길이를 무작정 적어보기 시작했습니다.\n정리해보니 닉네임 길이에 따른 규칙을 찾을 수 있었습니다. 여기서 이상한건 lovely5959 만 뭔가 -1 오차가 있었습니다. 찜찜하기는 해도 랜덤테스트해도 거의 다 맞긴 해도 100% 정확하지 않습니다\n코드 아프리카 TV 실시간 채팅 긁어오기.최종.최종,최종.최종의최종... 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 import certifi import json import ssl import base64 import asyncio import requests import websockets # WSS 연결 위한 SSL 설정 ssl_context = ssl.create_default_context() ssl_context.load_verify_locations(certifi.where()) ssl_context.check_hostname = False ssl_context.verify_mode = ssl.CERT_NONE def decode(bytes): test = bytes.split(b\u0026#39;\\x0c\u0026#39;) res = [] for i in test: res.append(str(i, \u0026#39;utf-8\u0026#39;)) if(res[1] != \u0026#39;-1\u0026#39; and res[1] != \u0026#39;1\u0026#39; and \u0026#39;|\u0026#39; not in res[1]): if(len(res) \u0026gt; 5): print(res[1], \u0026#39;\\t| \u0026#39;, res[2], \u0026#39;|\u0026#39;, res[6]) else: print(res) pass def player_live_api(bno, bid): # type=aid 일 경우 aid(.A32~~~)불러옴 data = {\u0026#39;bid\u0026#39;: bid, \u0026#39;bno\u0026#39;:bno, \u0026#39;type\u0026#39;:\u0026#39;live\u0026#39;, \u0026#39;confirm_adult\u0026#39;:\u0026#39;false\u0026#39;, \u0026#39;player_type\u0026#39;:\u0026#39;html5\u0026#39;, \u0026#39;mode\u0026#39;:\u0026#39;landing\u0026#39;, \u0026#39;from_api\u0026#39;:\u0026#39;0\u0026#39;, \u0026#39;pwd\u0026#39;:\u0026#39;\u0026#39;, \u0026#39;stream_type\u0026#39;:\u0026#39;common\u0026#39;, \u0026#39;quality\u0026#39;:\u0026#39;HD\u0026#39;} res = requests.post(f\u0026#39;https://live.afreecatv.com/afreeca/player_live_api.php?bjid={bid}\u0026#39;, data=data).json() CHDOMAIN = res[\u0026#34;CHANNEL\u0026#34;][\u0026#34;CHDOMAIN\u0026#34;].lower() CHATNO = res[\u0026#34;CHANNEL\u0026#34;][\u0026#34;CHATNO\u0026#34;] FTK = res[\u0026#34;CHANNEL\u0026#34;][\u0026#34;FTK\u0026#34;] TITLE = res[\u0026#34;CHANNEL\u0026#34;][\u0026#34;TITLE\u0026#34;] BJID = res[\u0026#34;CHANNEL\u0026#34;][\u0026#34;BJID\u0026#34;] CHPT = str(int(res[\u0026#34;CHANNEL\u0026#34;][\u0026#34;CHPT\u0026#34;]) + 1) return CHDOMAIN, CHATNO, FTK, TITLE, BJID, CHPT async def connect(url): BNO = str(url.split(\u0026#39;/\u0026#39;)[-1]) BID = str(url.split(\u0026#39;/\u0026#39;)[-2]) CHDOMAIN, CHATNO, FTK, TITLE, BJID, CHPT = player_live_api(BNO, BID) KEY = \u0026#39;\u0026#39; if(len(BJID) == 5): KEY = \u0026#39;80\u0026#39; elif(len(BJID) == 6): KEY = \u0026#39;81\u0026#39; elif(len(BJID) == 7): KEY = \u0026#39;82\u0026#39; elif(len(BJID) == 8): KEY = \u0026#39;83\u0026#39; elif(len(BJID) == 9): KEY = \u0026#39;84\u0026#39; elif(len(BJID) == 10): KEY = \u0026#39;85\u0026#39; elif(len(BJID) == 11): KEY = \u0026#39;86\u0026#39; elif(len(BJID) == 12): KEY = \u0026#39;87\u0026#39; handshake = f\u0026#39;\u001b\t00020002{KEY}00\f{CHATNO}\f{FTK}\f0\flog\u0011\u0006\u0026amp;\u0006set_bps\u0006=\u00068000\u0006\u0026amp;\u0006view_bps\u0006=\u00061000\u0006\u0026amp;\u0006quality\u0006=\u0006normal\u0006\u0026amp;\u0006uuid\u0006=\u00061e43cf6d37913c36b35d580e0b5656ec\u0006\u0026amp;\u0006geo_cc\u0006=\u0006KR\u0006\u0026amp;\u0006geo_rc\u0006=\u000611\u0006\u0026amp;\u0006acpt_lang\u0006=\u0006ko_KR\u0006\u0026amp;\u0006svc_lang\u0006=\u0006ko_KR\u0012pwd\u0011\u0012auth_info\u0011NULL\u0012pver\u00111\u0012access_system\u0011html5\u0012\f\u0026#39; async with websockets.connect(f\u0026#34;wss://{CHDOMAIN}:{CHPT}/Websocket/{BID}\u0026#34;, subprotocols=[\u0026#39;chat\u0026#39;],ssl=ssl_context, ping_interval=None) as websocket: # 핸드쉐이크 # await websocket.send(secret_1) await websocket.send(\u0026#39;\u001b\t000100000600\f16\f\u0026#39;) data = await websocket.recv() await websocket.send(handshake) # 이후부터 채팅내용 받아와짐 while True: try: data = await websocket.recv() decode(data) except Exception as e: print(\u0026#34;ERROR:\u0026#34;, e) # break url = input(\u0026#34;아프리카TV URL을 입력해주세요 : \u0026#34;) asyncio.get_event_loop().run_until_complete(connect(url)) 결과 마무리 채팅방과 소켓으로 연결되기 때문에 성능적으로 매우 편~~안 했습니다.\n소켓 연결은 5분마다 끊어지기 때문에 while문 돌면서 지속해서 새로 handshake 부터 다시 시작하면 됩니다.\n게시글 이동되어 archive합니다\n번외판) 아프리카도우미 크롤링하기 들어가며 아프리카도우미 서비스를 이용하면 유튜브 댓글과 아프리카 댓글을 함께 긁을 수 있습니다.\n아프리카 도우미는 채팅이 중간 중간 멈추는 현상이 있습니다.\n셀레늄 VS 소켓 아프리카 도우미를 분석해보면 소켓으로 통신하고 있는데 아프리카 도우미에 연결된 소켓 주소로 메세지 정보를 바로 받아올 수 있겠다고 생각되어 분석을 해보았으나 후원 관련된 내용들만 받아와졌습니다. 아프리카 도우미가 자체적으로 계속 멈추기 때문에 성능적으로 크게 효과를 보지 못할 것으로 생각됩니다.\nnomomo 님께서 도움 주셨습니다. Twip-Toonation-Afreehp-Parser-Example issues #4 감사합니다 🙏\n코드 main.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 import time import re import threading as th from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager # 상수, 아프리카도우미 채팅 URL, 컴퓨터 성능에 따라 STRESS 조절 URL = input(\u0026#39;아프리카 도우미 채팅 url\u0026#39;) STRESS = 3 DEBUG = False CAPTURING = True # SELENIUM 초기화 DRIVER = webdriver.Chrome(service=Service(ChromeDriverManager().install())) # global 변수 USERS = [] # 결과값 CONTENTS = [] # DEBUG일때 채팅 내용 터미널에 표시 CNT = 0 def stop_catpure(): global CAPTURING input() CAPTURING = False def start_capture(): global USERS global CONTENTS global CNT DRIVER.get(URL) time.sleep(STRESS) th.Thread(target=stop_catpure, args=(), name=\u0026#39;stop_catpure\u0026#39;, daemon=True).start() while CAPTURING: try: DRIVER.implicitly_wait(STRESS) chat_list = DRIVER.find_element(By.CLASS_NAME, \u0026#39;chat_list\u0026#39;) chats = chat_list.find_elements(By.TAG_NAME, \u0026#39;li\u0026#39;) # print(\u0026#34;chat len \u0026gt; \u0026#34;, len(chats)) for chat in chats: name = chat.get_attribute(\u0026#39;data-name\u0026#39;) id = chat.get_attribute(\u0026#39;data-id\u0026#39;) classname = chat.get_attribute(\u0026#39;class\u0026#39;) platform = \u0026#39;afreeca\u0026#39; if \u0026#39;afreeca\u0026#39; in classname else \u0026#39;youtube\u0026#39; user = f\u0026#39;{name} | {platform}\u0026#39; if platform == \u0026#39;afreeca\u0026#39; else f\u0026#39;{name} | @{id} | {platform}\u0026#39; secrets = str(re.sub(r\u0026#39;[^0-9]\u0026#39;, \u0026#39;\u0026#39;, classname)) + user if (user not in USERS) and (len(name) * len(id) != 0): USERS.append(user) content = chat.find_element(By.CLASS_NAME, \u0026#39;text\u0026#39;).text if secrets not in CONTENTS: CONTENTS.append(secrets) if DEBUG: print(f\u0026#39;{platform} - {content} - {name} - {secrets}\u0026#39;) else: print(f\u0026#39;------- \\t 아이디 : {user}\\n\\t\\t 내용 : {content}\u0026#39;) CNT += 1 time.sleep(STRESS / 3) except KeyboardInterrupt: break except Exception as e: print(\u0026#34;!\u0026#34;, e) def quit_capture(): DRIVER.close() def print_results(): global USERS global CONTENTS users = (list(set(USERS))) print(users) start_capture() quit_capture() print_results() 코드설명 셀레늄을 이용하여 웹 드라이버가 해당 소스를 렌더링 한 이후에 긁어옵니다.\n1 2 # line 16 DRIVER = webdriver.Chrome(service=Service(ChromeDriverManager().install())) 해당 프로그램은 pyinstaller로 exe파일로 만들어야 하기 떄문에 웹 드라이버를 자동으로 최신 버전으로 받아옵니다. 정적인 드라이버를 사용할 경우에 파일을 실행하는 컴퓨터의 드라이버와 맞지 않는 것을 방지할 수 있습니다.\n1 2 3 4 5 6 7 8 # line 24 def stop_catpure(): global CAPTURING input() CAPTURING = False ... # line 37 th.Thread(target=stop_catpure, args=(), name=\u0026#39;stop_catpure\u0026#39;, daemon=True).start() 라이브 방송이 끝나기 전에 추출을 종료하기 위해서 해당 함수를 쓰레드로 실행시킵니다. 키보드 인풋이 있는경우 추출을 자동으로 종료합니다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 # line 38 while CAPTURING: try: DRIVER.implicitly_wait(STRESS) chat_list = DRIVER.find_element(By.CLASS_NAME, \u0026#39;chat_list\u0026#39;) chats = chat_list.find_elements(By.TAG_NAME, \u0026#39;li\u0026#39;) # print(\u0026#34;chat len \u0026gt; \u0026#34;, len(chats)) for chat in chats: name = chat.get_attribute(\u0026#39;data-name\u0026#39;) id = chat.get_attribute(\u0026#39;data-id\u0026#39;) classname = chat.get_attribute(\u0026#39;class\u0026#39;) platform = \u0026#39;afreeca\u0026#39; if \u0026#39;afreeca\u0026#39; in classname else \u0026#39;youtube\u0026#39; user = f\u0026#39;{name} | {platform}\u0026#39; if platform == \u0026#39;afreeca\u0026#39; else f\u0026#39;{name} | @{id} | {platform}\u0026#39; secrets = str(re.sub(r\u0026#39;[^0-9]\u0026#39;, \u0026#39;\u0026#39;, classname)) + user if (user not in USERS) and (len(name) * len(id) != 0): USERS.append(user) content = chat.find_element(By.CLASS_NAME, \u0026#39;text\u0026#39;).text if secrets not in CONTENTS: CONTENTS.append(secrets) if DEBUG: print(f\u0026#39;{platform} - {content} - {name} - {secrets}\u0026#39;) else: print(f\u0026#39;------- \\t 아이디 : {user}\\n\\t\\t 내용 : {content}\u0026#39;) CNT += 1 time.sleep(STRESS / 3) except KeyboardInterrupt: break except Exception as e: print(\u0026#34;!\u0026#34;, e) 셀레늄을 이용해서 아프리카도우미 페이지를 긁어옵니다.\n결과 마치며 레퍼런스가 없어서 진행하기 어려운 부분이 있었는데 해결되어서 성취감을 많이 느꼈습니다. 궁금하신 점이 있으면 댓글로 남겨주시기 바라며 해당 글이 도움이 되셨길 바랍니다 :)\n","permalink":"https://cha2hyun.blog/content/projects/%EB%B0%B0%EB%8F%8C%EC%9D%B4%EC%9D%98%EB%8B%B9%EA%B5%AC%EC%83%9D%ED%99%9C/afreecatv-crawling/","summary":"리버스엔지니어링 + 소켓연결 + 네트워크탭 분석. feat) 아프리카도우미","title":"아프리카TV 실시간채팅 크롤링"},{"content":" 💡 현재 글은 미완성 단계입니다.\n브라우저 브라우저에 URL을 입력하면 생기는 일 URL해석 브라우저에 입력된 URL을 해석하여 해당하는 웹페이지의 호스트명, 프로토콜 정보를 추출 DNS 조회 (분산형 DB) 호스트명을 IP주소로 변환하기 위해 DNS 서버에 쿼리를 보내고 DNS서버는 IP주소를 응답 이때 PC의 host 파일 - DNS cache - 공유기(라우터)의 DNS - ISP의 DNS를 먼저 확인하고 없다면 DNS 서버에 질의 접속자의 IP에 따라서 가장 가까운 CDN(GSBL)을 전달받을 수도 있음 서버연결 브라우저는 DNS 서버로 부터 얻은 IP 주소를 이용하여 해당 서버에 TCP 연결을 시도 (TCP 연결이 성공하면 HTTP requests가 보내짐) 이때 URL해석에서 얻은 프로토콜이 HTTPS 일 경우 TLS 핸드셰이크 과정을 거침. 서버에 요청 전송 및 응답 TCP연결에 성공했다면 브라우저는 서버에 HTTP 요청을 보내고 서버는 해당하는 웹페이지의 내용에 포함된 HTTP응답 메세지를 전송 (HTML, CSS, JS 등의 파일을 받아옴) 서버로받은 HTML파일을 렌더링 브라우저 렌더링 과정 참고 : https://www.youtube.com/watch?v=z1Jj7Xg-TkU\nHTML 파싱 → DOM트리 생성 서버로부터 전달받은 HTML은 바이트스트림(8비트)으로 되어있는데 이를 문자열로 변경 토큰화과정을 진행. 브라우저가 가지고 있는 토큰과 비교하여 해당 문자가 HTML 파일인지 비교합니다. 이때 토큰은 시작 혹은 종료태그 속성, 속성값 등을 의미 토큰화과정에서 노드가 생성되고 노드가 모여 거대한 DOM트리가 생성 DOM트리를 생성하는 과정에서 img나 link같은 태그를 만나게 되면 태그안에 있는 리소스를 다운 DOM트리를 생성하는 과정에서 script 태그를 만나게 되면 브라우저는 DOM생성을 중단하고 script 태그안에있는 자바스크립트를 해석 CSS 파싱 → CSSOM 트리 생성 DOM 트리를 생성하는 과정과 유사 렌더트리 생성 DOM 트리 + CSSOM 트리를 합쳐서 렌더트리를 생성한다 레이아웃 (리플로우) 위치, 크기, 계산 속성 요소의 크기나 좌표와 같은 좌표를 갖은 레이아웃 트리를 생성 이때 display:none 속성은 렌더트리에 포함되지 않음 position, left, top, right, weidth, hegith, margin, padding, border, display, float 등 페인트 (리페인트) 꾸미기 속성 렌더트리를 따라서 페인트 기록이 생성. 요소를 렌더링하는 순서나 여러개의 레이어로 나눈다음 텍스트, 색, 이미지 보더, 그림자 등 시각적인 부분을 그리는 과정을 지남 background, box-shadow, border-radius, border-style, color, outline 등 Composite 페인트 단계에서 만든 여러가지 레이어를 하나로 합성하고 스크린에 픽셀로 나타내게 됨 수정이 일어난다면 렌더트리를 다시 생성하게 된다. (연산이 많이 일어나는 작업) 이때 수정된 부분이 어떤것인지에 따라서 리플로우 과정을 건너뛸 수 있다. 단 transform, opacity는 GPU가 관여하는 속성으로 DOM트리를 변경하지 않도록 설계되어 있기 때문에 리플로우 리페인트를 건너띌 수 있다. HTTPS (Hyper Text Transfer Protocol Secure) 과정 참고 : https://www.youtube.com/watch?v=H6lpFRpyl14\n핸드셰이크 과정을 거침. 클라이언트는 랜덤데이터를 서버에 보내고 서버는 응답으로 랜덤데이터와 인증서를 함께 보냄 클라이언트는 전달받은 인증서가 진짜인지 브라우저에 내장된 CA들의 정보를 통해 확인함 인증서는 CA의 개인키로 암호화되어 있고 브라우저에 저장된 CA의 공개키로 복호화를 합니다. 이때 복호화에 성공하면 서버로부터 받은 인증서는 검증된 것임. 복호화할 수 없다면 안전하지 않은 페이지로 Warning이 표시됨 복호화된 인증서에는 서버의 공개키가 포함되어 있음. 핸드셰이크때 이용한 랜덤데이터를 서버의 공개키로 암호화해서 서버로 보내지게되고 양쪽에서 일련의 과정을 통해 동일한 대칭키를 갖게 됨 서버와 클라이언트는 대칭키를 이용해서 데이터를 암호화하여 주고 받게됩니다. (계속 비대칭키를 이용하면 연산이 많아지기 때문 즉, 비대칭키로 만든 대칭키를 사용하게됩니다.) URL vs URI URI (Uniform Resource Identifier) 정의: 웹 상의 자원을 식별하는 유일한 주소입니다. URI는 리소스를 찾기 위한 일반적인 용어로, URL과 URN을 포함하는 상위 개념입니다. 예시: https://example.com/path/to/resource 이것은 리소스를 찾기 위한 전체적인 주소를 나타냅니다. URL (Uniform Resource Locator) 정의: 리소스가 실제로 위치한 곳을 가리키는 주소입니다. URL은 리소스에 접근하기 위한 구체적인 위치 정보를 제공합니다. 구성 요소: 프로토콜(예: http, https), 호스트 이름(도메인 이름 또는 IP 주소), 포트 번호(선택적), 경로(리소스가 있는 위치), 쿼리 문자열(선택적), 프래그먼트(선택적) 등을 포함합니다. 예시: https://www.example.com/articles/uri-vs-url-vs-urn 이것은 웹 상의 특정 자원을 찾기 위한 구체적인 위치를 나타냅니다. URN (Uniform Resource Name) 정의: 리소스의 위치와 무관하게 리소스를 유일하게 식별하는 이름입니다. URN은 리소스가 실제로 어디에 있는지, 혹은 어떻게 접근해야 하는지와는 관계없이 리소스를 식별하기 위한 영구적인 이름을 제공합니다. 용도: 주로 도서관, 문서 아카이브 등에서 문서나 출판물 등의 리소스에 대한 영구적인 식별자로 사용됩니다. 예시: urn:isbn:0451450523 이것은 특정한 책을 식별하기 위한 ISBN 코드를 나타냅니다. Javascript Prototype 참고 - https://www.youtube.com/watch?v=wUgmzvExL_E\n1 2 3 4 5 6 7 8 9 10 11 12 var test = [2, 1, 4]; // var test = new Array(2, 1, 4); test.sort(); // Array 객체의 prototype중 sort가 있기에 사용할 수 있음 test.sortReverse(); // undefined, Array 객체가 prototype중 sortReverse 가 없기 때문 Array.prototype.sortReverse = function () { return ... } test.sortReverse() [5,7,6].sortReverse(); // Array객체의 prototype에 sortReverse를 정의해주어서 앞으로 모든 배열에 사용할 수 있게됨 // prototype은 상위 객체의 유전자라고 생각하면 편함 prototype은 무엇인가요?\nJS는 프로토타입 기반 언어로 객체의 상속이 프로토타입으로 이루어집니다. 프로토타입은 특정 객체 유형의 모든 인스턴스가 공유하는 객체입니다. 이 속성은 메서드와 속성을 상속하기 위해 사용됩니다. prototype chain은 무엇인가요?\n객체가 속성이나 메서드에 접근하려고 시도할 때, 해당 속성이나 메서드를 찾기 위해 JS엔진이 따르는 경로입니다. 객체에서 직접 속성이나 메서드를 찾을 수 없는 경우, JS는 객체의 프로토타입 (즉 그 객체의 생성자 함수의 prototype)으로 이동하여 거기에서 속성이나 메서드를 찾는데 이 과정은 프로토타입 체인의 최상단에 도달할때 까지, 즉 null이 프로토타입으로 설정될 때 까지 반복됩니다. 즉 프로토타입을 계속 상속하는데 필요한 속성이나 메서드에 접근할때 찾을 때 까지 상위 객체로 올라가게 되는데 이것을 프로토타입 체인이라고 합니다. function.bind가 되는 이유는 무엇인가요?\n자바스크립트의 모든 함수가 Function 객체의 인스턴스인데 function 객체의 프로토타입에 정의되어 있기에 모든 함수에서 bind를 사용할 수 있습니다. function.bind는 새로운 함수를 생성하며, 이 새로운 함수의 this가 bind 메서드에 전돨된 값으로 설정됩니다. 이는 함수가 호출될 때 주어진 this 값과 인자들을 사용하여 실행되도록 합니다. bind는 함수의 실행 컨텍스트를 명시적으로 설정할 때 유용하며 특히 콜백 함수나 이벤트 핸들러에서 this의 값이 예상대로 설정되지 않을때 문제를 해결하는데 사용됩니다. 상속은 어떻게 구현하나요?\n생성자 함수인 경우에는 프로토타입 체인을 사용하여 상속을 구현할 수 있습니다. 하위 클래스(생성자함수)의 프로토타입을 상위 클래스의 인스턴스로 설정함으로써 이루어집니다. 생성자 함수에서는 .prototype 객체에 직접 하려면 .__proto__ 명령어로 상속을 진행할 수 있습니다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 const Bmw = function (color) { this.color = color; }; Bmw.prototype.wheels = 4; Bmw.prototype.drive = function () { console.log(\u0026#34;Drive\u0026#34;); }; Bmw.prototype.navigation = 1; Bmw.prototype.stop = function () { console.log(\u0026#34;Stop\u0026#34;); }; const ix3 = new Bmw(\u0026#34;red\u0026#34;); // ix3 instanceof Bmw --\u0026gt; true const x5 = new Bmw(\u0026#34;blue\u0026#34;); // x5.constructor === Bmw --\u0026gt; true // 프로토타입을 아래처럼 한번에 선언하게되면 consturctor가 사라지게됨 // x5.constructor === Bmw --\u0026gt; false // 그래서 한줄씩 풀어서 쓰는거나 Bmw.prototype = { constructor: Bmw, // constructor를 직접 명시해야함 wheels: 4, drive() { console.log(\u0026#34;Drive\u0026#34;); } navigation: 1, stop() { console.log(\u0026#34;Stop\u0026#34;); }, } Class인 경우에는 extends를 사용하여 클래스간에 상속을 정의할 수 있으며 이때 상위 클래스의 내용이 Prototype으로 상속됩니다. super 를 사용하여 부모 클래스의 생성자를 호출할 수 있습니다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 class Car { constructor(color) { this.color = color; this.wheels = 4; } dirve() { console.log(\u0026#34;Drive..\u0026#34;); } stop() { console.log(\u0026#34;Stop!\u0026#34;); } } class Bmw extends Car { constructor(color) { // 부모클래스의 constructor를 오버라이딩 해야하기 때문에 동일한 color 인자를 받아야함 super(color); // constructor에서 this를 빈 객체 {} 로 받아와 리턴하는데 상속받는 경우에 이 과정을 건너뛰기 때문에 super를 사용하여 부모 클래스의 constructor를 super를 이용하여 선언해야함 this.navigation = 1; } // constructor 생성자를 따로 호출하지 않으면 아래처럼 실행됨 // constructor(..args){ // super(...args); //} park() { console.log(\u0026#34;Park\u0026#34;); } stop() { console.log(\u0026#34;Why\u0026#34;); // 이때 프로토타입 체인을 통해서 부모클래스 까지 가지 않고 메서드 오버라이딩이 되어버림 super.stop(); // 부모 클래스의 stop을 사용하려면 이렇게 } } this 참고 : https://www.youtube.com/watch?v=tDZROpAdJ9w\nthis는 무엇인가요?\nthis는 현재 실행 컨텍스트의 객체를 참조하는 식별자입니다. 즉, 현재 코드가 실행되고 있는 \u0026ldquo;주변 환경\u0026quot;을 가리킵니다. 함수 내에서 this의 값은 그 함수가 호출되는 방식에 따라 달라집니다. this는 언제 결정되나요?\nthis의 값은 함수가 호출될 때 결정됩니다. 주요 규칙은 다음과 같습니다 전역 컨텍스트에서 this는 전역 객체를 가리킵니다. 브라우저에서는 window, Node.js에서는 global 객체가 됩니다. 객체의 메서드로서 호출될 때, this는 그 메서드를 호출한 객체를 가리킵니다. 생성자 함수에서, this는 새로 생성되는 객체 인스턴스를 가리킵니다. 이벤트 핸들러에서, this는 이벤트를 받는 DOM 요소를 가리킵니다. Arrow Function에서의 this는 무엇인가요?\n화살표 함수(arrow function)에서의 this는 다릅니다. 화살표 함수는 자신의 this를 가지지 않고, 대신에 함수가 생성될 때의 this값을 \u0026ldquo;렉시컬하게\u0026rdquo; 바인딩합니다. 즉, 화살표 함수 내부에서의 this는 화살표 함수를 둘러싼 외부 스코프의 this와 같습니다. this를 변경하는 방법은 무엇인가요?\n참조 : https://www.youtube.com/watch?v=KfuyXQLFNW4 JavaScript에서는 call, apply, 그리고 bind 함수를 이용해 함수 호출 시 this의 값을 명시적으로 설정할 수 있습니다. call과 apply는 함수를 즉시 호출하면서 첫 번째 인자로 this를 설정합니다. call은 개별 인자를, apply는 인자 배열을 받습니다. bind는 함수의 this값을 영구히 바꿀 수 있는 새 함수를 반환합니다. 이는 Function.prototype에 정의되어 있으며, bind를 호출하는 함수의 복사본을 생성하고, 이 복사본에서 this의 값을 첫 번째 인자로 전달된 값으로 고정합니다. Scope 스코프란 무엇인가요?\n변수이름, 함수이름, 클래스이름 과같은 식별자가 본인이 선언된 위치에 따라 다른 코드에서 자신이 참조될 수 있을지 없을지 결정되는 것 스코프 체인이란 무엇인가요 ?\n스코프는 함수의 위치에 따라서 계층적인 구조를 가질 수 있음 이렇게 계층적인 구조로 연결된 것을 스코프체인이라고 함 예를들어 Inner scope \u0026gt; Outer Scope \u0026gt; 전역 스코프 처럼 계층적인 구조임 변수를 참조할때 JS 엔진은 스코프 체인을 통해 변수를 참조함 아래에서 위로 반방향성으로만 존재하여 하위스코프는 상위스코프를 참조할 수 있지만 반대는 되지 않음 스코프 레벨이란 무엇인가 ?\n블록 레벨 스코프 (if, for) = ES6의 let, const 함수 레벨 스코프 = 함수 동적스코프 - 함수가 호출되는 시점에 결정\n정적스코프(렉시컬스코프) - 함수가 정의되는 시점에 결정 자바스크립트에서 함수는 상위 스코프를 참조하기 때문에 본인의 내부 슬롯에 상위 스코프에 대한 참조를 저장합니다.\n함수가 호출되면 실행되는 과정은 ?\n함수호출 -\u0026gt; 실행 컨텍스트 생성 -\u0026gt; 실행 컨텍스트 스택 푸쉬 -\u0026gt; 렉시컬 환경 생성 (식별자, 식별자에 바인딩 된 값, 상위 렉시켤 환경에 대한 참조) -\u0026gt; 실행 컨덱스트 스택 POP Closure 참고 : https://www.youtube.com/watch?v=tpl2oXQkGZs\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const Bmw = function (color) { this.color = color; }; const ix3 = new Bmw(\u0026#34;red\u0026#34;); ix3.color = \u0026#34;blue\u0026#34;; // 이렇게 사용하면 color가 바뀌어짐 // 클로져를 활용해서 바꿀수 없고 초기값만 받아올 수 있게 할 수 있음 const Bmw = function (color) { const c = color; this.getColor = function () { // getColor는 생성될 당시의 컨텍스트를 기억함 console.log(c); }; }; ix3.getColor(); // 클로져를 색상을 가져올 수 있으나 바꿀 수 없게 할 수 있음 클로져는 무엇인가요? 상위 스코프의 식별자를 참조하고 있음. 클로져를 활용한 구현경험 커링이 무엇인지 고차함수는 무엇인지 스코프-클로져 Functional Programming 배열의 고차함수 어떤것을 사용하는지 reduce를 어떻게 구현하는지 합성은 상속과 어떤 장점이 있는지 Immutable 장단점은 어떤것이 있는지 OOP ES lasses 상속은 어떻게 하고 캡슐화는 어떻게하는지 객체를 나누는 단위는? 의존성을 낮추는 방법은? (사례중심) 비동기 Promise, async/await 차이는?\n1 2 3 4 5 6 7 8 9 10 11 new Promise 는 state와 result를 property로 갖음 state: pending result: undefined resolve(value) resolve가 실행되면 state: fulfilled (이행됨) result: value reject(error) 실패하면 state: rejected(거부됨) result: error 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const pr = new Promise((resolve, reject) =\u0026gt; { // resolve -\u0026gt; 성공한 경우 실행되는 Callback 함수 // reject -\u0026gt; 실패한 경우 실행되는 Callback 함수 }); pr.then( function (result) {}, // 이행되었을 때 실행 function (err) {} // 거절되었을 때 실행 ); // catch를 이용하여 명확하게 구분할 수 있음 pr.then(function (result) {}) .catch(function (err) {}) .finally(function () { // 이행이 되었든 거절이되었든 처리가 완료되면 항상 실행됨 ex) 로딩화면 없애기 console.log(\u0026#34;끝\u0026#34;); }); 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 const order1 = () =\u0026gt; { return new Promise((res, rej) =\u0026gt; { setTimeout(() =\u0026gt; { res(\u0026#34;1번주문완료\u0026#34;); }, 1000); }); }; const order2 = (message) =\u0026gt; { console.log(message); return new Promise((res, rej) =\u0026gt; { setTimeout(() =\u0026gt; { rej(\u0026#34;2번주문실패\u0026#34;); }, 3000); }); }; const order3 = (message) =\u0026gt; { console.log(message); return new Promise((res, rej) =\u0026gt; { setTimeout(() =\u0026gt; { res(\u0026#34;3번주문완료\u0026#34;); }, 2000); }); }; // promise chain // 하나의 작업이 완료되면 다시 넘어감 총 1 + 3 + 2 = 6초가 걸림 order1() .then((res) =\u0026gt; order2(res)) // \u0026#34;1번주문완료\u0026#34; 출력 후 reject .then((res) =\u0026gt; order3(res)) // 실행되지 않음 .then((res) =\u0026gt; consol.log(res)); // 실행되지 않음 .catch(console.log) // \u0026#34;2번주문실패\u0026#34; 출력 .finally(()=\u0026gt; {console.log(\u0026#34;끝\u0026#34;)}) // \u0026#34;끝\u0026#34; 출력 // async await 사용하면 이렇게 바꿀 수 있음 // promise then을 사용하는 것보다 가독성이 좋고 명확하게 보여짐 async function order() { try{ const result1 = await order1(); const result2 = await order2(result1); const result3 = await order2(result2); console.log(result3); } catch (e) { console.log(e) } console.log(\u0026#34;끝\u0026#34;); } order(); // Promise.all // 비동기로 실행 모든 작업이 완료될때 까지 기다리는데 3초정도 걸림 // 만약 하나라도 reject가 되면 에러 발생함 따라서 모든 값들이 필요할때 사용하면 됨 // 전부 성공하면 [\u0026#34;1번주문완료\u0026#34;, \u0026#34;2번주문완료\u0026#34;, \u0026#34;3번주문완료\u0026#34;] Promise.all([order1(), order2(), order3()]).then(res)=\u0026gt;{ console.log(res); } // async await 사용하면 이렇게 바꿀 수 있음 // promise then을 사용하는 것보다 가독성이 좋고 명확하게 보여짐 async function order() { try{ const result = await Promise.all([order1(), order2(), order3()]); } catch (e) { console.log(e) } } order(); // Promise.race // 하나라도 끝나는 작업이 있으면 리턴함 // \u0026#34;1번주문완료\u0026#34; 가 1초만에 출력되고 종료됨 // 이행되든 거부되든 가장 빠른걸 먼저 출력 Promise.race([order1(), order2(), order3()]).then(res)=\u0026gt;{ console.log(res); } // Promise.any // 프로미스중에 가장 먼저 이행된 객체 반환 Promise.any([order1(), order2(), order3()]).then(res)=\u0026gt;{ console.log(res); } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 async function getName() { // 함수앞에 async를 붙히면 Promise를 반환하게됨 return \u0026#34;cha2hyun\u0026#34;; } console.log(getName()); // Promise {\u0026lt;fulfilled\u0026gt;:\u0026#34;cha2hyun\u0026#34;} getName().then((name) =\u0026gt; console.log(name)); // cha2hyun function getName() { return new Promise((resolve, reject) =\u0026gt; { setTimeout(() =\u0026gt; { resolve(name); }, 1000); }); } 1 2 3 4 5 6 7 async function showName() { const result = await getName(\u0026#34;cha2hyun\u0026#34;); // Promise가 끝날때 까지 기다림 즉 1초후에 이름이 찍힘 console.log(result); } console.log(\u0026#34;시작\u0026#34;); // \u0026#34;시작\u0026#34; 찍히고 showName(); // 1초후에 \u0026#34;cha2hyun\u0026#34; 찍힘 promise 패턴 설명\nsetTimeout에 Promise를 적용하면?\n동시에 여러개의 관계없는 요청을 한다면?\nTask queue vs Micro task queue?\nGenerator generator란 ?\n함수의 실행을 멈추었다가 다시 실행할 수 있음\nSymbol.iterator 메서드가 있음. iterator를 반환함 예시) 배열, 문자열\niterator는 next 메서드를 가지고 value와 done 속성을 가진 객체를 반환한다\n작업이 끝나면 done은 true를 갖는다\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 // 함수 앞에 *을 붙힘 function* fn() { try { console.log(\u0026#34;1\u0026#34;); yield 1; console.log(\u0026#34;2\u0026#34;); yield 2; console.log(\u0026#34;3\u0026#34;); yield 3; return \u0026#34;finish\u0026#34;; } catch (e) { console.log(e); } } const a = fn(); // next() // a -\u0026gt; fn{\u0026lt;suspended\u0026gt;} // a.next -\u0026gt; \u0026#34;1\u0026#34; {value: 1, done: false} // a.next -\u0026gt; \u0026#34;2\u0026#34; {value: 2, done: false} // a.next -\u0026gt; \u0026#34;3\u0026#34; {value: 3, done: false} // a.next -\u0026gt; {value: \u0026#34;finish\u0026#34;, done: true} // a.next -\u0026gt; {value: undefined, done: true} // return() // a -\u0026gt; fn{\u0026lt;suspended\u0026gt;} // a.next -\u0026gt; \u0026#34;1\u0026#34; {value: 1, done: false} // a.return(\u0026#34;end\u0026#34;) -\u0026gt; {value: end, done: true} // a.next -\u0026gt; {value: undefined, done: true} // throw() // a -\u0026gt; fn{\u0026lt;suspended\u0026gt;} // a.next -\u0026gt; \u0026#34;1\u0026#34; {value: 1, done: false} // a.throw(new Error(\u0026#39;err\u0026#39;)) -\u0026gt; \u0026#34;Error: err\u0026#34; {value: undefined, done: true} 객체 객체 표현 방식중 자주 사용하는지? class, prototype, literal 차이 자주 사용하는 메서드는? JSON 데이터 파싱시 가장 신경쓰이는 것은? 대문자 객체와 소문자 객체는 무엇이 차이있는지 (Object vs object) 얕은복사와 깊은복사 이벤트 실행방법 이벤트 버블링, 이벤트 캡쳐링 타입스크립트 Pick, Omit\n접근제한자\npublic - 자식클래스, 클래스인스턴스 모두 접근 가능 protected - 자식 클래스에서 접근 가능 인스턴스에서는 접근 불가 private - 해당 클래스 내부에서만 접근 가능 (변수명 앞에 #을 찍는걸로 할 수 있음) readonly - 수정할 수 없게 (constructor에서만 수정할 수 있음) static - this로 선언 못하고 class 명으로 선언해야함 추상클래스\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 // 추상클래스 abstract class Car { // class 앞에 abstract가 있으면 추상클래스로 선언됨 color: string; constructor(color: string) { this.color = color; } start() { console.log(\u0026#39;start\u0026#39;); } abstract doSomething():void; } const car = new Car(\u0026#34;red\u0026#34;) // abstract class는 new로 선언할 수 없음 class Bmw extends Car { constructor(color: string) { super(color); } doSomething() {} // doSomething이 abstract이기 때문에 오버라이드해서 구현을 꼭 해야함 } const car = new Bmw(\u0026#34;red\u0026#34;) // 호출할 수 있게됨 Generic\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 // 유니온 타입 이용시 function getSize(arr: number[] | string[] : boolean[]: object[] ): number { return arr.length; } const arr1 = [1, 2, 3]; const arr2 = [\u0026#34;a\u0026#34;, \u0026#34;b\u0026#34;, \u0026#34;c\u0026#34;]; const arr3 = [true, false, true]; const arr4 = [{}, {}, { name: \u0026#34;cha2hyun\u0026#34; }]; // Generic 타입 이용시 function getSize\u0026lt;T\u0026gt;(arr: T[]): number { return arr.length; } const arr1 = [1, 2, 3]; const arr2 = [\u0026#34;a\u0026#34;, \u0026#34;b\u0026#34;, \u0026#34;c\u0026#34;]; const arr3 = [true, false, true]; const arr4 = [{}, {}, { name: \u0026#34;cha2hyun\u0026#34; }]; getSize\u0026lt;number\u0026gt;(arr1); 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 // any type interface Mobile { name: string; price: number; options: any; } // Generic type interface Mobile\u0026lt;T\u0026gt; { name: string; price: number; options: T; } const m1: Mobile\u0026lt;string\u0026gt; = { name: \u0026#34;cha2hyun\u0026#34;, price: 900, options: \u0026#34;good\u0026#34;, }; const m2: Mobile\u0026lt;{ color: string }\u0026gt; = { name: \u0026#34;cha2hyun\u0026#34;, price: 900, options: { color: \u0026#34;red\u0026#34; }, }; keyof : property 키값을 union type으로\n1 2 3 4 5 6 interface User { id: number; name: string; } type UserKey = keyof User; // \u0026#39;id\u0026#39; | \u0026#39;name\u0026#39; const uk:UserKey = \u0026#34;id\u0026#34; Partial : property를 모두 optional로 변경\n1 2 3 4 5 6 7 8 9 10 11 12 interface User { id: number; name: string; } // 이렇게하면 name이 없어서 에러 let admin: User = { id: 1, }; // Partial을 쓰면 User 프로퍼티가 모두 Optional로 변경되서 사용 가능 let admin: Partial\u0026lt;User\u0026gt; = { id: 1, }; Required : property를 모두 필수로 바까줌\n1 2 3 4 5 6 7 8 9 10 11 12 interface User { id: number; name?: string; } // 이렇게하면 name은 옵셔널이기 때문에 에러가 발생하지 않음 let admin: User = { id: 1, }; // 에러발생 옵셔널을 모두 필수로 바꾸어서 name도 필수값이 됨 let admin: Required\u0026lt;User\u0026gt; = { id: 1, }; Record\u0026lt;K, T\u0026gt;\n1 2 3 4 5 6 7 8 type Grade = \u0026#34;1\u0026#34; | \u0026#34;2\u0026#34; | \u0026#34;3\u0026#34; | \u0026#34;4\u0026#34;; type Score = \u0026#34;A\u0026#34; | \u0026#34;B\u0026#34; | \u0026#34;C\u0026#34; | \u0026#34;D\u0026#34;; const score: Record\u0026lt;Grade, Score\u0026gt; = { 1: \u0026#34;A\u0026#34;, 2: \u0026#34;B\u0026#34;, 3: \u0026#34;C\u0026#34;, 4: \u0026#34;D\u0026#34;, }; 기타 generateor가 무엇인가요? ES Next 관심있는 문법은? 정규표현식은 무엇이고 언제사용하는지? Debugging 버그 문제를 어떻게 해결하는지? 본인만의 디버깅 방식은? calling stack? network 오류 상황에 어떻게 확인하는지? (break 포인트?) null vs undefined null과 undefined는 JavaScript에서 \u0026ldquo;없음\u0026quot;을 나타내는 두 가지 다른 값입니다. 둘 사이에는 몇 가지 주요 차이점이 있습니다\n의미적 차이\nundefined는 변수가 선언되었지만 값이 할당되지 않은 상태를 나타냅니다. 즉, 변수의 값이 아직 정의되지 않았음을 의미합니다. null은 변수에 값이 없음을 의도적으로 나타내는 데 사용됩니다. 즉, 변수가 \u0026ldquo;비어있음\u0026rdquo; 또는 \u0026ldquo;아무것도 가리키지 않음\u0026quot;을 의도적으로 표현할 때 사용합니다. 타입:\ntypeof undefined는 \u0026ldquo;undefined\u0026quot;를 반환합니다. typeof null은 오래된 JavaScript의 버그로 인해 \u0026ldquo;object\u0026quot;를 반환합니다. 이는 null이 실제로는 기본 타입임에도 불구하고 발생하는 일종의 이상한 결과입니다. 기본값:\n함수에서 명시적인 반환값이 없을 경우 undefined를 반환합니다. 변수를 선언만 하고 초기화하지 않았을 때, 그 변수의 기본값은 undefined입니다. 사용 상황:\nundefined는 주로 JavaScript 엔진에 의해 할당되며, 개발자가 직접 사용하는 경우는 드뭅니다. null은 개발자가 변수에 \u0026lsquo;값이 없음\u0026rsquo;을 명시적으로 나타내고 싶을 때 사용합니다. 이러한 차이점에도 불구하고, null과 undefined는 둘 다 \u0026ldquo;없음\u0026quot;을 나타내기 때문에, 느슨한 동등 연산자(==)를 사용하여 비교할 경우 서로 같다고 평가됩니다(null == undefined는 true를 반환). 그러나, 엄격한 동등 연산자(===)를 사용할 경우, 이 두 값은 서로 다르다고 평가됩니다(null === undefined는 false를 반환). ","permalink":"https://cha2hyun.blog/content/algorithm/frontend-algorithm/","summary":"💡 현재 글은 미완성 단계입니다.\n브라우저 브라우저에 URL을 입력하면 생기는 일 URL해석 브라우저에 입력된 URL을 해석하여 해당하는 웹페이지의 호스트명, 프로토콜 정보를 추출 DNS 조회 (분산형 DB) 호스트명을 IP주소로 변환하기 위해 DNS 서버에 쿼리를 보내고 DNS서버는 IP주소를 응답 이때 PC의 host 파일 - DNS cache - 공유기(라우터)의 DNS - ISP의 DNS를 먼저 확인하고 없다면 DNS 서버에 질의 접속자의 IP에 따라서 가장 가까운 CDN(GSBL)을 전달받을 수도 있음 서버연결 브라우저는 DNS 서버로 부터 얻은 IP 주소를 이용하여 해당 서버에 TCP 연결을 시도 (TCP 연결이 성공하면 HTTP requests가 보내짐) 이때 URL해석에서 얻은 프로토콜이 HTTPS 일 경우 TLS 핸드셰이크 과정을 거침.","title":"FE 기술면접 준비"},{"content":"\n요약 next/image를 그냥 사용할 때 서버에 저장되는 캐쉬가 점점 늘어나서 서버가 곧 죽게되는 슬픈 이야기..\n어느날 갑자기 찾아온 시련 😱 이미지가 로딩이 안될때 있으시죠? 그게 저에요\n이번년도에 앱으로만 이용되었던 큐찾사를 홈페이지도 개발, 배포하여 수개월동안 안정적으로 운영되었습니다. 일부 마이그레이션이 되지 않은 기능들을 하나씩 붙히고 있던 와중에 어느날 갑자기 이미지가 로딩이되지 않는 사태가 발생했습니다.\n이미지는 S3 버켓에 저장되어 있었는데, S3 url로는 이미지가 잘 로딩되고, 새로고침시 나오는 이미지가 있고 표시되지 않는 이미지도 있었습니다. 앱에서는 모든 사진이 정상적으로 표시되었습니다. 따라서 당연히 홈페이지에 기능을 새로 붙히다가 충돌이 난 것으로 생각했습니다. (지속적으로 안정적으로 운영이 되었기에, 이미지를 뭘 잘못 업로드 했을 거라고도 생각했습니다.)\n그렇게 원인을 부랴부랴 확인하던 와중\u0026hellip; 도메인 접속 조차 되지 않는 초유의 사태가 발생하였습니다.\n이러면 진짜 땀이 줄줄납니다.\n아무리 기능이 충돌나더라도 404나 에러페이지로 가지거나 응답은 받아와야하는데 곧이어 nginx 오류 502 bad gateway까지 표시됬습니다. 이때부터는 api서버도 뻗어버렸습니다. 부랴부랴 또 EC2를 확인해보러갑니다..\n아니 넌 왜 또?????????????\nEC2는 넉넉한 인스턴스 플랜을 사용중인데 평상시엔 10% 미만이어야하는 CPU 사용량이 99.9%까지 상한가를 3번 치고 수직 상승했었네요. (Loadbalancer-Auto Scailing을 적용해놨었지만 실제로 테스트는 못해봤지만 이때 적용이 잘 안된다는 것을 이렇게 확인했습니다..ㅎㅎ)\n일단 원인은 비교적 쉽게 찾았습니다. 갑자기 바이럴타서 대박이나서 유저가 순식간에 몰렸던거면 참 좋았겠지만ㅋ, EBS 용량이 40기가였는데 꽉 찼더라구요 더 이상 여유 공간이 없어서 뻗어버렸던 것 같습니다. 인스턴스만 좋으면 뭐하냐고..\n아니, 도커 몇개 뛰우는용인데 40기가가 모잘라..?? 라고 생각하지도 못하고 우선 급한대로 EBS 용량을 늘리고 보는데 EBS가 꽉찼던건 다름 아닌 next/image 캐쉬가 수십기가가 쌓여있었습니다. 우선은 임시방편으로 cron으로 캐쉬폴더를 정리해주는 것을 추가해두고 트러블 슈팅을 진행했습니다. (next/image에서 cache flush를 지원하지 않습니다)\nnext/image 알고 쓰자 S3 버킷 총 용량이 12기가인데 next/image 캐쉬 폴더엔 수십기가가 쌓여있었습니다. next/image 사용시 자동으로 사용자 환경에 따라 이미지를 optimized하고 캐쉬폴더에 저장하기 때문에 여러 환경에서 이미지를 불러오면 각 환경에 맞게 이미지를 optimized 하여 캐쉬로 저장시키기 때문에 배보다 배꼽이 더 커지게 됩니다.\n예를 들면 S3엔 이미지 1Mb x 1개 = 1mb여도 EC2 캐쉬폴더엔 최적화된 이미지 xMb x y개 = xyMb 가 되어 버립니다.\n아니, 그럼 S3에 뭣하러 저장을 허냐.. 어차피 EC2에서 다시 저장할거,, 라고 생각되실 수 있습니다. 저도 그랬습니다. 구글링해보니 Image Doubling 문제를 고민하는 사람들이 많이 있더라구요. 결국 해답은 찾지 못했습니다. Discussion 59234\n우선 CDN을 사용하기로 합니다. 근데\u0026hellip; 중요한건\u0026hellip; 아니 우리는 이미 CloudFront로 CDN 이미 사용중인데..? 수십기가씩 HIT되고 있었다.. 가성비 좋은 기특한 녀석\n머리가 하얗게 띵해집니다. 분명히 CDN을 애초에 프로젝트 초기부터 썻는데 S3 \u0026gt; CDN \u0026gt; EC2 next.js optimized \u0026gt; 유저 여태동안 이렇게 이미지가 불러와졌다고 \u0026hellip; ? 분명히 아닐 수 밖에 없는게, 그랬다면 진작에 서버가 뻑났어야합니다. 수 개월동안 멀쩡하던게 이제와서 그러는지 이해가 안되던 찰라 문제를 발견했습니다.\n1 2 3 experimental: { images: { unoptimized: true }, } 실험 기능이었던 unoptimized가 next.js 13으로 오면서 실험 기능이 아닌게 되어버린 것.\n1 2 3 4 images: { unoptimized: true domains: [...] }, 진짜.. 코드 한 줄이 서버를 뻑나게 할 수 도 있구나를 몸소 체험해버렸습니다.\n아.. 이래서 deprecated나 experimental은 진짜 주의해야하는 구나..\n이후 홈페이지는 정상 작동 되고 더 이상 배보다 배꼽이 큰 캐쉬를 만들지 않고 CDN에서 알아서 캐싱해서 유저에게 빠르게 보내주고 있습니다.\n결론 next/image를 그냥 사용하면 100% 서버가 뻑나게 되어있음 !!! (EBS용량은 생각보다 비쌉니다) EBS 용량은 한번 늘리면 줄일 수 없으니 주의. 또한 EC2가 S3용량보다 커지게 되는 현상이 무조건 발생한다. CDN을 사용한다면 config에 unuptimized를 둘 것 !!! 코드 한줄이 서버를 뻑나게 할 수 있으니 deprecated나 experimental 사용은 항상 주의. 실전 에러가 압축 성장에 좋다 (?) jpg 보단 webp로 저장하기 원본이미지 하나만 저장하지 않고 여러 상황에 맞는 썸네일 이미지나 디바이스 크기별로 리사이징하여 저장하기 결론보단 교훈에 가깝습니다. 다행히(?) 유저가 많이 몰리지 않는 아침시간에 발생했고 거의 바로 확인해서 즉시 대응은 가능했습니다. 안되면 큰일난다 마인드로 대처하니 순식간에 성장한 느낌도 있습니다.\nnext/image를 일반적으로 사용하면 무조건 서버에서는 캐쉬폴더가 점점 늘어나서 뻑날 수 밖에 없는 구조이지만 이런 내용이 주의적으로 다루어져있지 않았던게 아쉬웠습니다. 로컬 테스트 환경에서는 쉽게 확인할 수 없는 이슈기 때문에 더욱 주의해서 사용해야할 것 같습니다. 😃\nReference https://nextjs.org/docs/pages/api-reference/components/image https://nextjs.org/docs/app/building-your-application/optimizing/images https://velog.io/@devohda/Next.js-%EB%A1%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%B5%9C%EC%A0%81%ED%99%94-%EC%8B%9C%EB%8F%84%ED%95%98%EA%B8%B0ing ","permalink":"https://cha2hyun.blog/content/projects/%ED%81%90%EC%B0%BE%EC%82%AC/nextimage/","summary":"next/image 사용하다가 플aws 서버가 뻗었습니다. 모르고 쓰면 100% 서버 뻗습니다.","title":"next/image 그냥 쓰면 서버는 시한부입니다 💀"},{"content":"Recoil yarn add recoil @types/recoil\n주요 API 호출방법 useRecoilState() - Atom을 읽고 쓸 때 사용한다.\n1 2 3 4 5 6 7 8 const counterState = atom({ key: \u0026#34;counterState\u0026#34;, default: 0, }); const [counter, setCounter] = useRecoilState(counterState); const onIncrement = () =\u0026gt; setCounter((prev) =\u0026gt; prev + 1); // counter increases useRecoilValue() - Atom의 값을 반환하고 컴포넌트를 해당 atom에 구독한다.\n1 2 3 4 5 6 7 8 const userState = atom({ key: \u0026#34;userState\u0026#34;, default: { name: \u0026#34;moon\u0026#34;, age: \u0026#34;20\u0026#34; }, }); const user = useRecoilValue(userState); console.log(user.name, user.age); // moon, 20 useSetRecoilState() - 쓰기 가능한 Atom을 업데이트하기 위한 setter 함수를 반환한다.\n1 2 3 4 5 6 7 8 const userState = atom({ key: \u0026#34;userState\u0026#34;, default: { name: \u0026#34;moon\u0026#34;, age: \u0026#34;20\u0026#34; }, }); const setUserState = userSetRecoilState(userState); setUserState((prev) =\u0026gt; ({ ...prev, name: \u0026#34;ga\u0026#34; })); // name change to \u0026#34;ga\u0026#34; useResetRecoilState() - Atom을 기본값으로 리셋하고 컴포넌트를 해당 atom에 구독한다.\n1 2 3 4 5 6 7 8 9 10 const userState = atom({ key: \u0026#34;userState\u0026#34;, default: { name: \u0026#34;moon\u0026#34;, age: \u0026#34;20\u0026#34; }, }); const setUserState = userSetRecoilState(userState); const resetUserState = useResetRecoilState(userState); setUserState((prev) =\u0026gt; ({ ...prev, name: \u0026#34;ga\u0026#34; })); // name changed to \u0026#34;ga\u0026#34; resetUserState(userState); // name reset to \u0026#34;moon\u0026#34; useRecoilStateLoadable() - 비동기 selector로부터 상태를 불러올 때, 상태가 불러와질 때까지 대기하고 setter가 포함된 Loadable 객체를 반환한다.\n1 2 3 4 5 6 7 8 9 10 11 const [userState, setUserState] = useRecoilStateLoadable( fetchUserState(userID) ); switch (userState.state) { case \u0026#34;hasValue\u0026#34;: return \u0026lt;div\u0026gt;{userState.contents.name}\u0026lt;/div\u0026gt;; case \u0026#34;loading\u0026#34;: return \u0026lt;div\u0026gt;Loading...\u0026lt;/div\u0026gt;; case \u0026#34;hasError\u0026#34;: throw userState.contents; } useRecoilValueLoadable() - useRecoilStateLoadable()과 유사하지만 setter가 포함되지 않은 Loadable 객체를 반환한다.\n1 2 3 4 5 6 7 8 9 const userState = useRecoilValueLoadable(fetchUserState(userID)); switch (userState.state) { case \u0026#34;hasValue\u0026#34;: return \u0026lt;div\u0026gt;{userState.contents.name}\u0026lt;/div\u0026gt;; case \u0026#34;loading\u0026#34;: return \u0026lt;div\u0026gt;Loading...\u0026lt;/div\u0026gt;; case \u0026#34;hasError\u0026#34;: throw userState.contents; } 기본적인 atom, selector 사용 방법 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 export const meState = atom\u0026lt;string\u0026gt;({ key: \u0026#34;me\u0026#34;, default: \u0026#34;채수현\u0026#34;, }); export const meStateSelector = selector\u0026lt;string\u0026gt;({ key: \u0026#34;testSelector\u0026#34;, get: ({ get }) =\u0026gt; { const me = get(meState); // const newMe = DO SOMETHING... return newMe; }, set: ({ set }, newValue) =\u0026gt; { set(meState, newValue); }, }); 비동기 처리방법 (⭐️공식문서 꼭 보기) atom, selector, suspense를 이용 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 export const meState = atom\u0026lt;string\u0026gt;({ key: \u0026#39;meState\u0026#39;, default: \u0026#39;홍길동\u0026#39;, }); export const fetchMeState = selector\u0026lt;any\u0026gt;({ key: \u0026#39;fetchMeState\u0026#39;, get: async () =\u0026gt; { try { const newMe = await getMe(); // {name: \u0026#39;채수현\u0026#39;} return newMe.name; } catch (err) { throw Error(\u0026#39;err\u0026#39;); } }, }); ... export function Test() { const [me, setMe] = useRecoilState(meState); const fetchMe = useRecoilValue(fetchMeState); useEffect(() =\u0026gt; { setMe(fetchMe); }, [fetchMe, setMe]); return ( \u0026lt;div\u0026gt;{me}\u0026lt;/div\u0026gt; ); } ... // App.tsx \u0026lt;Suspense fallback={\u0026lt;div\u0026gt;loading\u0026lt;/div\u0026gt;}\u0026gt; \u0026lt;PageLayout\u0026gt; \u0026lt;Routes /\u0026gt; \u0026lt;/PageLayout\u0026gt; \u0026lt;/Suspense\u0026gt; useRecoilValueloadable 로 Suspense 없이 사용 1 2 3 4 5 6 7 8 9 10 11 export const fetchMeState = selector\u0026lt;string\u0026gt;({ key: \u0026#34;fetchMeState\u0026#34;, get: async () =\u0026gt; { try { const newMe = await getMe(); // {name: \u0026#39;채수현\u0026#39;} return newMe.name; } catch (err) { throw Error(\u0026#34;err\u0026#34;); } }, }); 1 2 3 4 5 6 7 8 9 10 11 12 13 // 이렇게 사용하거나 export function test() { const fetchMe = useRecoilValueLoadable(fetchMeState); return ( \u0026lt;\u0026gt; {fetchMe.state === \u0026#34;hasValue\u0026#34; ? ( \u0026lt;div\u0026gt;{fetchMe.contents}\u0026lt;/div\u0026gt; ) : ( \u0026lt;div\u0026gt;Loading...\u0026lt;/div\u0026gt; )} \u0026lt;/\u0026gt; ); } 1 2 3 4 5 6 7 8 9 10 11 // 이렇게 사용할 수 있음 export function test() { const [me, setMe] = useRecoilState(meState); const fetchMe = useRecoilValueLoadable(fetchMeState); useEffect(() =\u0026gt; { if (fetchMe.state === \u0026#34;hasValue\u0026#34;) { setMe(fetchMe.contents); } }, [fetchMe.contents, fetchMe.state, setMe]); return \u0026lt;div\u0026gt;{me}\u0026lt;/div\u0026gt;; } useCallback, useEffect로 fecth하고 저장 1 2 3 4 5 6 7 8 9 10 11 12 13 const [myName, setMyname] = useRecoilState(myNameState); const fetch = useCallback(async () =\u0026gt; { try { const { name } = await getMe(); setMyname(name); } catch (err) { console.error(err); } }, [setMyname]); useEffect(() =\u0026gt; { fetch(); }, [fetch]); interval + 비동기가 필요할 때 1 2 3 4 5 6 7 8 9 10 11 12 13 const [myName, setMyname] = useRecoilState(myNameState); useEffect(() =\u0026gt; { const interval = setInterval(async () =\u0026gt; { if (myName) { const { progress } = await getUserInfo(myName); // \u0026#39;progressing\u0026#39; | \u0026#39;error\u0026#39; | \u0026#39;complete\u0026#39; if (progress === \u0026#34;complete\u0026#34;) { // const newName = Do Something // setMyname(newName) } } }, 1000); return () =\u0026gt; clearInterval(interval); }, [myName, router]); Library js-cookie : 쿠키 관련 라이브러리 (github) yarn add js-cookie @types/js-cookie\n1 2 3 4 5 6 Cookies.set(\u0026#34;name\u0026#34;, \u0026#34;value\u0026#34;); Cookies.get(\u0026#34;name\u0026#34;); // =\u0026gt; \u0026#39;value\u0026#39; Cookies.get(\u0026#34;nothing\u0026#34;); // =\u0026gt; undefined Cookies.remove(\u0026#34;name\u0026#34;); Cookies.set(\u0026#34;name\u0026#34;, \u0026#34;value\u0026#34;, { domain: \u0026#34;subdomain.site.com\u0026#34; }); Cookies.get(\u0026#34;name\u0026#34;); // =\u0026gt; undefined (need to read at \u0026#39;subdomain.site.com\u0026#39;) Custom Hook useAsync (blog) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 import { AxiosError, AxiosRequestConfig } from \u0026#34;axios\u0026#34;; import { useCallback, useReducer } from \u0026#34;react\u0026#34;; type StateType\u0026lt;T = any\u0026gt; = { data: T | null; loading: boolean; error: AxiosError | null; }; type ActionType\u0026lt;T\u0026gt; = { type: string; data?: T; error?: AxiosError; }; type Reducer\u0026lt;T = any\u0026gt; = ( state: StateType\u0026lt;T\u0026gt;, action: ActionType\u0026lt;T\u0026gt; ) =\u0026gt; StateType\u0026lt;T\u0026gt;; const reducer: Reducer = (state, action) =\u0026gt; { switch (action.type) { case \u0026#34;LOADING\u0026#34;: return { data: null, loading: true, error: null, }; case \u0026#34;SUCCESS\u0026#34;: return { data: action.data as any, loading: false, error: null, }; case \u0026#34;ERROR\u0026#34;: return { data: null, loading: false, error: action.error as AxiosError, }; default: return state; } }; export type AsyncFc\u0026lt;TResult\u0026gt; = ( [...arg]: any[], config: AxiosRequestConfig ) =\u0026gt; Promise\u0026lt;TResult\u0026gt;; const useAsync = \u0026lt;TResult\u0026gt;( callback: AsyncFc\u0026lt;TResult\u0026gt;, config: AxiosRequestConfig = {} ) =\u0026gt; { const [state, dispatch] = useReducer\u0026lt;Reducer\u0026lt;TResult\u0026gt;\u0026gt;(reducer, { data: null, loading: false, error: null, }); const run = useCallback( async (...args) =\u0026gt; { dispatch({ type: \u0026#34;LOADING\u0026#34; }); try { const data = await callback([...args], config); dispatch({ type: \u0026#34;SUCCESS\u0026#34;, data }); return data; } catch (error) { dispatch({ type: \u0026#34;ERROR\u0026#34;, error }); } }, [callback, config] ); return { ...state, run }; }; export default useAsync; Callback 인자로 들어오는 api 예시 1 2 3 4 5 6 7 8 9 10 11 12 13 14 import { AsyncFc } from \u0026#34;~shared/hooks/useAsync\u0026#34;; export const patchRenewal: AsyncFc\u0026lt;RenewalItem\u0026gt; = async ( [id, params], config ) =\u0026gt; { const response = await apiClient.patch( `/api/contracts/issues/${id}`, params, config ); return response.data; }; 컴포넌트 단 사용 예시 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 import { useState } from \u0026#34;react\u0026#34;; import useAsync from \u0026#34;~shared/hooks/useAsync\u0026#34;; import { User } from \u0026#34;~shared/schemas/user\u0026#34;; import { updateUser } from \u0026#34;../../api\u0026#34;; const Users = () =\u0026gt; { const { run, loading, error } = useAsync\u0026lt;User\u0026gt;(updateUser); const [users, setUsers] = useState\u0026lt;User[]\u0026gt;([]); const handleClick = async (user: any) =\u0026gt; { const data = await run(user.id, user); if (data) { const next = users.map((user) =\u0026gt; (user.id === data.id ? data : user)); setUsers(next); } }; if (loading) return \u0026#34;Loading...\u0026#34;; if (error) return `Something went wrong: ${error.message}`; return ( \u0026lt;ul\u0026gt; {users.map((user: any) =\u0026gt; ( \u0026lt;li key={user.id}\u0026gt; \u0026lt;button onClick={() =\u0026gt; handleClick(user)}\u0026gt;Update User\u0026lt;/button\u0026gt; {/*{users 의 상태값을 변경하는 코드...}*/} \u0026lt;/li\u0026gt; ))} \u0026lt;/ul\u0026gt; ); }; export default Users; 계속 \u0026hellip;\n","permalink":"https://cha2hyun.blog/content/algorithm/react-algorithm/","summary":"Recoil yarn add recoil @types/recoil\n주요 API 호출방법 useRecoilState() - Atom을 읽고 쓸 때 사용한다.\n1 2 3 4 5 6 7 8 const counterState = atom({ key: \u0026#34;counterState\u0026#34;, default: 0, }); const [counter, setCounter] = useRecoilState(counterState); const onIncrement = () =\u0026gt; setCounter((prev) =\u0026gt; prev + 1); // counter increases useRecoilValue() - Atom의 값을 반환하고 컴포넌트를 해당 atom에 구독한다.\n1 2 3 4 5 6 7 8 const userState = atom({ key: \u0026#34;userState\u0026#34;, default: { name: \u0026#34;moon\u0026#34;, age: \u0026#34;20\u0026#34; }, }); const user = useRecoilValue(userState); console.","title":"자주사용되는 리액트 알고리즘, 라이브러리 정리"},{"content":"이전글) 무한스크롤 리스트 구현 CHECK 👉 Next.js] InfiniteScroll로 무한 스크롤 구현하기\n결과 미리보기 중고큐 키워드 검색시 최상단 노출에 성공 했습니다 👏👏 당연히 다른 키워드나 상품명 (브랜드, 카테고리 등) 전부 검색엔진에 잘 표시 됩니다.\n구글 검색\n네이버 검색\n들어가며 위 글에서 다룬 내용중 무한스크롤 + Modal로 상품상세페이지를 빠르게 표시하는 방법이 있었습니다. API에서 리스트를 불러올때 리스트안에 상품상세페이지를 구성하는 모든 값들이 있었기 때문에 해당 인자를 Modal로 넘겨주면 새로운 Fetch가 필요 없으므로 로딩이 필요 없는 것이 중요한 포인트였습니다. 최상의 로딩속도 경험을 제공해주는데 비해 여러가지 문제점들이 있었습니다.\n문제점 모달은 SEO가 되지 않으므로 구글검색에서 상품이 표시되지 않는 문제 😵 모바일 기기에서 자연스럽게 왼쪽으로 스와이프로 뒤로가기시 모달이 닫히는 것 이 아니라 페이지 이동이 되어버림. Google Analytics에서 모달을 페이지로 인식하지 못하는 문제. 리스트에서 상세를 모달로 오픈하는 것은 속도나 경험 측면에서 유리한 점을 제공하기도 하지만 일반적인 상품상세페이지에서는 득보다 실이 더 많다고 판단되어 Next.js의 꽃이라고도 할 수 있는 Dynamic Route를 이용해 모달 방식이 아닌 상세페이지로 링크 이동되게 진행하였습니다. (리퀘스트 1번이 API서버에 영향을 주진 않겠지만, 유저의 속도 경험을 위해 고민했던 내용입니다.)\n궁금했던점 나는 이미 리스트에서 상세페이지에 대한 내용을 전부 가지고 있는데 이동되면서 한번더 Fetch를 하게되면 손해가 발생하는 것이 아닐까? 라는 생각을 했습니다. 상세페이지로 이동하는 경우의수는 두가지입니다.\n리스트에서 상품을 클릭하여 상세페이지로 넘어가는 경우 (이때 불필요한 request 발생) 검색이나 링크로 직접 상세페이지로 접속하는 경우 따라서 1번의 경우에 \u0026lt;Link\u0026gt;에 인자를 넣어서 넘겨주거나, LocalStorage에 인자를 저장해놓고 [id].tsx에서 인자가 있는지 없는지 여부를 확인하여 부분적 SSR를 이용할 수 있지 않을까? 라는 생각이 들었습니다.\n해당 내용은 vercel/next.js에 Discussion을 통해 질문하였습니다. 상세한 답변과 내용은 링크를 확인해주세요 👉 Is there a trick on getServerSideProps to check if user types the URL directly #45077\n결론 리스트에서 이미 Fetch된 데이터를 모달 or 상세페이지로 인자로 넘겨주고 상세페이지에서는 인자가 있는지 없는지 여부를 확인해 부분적으로 Fetch 하는 것은 오히려 비효율적이다 !!! 특히 SEO가 안되므로 !!\nSSR (Server-Side-Rendering) SEO 최적화를 위해서는 🔥꼭 서버 사이드 렌더링을🔥 사용해야합니다. Next.js에서 페이지가 생성된 이후에 Fetch된 Data는 인식하지 못하고 Undefined로 표시됩니다. 따라서 useEffect (조건문에 의한 Axios or Request)로 불러올 경우에는 SEO가 적용되지 않습니다. DynamicRoute에서 SSR을 적용한 코드는 다음과 같습니다. 단 [id].tsx처럼 다이나믹 라우트가 적용된 페이지에서는 별도의 사이트맵을 생성해주어야 합니다\nsrc \u0026gt; pages \u0026gt; findcue \u0026gt; [id].tsx src \u0026gt; pages \u0026gt; findcue \u0026gt; [id].tsx 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 export default function CueDetail({ cue }: { cue: Results }) { ... return ( \u0026lt;Layout\u0026gt; // \u0026lt;-- 카카오 공유하기 sdk 초기화 // SEO {cue ? ( \u0026lt;Seo title={seo_title(cue)} description={seo_description(cue)} image={seo_OG_images(cue)} /\u0026gt; ) : ( \u0026lt;Seo /\u0026gt; )} \u0026lt;main\u0026gt; \u0026lt;section className=\u0026#34;bg-white\u0026#34;\u0026gt; \u0026lt;div className=\u0026#34;layout\u0026#34;\u0026gt; \u0026lt;div\u0026gt; {/* 큐 영역 보여지는 구간 */} {cue \u0026amp;\u0026amp; \u0026lt;div data-aos=\u0026#34;fade-up\u0026#34;\u0026gt;...\u0026lt;/div\u0026gt;} \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/section\u0026gt; \u0026lt;/main\u0026gt; \u0026lt;/Layout\u0026gt; ); } // SERVER SIDE RENDERING export const getServerSideProps: GetServerSideProps = async (context) =\u0026gt; { const fetchUrl = `${apiUrl}/.../v3/?id=${context.params?.id}`; const res = await axios.get(fetchUrl); const cue = await res.data.results[0]; // 못불러오면 404 페이지로 if (!cue) { return { notFound: true, }; } return { props: { cue }, }; }; 찾으려는 id가 없을 경우에는 notFound = true를 리턴해야 404 페이지로 넘어갑니다. API에 따라서 catch문을 사용해야할 수 도 있습니다. 해당 코드에서는 정상적으로 리턴된 데이터 값을 cue로 인자를 전달하였습니다.\nSEO 공통 컴포넌트 만들기 SEO 최적화를 위해 모든 페이지에서 SEO를 불러드릴 컴포넌트를 만듭니다. 해당 컴포넌트는 @theodorusclarence/ts-nextjs-tailwind-starter 를 사용하였습니다.\nsrc \u0026gt; components \u0026gt; Seo.tsx src \u0026gt; components \u0026gt; Seo.tsx 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 import Head from \u0026#34;next/head\u0026#34;; import { useRouter } from \u0026#34;next/router\u0026#34;; import { openGraph } from \u0026#34;@/lib/helper\u0026#34;; const defaultMeta = { title: \u0026#34;큐찾사 | 중고큐 거래는 큐찾사\u0026#34;, siteName: \u0026#34;큐찾사 | 중고큐 거래는 큐찾사\u0026#34;, description: \u0026#34;1,000만 당구인을 위해 (주)김치빌리아드가 제작 운영하는 안전한 중고큐 거래 어플리케이션\u0026#34;, url: \u0026#34;https://www.cue8949.com\u0026#34;, type: \u0026#34;website\u0026#34;, robots: \u0026#34;follow, index\u0026#34;, image: \u0026#34;https://www.cue8949.com/....../default.png\u0026#34;, }; type SeoProps = { date?: string; templateTitle?: string; } \u0026amp; Partial\u0026lt;typeof defaultMeta\u0026gt;; export default function Seo(props: SeoProps) { const router = useRouter(); const meta = { ...defaultMeta, ...props, }; meta[\u0026#34;title\u0026#34;] = props.templateTitle ? `${props.templateTitle} | ${meta.siteName}` : meta.title; return ( \u0026lt;Head\u0026gt; \u0026lt;title\u0026gt;{meta.title}\u0026lt;/title\u0026gt; \u0026lt;meta name=\u0026#34;robots\u0026#34; content={meta.robots} /\u0026gt; \u0026lt;meta content={meta.description} name=\u0026#34;description\u0026#34; /\u0026gt; \u0026lt;meta property=\u0026#34;og:url\u0026#34; content={`${meta?.url}${router.asPath}`} /\u0026gt; \u0026lt;link rel=\u0026#34;canonical\u0026#34; href={`${meta?.url}${router.asPath}`} /\u0026gt; {/* Open Graph */} \u0026lt;meta property=\u0026#34;og:type\u0026#34; content={meta.type} /\u0026gt; \u0026lt;meta property=\u0026#34;og:site_name\u0026#34; content={meta.siteName} /\u0026gt; \u0026lt;meta property=\u0026#34;og:description\u0026#34; content={meta.description} /\u0026gt; \u0026lt;meta property=\u0026#34;og:title\u0026#34; content={meta.title} /\u0026gt; \u0026lt;meta name=\u0026#34;image\u0026#34; property=\u0026#34;og:image\u0026#34; content={meta.image} /\u0026gt; {/* Twitter */} {/* \u0026lt;meta name=\u0026#39;twitter:card\u0026#39; content=\u0026#39;summary_large_image\u0026#39; /\u0026gt; \u0026lt;meta name=\u0026#39;twitter:site\u0026#39; content=\u0026#39;@th_clarence\u0026#39; /\u0026gt; \u0026lt;meta name=\u0026#39;twitter:title\u0026#39; content={meta.title} /\u0026gt; \u0026lt;meta name=\u0026#39;twitter:description\u0026#39; content={meta.description} /\u0026gt; \u0026lt;meta name=\u0026#39;twitter:image\u0026#39; content={meta.image} /\u0026gt; */} {meta.date \u0026amp;\u0026amp; ( \u0026lt;\u0026gt; \u0026lt;meta property=\u0026#34;article:published_time\u0026#34; content={meta.date} /\u0026gt; \u0026lt;meta name=\u0026#34;publish_date\u0026#34; property=\u0026#34;og:publish_date\u0026#34; content={meta.date} /\u0026gt; \u0026lt;meta name=\u0026#34;author\u0026#34; property=\u0026#34;article:author\u0026#34; content=\u0026#34;Cuechatsa\u0026#34; /\u0026gt; \u0026lt;/\u0026gt; )} {/* Favicons */} {favicons.map((linkProps) =\u0026gt; ( \u0026lt;link key={linkProps.href} {...linkProps} /\u0026gt; ))} \u0026lt;meta name=\u0026#34;msapplication-TileColor\u0026#34; content=\u0026#34;#ffffff\u0026#34; /\u0026gt; \u0026lt;meta name=\u0026#34;msapplication-TileImage\u0026#34; content=\u0026#34;/favicon/ms-icon-144x144.png\u0026#34; /\u0026gt; \u0026lt;meta name=\u0026#34;theme-color\u0026#34; content=\u0026#34;#ffffff\u0026#34; /\u0026gt; \u0026lt;/Head\u0026gt; ); } type Favicons = { rel: string; href: string; sizes?: string; type?: string; }; const favicons: Array\u0026lt;Favicons\u0026gt; = [ { rel: \u0026#34;apple-touch-icon\u0026#34;, sizes: \u0026#34;57x57\u0026#34;, href: \u0026#34;/favicon/apple-icon-57x57.png\u0026#34;, }, { rel: \u0026#34;apple-touch-icon\u0026#34;, sizes: \u0026#34;60x60\u0026#34;, href: \u0026#34;/favicon/apple-icon-60x60.png\u0026#34;, }, { rel: \u0026#34;apple-touch-icon\u0026#34;, sizes: \u0026#34;72x72\u0026#34;, href: \u0026#34;/favicon/apple-icon-72x72.png\u0026#34;, }, { rel: \u0026#34;apple-touch-icon\u0026#34;, sizes: \u0026#34;76x76\u0026#34;, href: \u0026#34;/favicon/apple-icon-76x76.png\u0026#34;, }, { rel: \u0026#34;apple-touch-icon\u0026#34;, sizes: \u0026#34;114x114\u0026#34;, href: \u0026#34;/favicon/apple-icon-114x114.png\u0026#34;, }, { rel: \u0026#34;apple-touch-icon\u0026#34;, sizes: \u0026#34;120x120\u0026#34;, href: \u0026#34;/favicon/apple-icon-120x120.png\u0026#34;, }, { rel: \u0026#34;apple-touch-icon\u0026#34;, sizes: \u0026#34;144x144\u0026#34;, href: \u0026#34;/favicon/apple-icon-144x144.png\u0026#34;, }, { rel: \u0026#34;apple-touch-icon\u0026#34;, sizes: \u0026#34;152x152\u0026#34;, href: \u0026#34;/favicon/apple-icon-152x152.png\u0026#34;, }, { rel: \u0026#34;apple-touch-icon\u0026#34;, sizes: \u0026#34;180x180\u0026#34;, href: \u0026#34;/favicon/apple-icon-180x180.png\u0026#34;, }, { rel: \u0026#34;icon\u0026#34;, type: \u0026#34;image/png\u0026#34;, sizes: \u0026#34;192x192\u0026#34;, href: \u0026#34;/favicon/android-icon-192x192.png\u0026#34;, }, { rel: \u0026#34;icon\u0026#34;, type: \u0026#34;image/png\u0026#34;, sizes: \u0026#34;32x32\u0026#34;, href: \u0026#34;/favicon/favicon-32x32.png\u0026#34;, }, { rel: \u0026#34;icon\u0026#34;, type: \u0026#34;image/png\u0026#34;, sizes: \u0026#34;96x96\u0026#34;, href: \u0026#34;/favicon/favicon-96x96.png\u0026#34;, }, { rel: \u0026#34;icon\u0026#34;, type: \u0026#34;image/png\u0026#34;, sizes: \u0026#34;16x16\u0026#34;, href: \u0026#34;/favicon/favicon-16x16.png\u0026#34;, }, { rel: \u0026#34;manifest\u0026#34;, href: \u0026#34;/favicon/manifest.json\u0026#34;, }, ]; 어느 페이지에서든 SEO 컴포넌트를 불러올 때 Override가 필요한 인자값들을 입력하면 됩니다.\nsrc/pages/findcue/[id].tsx src \u0026gt; components \u0026gt; src/pages/findcue/[id].tsx 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 export default function CueDetail({ cue }: { cue: Results }) { return ( \u0026lt;Layout\u0026gt; {/* \u0026lt;Seo templateTitle=\u0026#39;Home\u0026#39; /\u0026gt; */} {cue ? ( \u0026lt;Seo title={seo_title(cue)} description={seo_description(cue)} image={cue.images.length \u0026gt; 0 ? cue.images[0].image : DefaultOGImage} /\u0026gt; ) : ( \u0026lt;Seo /\u0026gt; )} \u0026lt;section\u0026gt;...\u0026lt;/section\u0026gt; \u0026lt;/Layout\u0026gt; ); } 파비콘 만들기 파비콘의 경우 https://www.favicon-generator.org/ 여기서 기기별, 사이즈별 한번에 제작할 수 있습니다. 해당 사이트에서 제작한 파비콘을 통째로 /public/favicon 경로에 넣어주었습니다.\n다운로드하면 사이즈별로 알아서 제작됩니다.\nOpenGraph 테스트 카카오톡 OG 디버깅 : https://developers.kakao.com/tool/debugger/sharing\n배포된 서버만 테스트가 가능합니다. 카카오 공화국에선 OG는 필수\n여러 플랫폼 OG 테스트 : https://www.opengraph.xyz/\nlocalhost로는 테스트가 불가하므로 포트포워딩을 통해 ip로 접근해야합니다.\n사이트에서 수정해서 어떻게 보여지는지 볼 수 있다\n결과 위 컴포넌트만 잘 활용하신다면 Lighthouse SEO 테스트 100점은 쉽게 가져가실 수 있습니다. 나머지 점수도 100점을 향해 고도화가 되어야 겠습니다. 100점!!\nnext-sitemap (네이버, 구글이 SSR을 인식하게하자) Next는 [id].tsx 와 같은 다이나믹 라우팅에 대해서 사이트맵을 생성하지 않습니다. 당연하게도 id가 0부터 100까지인지 aaa부터 zzzzz까지 인지 모르기 때문이죠. 따라서 운영하는 사이트에서 어떤 글들이 있는지 상품들이 있는지 직접 추가해야합니다.\n하지만 !! 여기서 핵심은 새로 게시글이나 상품이 올라왔을때 자동으로 해당 페이지를 사이트맵에 포함시켜야합니다. 정확히 말하자면 서치 로봇이 크롤링을 돌때마다 해당 페이지를 사이트맵에 포함시켜줘야합니다. 그러기 위해서는 사이트맵 또한 다이나믹하게 API로 부터 추가된 내용을 받아서 변경되어야합니다.next-sitemap#generating-dynamicserver-side-sitemaps를 참고하여 제작하였습니다.\n어떤 글들이 있는지, 어떤 상품들이 있는지 리턴해주는 API 예를들어 내큐찾기의 상품의 경우 /findcue/[상품번호] url을 가지고 있는데 어떤 상품번호들이 유저에게 보여져도 되는지, 구글 네이버가 수집해도 좋을지를 Robots.txt를 통해 알려주어야 합니다. 그래서 조건에 맞는 상품번호들과 최초 생성일을 알려주는 간단한 API를 제작해야합니다. Django로 제작하였습니다.\nviews.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 class SiteModelModelViewWeb_V3(viewsets.ModelViewSet): \u0026#34;\u0026#34;\u0026#34; 네이버, 구글 서치어드바이저에 사이트맵 제출 위해서 필요한 도큐멘트 ID만 리턴해준다. \u0026#34;\u0026#34;\u0026#34; queryset = ContentModel.objects.exclude(Q(\u0026#34;쿼리셋 조건\u0026#34;)).all() permission_classes = (\u0026#34;권한\u0026#34;) serializer_class = ContentWebSitemapSerializer pagination_class = LimitOffsetPagination filter_backends = (DjangoFilterBackend, filters.OrderingFilter) ordering_fields = (\u0026#39;document_id\u0026#39;) ordering = (\u0026#39;-document_id\u0026#39;) def list(self, request, *args,**kwargs): data = super().list(request, *args, **kwargs).data return Response(data=data) serializer.py 1 2 3 4 5 6 7 class ContentWebSitemapSerializer(serializers.ModelSerializer): class Meta: model = ContentModel fields = ( \u0026#39;document_id\u0026#39;, \u0026#39;updated_at\u0026#39;, ) 리턴 결과 sitemap을 위한 api\n동적 페이지를 사이트맵에 추가하자 next-sitemap라이브러리 설치가 필요합니다.\n설치 이후 pages \u0026gt; server-sitemap.xml \u0026gt; index.tsx 를 생성해줍니다. 해당 server-sitemap.xml 에서 기본적인 사이트맵 이외에 다이나믹 라우트를 활용한 즉, SSR되는 리스트들을 사이트맵에 추가해줍니다.\npages \u0026gt; server-sitemap.xml \u0026gt; index.tsx pages \u0026gt; server-sitemap.xml \u0026gt; index.tsx 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 /* eslint-disable import/no-anonymous-default-export */ /* eslint-disable @typescript-eslint/no-empty-function */ import { GetServerSideProps } from \u0026#34;next\u0026#34;; import { getServerSideSitemap, ISitemapField } from \u0026#34;next-sitemap\u0026#34;; import { getGoodsSiteMap, getPostsSiteMap } from \u0026#34;@/pages/api\u0026#34;; type Posts = { id: string; created_at: string; }; type Goods = { document_id: string; updated_at: string; }; export const getServerSideProps: GetServerSideProps = async (ctx) =\u0026gt; { // Method to source urls from cms const posts: Posts[] = await getPostsSiteMap(); const posts_sitemap_fields: ISitemapField[] = posts.map((post) =\u0026gt; { return { loc: `${process.env.NEXT_PUBLIC_URL}/community/post/${post[\u0026#34;id\u0026#34;]}`, lastmod: post[\u0026#34;created_at\u0026#34;], changefreq: \u0026#34;always\u0026#34;, priority: 1.0, }; }); const goods: Goods[] = await getGoodsSiteMap(); const goods_sitemap_fields: ISitemapField[] = goods.map((good) =\u0026gt; { return { loc: `${process.env.NEXT_PUBLIC_URL}/goods/${good[\u0026#34;document_id\u0026#34;]}`, lastmod: good[\u0026#34;updated_at\u0026#34;], changefreq: \u0026#34;always\u0026#34;, priority: 1.0, }; }); const sitemap_fields = [...posts_sitemap_fields, ...goods_sitemap_fields]; return getServerSideSitemap(ctx, sitemap_fields); }; // Default export to prevent next.js errors export default function Sitemap() {} /server-sitemap.xml에 접속하면 다음과 같이 xml파일이 잘 만들어진 것을 확인할 수 있습니다.\n실행결과\n잘 만들어진 사이트맵을 빌드할 때 robots.txt에 포함될 수 있도록 설정해주어야 합니다.\nnext-sitemap.config.js next-sitemap.config.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 /** * @type {import(\u0026#39;next-sitemap\u0026#39;).IConfig} * @see https://github.com/iamvishnusankar/next-sitemap#readme */ module.exports = { siteUrl: `${process.env.NEXT_PUBLIC_URL}`, generateRobotsTxt: true, exclude: [ \u0026#34;/mypage\u0026#34;, \u0026#34;/404\u0026#34;, \u0026#34;/sandbox/dialog-zustand\u0026#34;, \u0026#34;/findcue/utils\u0026#34;, \u0026#34;/components\u0026#34;, ], robotsTxtOptions: { policies: [{ userAgent: \u0026#34;*\u0026#34;, allow: \u0026#34;/\u0026#34; }], additionalSitemaps: [`${process.env.NEXT_PUBLIC_URL}/server-sitemap.xml`], // \u0026lt;-- 여기 }, }; package.json package.json 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 { \u0026#34;name\u0026#34;: \u0026#34;cuechatsa_apppage_v3\u0026#34;, \u0026#34;version\u0026#34;: \u0026#34;0.1.1\u0026#34;, \u0026#34;private\u0026#34;: true, \u0026#34;scripts\u0026#34;: { \u0026#34;dev\u0026#34;: \u0026#34;next dev -p 3001\u0026#34;, \u0026#34;build\u0026#34;: \u0026#34;next build\u0026#34;, \u0026#34;export\u0026#34;: \u0026#34;next export -o out\u0026#34;, \u0026#34;start\u0026#34;: \u0026#34;next start\u0026#34;, \u0026#34;lint\u0026#34;: \u0026#34;next lint\u0026#34;, \u0026#34;lint:fix\u0026#34;: \u0026#34;eslint src --fix \u0026amp;\u0026amp; yarn format\u0026#34;, \u0026#34;lint:strict\u0026#34;: \u0026#34;eslint --max-warnings=0 src\u0026#34;, \u0026#34;typecheck\u0026#34;: \u0026#34;tsc --noEmit --incremental false\u0026#34;, \u0026#34;test:watch\u0026#34;: \u0026#34;jest --watch\u0026#34;, \u0026#34;test\u0026#34;: \u0026#34;jest\u0026#34;, \u0026#34;format\u0026#34;: \u0026#34;prettier -w .\u0026#34;, \u0026#34;format:check\u0026#34;: \u0026#34;prettier -c .\u0026#34;, \u0026#34;postbuild\u0026#34;: \u0026#34;next-sitemap --config next-sitemap.config.js\u0026#34;, // \u0026lt;- 이부분 \u0026#34;prepare\u0026#34;: \u0026#34;husky install\u0026#34; }, ... } 이후에 yarn build하면 robots.txt에 자동으로 추가되는걸 볼 수 있습니다.\n1 2 3 4 5 6 7 8 9 10 # * User-agent: * Allow: / # Host Host: https://www.cue8949.com # Sitemaps Sitemap: https://www.cue8949.com/sitemap.xml Sitemap: https://www.cue8949.com/server-sitemap.xml \u0026lt;\u0026lt; 여기 네이버, 구글 웹마스터도구 등록 네이버 서치어드바이저(웹마스터도구)에 등록 후 몇가지 추가로 해야할 것 이 있습니다. 이 url이 나의 소유임을 확인하는 Meta 태그를 추가해야합니다. 이는 구글과 동일한 방식을 사용합니다. 네이버 서치어드바이저에서 활용할 수 잇는 사이트 연관채널도 추가하려합니다.\n네이버 검색 연관채널\n앞서 만들었던 Seo.tsx 컴포넌트를 활용합니다. 연관채널은 가이드를 참고하여 JSON-LD 형식으로 구현합니다.\n네이버 검색 연관채널 가이드\nsrc \u0026gt; components \u0026gt; Seo.tsx src \u0026gt; components \u0026gt; Seo.tsx 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 \u0026lt;Head\u0026gt; ... ... {/* 네이버 서치어드바이저 */} \u0026lt;meta name=\u0026#34;naver-site-verification\u0026#34; content=\u0026#34;발급받은 문자열\u0026#34; /\u0026gt; // 연관채널 \u0026lt;script type=\u0026#34;application/ld+json\u0026#34; dangerouslySetInnerHTML={{ __html: JSON.stringify({ \u0026#34;@context\u0026#34;: \u0026#34;http://schema.org\u0026#34;, \u0026#34;@type\u0026#34;: \u0026#34;Person\u0026#34;, name: \u0026#34;큐찾사 | 중고큐 거래는 큐찾사\u0026#34;, url: \u0026#34;https://www.cue8949.com\u0026#34;, sameAs: [ \u0026#34;https://www.facebook.com/kimchibilliards\u0026#34;, \u0026#34;https://www.youtube.com/channel/UCO_ynY-y2HaR9LiHr03beVw\u0026#34;, \u0026#34;https://www.instagram.com/kimchibilliards\u0026#34;, \u0026#34;https://play.google.com/store/apps/details?id=com.cuechatsaapp\u0026#34;, \u0026#34;https://apps.apple.com/app/id1524591264\u0026#34;, ], }), }} /\u0026gt; {/* 구글 서치어드바이저 */} \u0026lt;meta name=\u0026#34;google-site-verification\u0026#34; content=\u0026#34;발급받은 문자열\u0026#34; /\u0026gt; ... ... \u0026lt;/Head\u0026gt; 네이버 서치어드바이저에서 수집된 robots.txt를 보면 잘 수집된걸 볼 수 잇습니다. 네이버 서치어드바이저\n구글 웹마스터도구에서도 사이트맵 제출이 잘 되는걸 볼 수 있습니다. 구글 웹마스터도구\n만들어진 사이트맵은 저처럼 직접 제출해도 되고 (빠름), 검색 로봇이 서치하게 두어도 됩니다(느림).\n카카오 공유하기 좌: 카카오 공유하기 | 우: OpenGraph\n카카오 공화국인 한국인 만큼 카카오 공유하기도 추가해야겠다고 생각하였습니다. 커스텀 템플릿을 이용하면 매우 쉽게 카카오 공유하기를 입맛대로 구성할 수 있습니다.\n카카오디벨로퍼에서 키 발급 카카오디벨로퍼에서 애플리케이션을 만들고 Javascript 키를 .env에 복사해줍니다.\n.env .env 1 2 3 ... NEXT_PUBLIC_KAKAO_API_KEY=\u0026#34;발급받은 Javascript 키\u0026#34; ... 변수명에 NEXT_PUBLIC을 붙혀주지 않으면 페이지에서 불러올 수 없으니 유의해주세요. 카카오 SDK 설치 src \u0026gt; pages \u0026gt; _app.tsx 에서 Window 인터페이스를 선언해줍니다. src \u0026gt; pages \u0026gt; _app.tsx 1 2 3 4 5 6 7 8 9 10 11 12 13 declare global { interface Window { Kakao: any; } } const MyApp = ({...}: {...}) =\u0026gt; { return ( ... ); }; export default MyApp; src \u0026gt; pages \u0026gt; _document.tsx에서 카카오 sdk를 실행합니다. src \u0026gt; pages \u0026gt; _document.tsx 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 import Document, { Head, Html, Main, NextScript, } from \u0026#39;next/document\u0026#39;; class MyDocument extends Document { ... render() { return ( \u0026lt;Html lang=\u0026#39;en\u0026#39;\u0026gt; \u0026lt;Head\u0026gt; ... {/* kakao */} \u0026lt;script defer src=\u0026#39;https://developers.kakao.com/sdk/js/kakao.min.js\u0026#39; \u0026gt;\u0026lt;/script\u0026gt; \u0026lt;/Head\u0026gt; ... \u0026lt;body\u0026gt; \u0026lt;Main /\u0026gt; \u0026lt;NextScript /\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/Html\u0026gt; ); } } export default MyDocument; src \u0026gt; components \u0026gt; layout \u0026gt; Layout.tsx Window.kakao를 초기화 해주어야 합니다. 꼭 해당 컴포넌트에서 안해도 되지만 프로젝트 구성상 Layout 컴포넌트가 모든 페이지의 뼈대가 되므로 저는 Layout에서 초기화 해주었습니다. 초기화를 여러번 진행하면 에러가 발생하므로 useEffect로 이미 초기화가 진행되었는지 확인해주어야합니다.\nsrc \u0026gt; components \u0026gt; layout \u0026gt; Layout.tsx 1 2 3 4 5 6 7 8 9 10 11 12 13 14 export default function Layout({ children }: { children: React.ReactNode }) { ... // kakao 공유하기 React.useEffect(() =\u0026gt; { if (!window.Kakao.isInitialized()) { window.Kakao.init(process.env.NEXT_PUBLIC_KAKAO_API_KEY); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); ... return ( ... ); } 카카오 메세지 템플릿 만들기 카카오디벨로퍼 \u0026gt; 도구 \u0026gt; 메세지템플릿 에서 템플릿을 만들 수 있습니다. 피드형으로 진행하였습니다. 파라미터는 입력필드에 포맷으로 ${key변수명}을 입력하면 해당 값은 변수처럼 사용될 수 있습니다. 코드 적용하기 KakaoBtn 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 const KakaoBtn = ({ title, description, price, state, document_id, imageUrl, like_cnt, view_cnt, ... }: { title: string; description: string; price: number; state: string; document_id: string; imageUrl: string; like_cnt: number; view_cnt: number; ... }) =\u0026gt; { const onClick = () =\u0026gt; { // eslint-disable-next-line unused-imports/no-unused-vars const { Kakao, location } = window; Kakao.Link.sendCustom({ templateId: 89436, // \u0026lt;-- 자신의 템플릿 ID를 입력 templateArgs: { // \u0026lt;-- 템플릿에서 만들었던 변수명을 보냄 title: `${title}`, description: `${description}`, sub_title: `${sub_title}`, sub_description: `${sub_description}`, button_title: `${button_title}`, document_id: `${document_id}`, imageUrl: `${imageUrl}`, header: `${document_id}`, like_cnt: `${like_cnt}`, view_cnt: `${view_cnt}`, price: `${price}`, }, }); }; return ( \u0026lt;\u0026gt; \u0026lt;div onClick={onClick} className=\u0026#34;mb-3 flex cursor-pointer justify-center\u0026#34; \u0026gt; \u0026lt;div className=\u0026#34;flex h-[45px] w-full flex-row rounded-xl bg-[#FFEB01] py-2 px-5 md:flex md:h-[55px]\u0026#34;\u0026gt; \u0026lt;KakaoSVG className=\u0026#34;my-auto w-12 text-3xl md:text-4xl\u0026#34; /\u0026gt; \u0026lt;div className=\u0026#34;h5 md:h4 my-auto w-full pl-4 text-[#3C1E1E]\u0026#34;\u0026gt; 카카오톡으로 공유하기 \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/\u0026gt; ); }; 만들어진 버튼은 다음과 같습니다. 해당 버튼을 누르면 PC는 새로운 창이 열리면서 공유할 친구 목록 선택 할 수 있으며 모바일은 카톡앱이 켜진 후 보낼 대상을 선택할 수 있습니다. 마치며 이로서 SSR + SEO + Sitemap 모두 적용되어 네이버, 구글 검색결과에 표시될 준비가 모두 끝났습니다. 🔥\n구글, 네이버 검색결과에 상위에 노출되기만 남았습니다(제발~~).\nReference https://github.com/iamvishnusankar/next-sitemap#generating-dynamicserver-side-sitemaps https://github.com/theodorusclarence/ts-nextjs-tailwind-starter https://github.com/vercel/next.js/discussions/45077#discussioncomment-4774680 https://www.opengraph.xyz https://developers.kakao.com/tool/debugger/sharing 해당글이 Next.js를 이용해서 SEO 최적화 하시는데 도움이 되셨길 바랍니다 😆\n궁금하신 점이 있으시면 아래 Comments 남겨주세요 👇\n","permalink":"https://cha2hyun.blog/content/projects/%ED%81%90%EC%B0%BE%EC%82%AC/ssrandseo/","summary":"Next.js의 꽃 SSR \u0026amp; SEO, Lighthouse, 네이버 서치어드바이저, 구글 웹마스터도구","title":"SSR를 이용한 SEO 최적화 (구글, 네이버 최상단 노출 성공)"},{"content":"\n들어가며 새로운 홈페이지를 기획, 개발하면서 최대한 앱과 동일한 사용자 경험을 주기 위해 고민을 많이 했습니다. 일반적인 페지네이션으로는 모바일에서 앱과 같은 경험을 주지 못하는 점이 아쉬운 점이 있습니다. React Query의 infiniteScroll을 infiniteScroll을 도입하면서 배운 내용들을 다룹니다. 속도가 중요한 것은 Modal로 표시하고 상호작용이 중요한 것은 Link로 페이지 이동하였습니다. 이미지 캐싱을 위한 방법과 불러온 데이터를 효과적으로 저장하는 방법에 대해서 알아봅니다. API는 장고로 만들었습니다.\n고치는 것 보다 새로 만드는게 더 빠른\u0026hellip;\nCustom Modal vs Link 무한 스크롤을 도입할 때 다음과 같은 내용이 고려되었습니다.\n무한 스크롤이 적용된 리스트를 클릭했을때 어떤 식으로 상세페이지를 제공할 것인가? 상세페이지에서 다시 리스트로 돌아갈때 이미 불러온 데이터를 다시 불러오지 않아야한다. 2번이 적용된다면 다시 리스트로 돌아갈때 스크롤 했던 위치로 돌아가져야한다. 무한 스크롤이 도입될 곳은 내큐찾기와 커뮤니티 였습니다. 다만 두 카테고리는 각자 다른 특성을 가지고 있습니다. 서비스 핵심 모델이 적요되는 내큐찾기에서는 최대한 빠르게 유저에게 로딩되는 경험을 주어야하고, 커뮤니티에서는 댓글, 답글 등의 추가 기능들이 필요했습니다.\n속도가 중요한 내큐찾기에는 Modal 을 이용하였으며, API 상호작용 및 새로고침이 필요한 커뮤니티에서는 새로운 페이지로 이동하고 뒤로가기 시에 LocalStorage를 참조하여 저장된 ScrollY값으로 스크롤 이동되게 하였습니다.\n내큐찾기 (적용된 페이지)\n현재는 SEO 문제로 모달로 표현되지 않습니다. CHECK 👉 Next.js] SSR를 이용한 상세페이지 SEO 최적화 (모달을 포기한 이유)\n페이지 이동없이 Modal이 보여지는 형식으로 제공한다. Modal이 열릴때 따로 API에서 정보 불러오지 않고 즉시 보여질 수 있도록 리스트 불러올 때 API를 모달안의 정보까지 제공할 수 있도록 제작한다. 한번 불러온 이미지나 캐시에 남겨 로딩할 때도 매우빠른 로딩속도를 제공한다. 스크롤 시 마지막 아이템이 보여질 때 마다 Fade in 애니메이션을 적용하여 로딩이 즉각적으로 되는 것처럼 보여지게 한다. Infinite Scroll + Modal + aos fadein\n커뮤니티 (적용된 페이지)\nList에서 Item 클릭 시 새로운 상세페이지로 이동 이동할때 이때 LocalStoarge에 scrollY 값을 저장 상세페이지에서 다시 리스트로 돌아올 때 scrollY 값으로 스크롤 위치 이동 단, 이때 처음부터 List를 다시 불러온다면 저장된 scrollY 값으로 가질 수 없으므로 (1번 이상 추가로딩이 되었을 경우) 리스트 데이터는 새로 불러오면 안된다. Infinite Scroll + Link + LocalStorage\nAPI 준비 API는 Django를 사용합니다. ModelViewSet을 사용하였으며 LimitOffsetPagination을 적용하였습니다.\n일반적인 페이지 페지네이션의 경우 사용자가 다음 리스트를 불러오기 전에 새로운 아이템이 1개가 등록되고 리스트를 불러온다면 중복되는 아이템이 표시될 수 있으므로 offset pagination을 적용하여 전체 게시글이 늘었는지 확인하고 늘은 만큼 offset을 추가로 더해주어야 중복되는 아이템을 방지할 수 있습니다.\n리스트에서 로딩될 필터링 조건을 parameter 로 받기 위해 queryset을 변경하는 과정이 필요하였습니다. offset또한 파라미터로 받아옵니다.\nviews.py views.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 class someview(viewsets.ModelViewSet): queryset = somemodel.objects.exclude(Q(something)).prefetch_related(Prefetch(something)).all() permission_classes = somepermission serializer_class = someSerializer pagination_class = LimitOffsetPagination filter_backends = (somefilters) ordering_fields = (\u0026#39;created_at\u0026#39;,\u0026#39;id\u0026#39;) ordering = (\u0026#39;-created_at\u0026#39;) search_fields = [\u0026#39;$title\u0026#39;, \u0026#39;$content\u0026#39;] filterset_fields = (\u0026#39;__all__\u0026#39;) def get_queryset(self): qs = super().get_queryset() # some condition return qs.filter(Query) 리턴 예시\nGET 1 2 3 4 5 6 7 8 { \u0026#34;count\u0026#34;: 131, \u0026#34;next\u0026#34;: \u0026#34;api주소/?limit=20\u0026amp;offset=55\u0026amp;필터링조건\u0026#34;, \u0026#34;previous\u0026#34;: \u0026#34;api주소/?limit=20\u0026amp;offset=15\u0026amp;필터링조건\u0026#34;, \u0026#34;results\u0026#34;:[ ... ] } Next 프로젝트 구조 Next + Tailwind + Typescript 스타터팩인 theodorusclarence/ts-nextjs-tailwind-starter를 설치하였습니다.\n프로젝트 구조는 다음을 참고해주시면 됩니다.\nsrc Tree 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 . ├── components │ ├── dialog │ │ └── BaseDialog.tsx │ └── layout │ ├── DialogZustandLayout.tsx │ └── Layout.tsx ├── hooks │ ├── useDialog.tsx │ ├── useObserver.js │ └── useIntersectionObserver.js ├── pages │ ├── sandbox │ │ └── dialog-zustand.tsx │ └── somepages ├── partials │ └── somepartials ├── store │ └── useDialogStore.tsx └── utils └── Transition.jsx infiniteScroll 설치 react-qeury를 설치해줍니다. 사용된 버전은 다음과 같습니다.\npackage.json 1 2 3 ... \u0026#34;react-query\u0026#34;: \u0026#34;^3.39.2\u0026#34;, ... Hydration을 통해 SSR(Server Side Rendering)을 할 수 도 있지만, 시도해본 결과 매 뒤로가기시 캐싱되지 않는 문제가 있었습니다. 정확히 말하면 이미 로딩했던 내용들을 다시 처음부터 로딩해야 했습니다. 내큐찾기 처럼 모달을 사용할 경우 Hydration 사용해도 지장없지만, 페이지 이동이 필요한 경우 해당 방법을 사용하면 비효율적입니다.\n_app.tsx (Hydration) src \u0026gt; pages \u0026gt; _app.tsx (Hydration) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 /* eslint-disable @typescript-eslint/no-explicit-any */ import AOS from \u0026#34;aos\u0026#34;; import Head from \u0026#34;next/head\u0026#34;; import { SessionProvider } from \u0026#34;next-auth/react\u0026#34;; import React, { useEffect } from \u0026#34;react\u0026#34;; import { QueryClient, QueryClientProvider } from \u0026#34;react-query\u0026#34;; import \u0026#34;@/styles/aos.scss\u0026#34;; import \u0026#34;@/styles/globals.css\u0026#34;; import \u0026#34;@/styles/colors.css\u0026#34;; const MyApp: NextComponentType\u0026lt;AppContext, AppInitialProps, AppProps\u0026gt; = ({ Component, pageProps, }) =\u0026gt; { const queryClientRef = React.useRef\u0026lt;QueryClient\u0026gt;(); if (!queryClientRef.current) { queryClientRef.current = new QueryClient(); } useEffect(() =\u0026gt; { AOS.init({ once: true, duration: 500, easing: \u0026#34;ease-out-cubic\u0026#34;, }); }); return ( \u0026lt;\u0026gt; \u0026lt;QueryClientProvider client={queryClientRef.current}\u0026gt; \u0026lt;Hydrate state={pageProps.dehydratedState}\u0026gt; \u0026lt;Component {...pageProps} /\u0026gt; \u0026lt;/Hydrate\u0026gt; {/* \u0026lt;ReactQueryDevtools /\u0026gt; */} \u0026lt;/QueryClientProvider\u0026gt; \u0026lt;/\u0026gt; ); }; MyApp.getInitialProps = async ({ Component, ctx, }: AppContext): Promise\u0026lt;AppInitialProps\u0026gt; =\u0026gt; { let pageProps = {}; if (Component.getInitialProps) { pageProps = await Component.getInitialProps(ctx); } return { pageProps }; }; export default MyApp; 따라서 최종적으로 다음과 같이 적용했습니다. 중간에 meta 태그는 아이폰에서 모달이 열릴때 가로폭이 고정되지 않는 문제점을 해결하기 위해 넣었으며 따로 meta tag를 관리하는 컴포넌트가 있으면 추가해주시면 됩니다. 커뮤니티에서 댓글 작성이 로그인 여부를 확인하기 때문에 SessionProvider (next-auth)가 있습니다. 로그인 구현이 필요 없다면 넘기셔도 됩니다.\n_app.tsx src \u0026gt; pages \u0026gt; _app.tsx 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 /* eslint-disable @typescript-eslint/no-explicit-any */ import AOS from \u0026#34;aos\u0026#34;; import Head from \u0026#34;next/head\u0026#34;; import { SessionProvider } from \u0026#34;next-auth/react\u0026#34;; import React, { useEffect } from \u0026#34;react\u0026#34;; import { QueryClient, QueryClientProvider } from \u0026#34;react-query\u0026#34;; import \u0026#34;@/styles/aos.scss\u0026#34;; import \u0026#34;@/styles/globals.css\u0026#34;; import \u0026#34;@/styles/colors.css\u0026#34;; const MyApp = ({ Component, pageProps: { session, ...pageProps }, }: { Component: any; pageProps: any; }) =\u0026gt; { const [queryClient] = React.useState(() =\u0026gt; new QueryClient()); // AOS 애니메이션 적용 useEffect(() =\u0026gt; { AOS.init({ once: true, duration: 500, easing: \u0026#34;ease-out-cubic\u0026#34;, }); }); (\u0026#34;\u0026#34;); return ( // next-auth 로그인 세션을 위해 사용됨 \u0026lt;SessionProvider session={session}\u0026gt; \u0026lt;Head\u0026gt; {/* 아이폰 가로값 고정되지 않는 이슈 해결 */} \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover\u0026#34; /\u0026gt; \u0026lt;/Head\u0026gt; {/* infiniteScroll 위해 선언 */} \u0026lt;QueryClientProvider client={queryClient}\u0026gt; {/* PageProps 표시 영역 */} \u0026lt;Component {...pageProps} /\u0026gt; {/* Debug 모드에서만 활용할 devtools */} {/* import { ReactQueryDevtools } from \u0026#39;react-query/devtools\u0026#39;; \u0026lt;ReactQueryDevtools initialIsOpen={false} /\u0026gt; */} \u0026lt;/QueryClientProvider\u0026gt; \u0026lt;/SessionProvider\u0026gt; ); }; export default MyApp; 무한 로딩 리스트를 구현하는 큰 뼈대는 다음과 같습니다. useIntersectionObserve 훅을 사용하여 리스트를 fetch 할지를 확인합니다.\nInfiniteListSkeleton.tsx InfiniteListSkeleton.tsx 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 const InfiniteListSkeleton = (\u0026#39;필터링 될 내용들\u0026#39;) =\u0026gt; { const { data, hasNextPage, fetchNextPage, refetch, isFetching } = useInfiniteQuery( \u0026#39;apiRequestFunction\u0026#39;, ({ pageParam = \u0026#39;\u0026#39; }) =\u0026gt; // FetchList 함수는 axios로 api에 request 보내는 함수입니다. (파라미터로 필터링 될 내용을 받습니다.) FetchList( pageParam, \u0026#39;필터링 될 내용들\u0026#39; ), { // 다음 페이지는 offset 으로 확인합니다. getNextPageParam: (lastPage) =\u0026gt; { if (lastPage.next === null) { return undefined; } const url = new URL(lastPage.next); const lastOffset = url.searchParams.get(\u0026#39;offset\u0026#39;); if (lastOffset) { return parseInt(lastOffset); } else { return undefined; } }, staleTime: 60 * 1000, cacheTime: 60 * 1000, keepPreviousData: true, refetchOnMount: false, refetchOnWindowFocus: false, } ); // 1. 모달로 오픈할 거면 (커스텀 훅) const dialog = useDialog(); const openModal = (props: Results) =\u0026gt; { dialog({ title: \u0026#39;\u0026#39;, description: \u0026lt;\u0026gt;\u0026lt;/\u0026gt;, catchOnCancel: true, submitText: \u0026#39;네\u0026#39;, cancleText: \u0026#39;닫기\u0026#39;, variant: \u0026#39;warning\u0026#39;, props: props, }) .then() .catch(); return \u0026lt;\u0026gt;\u0026lt;/\u0026gt;; }; // 2. 페이지 이동후 스크롤 Y 값을 저장하여 해당 위치로 바로 보여지게 할 경우 const [scrollY, setScrollY] = useLocalStorage(\u0026#39;someScroll\u0026#39;, 0); React.useEffect(() =\u0026gt; { if (scrollY !== 0) { window.scrollTo(0, Number(scrollY)); setScrollY(0); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // 필터가 적용되면 offset을 초기화하고 처음부터 로딩되어야 합니다. React.useEffect(() =\u0026gt; { if (isRefreshing) { refetch(); } setIsRefreshing(false); }, [isRefreshing, refetch, setIsRefreshing]); // useRef를 이용하여 해당 Ref를 하단에 두고 보여지면 다음 페이지를 로딩합니다. const loadMoreButtonRef = React.useRef(null); // https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver useIntersectionObserver({ root: null, target: loadMoreButtonRef, onIntersect: fetchNextPage, enabled: hasNextPage, }); return ( \u0026lt;\u0026gt; \u0026lt;ul\u0026gt; {/* 로딩중일때 */} {isFetching \u0026amp;\u0026amp; \u0026lt;LoadingDiv /\u0026gt;} {/* 리스트를 표시합ㄴ디ㅏ. */} {data?.pages.map((page) =\u0026gt; page.results.map((props: Results) =\u0026gt; ( // data-aos 로 애니메이션 효과를 줍니다. (라이브러리 설치 필요)) \u0026lt;li key={props.id} data-aos=\u0026#39;fade-up\u0026#39; data-aos-duration=\u0026#39;200\u0026#39;\u0026gt; {/* 1. 모달로 오픈할꺼면 */} \u0026lt;div onClick={() =\u0026gt; openModal(props)}\u0026gt; Click ${props.id} to open Modal \u0026lt;/div\u0026gt; {/* 2. 페이지 이동할거면 */} \u0026lt;Link href={{ pathname: `url/${props.id}`, query: { props: JSON.stringify(props) }, }} as={`url/${props.id}`} passHref \u0026gt; \u0026lt;a href={`url/${props.id}`} onClick={() =\u0026gt; setScrollY(window.scrollY)} \u0026gt; \u0026lt;div\u0026gt;Click ${props.id} to navigate\u0026lt;/div\u0026gt; \u0026lt;/a\u0026gt; \u0026lt;/Link\u0026gt; \u0026lt;/li\u0026gt; )))} \u0026lt;/ul\u0026gt; {/* 로딩중 표시를 하고 ref가 focus 될때 (리스트의 끝일때) 마다 다음 리스트를 로딩합니다. */} {hasNextPage \u0026amp;\u0026amp; ( \u0026lt;\u0026gt; \u0026lt;LoadingDiv /\u0026gt; \u0026lt;button onClick={() =\u0026gt; fetchNextPage()} className=\u0026#39;text-center\u0026#39; /\u0026gt; \u0026lt;/\u0026gt; )} \u0026lt;div ref={loadMoreButtonRef} /\u0026gt; \u0026lt;/\u0026gt; ); }; export default InfiniteListSkeleton; apiRequestFunction.js 앞에서 limitOffsetPagination 으로 만든 API를 불러옵니다. 상황에 따라서 params가 추가될 수 있습니다. (필터링 조건 등)\nsrc \u0026gt; pages \u0026gt; api \u0026gt; index.tsx 1 2 3 4 5 6 export const apiRequestFunction = async (offset = 0, ... , params) =\u0026gt; { const { data } = await axios.get( `${apiUrl}?limit=${limit}\u0026amp;offset=${offset}...` ); return data; }; useIntersectionObserver.js src \u0026gt; hooks \u0026gt; useIntersectionObserver.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 import React from \u0026#34;react\u0026#34;; export default function useIntersectionObserver({ root, target, onIntersect, threshold = 1.0, rootMargin = \u0026#34;0px\u0026#34;, enabled = true, }) { React.useEffect(() =\u0026gt; { if (!enabled) { return; } const observer = new IntersectionObserver( (entries) =\u0026gt; entries.forEach((entry) =\u0026gt; entry.isIntersecting \u0026amp;\u0026amp; onIntersect()), { root: root \u0026amp;\u0026amp; root.current, rootMargin, threshold, } ); const el = target \u0026amp;\u0026amp; target.current; if (!el) { return; } observer.observe(el); return () =\u0026gt; { observer.unobserve(el); }; }, [target, enabled, root, threshold, rootMargin, onIntersect]); } useObserver.js src \u0026gt; hooks \u0026gt; useObserver.js 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 import React from \u0026#34;react\u0026#34;; export default function useIntersectionObserver({ root, target, onIntersect, threshold = 1.0, rootMargin = \u0026#34;0px\u0026#34;, enabled = true, }) { React.useEffect(() =\u0026gt; { if (!enabled) { return; } const observer = new IntersectionObserver( (entries) =\u0026gt; entries.forEach((entry) =\u0026gt; entry.isIntersecting \u0026amp;\u0026amp; onIntersect()), { root: root \u0026amp;\u0026amp; root.current, rootMargin, threshold, } ); const el = target \u0026amp;\u0026amp; target.current; if (!el) { return; } observer.observe(el); return () =\u0026gt; { observer.unobserve(el); }; }, [target, enabled, root, threshold, rootMargin, onIntersect]); } infiniteScroll + Modal 모달은 theodorusclarence/expansion-pack의 Dialog using Zustand를 설치 후 커스텀하였습니다. 설치하면 DialogZustandLayout.tsx 샘플이 생깁니다. 이를 참고하여 수동으로 Layout.tsx 를 수정해주어야합니다.\nLayout.tsx src \u0026gt; layout \u0026gt; Layout.tsx 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 import * as React from \u0026#34;react\u0026#34;; import BaseDialog from \u0026#34;@/components/dialog/BaseDialog\u0026#34;; import Footer from \u0026#34;@/components/layout/Footer\u0026#34;; import useDialogStore from \u0026#34;@/store/useDialogStore\u0026#34;; import Header from \u0026#34;./Header\u0026#34;; export default function Layout({ children }: { children: React.ReactNode }) { const open = useDialogStore.useOpen(); const state = useDialogStore.useState(); const handleClose = useDialogStore.useHandleClose(); const handleSubmit = useDialogStore.useHandleSubmit(); return ( \u0026lt;\u0026gt; \u0026lt;Header mode=\u0026#34;\u0026#34; /\u0026gt; \u0026lt;section className=\u0026#34;min-h-full font-primary\u0026#34;\u0026gt;{children}\u0026lt;/section\u0026gt; \u0026lt;BaseDialog onClose={handleClose} onSubmit={handleSubmit} open={open} options={state} /\u0026gt; \u0026lt;Footer /\u0026gt; \u0026lt;/\u0026gt; ); } useDialog.tsx src \u0026gt; hooks \u0026gt; useDialog.tsx 1 2 3 4 5 import useDialogStore from \u0026#34;@/store/useDialogStore\u0026#34;; export default function useDialog() { return useDialogStore.useDialog(); } 여기까지 진행되면 모달을 열 수 있는 환경이 구성됩니다. src \u0026gt; components \u0026gt; dialog \u0026gt; baseDialog.tsx 가 모달창입니다. 원래는 submt이나 error를 확인하는 모달이였으므로 리스트에서 클릭하여 모달이 열릴때 props를 같이 받아올 수 있도록 해당 내용을 커스텀 해줍니다. options에 props를 추가하면 됩니다.\nbaseDialog.tsx src \u0026gt; components \u0026gt; dialog \u0026gt; baseDialog.tsx 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 type BaseDialogProps = { open: boolean; onSubmit: () =\u0026gt; void; onClose: () =\u0026gt; void; options: DialogOptions; }; export type DialogOptions = { catchOnCancel?: boolean; title: React.ReactNode; description: React.ReactNode; variant: \u0026#34;success\u0026#34; | \u0026#34;warning\u0026#34; | \u0026#34;danger\u0026#34;; submitText: React.ReactNode; cancleText: React.ReactNode; props: Results | null; }; export default function BaseDialog({ open, onSubmit, onClose, options: { title, description, variant, submitText, cancleText, props }, // \u0026lt;-- 추가 }: BaseDialogProps) { const current = colorVariant[variant]; return ( \u0026lt;Transition.Root show={open} as={React.Fragment}\u0026gt; \u0026lt;Dialog as=\u0026#34;div\u0026#34; static className=\u0026#34;fixed inset-0 z-40 overflow-y-auto\u0026#34; open={open} onClose={() =\u0026gt; onClose()} \u0026gt; \u0026lt;div className=\u0026#34;flex min-h-screen items-start justify-center px-5 py-5 text-center drop-shadow-xl sm:block sm:p-0 md:mt-16\u0026#34;\u0026gt; \u0026lt;Transition.Child as={React.Fragment} enter=\u0026#34;ease-out duration-300\u0026#34; enterFrom=\u0026#34;opacity-0\u0026#34; enterTo=\u0026#34;opacity-100\u0026#34; leave=\u0026#34;ease-in duration-300\u0026#34; leaveFrom=\u0026#34;opacity-100\u0026#34; leaveTo=\u0026#34;opacity-0\u0026#34; \u0026gt; \u0026lt;Dialog.Overlay className=\u0026#34;fixed inset-0 bg-black bg-opacity-75 transition-opacity\u0026#34; /\u0026gt; \u0026lt;/Transition.Child\u0026gt; \u0026lt;span className=\u0026#34;hidden sm:inline-block sm:h-screen sm:align-middle\u0026#34; aria-hidden=\u0026#34;true\u0026#34; \u0026gt; \u0026amp;#8203; \u0026lt;/span\u0026gt; \u0026lt;Transition.Child as={React.Fragment} enter=\u0026#34;ease-out duration-300\u0026#34; enterFrom=\u0026#34;opacity-0 trangray-y-4 sm:trangray-y-0 sm:scale-95\u0026#34; enterTo=\u0026#34;opacity-100 trangray-y-0 sm:scale-100\u0026#34; leave=\u0026#34;ease-in duration-200\u0026#34; leaveFrom=\u0026#34;opacity-100 trangray-y-0 sm:scale-100\u0026#34; leaveTo=\u0026#34;opacity-0 trangray-y-4 sm:trangray-y-0 sm:scale-95\u0026#34; \u0026gt; \u0026lt;div className=\u0026#34;z-auto inline-block w-full transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left align-bottom shadow-xl transition-all sm:my-12 sm:min-h-[1000px] sm:max-w-6xl sm:p-6 sm:align-middle\u0026#34;\u0026gt; \u0026lt;div className=\u0026#34;absolute top-0 right-0 block pt-4 pr-4\u0026#34;\u0026gt; \u0026lt;button type=\u0026#34;button\u0026#34; className={clsx( \u0026#34;rounded-md bg-white text-gray-400 hover:text-gray-500\u0026#34;, \u0026#34;focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2\u0026#34;, \u0026#34;disabled:cursor-wait disabled:brightness-90 disabled:filter\u0026#34; )} onClick={onClose} \u0026gt; \u0026lt;span className=\u0026#34;sr-only\u0026#34;\u0026gt;Close\u0026lt;/span\u0026gt; \u0026lt;HiOutlineX className=\u0026#34;h-6 w-6\u0026#34; aria-hidden=\u0026#34;true\u0026#34; /\u0026gt; \u0026lt;/button\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;div\u0026gt; {/* 불러온 props를 표시해주면 됩니다. */} {props \u0026amp;\u0026amp; \u0026lt;div\u0026gt; \u0026lt;/div\u0026gt;} \u0026lt;/div\u0026gt; \u0026lt;div className=\u0026#34;mt-5 sm:mt-4 sm:flex sm:flex-row-reverse\u0026#34;\u0026gt; \u0026lt;Button type=\u0026#34;button\u0026#34; variant=\u0026#34;outline\u0026#34; onClick={onClose} className=\u0026#34;font mt-3 w-full items-center justify-center !font-medium sm:mt-0 sm:w-auto sm:text-sm\u0026#34; \u0026gt; {cancleText} \u0026lt;/Button\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/Transition.Child\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/Dialog\u0026gt; \u0026lt;/Transition.Root\u0026gt; ); } const colorVariant = { success: { bg: { light: \u0026#34;bg-green-100\u0026#34;, }, text: { primary: \u0026#34;text-green-500\u0026#34;, }, icon: HiOutlineCheck, }, warning: { bg: { light: \u0026#34;bg-yellow-100\u0026#34;, }, text: { primary: \u0026#34;text-yellow-500\u0026#34;, }, icon: HiOutlineExclamation, }, danger: { bg: { light: \u0026#34;bg-red-100\u0026#34;, }, text: { primary: \u0026#34;text-red-500\u0026#34;, }, icon: HiExclamationCircle, }, }; useDialogStore.tsx src \u0026gt; store \u0026gt; useDialogStore.tsx 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 import { createSelectorHooks } from \u0026#34;auto-zustand-selectors-hook\u0026#34;; import produce from \u0026#34;immer\u0026#34;; import create from \u0026#34;zustand\u0026#34;; import { DialogOptions } from \u0026#34;@/components/dialog/BaseDialog\u0026#34;; type DialogStoreType = { awaitingPromise: { resolve?: () =\u0026gt; void; reject?: () =\u0026gt; void; }; open: boolean; state: DialogOptions; dialog: (options: Partial\u0026lt;DialogOptions\u0026gt;) =\u0026gt; Promise\u0026lt;void\u0026gt;; handleClose: () =\u0026gt; void; handleSubmit: () =\u0026gt; void; }; const useDialogStoreBase = create\u0026lt;DialogStoreType\u0026gt;((set) =\u0026gt; ({ awaitingPromise: {}, open: false, state: { title: \u0026#34;Title\u0026#34;, description: \u0026#34;Description\u0026#34;, submitText: \u0026#34;Yes\u0026#34;, cancleText: \u0026#34;No\u0026#34;, variant: \u0026#34;warning\u0026#34;, catchOnCancel: false, props: null, // \u0026lt;-- 추가 }, dialog: (options) =\u0026gt; { set( produce\u0026lt;DialogStoreType\u0026gt;((state) =\u0026gt; { state.open = true; state.state = { ...state.state, ...options }; }) ); return new Promise\u0026lt;void\u0026gt;((resolve, reject) =\u0026gt; { set( produce\u0026lt;DialogStoreType\u0026gt;((state) =\u0026gt; { state.awaitingPromise = { resolve, reject }; }) ); }); }, handleClose: () =\u0026gt; { set( produce\u0026lt;DialogStoreType\u0026gt;((state) =\u0026gt; { state.state.catchOnCancel \u0026amp;\u0026amp; state.awaitingPromise?.reject?.(); state.open = false; }) ); }, handleSubmit: () =\u0026gt; { set( produce\u0026lt;DialogStoreType\u0026gt;((state) =\u0026gt; { state.awaitingPromise?.resolve?.(); state.open = false; }) ); }, })); const useDialogStore = createSelectorHooks(useDialogStoreBase); export default useDialogStore; 이제 아까 만들었던 (예제리스트)처럼 openModal 함수에서 props를 함께 넘겨주어 모달이 열릴때 미리 받아온 데이터를 넘겨받을 수 있습니다.\nopenModal with props 1 2 /* 1. 모달로 오픈할꺼면 */ \u0026lt;div onClick={() =\u0026gt; openModal(props)}\u0026gt;Click ${props.id} to open Modal\u0026lt;/div\u0026gt; console에 찍어보면 다음과 같습니다. 맨 처음 리스트를 불러올 때 모달에 들어갈 내용들도 함께 불러왔으므로 모달이 열릴때 추가로 api에서 불러올 필요가 없어서 즉각적으로 표시되어 로딩속도가 빠르게 느껴지는 장점이 있습니다.\n응용하여 모달창이 열렸을 때 새로고침을 할 경우 router.push 하여 해당 아이템의 상세페이지로 이동하게 할 수 있습니다. 빠른 반응을 위해서 리스트에서 클릭 시에만 모달이 열리고, 새로고침, 링크공유, SEO 최적화 위해 상세페이지를 제작해 두는 것이 좋습니다.\ninfiniteScroll + LocalStorage + ScrollY 리스트에서 아이템을 클릭할 때 scrollY값을 LocalStorage에 저장하고, useEffect를 이용하여 scrollY가 0이 아닌 경우에는 LocalStorage에 저장된 scrollY 값으로 이동시켜주면 됩니다. Next/Link를 사용할때 onClick 이벤트를 발생시키기 위해서 a 태그를 중첩시켜주어야 합니다.\nListExample.tsx ListExample.tsx 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 const ListExample = () =\u0026gt; { const { data, hasNextPage, fetchNextPage, refetch, isFetching } = useInfiniteQuery( ... ); const loadMoreButtonRef = React.useRef(null); useIntersectionObserver({ root: null, target: loadMoreButtonRef, onIntersect: fetchNextPage, enabled: hasNextPage, }); // 스크롤 값을 확인 const [scrollY, setScrollY] = useLocalStorage(\u0026#39;PostListScroll\u0026#39;, 0); React.useEffect(() =\u0026gt; { if (scrollY !== 0) { window.scrollTo(0, Number(scrollY)); setScrollY(0); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( \u0026lt;\u0026gt; \u0026lt;ul\u0026gt; {isFetching \u0026amp;\u0026amp; \u0026lt;LoadingDiv /\u0026gt;} {data?.pages.map((page) =\u0026gt; page.results.map((post: Results) =\u0026gt; ( \u0026lt;li key={post.id} data-aos=\u0026#39;fade-up\u0026#39; data-aos-duration=\u0026#39;200\u0026#39;\u0026gt; \u0026lt;Link href={{ pathname: `community/post/${post.id}`, query: { post: JSON.stringify(post) }, }} as={`community/post/${post.id}`} passHref \u0026gt; \u0026lt;a href={`community/post/${post.id}`} onClick={() =\u0026gt; setScrollY(window.scrollY)} // 클릭시에 해당 y값을 저장해줌 \u0026gt; \u0026lt;/a\u0026gt; \u0026lt;/Link\u0026gt; \u0026lt;/li\u0026gt; )) )} \u0026lt;/ul\u0026gt; \u0026lt;div ref={loadMoreButtonRef} /\u0026gt; \u0026lt;/\u0026gt; ); }; export default ListExample; 콘솔을 찍어보면 다음과 같습니다. LocalStorage를 초기화 해주는 조건을 Header나 다른 감싸지는 컴포넌트에 추가해주어서 A리스트 \u0026gt; B리스트 \u0026gt; A리스트로 다른 리스트를 거쳐 돌아왔을때 A리스트의 scrollY값을 초기화 해주는 것도 필요합니다.\n저의 경우엔 Header에 추가주었습니다.\nheader.tsx header.tsx 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 ... const [, setCommunityScrollY] = useLocalStorage\u0026lt;number\u0026gt;(\u0026#39;PostListScroll\u0026#39;, 0); const NavigateCommunity = () =\u0026gt; { setCommunityScrollY(0); router.push(\u0026#39;/community\u0026#39;); window.scrollTo(0, 0); setMobileNavOpen(false); }; const [, setFindcueScrollY] = useLocalStorage\u0026lt;number\u0026gt;(\u0026#39;CueListScroll\u0026#39;, 0); const NavigateFindCue = () =\u0026gt; { setFindcueScrollY(0); router.push(\u0026#39;/findcue\u0026#39;); window.scrollTo(0, 0); setMobileNavOpen(false); }; ... 마치며 이제 무한 스크롤을 구현할 때 상황에 따라서 모달로 열거나 새로운 페이지 이동 후 뒤로가기시 로딩된 값은 다시 불러오지 않고 해당 값으로 다시 돌아오게 할 수 있습니다.\nReference https://github.com/theodorusclarence/ts-nextjs-tailwind-starter https://github.com/theodorusclarence/expansion-pack https://tanstack.com https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver https://velog.io/@hdpark 궁금하신 점이 있으시면 아래 Comments 남겨주세요 👇\n","permalink":"https://cha2hyun.blog/content/projects/%ED%81%90%EC%B0%BE%EC%82%AC/infinitescroll/","summary":"무한 스크롤과 뒤로가기시 스크롤된 위치를 기억하기.","title":"InfiniteScroll로 무한 스크롤 구현하기"},{"content":"\n알고리즘 Insertion vs Merge vs Heap vs Quick 상명대학교 컴퓨터과학과 동아리 파느쎄 스터디할때 찍어놨던 유물\nDFS (깊이우선탐색) 모든 노드를 방문할때 이방법을 선택\n최대한 깊이 내려간 뒤 더이상 깊이 갈 곳이 없을 경우 옆으로 이동 다른 노드로 넘어가기 전에 해당 깊이를 완벽하게 탐색 경로의 특징을 저장해야 하는 문제에서 유리 스택 혹은 재귀함수 이용 DFS 1 2 3 4 5 6 7 8 9 # DFS 메서드 정의 def dfs(graph, v, visited): # 현재 노드를 방문 처리 visited[v] = True print(v, end = \u0026#39;\u0026#39;) # 현재 노드와 연결된 다른 노드를 재귀적으로 방문 for i in graph[v]: if not visited[i]: dfs(graph, i, visited) BFS (너비우선탐색) 두 노드 사이의 최단 거리를 찾을 때 사용\n최대한 넓게 이동한 다음, 더이상 넓게 갈 수 없을때 아래로 이동 미로찾기처럼 최단거리를 구해야하는 경우 유리 아래 예시는 큐를 사용 BFS 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 from collections import deque # BFS 메서드 정의 def bfs(graph, start, visited): queue = deque([start]) # 현재 노드를 방문 처리 visited[start] = True # 큐가 빌 때까지 반복 while queue: # 큐에서 하나의 원소를 뽑아 출력 v = deque.popleft() print(v, end = \u0026#39; \u0026#39;) # 해당 원소와 연결된, 아직 방문하지 않은 원소들을 큐에 삽입 for i in graph[v]: if not visited[i]: queue.append(i) visited[i] = True GREEDY (탐욕법) 최적의 해를 찾을 수 있을 때 사용\n시간복잡도에서 우위를 차지할 수 있음 가장 큰 순서대로, 가장 작은 순서대로, 평균값부터 접근한다고 문제에서 주어졌을 때 최적의 해 (아이디어)가 생각나지 않으면 DP나 Graph로 풀어야한다 BINARY SEARCH (이분탐색) 리스트를 절반씩 나누어가며 탐색하는 알고리즘, 낮은 시간복잡도\n순차 탐색으로는 O(N)의 시간 복잡도라면, 이분 탐색은 O(log N)이다. 리스트의 중간값 mid 의 값이 key보다 작으면 좌측은 탐색할 필요가 없으므로 left = mid + 1이된다 left \u0026gt; right 라면 리스트에 원하는 데이터가 없다 mid의 값이 key와 같으면 key값을 찾았기 때문에 answer = mid 탐색을 종료한다. 이분탐색 1 2 3 4 5 6 7 8 9 10 11 def binary_search(target, data): left, right = 0, len(data) - 1 while left \u0026lt;= right: mid = (left + right) // 2 if data[mid] == target: return mid elif data[mid] \u0026lt; target: left = mid + 1 else: right = mid -1 return None 이분탐색 재귀 1 2 3 4 5 6 7 8 9 10 11 def binary_search_recursion(target, start, end, data): if left \u0026gt; right: return None mid = (left + right) // 2 if data[mid] == target: return mid elif data[mid] \u0026gt; target: right = mid - 1 else: left = mid + 1 return binary_search_recursion(target, left, right, data) DIJSTRA (다익스트라) 그래프에서 노드에서 다른 노드까지 최단 경로를 구하는 알고리즘\n출발 노드와 도착 노드를 설정한다. 최단 거리 테이블을 초기화한다. 현재 노드의 인접 노드 중 방문하지 않은 노드를 구별하고, 방문하지 않은 노드 중 거리가 가장 짧은 노드를 택하여 방문 처리한다. 해당 노드를 거쳐 다른 노드로 넘어가는 간선 비용(가중치)을 계산해 최단 거리 테이블을 업데이트한다. 3 ~ 4 의 과정을 반복한다. 최단 거리 테이블은 1차원 배열로, N개 노드까지 오는 데 필요한 최단 거리를 기록. N개(1부터 시작하는 노드 번호와 일치시키려면 N + 1개) 크기의 배열을 선언하고 큰 값을 넣어 초기화시킨다. 노드 방문 여부 체크 배열은 방문한 노드인지 아닌지 기록하기 위한 배열로, 크기는 최단 거리 테이블과 같다. 기본적으로는 False로 초기화하여 방문하지 않았음을 명시한다. 간선의 개수가 E, 노드의 개수가 V 일때 시간 복잡도는 O(ElogV) DIJSTRA 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 import heapq def dikjstra(start, distance, graph): q = [] # 시작노드 정보 우선순위 큐에 삽입 heapq.heappush(q, (0, start)) # 시작노드-\u0026gt;시작노드 거리 기록 distance[start] = 0 while q: dist, now = heapq.heappop(q) # 큐에서 뽑아낸 거리가 이미 갱신된 거리보다 클 경우(=방문한 셈) 무시 if distance[now]\u0026lt;dist: continue # 큐에서 뽑아낸 노드와 연결된 인접노드들 탐색 for i in graph[now]: # 시작-\u0026gt;node거리 + node-\u0026gt;node의인접노드 거리 cost = dist+i[1] # cost \u0026lt; 시작-\u0026gt;node의인접노드 거리 if cost \u0026lt; distance[i[0]]: distance[i[0]] = cost heapq.heappush(q, (cost, i[0])) Python 함수 itertools 순열, 조합 사용시 사용하는 함수\ncombinations combinations(iterable, r): iterable에서 원소 갯수가 r개인 튜플 리턴 (중복 제거) combinations(iterable, r) 1 2 3 4 5 6 7 8 9 10 11 # combinations from itertools import combinations example = [1, 2, 3] print(list(combinations(example, 1))) # [(1,), (2,), (3,)] print(list(combinations(example, 2))) # [(1, 2), (1, 3), (2, 3)] print(list(combinations(example, 3))) # [(1, 2, 3)] print(list(combinations(example, 4))) # [] combinations_with_replacement combinations_with_replacement(iterable, r): iterable에서 원소 갯수가 r개인 중복 포함 튜플 리턴 combinations_with_replacement(iterable, r) 1 2 3 4 5 6 7 # combinations_with_replacement from itertools import combinations_with_replacement example = [1, 2] print(list(combinations_with_replacement(example, 2))) # [(1, 1), (1, 2), (2, 2)] print(list(combinations_with_replacement(example, 3))) # [(1, 1, 1), (1, 1, 2), (1, 2, 2), (2, 2, 2)] permutations permutations(iterable, r=None) : iterable에서 원소 갯수가 r개인 순열 튜플 리턴 permutations(iterable, r=None) 1 2 3 4 5 6 7 # permutation from itertools import permutations example = [1, 2, 3] print(list(permutations(example))) # [(1, 2, 3), (1, 3, 2), (2, 1, 3), (2, 3, 1), (3, 1, 2), (3, 2, 1)] print(list(permutations(example, 2))) # [(1, 2), (1, 3), (2, 1), (2, 3), (3, 1), (3, 2)] product product(*iterables, repeat=1) : 여러 iterable의 데카르트곱 튜플 리턴 product(*iterables, repeat=1) 1 2 3 4 5 6 7 8 9 # product from itertools import product number = [1, 2] alpha = [\u0026#39;a\u0026#39;, \u0026#39;b\u0026#39;] fruit = [\u0026#39;apple\u0026#39;, \u0026#39;banana\u0026#39;] print(list(product(number, alpha))) # [(1, \u0026#39;a\u0026#39;), (1, \u0026#39;b\u0026#39;), (2, \u0026#39;a\u0026#39;), (2, \u0026#39;b\u0026#39;)] print(list(product(number, alpha, fruit))) # [(1, \u0026#39;a\u0026#39;, \u0026#39;apple\u0026#39;), (1, \u0026#39;a\u0026#39;, \u0026#39;banana\u0026#39;), (1, \u0026#39;b\u0026#39;, \u0026#39;apple\u0026#39;), (1, \u0026#39;b\u0026#39;, \u0026#39;banana\u0026#39;), (2, \u0026#39;a\u0026#39;, \u0026#39;apple\u0026#39;), (2, \u0026#39;a\u0026#39;, \u0026#39;banana\u0026#39;), (2, \u0026#39;b\u0026#39;, \u0026#39;apple\u0026#39;), (2, \u0026#39;b\u0026#39;, \u0026#39;banana\u0026#39;)] collections 자료형(list, tuple, dict)를 이용할때 확장된 기능을 사용할 수 있는 기본 라이브러리\ndeque 스택이나 큐를 이용하여 알고리즘 풀때 사용하며 속도가 list 보다 빠르다. 따라서 push, pop을 많이 사용해야하는 문제에서 사용하기 적합하다.\n리스트 자료형으로 구현하기 복잡한 것\ndeque.popleft(): 데크의 왼쪽 끝 원소 리턴하면서 데크에서 삭제 deque.rotate(num): 데크를 num만큼 이동한다 (양수면 오른쪽, 음수면 왼쪽) 리스트 자료형으로도 구현하기 쉬운 것\ndeque.append(item): item을 데크의 오른쪽 끝에 삽입 (list append) deque.extend(array): 배열을 순회하면서 데크의 오른쪽에 추가 (list extend) deque.extendleft(array): 배열을 순회하면서 데크의 왼쪽에 추가 (list extend) deque.appendleft(item): item을 데크의 왼쪽 끝에 삽입 (list insert) deque.pop(): 데크의 오른쪽 끝 원소 리턴하면서 데크에서 삭제 (list pop) deque.remove(item): item을 데크에서 삭제 (list del) Counter 리스트가 주어졌을때 중복되지 않는 원소들과 갯수를 딕셔너리 형태로 리턴함 Counter 1 2 3 from collections import Counter print(Counter([\u0026#34;a\u0026#34;, \u0026#34;b\u0026#34;, \u0026#34;c\u0026#34;, \u0026#34;a\u0026#34;, \u0026#34;b\u0026#34;])) # Counter({\u0026#39;a\u0026#39;: 2, \u0026#39;b\u0026#39;: 2, \u0026#39;c\u0026#39;: 1}) defaultdict 딕셔너리값의 초기값을 지정할 수 있다. 처음 선언할때 어떤 자료형을 사용할 건지 입력받아야한다. keyError 를 우회할 수 있다. defaultdict 1 2 3 4 5 6 7 8 from collections import defaultdict example = defaultdict(list) print(example[\u0026#39;list\u0026#39;]) # [] example = {} print(example[\u0026#39;list\u0026#39;]) # keyError \u0026#39;list\u0026#39; sort sorted() vs sort()\nsorted(X) \u0026quot; 원본을 변형시키지 않고 정렬된 리스트를 리턴함 X.sort() : 원본을 변형시켜줌, 리턴값 없음 Params : sort와 sorted 모두 동일한 파라미터를 갖을 수 있다. 파라미터는 key 와 reverse가 있으며 lambda와 함께 사용하요 내림차순, 오름차순, 배열의 몇번째 원소 기준으로 정렬할지 정할 수 있다. 문자는 알파벳순(가나다순), 숫자는 오름차순이 기본 sort 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 example = [1, 4, 3, 2] print(sorted(example), example) # [1, 2, 3, 4] [1, 4, 3, 2] print(example.sort(), example) # None [1, 2, 3, 4] # reverse 를 이용한 내림차순 print(sorted(example, reverse=True)). # [4, 3, 2, 1] # key 를 이용한 2차원 배열(튜플) 정렬 example = [[1, 3], [2, 2], [3, 1]] # 첫번째 배열 원소를 이용해 정렬 print(sorted(example, key= lambda x : x[0])) # [[1, 3], [2, 2], [3, 1]] # 두번째 배열 원소를 이용해 정렬 print(sorted(example, key= lambda x : x[1])) # [[3, 1], [2, 2], [1, 3]] # key와 reverse를 동시에 이용할 수 도 있다. print(sorted(example, key= lambda x : x[0], reverse=True)) # [[3, 1], [2, 2], [1, 3]] zip iterable한 자료형을 순서대로 묶어서 리턴해준다.\n되도록 zip할 자료형의 갯수가 같을때 사용 (같지 않을 경우 최소 길이의 자료형으로 리턴한다.) 자료형은 리스트나 튜플같은 반복 가능한 자료형을 의미한다. zip 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 print(zip([1, 2, 3], [\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;,\u0026#39;c\u0026#39;])) # \u0026lt;zip object at 0x100a76e48\u0026gt; print(list(zip([1, 2, 3], [\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;,\u0026#39;c\u0026#39;]))) # [(1, \u0026#39;a\u0026#39;), (2, \u0026#39;b\u0026#39;), (3, \u0026#39;c\u0026#39;)] print(list(zip([1, 2, 3], [\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;]))) # [(1, \u0026#39;a\u0026#39;), (2, \u0026#39;b\u0026#39;)] # zip을 이용한 for문 예시, List 형태로 변환하지 않아도 됨 example = zip([1, 2, 3], [\u0026#39;a\u0026#39;,\u0026#39;b\u0026#39;,\u0026#39;c\u0026#39;]) for number, alpha in example: print(number, alpha) # 1 a # 2 b # 3 c # 딕셔너리 자료형 생성 예시 print({ number:alpha for number, alpha in example }) # {1: \u0026#39;a\u0026#39;, 2: \u0026#39;b\u0026#39;, 3: \u0026#39;c\u0026#39;} re 정규식\nre.sub(pattern, replacement, string) : 정규식 패턴과 일치하는 내용을 replacement로 변경 \\uAC00-\\uD7A30 : 모든 한글 음절(가-힣) a-z : 영어 소문자 A-Z : 영어 대문자 0-9 : 숫자 \\s : 띄어쓰기 re 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import re example = \u0026#39;aㄱ1*bㄴ2@\u0026#39; # 특수문자 제거 print(re.sub(r\u0026#34;[^\\uAC00-\\uD7A30-9a-zA-Z\\s]\u0026#34;, \u0026#34;\u0026#34;, example)) # a1b2 # 숫자만 가져오기 print(re.sub(r\u0026#34;[^0-9]\u0026#34;, \u0026#34;\u0026#34;, example)) # 12 # 숫자만 제거 print(re.sub(r\u0026#34;[0-9]\u0026#34;, \u0026#34;\u0026#34;, example)) # aㄱ*bㄴ@ # 숫자를 `~`로 변경 print(re.sub(r\u0026#34;[0-9]\u0026#34;, \u0026#34;~\u0026#34;, example)) # aㄱ~*bㄴ~@ 최소공배수, 최대공약수 파이썬 math 라이브러리 이용하면 쉽게 구할 수 있다\n최소공배수, 최대공약수 1 2 3 4 5 6 7 8 9 # python 3.9 미만 from math import gcd gcd(a, b) # 최대공약수 a * b // gcd(a, b) # 최소공약수 # python 3.9 이상 from math import gcd, lcm gcd(a, b) # 최대공약수 lcm(a, b) # 최소공약수 진수 내장 함수 bin을 이용하면 쉽게 구할 수 있음.\n최소공배수, 최대공약수 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 a = bin(4) # a = \u0026#39;ob100\u0026#39; b = int(a, 2) # b = 4 # 혹은 직접 구현할 수도 있음 def makeBin(len): result = [] while len != 0: if len % 2 == 1: result.append(\u0026#34;1\u0026#34;) len = (len - 1) / 2 else: result.append(\u0026#34;0\u0026#34;) len = len / 2 return result ","permalink":"https://cha2hyun.blog/content/algorithm/python-algorithms/","summary":"알고리즘 Insertion vs Merge vs Heap vs Quick 상명대학교 컴퓨터과학과 동아리 파느쎄 스터디할때 찍어놨던 유물\nDFS (깊이우선탐색) 모든 노드를 방문할때 이방법을 선택\n최대한 깊이 내려간 뒤 더이상 깊이 갈 곳이 없을 경우 옆으로 이동 다른 노드로 넘어가기 전에 해당 깊이를 완벽하게 탐색 경로의 특징을 저장해야 하는 문제에서 유리 스택 혹은 재귀함수 이용 DFS 1 2 3 4 5 6 7 8 9 # DFS 메서드 정의 def dfs(graph, v, visited): # 현재 노드를 방문 처리 visited[v] = True print(v, end = \u0026#39;\u0026#39;) # 현재 노드와 연결된 다른 노드를 재귀적으로 방문 for i in graph[v]: if not visited[i]: dfs(graph, i, visited) BFS (너비우선탐색) 두 노드 사이의 최단 거리를 찾을 때 사용","title":"자주사용되는 파이썬 알고리즘, 함수 정리"},{"content":"마지막 수정일 : 24년 01월 30일\nOverall 순서대로 진행하면 됩니다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 # Rosetta \u0026amp; homebrew /usr/sbin/softwareupdate --install-rosetta --agree-to-license # rosetta /bin/bash -c \u0026#34;$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\u0026#34; # homebrew brew install cask # iTerm2 \u0026amp; oh my zsh \u0026amp; powerlevel10k brew install --cask iterm2 sh -c \u0026#34;$(curl -fsSL https://raw.github.com/ohmyzsh/ohmyzsh/master/tools/install.sh)\u0026#34; # oh my zsh 설치 git clone --depth=1 https://github.com/romkatv/powerlevel10k.git ${ZSH_CUSTOM:-$HOME/.oh-my-zsh/custom}/themes/powerlevel10k # Powerlevel10k vi ~/.zshrc ZSH_THEME=\u0026#34;powerlevel10k/powerlevel10k\u0026#34; # ZSH_THEME 찾아서 수정 p10k configure # homebrew로 application 설치 brew install pipenv pyenv nvm gh hugo ollama pnpm sqlite zsh-autosuggestions zsh-syntax-highlighting brew install --cask visual-studio-code dbeaver-community datagrip docker insomnia altair-graphql-client # Dev brew install --cask discord notion slack figma # Work brew install --cask bettertouchtool clipy stats google-chrome naver-whale fig scroll-reverser shottr karabiner-elements switchresx microsoft-remote-desktop adobe-creative-cloud # Utils # Node (nvm) nvm install 18 nvm use 18 npm install -g yarn # Python (pyenv) pyenv install 3.11 pyenv global 3.11 pyenv local 3.11 python --version # git gh auth login git config --global user.name cha2hyun git config --global user.email [email protected] # ollama (LLM) ollama pull mistral:latest ollama pull codellama:latest ollama pull llama2-uncensored:7b-chat # Mac 설정 defaults write com.apple.dock autohide-time-modifier -float 0.25;killall Dock # 독 숨기기 속도 defaults write com.apple.finder CreateDesktop -bool FALSE; # 바탕화면 아이콘 숨기기 # 직접설치 필요 # https://github.com/Yash-Handa/logo-ls # https://github.com/gomjellie/zsh-hangul iTerm2 Profile 테마 - github dark theme\nProfile cha2hyun.json 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 { \u0026#34;Use Non-ASCII Font\u0026#34;: false, \u0026#34;Tags\u0026#34;: [], \u0026#34;Ansi 12 Color\u0026#34;: { \u0026#34;Red Component\u0026#34;: 0.42352941632270813, \u0026#34;Color Space\u0026#34;: \u0026#34;sRGB\u0026#34;, \u0026#34;Alpha Component\u0026#34;: 1, \u0026#34;Blue Component\u0026#34;: 0.97254902124404907, \u0026#34;Green Component\u0026#34;: 0.64313727617263794 }, \u0026#34;Ansi 6 Color\u0026#34;: { \u0026#34;Red Component\u0026#34;: 0.16862745583057404, \u0026#34;Color Space\u0026#34;: \u0026#34;sRGB\u0026#34;, \u0026#34;Alpha Component\u0026#34;: 1, \u0026#34;Blue Component\u0026#34;: 0.5372549295425415, \u0026#34;Green Component\u0026#34;: 0.45490196347236633 }, \u0026#34;Draw Powerline Glyphs\u0026#34;: true, \u0026#34;Bold Color\u0026#34;: { \u0026#34;Red Component\u0026#34;: 0.78823530673980713, \u0026#34;Color Space\u0026#34;: \u0026#34;sRGB\u0026#34;, \u0026#34;Alpha Component\u0026#34;: 1, \u0026#34;Blue Component\u0026#34;: 0.85098040103912354, \u0026#34;Green Component\u0026#34;: 0.81960785388946533 }, \u0026#34;Normal Font\u0026#34;: \u0026#34;MesloLGS-NF-Regular 27\u0026#34;, \u0026#34;Link Color\u0026#34;: { \u0026#34;Red Component\u0026#34;: 0.34509804844856262, \u0026#34;Color Space\u0026#34;: \u0026#34;sRGB\u0026#34;, \u0026#34;Alpha Component\u0026#34;: 1, \u0026#34;Blue Component\u0026#34;: 1, \u0026#34;Green Component\u0026#34;: 0.65098041296005249 }, \u0026#34;Custom Directory\u0026#34;: \u0026#34;No\u0026#34;, \u0026#34;Rows\u0026#34;: 25, \u0026#34;Default Bookmark\u0026#34;: \u0026#34;No\u0026#34;, \u0026#34;Right Option Key Sends\u0026#34;: 0, \u0026#34;Cursor Guide Color\u0026#34;: { \u0026#34;Red Component\u0026#34;: 0.70213186740875244, \u0026#34;Color Space\u0026#34;: \u0026#34;sRGB\u0026#34;, \u0026#34;Alpha Component\u0026#34;: 0.25, \u0026#34;Blue Component\u0026#34;: 1, \u0026#34;Green Component\u0026#34;: 0.9268307089805603 }, \u0026#34;Non-ASCII Anti Aliased\u0026#34;: true, \u0026#34;Use Bright Bold\u0026#34;: true, \u0026#34;Ansi 10 Color\u0026#34;: { \u0026#34;Red Component\u0026#34;: 0.33725491166114807, \u0026#34;Color Space\u0026#34;: \u0026#34;sRGB\u0026#34;, \u0026#34;Alpha Component\u0026#34;: 1, \u0026#34;Blue Component\u0026#34;: 0.39215686917304993, \u0026#34;Green Component\u0026#34;: 0.82745099067687988 }, \u0026#34;Ambiguous Double Width\u0026#34;: false, \u0026#34;Jobs to Ignore\u0026#34;: [\u0026#34;rlogin\u0026#34;, \u0026#34;ssh\u0026#34;, \u0026#34;slogin\u0026#34;, \u0026#34;telnet\u0026#34;], \u0026#34;Show Status Bar\u0026#34;: true, \u0026#34;Ansi 15 Color\u0026#34;: { \u0026#34;Red Component\u0026#34;: 1, \u0026#34;Color Space\u0026#34;: \u0026#34;sRGB\u0026#34;, \u0026#34;Alpha Component\u0026#34;: 1, \u0026#34;Blue Component\u0026#34;: 1, \u0026#34;Green Component\u0026#34;: 1 }, \u0026#34;Foreground Color\u0026#34;: { \u0026#34;Red Component\u0026#34;: 0.54509806632995605, \u0026#34;Color Space\u0026#34;: \u0026#34;sRGB\u0026#34;, \u0026#34;Alpha Component\u0026#34;: 1, \u0026#34;Blue Component\u0026#34;: 0.61960786581039429, \u0026#34;Green Component\u0026#34;: 0.58039218187332153 }, \u0026#34;Working Directory\u0026#34;: \u0026#34;/Users/staffsoohyun\u0026#34;, \u0026#34;Blinking Cursor\u0026#34;: false, \u0026#34;Disable Window Resizing\u0026#34;: true, \u0026#34;Sync Title\u0026#34;: false, \u0026#34;Prompt Before Closing 2\u0026#34;: false, \u0026#34;BM Growl\u0026#34;: true, \u0026#34;Command\u0026#34;: \u0026#34;\u0026#34;, \u0026#34;Description\u0026#34;: \u0026#34;Default\u0026#34;, \u0026#34;Mouse Reporting\u0026#34;: true, \u0026#34;Screen\u0026#34;: -1, \u0026#34;Selection Color\u0026#34;: { \u0026#34;Red Component\u0026#34;: 0.23137255012989044, \u0026#34;Color Space\u0026#34;: \u0026#34;sRGB\u0026#34;, \u0026#34;Alpha Component\u0026#34;: 1, \u0026#34;Blue Component\u0026#34;: 0.43921568989753723, \u0026#34;Green Component\u0026#34;: 0.31372550129890442 }, \u0026#34;Only The Default BG Color Uses Transparency\u0026#34;: true, \u0026#34;Columns\u0026#34;: 80, \u0026#34;Idle Code\u0026#34;: 0, \u0026#34;Ansi 13 Color\u0026#34;: { \u0026#34;Red Component\u0026#34;: 0.85882353782653809, \u0026#34;Color Space\u0026#34;: \u0026#34;sRGB\u0026#34;, \u0026#34;Alpha Component\u0026#34;: 1, \u0026#34;Blue Component\u0026#34;: 0.63529413938522339, \u0026#34;Green Component\u0026#34;: 0.3803921639919281 }, \u0026#34;Custom Command\u0026#34;: \u0026#34;No\u0026#34;, \u0026#34;ASCII Anti Aliased\u0026#34;: true, \u0026#34;Non Ascii Font\u0026#34;: \u0026#34;Monaco 12\u0026#34;, \u0026#34;Vertical Spacing\u0026#34;: 1, \u0026#34;Use Bold Font\u0026#34;: true, \u0026#34;Option Key Sends\u0026#34;: 0, \u0026#34;Selected Text Color\u0026#34;: { \u0026#34;Red Component\u0026#34;: 1, \u0026#34;Color Space\u0026#34;: \u0026#34;sRGB\u0026#34;, \u0026#34;Alpha Component\u0026#34;: 1, \u0026#34;Blue Component\u0026#34;: 1, \u0026#34;Green Component\u0026#34;: 1 }, \u0026#34;Background Color\u0026#34;: { \u0026#34;Red Component\u0026#34;: 0.062745101749897003, \u0026#34;Color Space\u0026#34;: \u0026#34;sRGB\u0026#34;, \u0026#34;Alpha Component\u0026#34;: 1, \u0026#34;Blue Component\u0026#34;: 0.086274512112140656, \u0026#34;Green Component\u0026#34;: 0.070588238537311554 }, \u0026#34;Character Encoding\u0026#34;: 4, \u0026#34;Ansi 11 Color\u0026#34;: { \u0026#34;Red Component\u0026#34;: 0.89019608497619629, \u0026#34;Color Space\u0026#34;: \u0026#34;sRGB\u0026#34;, \u0026#34;Alpha Component\u0026#34;: 1, \u0026#34;Blue Component\u0026#34;: 0.25490197539329529, \u0026#34;Green Component\u0026#34;: 0.70196080207824707 }, \u0026#34;Use Italic Font\u0026#34;: true, \u0026#34;Unlimited Scrollback\u0026#34;: false, \u0026#34;Keyboard Map\u0026#34;: { \u0026#34;0xf700-0x260000\u0026#34;: { \u0026#34;Action\u0026#34;: 10, \u0026#34;Text\u0026#34;: \u0026#34;[1;6A\u0026#34; }, \u0026#34;0x37-0x40000\u0026#34;: { \u0026#34;Action\u0026#34;: 11, \u0026#34;Text\u0026#34;: \u0026#34;0x1f\u0026#34; }, \u0026#34;0x32-0x40000\u0026#34;: { \u0026#34;Action\u0026#34;: 11, \u0026#34;Text\u0026#34;: \u0026#34;0x00\u0026#34; }, \u0026#34;0xf709-0x20000\u0026#34;: { \u0026#34;Action\u0026#34;: 10, \u0026#34;Text\u0026#34;: \u0026#34;[17;2~\u0026#34; }, \u0026#34;0xf70c-0x20000\u0026#34;: { \u0026#34;Action\u0026#34;: 10, \u0026#34;Text\u0026#34;: \u0026#34;[20;2~\u0026#34; }, \u0026#34;0xf729-0x20000\u0026#34;: { \u0026#34;Action\u0026#34;: 10, \u0026#34;Text\u0026#34;: \u0026#34;[1;2H\u0026#34; }, \u0026#34;0xf72b-0x40000\u0026#34;: { \u0026#34;Action\u0026#34;: 10, \u0026#34;Text\u0026#34;: \u0026#34;[1;5F\u0026#34; }, \u0026#34;0xf705-0x20000\u0026#34;: { \u0026#34;Action\u0026#34;: 10, \u0026#34;Text\u0026#34;: \u0026#34;[1;2Q\u0026#34; }, \u0026#34;0xf703-0x260000\u0026#34;: { \u0026#34;Action\u0026#34;: 10, \u0026#34;Text\u0026#34;: \u0026#34;[1;6C\u0026#34; }, \u0026#34;0xf700-0x220000\u0026#34;: { \u0026#34;Action\u0026#34;: 10, \u0026#34;Text\u0026#34;: \u0026#34;[1;2A\u0026#34; }, \u0026#34;0xf701-0x280000\u0026#34;: { \u0026#34;Action\u0026#34;: 11, \u0026#34;Text\u0026#34;: \u0026#34;0x1b 0x1b 0x5b 0x42\u0026#34; }, \u0026#34;0x38-0x40000\u0026#34;: { \u0026#34;Action\u0026#34;: 11, \u0026#34;Text\u0026#34;: \u0026#34;0x7f\u0026#34; }, \u0026#34;0x33-0x40000\u0026#34;: { \u0026#34;Action\u0026#34;: 11, \u0026#34;Text\u0026#34;: \u0026#34;0x1b\u0026#34; }, \u0026#34;0xf703-0x220000\u0026#34;: { \u0026#34;Action\u0026#34;: 10, \u0026#34;Text\u0026#34;: \u0026#34;[1;2C\u0026#34; }, \u0026#34;0xf701-0x240000\u0026#34;: { \u0026#34;Action\u0026#34;: 10, \u0026#34;Text\u0026#34;: \u0026#34;[1;5B\u0026#34; }, \u0026#34;0xf70d-0x20000\u0026#34;: { \u0026#34;Action\u0026#34;: 10, \u0026#34;Text\u0026#34;: \u0026#34;[21;2~\u0026#34; }, \u0026#34;0xf702-0x260000\u0026#34;: { \u0026#34;Action\u0026#34;: 10, \u0026#34;Text\u0026#34;: \u0026#34;[1;6D\u0026#34; }, \u0026#34;0xf729-0x40000\u0026#34;: { \u0026#34;Action\u0026#34;: 10, \u0026#34;Text\u0026#34;: \u0026#34;[1;5H\u0026#34; }, \u0026#34;0xf706-0x20000\u0026#34;: { \u0026#34;Action\u0026#34;: 10, \u0026#34;Text\u0026#34;: \u0026#34;[1;2R\u0026#34; }, \u0026#34;0x34-0x40000\u0026#34;: { \u0026#34;Action\u0026#34;: 11, \u0026#34;Text\u0026#34;: \u0026#34;0x1c\u0026#34; }, \u0026#34;0xf700-0x280000\u0026#34;: { \u0026#34;Action\u0026#34;: 11, \u0026#34;Text\u0026#34;: \u0026#34;0x1b 0x1b 0x5b 0x41\u0026#34; }, \u0026#34;0x2d-0x40000\u0026#34;: { \u0026#34;Action\u0026#34;: 11, \u0026#34;Text\u0026#34;: \u0026#34;0x1f\u0026#34; }, \u0026#34;0xf70e-0x20000\u0026#34;: { \u0026#34;Action\u0026#34;: 10, \u0026#34;Text\u0026#34;: \u0026#34;[23;2~\u0026#34; }, \u0026#34;0xf702-0x220000\u0026#34;: { \u0026#34;Action\u0026#34;: 10, \u0026#34;Text\u0026#34;: \u0026#34;[1;2D\u0026#34; }, \u0026#34;0xf703-0x280000\u0026#34;: { \u0026#34;Action\u0026#34;: 11, \u0026#34;Text\u0026#34;: \u0026#34;0x1b 0x1b 0x5b 0x43\u0026#34; }, \u0026#34;0xf700-0x240000\u0026#34;: { \u0026#34;Action\u0026#34;: 10, \u0026#34;Text\u0026#34;: \u0026#34;[1;5A\u0026#34; }, \u0026#34;0xf707-0x20000\u0026#34;: { \u0026#34;Action\u0026#34;: 10, \u0026#34;Text\u0026#34;: \u0026#34;[1;2S\u0026#34; }, \u0026#34;0xf70a-0x20000\u0026#34;: { \u0026#34;Action\u0026#34;: 10, \u0026#34;Text\u0026#34;: \u0026#34;[18;2~\u0026#34; }, \u0026#34;0x35-0x40000\u0026#34;: { \u0026#34;Action\u0026#34;: 11, \u0026#34;Text\u0026#34;: \u0026#34;0x1d\u0026#34; }, \u0026#34;0xf70f-0x20000\u0026#34;: { \u0026#34;Action\u0026#34;: 10, \u0026#34;Text\u0026#34;: \u0026#34;[24;2~\u0026#34; }, \u0026#34;0xf703-0x240000\u0026#34;: { \u0026#34;Action\u0026#34;: 10, \u0026#34;Text\u0026#34;: \u0026#34;[1;5C\u0026#34; }, \u0026#34;0xf701-0x260000\u0026#34;: { \u0026#34;Action\u0026#34;: 10, \u0026#34;Text\u0026#34;: \u0026#34;[1;6B\u0026#34; }, \u0026#34;0xf702-0x280000\u0026#34;: { \u0026#34;Action\u0026#34;: 11, \u0026#34;Text\u0026#34;: \u0026#34;0x1b 0x1b 0x5b 0x44\u0026#34; }, \u0026#34;0xf72b-0x20000\u0026#34;: { \u0026#34;Action\u0026#34;: 10, \u0026#34;Text\u0026#34;: \u0026#34;[1;2F\u0026#34; }, \u0026#34;0x36-0x40000\u0026#34;: { \u0026#34;Action\u0026#34;: 11, \u0026#34;Text\u0026#34;: \u0026#34;0x1e\u0026#34; }, \u0026#34;0xf708-0x20000\u0026#34;: { \u0026#34;Action\u0026#34;: 10, \u0026#34;Text\u0026#34;: \u0026#34;[15;2~\u0026#34; }, \u0026#34;0xf701-0x220000\u0026#34;: { \u0026#34;Action\u0026#34;: 10, \u0026#34;Text\u0026#34;: \u0026#34;[1;2B\u0026#34; }, \u0026#34;0xf70b-0x20000\u0026#34;: { \u0026#34;Action\u0026#34;: 10, \u0026#34;Text\u0026#34;: \u0026#34;[19;2~\u0026#34; }, \u0026#34;0xf702-0x240000\u0026#34;: { \u0026#34;Action\u0026#34;: 10, \u0026#34;Text\u0026#34;: \u0026#34;[1;5D\u0026#34; }, \u0026#34;0xf704-0x20000\u0026#34;: { \u0026#34;Action\u0026#34;: 10, \u0026#34;Text\u0026#34;: \u0026#34;[1;2P\u0026#34; } }, \u0026#34;Window Type\u0026#34;: 0, \u0026#34;Background Image Location\u0026#34;: \u0026#34;\u0026#34;, \u0026#34;Blur\u0026#34;: false, \u0026#34;Badge Color\u0026#34;: { \u0026#34;Red Component\u0026#34;: 0.21960784494876862, \u0026#34;Color Space\u0026#34;: \u0026#34;sRGB\u0026#34;, \u0026#34;Alpha Component\u0026#34;: 0.5, \u0026#34;Blue Component\u0026#34;: 0.99215686321258545, \u0026#34;Green Component\u0026#34;: 0.54509806632995605 }, \u0026#34;Scrollback Lines\u0026#34;: 50000, \u0026#34;Send Code When Idle\u0026#34;: false, \u0026#34;Close Sessions On End\u0026#34;: true, \u0026#34;Terminal Type\u0026#34;: \u0026#34;xterm-256color\u0026#34;, \u0026#34;Visual Bell\u0026#34;: true, \u0026#34;Flashing Bell\u0026#34;: false, \u0026#34;Status Bar Layout\u0026#34;: { \u0026#34;components\u0026#34;: [ { \u0026#34;class\u0026#34;: \u0026#34;iTermStatusBarCPUUtilizationComponent\u0026#34;, \u0026#34;configuration\u0026#34;: { \u0026#34;knobs\u0026#34;: { \u0026#34;base: priority\u0026#34;: 5, \u0026#34;base: compression resistance\u0026#34;: 1 }, \u0026#34;layout advanced configuration dictionary value\u0026#34;: { \u0026#34;algorithm\u0026#34;: 0, \u0026#34;remove empty components\u0026#34;: false, \u0026#34;auto-rainbow style\u0026#34;: 0 } } }, { \u0026#34;class\u0026#34;: \u0026#34;iTermStatusBarMemoryUtilizationComponent\u0026#34;, \u0026#34;configuration\u0026#34;: { \u0026#34;knobs\u0026#34;: { \u0026#34;base: priority\u0026#34;: 5, \u0026#34;base: compression resistance\u0026#34;: 1 }, \u0026#34;layout advanced configuration dictionary value\u0026#34;: { \u0026#34;algorithm\u0026#34;: 0, \u0026#34;remove empty components\u0026#34;: false, \u0026#34;auto-rainbow style\u0026#34;: 0 } } }, { \u0026#34;class\u0026#34;: \u0026#34;iTermStatusBarNetworkUtilizationComponent\u0026#34;, \u0026#34;configuration\u0026#34;: { \u0026#34;knobs\u0026#34;: { \u0026#34;base: priority\u0026#34;: 5, \u0026#34;base: compression resistance\u0026#34;: 1 }, \u0026#34;layout advanced configuration dictionary value\u0026#34;: { \u0026#34;algorithm\u0026#34;: 0, \u0026#34;remove empty components\u0026#34;: false, \u0026#34;auto-rainbow style\u0026#34;: 0 } } }, { \u0026#34;class\u0026#34;: \u0026#34;iTermStatusBarClockComponent\u0026#34;, \u0026#34;configuration\u0026#34;: { \u0026#34;knobs\u0026#34;: { \u0026#34;base: priority\u0026#34;: 5, \u0026#34;format\u0026#34;: \u0026#34;M/dd h:mm\u0026#34;, \u0026#34;base: compression resistance\u0026#34;: 1 }, \u0026#34;layout advanced configuration dictionary value\u0026#34;: { \u0026#34;algorithm\u0026#34;: 0, \u0026#34;remove empty components\u0026#34;: false, \u0026#34;auto-rainbow style\u0026#34;: 0 } } } ], \u0026#34;advanced configuration\u0026#34;: { \u0026#34;remove empty components\u0026#34;: false, \u0026#34;font\u0026#34;: \u0026#34;.AppleSystemUIFont 12\u0026#34;, \u0026#34;algorithm\u0026#34;: 0, \u0026#34;auto-rainbow style\u0026#34;: 0 } }, \u0026#34;Silence Bell\u0026#34;: false, \u0026#34;Ansi 14 Color\u0026#34;: { \u0026#34;Red Component\u0026#34;: 0.16862745583057404, \u0026#34;Color Space\u0026#34;: \u0026#34;sRGB\u0026#34;, \u0026#34;Alpha Component\u0026#34;: 1, \u0026#34;Blue Component\u0026#34;: 0.5372549295425415, \u0026#34;Green Component\u0026#34;: 0.45490196347236633 }, \u0026#34;Name\u0026#34;: \u0026#34;cha2hyun\u0026#34;, \u0026#34;Cursor Text Color\u0026#34;: { \u0026#34;Red Component\u0026#34;: 0.062745101749897003, \u0026#34;Color Space\u0026#34;: \u0026#34;sRGB\u0026#34;, \u0026#34;Alpha Component\u0026#34;: 1, \u0026#34;Blue Component\u0026#34;: 0.086274512112140656, \u0026#34;Green Component\u0026#34;: 0.070588238537311554 }, \u0026#34;Minimum Contrast\u0026#34;: 0, \u0026#34;Shortcut\u0026#34;: \u0026#34;\u0026#34;, \u0026#34;Cursor Color\u0026#34;: { \u0026#34;Red Component\u0026#34;: 0.78823530673980713, \u0026#34;Color Space\u0026#34;: \u0026#34;sRGB\u0026#34;, \u0026#34;Alpha Component\u0026#34;: 1, \u0026#34;Blue Component\u0026#34;: 0.85098040103912354, \u0026#34;Green Component\u0026#34;: 0.81960785388946533 }, \u0026#34;Ansi 0 Color\u0026#34;: { \u0026#34;Red Component\u0026#34;: 0, \u0026#34;Color Space\u0026#34;: \u0026#34;sRGB\u0026#34;, \u0026#34;Alpha Component\u0026#34;: 1, \u0026#34;Blue Component\u0026#34;: 0, \u0026#34;Green Component\u0026#34;: 0 }, \u0026#34;Guid\u0026#34;: \u0026#34;B262AC40-142C-4D80-94A5-F5EBCE07AD20\u0026#34;, \u0026#34;Horizontal Spacing\u0026#34;: 1, \u0026#34;Ansi 3 Color\u0026#34;: { \u0026#34;Red Component\u0026#34;: 0.89019608497619629, \u0026#34;Color Space\u0026#34;: \u0026#34;sRGB\u0026#34;, \u0026#34;Alpha Component\u0026#34;: 1, \u0026#34;Blue Component\u0026#34;: 0.25490197539329529, \u0026#34;Green Component\u0026#34;: 0.70196080207824707 }, \u0026#34;Ansi 4 Color\u0026#34;: { \u0026#34;Red Component\u0026#34;: 0.42352941632270813, \u0026#34;Color Space\u0026#34;: \u0026#34;sRGB\u0026#34;, \u0026#34;Alpha Component\u0026#34;: 1, \u0026#34;Blue Component\u0026#34;: 0.97254902124404907, \u0026#34;Green Component\u0026#34;: 0.64313727617263794 }, \u0026#34;Ansi 5 Color\u0026#34;: { \u0026#34;Red Component\u0026#34;: 0.85882353782653809, \u0026#34;Color Space\u0026#34;: \u0026#34;sRGB\u0026#34;, \u0026#34;Alpha Component\u0026#34;: 1, \u0026#34;Blue Component\u0026#34;: 0.63529413938522339, \u0026#34;Green Component\u0026#34;: 0.3803921639919281 }, \u0026#34;Transparency\u0026#34;: 0, \u0026#34;Ansi 7 Color\u0026#34;: { \u0026#34;Red Component\u0026#34;: 1, \u0026#34;Color Space\u0026#34;: \u0026#34;sRGB\u0026#34;, \u0026#34;Alpha Component\u0026#34;: 1, \u0026#34;Blue Component\u0026#34;: 1, \u0026#34;Green Component\u0026#34;: 1 }, \u0026#34;Ansi 8 Color\u0026#34;: { \u0026#34;Red Component\u0026#34;: 0.30000001192092896, \u0026#34;Color Space\u0026#34;: \u0026#34;sRGB\u0026#34;, \u0026#34;Alpha Component\u0026#34;: 1, \u0026#34;Blue Component\u0026#34;: 0.30000001192092896, \u0026#34;Green Component\u0026#34;: 0.30000001192092896 }, \u0026#34;Ansi 9 Color\u0026#34;: { \u0026#34;Red Component\u0026#34;: 0.9686274528503418, \u0026#34;Color Space\u0026#34;: \u0026#34;sRGB\u0026#34;, \u0026#34;Alpha Component\u0026#34;: 1, \u0026#34;Blue Component\u0026#34;: 0.40000000596046448, \u0026#34;Green Component\u0026#34;: 0.5058823823928833 }, \u0026#34;Ansi 1 Color\u0026#34;: { \u0026#34;Red Component\u0026#34;: 0.9686274528503418, \u0026#34;Color Space\u0026#34;: \u0026#34;sRGB\u0026#34;, \u0026#34;Alpha Component\u0026#34;: 1, \u0026#34;Blue Component\u0026#34;: 0.40000000596046448, \u0026#34;Green Component\u0026#34;: 0.5058823823928833 }, \u0026#34;Ansi 2 Color\u0026#34;: { \u0026#34;Red Component\u0026#34;: 0.33725491166114807, \u0026#34;Color Space\u0026#34;: \u0026#34;sRGB\u0026#34;, \u0026#34;Alpha Component\u0026#34;: 1, \u0026#34;Blue Component\u0026#34;: 0.39215686917304993, \u0026#34;Green Component\u0026#34;: 0.82745099067687988 } } Better Touch Tools Profile .bttpreset 확장자로 저장해야합니다.\nProfile cha2hyun.bttpreset 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 { \u0026#34;BTTPresetCreatorNotes\u0026#34;: \u0026#34;\u0026#34;, \u0026#34;BTTPresetInfoURL\u0026#34;: \u0026#34;\u0026#34;, \u0026#34;BTTPresetName\u0026#34;: \u0026#34;cha2hyun\u0026#34;, \u0026#34;BTTPresetColor\u0026#34;: \u0026#34;142.908800, 188.700000, 16.983000, 255.000000\u0026#34;, \u0026#34;BTTGeneralSettings\u0026#34;: { \u0026#34;BTTPathSampleSize\u0026#34;: 100, \u0026#34;BTTCMOnTop\u0026#34;: true, \u0026#34;BTTForceForceClickPressure2F\u0026#34;: 700, \u0026#34;BSTLeftHalfBlock\u0026#34;: true, \u0026#34;BTTMinDrawingMovement\u0026#34;: 2, \u0026#34;BTTTouchBarMouseModeClickBlock\u0026#34;: true, \u0026#34;BSTRightHalfBlock\u0026#34;: true, \u0026#34;disableScrollingIf2\u0026#34;: true, \u0026#34;batteryWarning\u0026#34;: false, \u0026#34;BSTDontShowSnapAreasWhileModMoving\u0026#34;: 0, \u0026#34;BSTWindowGrabPosY\u0026#34;: 10, \u0026#34;showTrackpadTab\u0026#34;: true, \u0026#34;singleFingerTapRight\u0026#34;: 0.05000000074505806, \u0026#34;BTTDrawingStrokeWidth\u0026#34;: 4, \u0026#34;cornerSnap\u0026#34;: true, \u0026#34;BTRScrollSpeed\u0026#34;: 1, \u0026#34;BTTTouchBarHapticFeedbackRelease\u0026#34;: \u0026#34;1\u0026#34;, \u0026#34;BSTIncreaseSnappingArea\u0026#34;: true, \u0026#34;showDrawingsTab\u0026#34;: true, \u0026#34;SIRIMouseSpeed\u0026#34;: 1, \u0026#34;BTTUseNewUI\u0026#34;: true, \u0026#34;BSTEnableEnhancementCheck\u0026#34;: true, \u0026#34;BTTNotchBarNotchWidgetModeHideLeftFixed\u0026#34;: false, \u0026#34;useAppleRemotePlugin\u0026#34;: false, \u0026#34;BTTTouchBarFontSize\u0026#34;: 15, \u0026#34;BSTSnapAreaDefaultPictoDistanceFromBottom\u0026#34;: 0.10000000149011612, \u0026#34;BTRMouseSpeed\u0026#34;: 1, \u0026#34;BSTPreventTopMissionControl\u0026#34;: true, \u0026#34;BSTSnapAreaDefaultDashedBorder\u0026#34;: false, \u0026#34;BTTEnablePalmRecognition\u0026#34;: true, \u0026#34;BSTBrokenAPICheckEnabled\u0026#34;: false, \u0026#34;BTTStageManagerLeftGap\u0026#34;: 190, \u0026#34;BTTAllowThumbIfAllFingersArePlacedSimultaneously\u0026#34;: true, \u0026#34;BTTNotchBarStandardMenubarModeHideRightFixed\u0026#34;: false, \u0026#34;BTTNotchBarNotchWidgetModeShowMiniMenubar\u0026#34;: true, \u0026#34;BTTStageManagerSnappingIncreaseAbsolute\u0026#34;: false, \u0026#34;BTTShowControlStripItem\u0026#34;: true, \u0026#34;BSTSnapAreaDefaultCornerRadius\u0026#34;: 20, \u0026#34;BTTNotchBarNotchMenubarModeHideLeftFixed\u0026#34;: true, \u0026#34;BTTMovePastedToTop\u0026#34;: true, \u0026#34;BTTMaxAllowedDrawingRotation\u0026#34;: 30, \u0026#34;BTTDidRegisterForUpdateStats\u0026#34;: \u0026#34;4.361\u0026#34;, \u0026#34;BTTNotchBarNotchWidgetModeHideRightFixed\u0026#34;: false, \u0026#34;BTTStageManagerLeaveGap\u0026#34;: true, \u0026#34;BTTStageManagerLeftSnappingAreaIncreasePercentage\u0026#34;: 0.125, \u0026#34;BTTTpFourFingerSwipeSensitivity\u0026#34;: 0.40000000000000002, \u0026#34;BSTMoveTreshold\u0026#34;: 2, \u0026#34;BTTHideFomControlStripWhenOpen\u0026#34;: false, \u0026#34;BTTNumberOfStarts\u0026#34;: 1531, \u0026#34;BTTForceNormalClickPressure3F\u0026#34;: 150, \u0026#34;showSiriRemoteTab\u0026#34;: true, \u0026#34;SIRIScrollSpeed\u0026#34;: 1, \u0026#34;BTTShowControlStrip\u0026#34;: true, \u0026#34;tpThreeFingerDoubleTapDelay\u0026#34;: 0.5, \u0026#34;showNormalMiceTab\u0026#34;: true, \u0026#34;BTTNotchBarStandardMenubarModeHideLeftScrollable\u0026#34;: true, \u0026#34;BSTSnapAreaDefaultBorderWidth\u0026#34;: \u0026#34;3\u0026#34;, \u0026#34;BTTTouchBarHapticFeedback\u0026#34;: \u0026#34;22\u0026#34;, \u0026#34;BTTForceNormalClickPressure\u0026#34;: 150, \u0026#34;BTTDrawingRightMouse\u0026#34;: true, \u0026#34;BTTConvertedDevices\u0026#34;: true, \u0026#34;snapBottomRight\u0026#34;: true, \u0026#34;BTTThreeFingerTipTapMinSpread\u0026#34;: 0.029999999999999999, \u0026#34;BTTTouchBarVisible\u0026#34;: false, \u0026#34;BTTIMGURDefault\u0026#34;: true, \u0026#34;BTTNotchBarShowOnStandardScreens\u0026#34;: true, \u0026#34;BSTSnapAreaDefaultBackgroundColor\u0026#34;: \u0026#34;129.991390, 201.122858, 227.012328, 104.550000\u0026#34;, \u0026#34;BSTSnapAreaDefaultPictoSize\u0026#34;: 0.69999998807907104, \u0026#34;BSTSnapAreaDefaultBorderColor\u0026#34;: \u0026#34;0.000000, 0.000000, 0.000000, 255.000000\u0026#34;, \u0026#34;BTTNotchBarNotchMenubarModeHideLeftScrollable\u0026#34;: true, \u0026#34;BTTNotchBarNotchMenubarModeShowOriginalStatusIcons\u0026#34;: true, \u0026#34;BSTTopMissionControlTreshold\u0026#34;: 34, \u0026#34;BTTNotchBarNotchWidgetModeHideRightScrollable\u0026#34;: false, \u0026#34;BSTSnapAreaDefaultHighlightColor\u0026#34;: \u0026#34;92.625563, 122.647995, 161.664347, 56.100000\u0026#34;, \u0026#34;BTTStageManagerLeftGapPercentage\u0026#34;: 0.10000000000000001, \u0026#34;BTTForceForceClickPressure5F\u0026#34;: 600, \u0026#34;BTTForceForceClickPressure\u0026#34;: 700, \u0026#34;BSTMemorySaver\u0026#34;: true, \u0026#34;BTTLastClamshellState\u0026#34;: false, \u0026#34;BTTStageManagerLeaveGapEvenIfHiding\u0026#34;: false, \u0026#34;BTTTouchBarAnimateGroups\u0026#34;: true, \u0026#34;BTTShowBTTWhenControlStripHidden\u0026#34;: true, \u0026#34;BTTFreeSpaceAfterESC\u0026#34;: 10, \u0026#34;mmZoomRepeatDelay\u0026#34;: 0.10000000000000001, \u0026#34;snapTopLeft\u0026#34;: true, \u0026#34;BTTDrawingAreaWidth\u0026#34;: 915, \u0026#34;BTTTiltWheelDelay\u0026#34;: 0.44999998807907104, \u0026#34;twoFingerDoubleTapDelay\u0026#34;: 0.5, \u0026#34;BTTForceNormalClickPressure5F\u0026#34;: 200, \u0026#34;BTTShowESCWhenControlStripHidden\u0026#34;: true, \u0026#34;showBTTRemoteTab\u0026#34;: true, \u0026#34;BTTFilterMagicMouseLeftRightEdge\u0026#34;: true, \u0026#34;BTTNotchBarStandardWidgetModeShowOriginalStatusIcons\u0026#34;: false, \u0026#34;BTTDrawingsHighlightStartPoint\u0026#34;: true, \u0026#34;BTTEnableUsageLogging\u0026#34;: true, \u0026#34;snapBottomLeft\u0026#34;: true, \u0026#34;BTTScreenshotOpenImgurInBrowser\u0026#34;: true, \u0026#34;BTTDontRestartAfterSleep\u0026#34;: true, \u0026#34;BTTForceNormalClickPressure2F\u0026#34;: 140, \u0026#34;BSTSnapAreaDefaultInvisible\u0026#34;: false, \u0026#34;BTTDrawingAreaHeight\u0026#34;: 626, \u0026#34;BTTForceForceClickPressure4F\u0026#34;: 650, \u0026#34;BTTTouchBarUseMonoSpacedFont\u0026#34;: true, \u0026#34;BSTCornerRoundness\u0026#34;: \u0026#34;10\u0026#34;, \u0026#34;BTTStageManagerLeftSnappingAreaIncrease\u0026#34;: 222, \u0026#34;BTTForceForceHapticResponse\u0026#34;: 13, \u0026#34;BTTNotchBarStandardMenubarModeHideLeftFixed\u0026#34;: true, \u0026#34;showMagicMouseTab\u0026#34;: true, \u0026#34;BTTNotchBarNotchWidgetModeShowOriginalStatusIcons\u0026#34;: false, \u0026#34;BTTAlwaysShowPresetIndicators\u0026#34;: true, \u0026#34;BTTStageManagerIncreaseEvenIfHiding\u0026#34;: true, \u0026#34;BTTForcedHidden\u0026#34;: true, \u0026#34;showTouchBarTab\u0026#34;: true, \u0026#34;BTTFreeSpaceAfterBTT\u0026#34;: 20, \u0026#34;BTTNotchBarStandardWidgetModeHideRightFixed\u0026#34;: false, \u0026#34;BTTTwoFingerTipTapMinSpread\u0026#34;: 0.029999999999999999, \u0026#34;BTTDismissIfNothingToShow\u0026#34;: true, \u0026#34;BTTDrawingsRestoreMousePosition\u0026#34;: true, \u0026#34;BSTDisableSnapAreas\u0026#34;: false, \u0026#34;BTTDefaultTBIconWidth\u0026#34;: 22, \u0026#34;BTTStageManagerLeftGapAbsolute\u0026#34;: false, \u0026#34;snapTopRight\u0026#34;: true, \u0026#34;BSTSnapAreaDefaultShowPictogram\u0026#34;: false, \u0026#34;BTTSelectedKeyboardTabIndex\u0026#34;: 0, \u0026#34;BTTTouchBarKeepIconRatio\u0026#34;: true, \u0026#34;BSTBrokenAPICheckDelay\u0026#34;: 0.20000000000000001, \u0026#34;BTTStageManagerIncrease\u0026#34;: true, \u0026#34;showKeyboardTab\u0026#34;: true, \u0026#34;BTTForceForceClickPressure3F\u0026#34;: 700, \u0026#34;disableScrollingIf3\u0026#34;: true, \u0026#34;BSTWindowGrabPosX\u0026#34;: 67, \u0026#34;BTTHandleThumbsRestingOnTopEdgeAndCorners\u0026#34;: true, \u0026#34;BTTAutoSwitchToOldKeyboardImplementation\u0026#34;: true, \u0026#34;BTTTpThreeFingerSwipeSensitivity\u0026#34;: 0.29999999999999999, \u0026#34;BTTCopyImgurURLToClipboard\u0026#34;: true, \u0026#34;BTTForceNormalHapticResponse\u0026#34;: 12, \u0026#34;BTTTouchBarSupportEnabled\u0026#34;: true, \u0026#34;BTTDaysToKeepHistory\u0026#34;: 14, \u0026#34;BTTNotchBarNotchMenubarModeHideRightFixed\u0026#34;: true, \u0026#34;BTTDefaultTBIconHeight\u0026#34;: 22, \u0026#34;BTTNotchBarStandardMenubarModeShowOriginalStatusIcons\u0026#34;: true, \u0026#34;BTTNotchBarStandardWidgetModeHideLeftFixed\u0026#34;: false, \u0026#34;singleFingerTapLeft\u0026#34;: 0.44999998807907104, \u0026#34;BTTNotchBarStandardWidgetModeHideLeftScrollable\u0026#34;: false, \u0026#34;BTTForceNormalClickPressure4F\u0026#34;: 175, \u0026#34;BTTNotchBarNotchWidgetModeHideLeftScrollable\u0026#34;: false, \u0026#34;BSTSnapAreaDefaultAnimationDuration\u0026#34;: 0.30000001192092896, \u0026#34;BTTNotchBarStandardWidgetModeHideRightScrollable\u0026#34;: false, \u0026#34;BTTNotchBarStandardMenubarModeHideRightScrollable\u0026#34;: false, \u0026#34;showOtherTriggersTab\u0026#34;: true, \u0026#34;BTTNotchBarNotchMenubarModeHideRightScrollable\u0026#34;: true, \u0026#34;BSTSnapAreaDefaultPictoDistanceFromLeft\u0026#34;: 0.10000000149011612 }, \u0026#34;BTTPresetUUID\u0026#34;: \u0026#34;5D3595D0-6F21-46A9-BD5B-F0D2DE22FC17\u0026#34;, \u0026#34;BTTPresetContent\u0026#34;: [ { \u0026#34;BTTAppBundleIdentifier\u0026#34;: \u0026#34;com.apple.finder\u0026#34;, \u0026#34;BTTAppName\u0026#34;: \u0026#34;Finder\u0026#34;, \u0026#34;BTTAppAutoInvertIcon\u0026#34;: 1, \u0026#34;BTTAppProcessMatchMode\u0026#34;: 2, \u0026#34;BTTAppProcessName\u0026#34;: \u0026#34;Finder\u0026#34;, \u0026#34;BTTAppSpecificSettings\u0026#34;: { \u0026#34;BTTDisableGlobalTriggers\u0026#34;: false, \u0026#34;BTTTouchBarMode\u0026#34;: 2 }, \u0026#34;BTTTriggers\u0026#34;: [] }, { \u0026#34;BTTAppBundleIdentifier\u0026#34;: \u0026#34;com.google.Chrome\u0026#34;, \u0026#34;BTTAppName\u0026#34;: \u0026#34;Google Chrome\u0026#34;, \u0026#34;BTTAppAutoInvertIcon\u0026#34;: 1, \u0026#34;BTTAppProcessMatchMode\u0026#34;: 2, \u0026#34;BTTAppProcessName\u0026#34;: \u0026#34;Google Chrome\u0026#34;, \u0026#34;BTTTriggers\u0026#34;: [] }, { \u0026#34;BTTAppBundleIdentifier\u0026#34;: \u0026#34;com.naver.Whale\u0026#34;, \u0026#34;BTTAppName\u0026#34;: \u0026#34;네이버 웨일\u0026#34;, \u0026#34;BTTAppAutoInvertIcon\u0026#34;: 1, \u0026#34;BTTAppProcessMatchMode\u0026#34;: 2, \u0026#34;BTTAppProcessName\u0026#34;: \u0026#34;네이버 웨일\u0026#34;, \u0026#34;BTTTriggers\u0026#34;: [ { \u0026#34;BTTLastUpdatedAt\u0026#34;: 1701048757.9956999, \u0026#34;BTTTriggerType\u0026#34;: 165, \u0026#34;BTTTriggerTypeDescription\u0026#34;: \u0026#34;2 Finger Swipe From Left Edge (start outside of the trackpad on the aluminum)\u0026#34;, \u0026#34;BTTTriggerClass\u0026#34;: \u0026#34;BTTTriggerTypeTouchpadAll\u0026#34;, \u0026#34;BTTLayoutIndependentActionChar\u0026#34;: \u0026#34;LEFT\u0026#34;, \u0026#34;BTTShortcutToSend\u0026#34;: \u0026#34;58,55,123\u0026#34;, \u0026#34;BTTUUID\u0026#34;: \u0026#34;41E5FE88-0AF7-41E3-96E3-AFFC287E5552\u0026#34;, \u0026#34;BTTEnabled\u0026#34;: 1, \u0026#34;BTTEnabled2\u0026#34;: 1, \u0026#34;BTTOrder\u0026#34;: 4 }, { \u0026#34;BTTLastUpdatedAt\u0026#34;: 1701048758.0122209, \u0026#34;BTTTriggerType\u0026#34;: 110, \u0026#34;BTTTriggerTypeDescription\u0026#34;: \u0026#34;4 Finger Tap\u0026#34;, \u0026#34;BTTTriggerClass\u0026#34;: \u0026#34;BTTTriggerTypeTouchpadAll\u0026#34;, \u0026#34;BTTLayoutIndependentActionChar\u0026#34;: \u0026#34;i\u0026#34;, \u0026#34;BTTShortcutToSend\u0026#34;: \u0026#34;58,55,34\u0026#34;, \u0026#34;BTTUUID\u0026#34;: \u0026#34;3E09207D-8152-43D5-B492-218F04AB5EAF\u0026#34;, \u0026#34;BTTEnabled\u0026#34;: 1, \u0026#34;BTTEnabled2\u0026#34;: 1, \u0026#34;BTTOrder\u0026#34;: 2 }, { \u0026#34;BTTLastUpdatedAt\u0026#34;: 1701048757.972424, \u0026#34;BTTTriggerType\u0026#34;: 166, \u0026#34;BTTTriggerTypeDescription\u0026#34;: \u0026#34;2 Finger Swipe From Right Edge (start outside of the trackpad on the aluminum)\u0026#34;, \u0026#34;BTTTriggerClass\u0026#34;: \u0026#34;BTTTriggerTypeTouchpadAll\u0026#34;, \u0026#34;BTTLayoutIndependentActionChar\u0026#34;: \u0026#34;RIGHT\u0026#34;, \u0026#34;BTTShortcutToSend\u0026#34;: \u0026#34;58,55,124\u0026#34;, \u0026#34;BTTUUID\u0026#34;: \u0026#34;3185A907-580C-47D3-80D3-9D5A6E1A151C\u0026#34;, \u0026#34;BTTEnabled\u0026#34;: 1, \u0026#34;BTTEnabled2\u0026#34;: 1, \u0026#34;BTTOrder\u0026#34;: 5 }, { \u0026#34;BTTLastUpdatedAt\u0026#34;: 1701048757.978236, \u0026#34;BTTTriggerType\u0026#34;: 179, \u0026#34;BTTTriggerTypeDescription\u0026#34;: \u0026#34;2 Finger Double-Tap\u0026#34;, \u0026#34;BTTTriggerClass\u0026#34;: \u0026#34;BTTTriggerTypeTouchpadAll\u0026#34;, \u0026#34;BTTLayoutIndependentActionChar\u0026#34;: \u0026#34;w\u0026#34;, \u0026#34;BTTShortcutToSend\u0026#34;: \u0026#34;55,13\u0026#34;, \u0026#34;BTTUUID\u0026#34;: \u0026#34;FC82F017-45A7-42E4-A218-CCC9683E96F0\u0026#34;, \u0026#34;BTTEnabled\u0026#34;: 1, \u0026#34;BTTEnabled2\u0026#34;: 1, \u0026#34;BTTOrder\u0026#34;: 6 }, { \u0026#34;BTTLastUpdatedAt\u0026#34;: 1701048757.9419, \u0026#34;BTTTriggerType\u0026#34;: 114, \u0026#34;BTTTriggerTypeDescription\u0026#34;: \u0026#34;TipTap Right (1 Finger Fix)\u0026#34;, \u0026#34;BTTTriggerClass\u0026#34;: \u0026#34;BTTTriggerTypeTouchpadAll\u0026#34;, \u0026#34;BTTLayoutIndependentActionChar\u0026#34;: \u0026#34;]\u0026#34;, \u0026#34;BTTShortcutToSend\u0026#34;: \u0026#34;55,30\u0026#34;, \u0026#34;BTTUUID\u0026#34;: \u0026#34;FF71EA4F-ECD7-4060-934E-DD10A44AB305\u0026#34;, \u0026#34;BTTEnabled\u0026#34;: 1, \u0026#34;BTTEnabled2\u0026#34;: 1, \u0026#34;BTTOrder\u0026#34;: 1 }, { \u0026#34;BTTLastUpdatedAt\u0026#34;: 1701048757.989536, \u0026#34;BTTTriggerType\u0026#34;: 113, \u0026#34;BTTTriggerTypeDescription\u0026#34;: \u0026#34;TipTap Left (1 Finger Fix)\u0026#34;, \u0026#34;BTTTriggerClass\u0026#34;: \u0026#34;BTTTriggerTypeTouchpadAll\u0026#34;, \u0026#34;BTTLayoutIndependentActionChar\u0026#34;: \u0026#34;[\u0026#34;, \u0026#34;BTTShortcutToSend\u0026#34;: \u0026#34;55,33\u0026#34;, \u0026#34;BTTUUID\u0026#34;: \u0026#34;54FEE28A-6235-4ADB-AECF-9DC7B72B5335\u0026#34;, \u0026#34;BTTEnabled\u0026#34;: 1, \u0026#34;BTTEnabled2\u0026#34;: 1, \u0026#34;BTTOrder\u0026#34;: 0 }, { \u0026#34;BTTLastUpdatedAt\u0026#34;: 1701048758.041115, \u0026#34;BTTTriggerType\u0026#34;: 167, \u0026#34;BTTTriggerTypeDescription\u0026#34;: \u0026#34;2 Finger Swipe From Top Edge\u0026#34;, \u0026#34;BTTTriggerClass\u0026#34;: \u0026#34;BTTTriggerTypeTouchpadAll\u0026#34;, \u0026#34;BTTLayoutIndependentActionChar\u0026#34;: \u0026#34;l\u0026#34;, \u0026#34;BTTShortcutToSend\u0026#34;: \u0026#34;55,37\u0026#34;, \u0026#34;BTTUUID\u0026#34;: \u0026#34;C055C8EE-6994-4CEA-9CDB-9B985EE410BA\u0026#34;, \u0026#34;BTTEnabled\u0026#34;: 1, \u0026#34;BTTEnabled2\u0026#34;: 1, \u0026#34;BTTOrder\u0026#34;: 3 } ] }, { \u0026#34;BTTAppBundleIdentifier\u0026#34;: \u0026#34;BT.G\u0026#34;, \u0026#34;BTTAppName\u0026#34;: \u0026#34;Global\u0026#34;, \u0026#34;BTTAppAutoInvertIcon\u0026#34;: 1, \u0026#34;BTTAppSpecificSettings\u0026#34;: { \u0026#34;BTTDisableGlobalTriggers\u0026#34;: false }, \u0026#34;BTTTriggers\u0026#34;: [ { \u0026#34;BTTLastUpdatedAt\u0026#34;: 1701048757.900574, \u0026#34;BTTTriggerType\u0026#34;: -1, \u0026#34;BTTTriggerClass\u0026#34;: \u0026#34;BTTTriggerTypeKeyboardShortcut\u0026#34;, \u0026#34;BTTKeyboardShortcutKeyboardType\u0026#34;: 0, \u0026#34;BTTUUID\u0026#34;: \u0026#34;72F0D21C-EEBB-4BC6-B09F-A6CC4BB5513C\u0026#34;, \u0026#34;BTTTriggerOnDown\u0026#34;: 1, \u0026#34;BTTEnabled\u0026#34;: 1, \u0026#34;BTTEnabled2\u0026#34;: 1, \u0026#34;BTTShortcutKeyCode\u0026#34;: -1, \u0026#34;BTTShortcutModifierKeys\u0026#34;: -1, \u0026#34;BTTOrder\u0026#34;: 3, \u0026#34;BTTAutoAdaptToKeyboardLayout\u0026#34;: 0 }, { \u0026#34;BTTLastUpdatedAt\u0026#34;: 1701048758.0499239, \u0026#34;BTTTriggerType\u0026#34;: 0, \u0026#34;BTTTriggerClass\u0026#34;: \u0026#34;BTTTriggerTypeKeyboardShortcut\u0026#34;, \u0026#34;BTTPredefinedActionType\u0026#34;: 19, \u0026#34;BTTPredefinedActionName\u0026#34;: \u0026#34;Maximize Window Left Half\u0026#34;, \u0026#34;BTTAdditionalConfiguration\u0026#34;: \u0026#34;1572904\u0026#34;, \u0026#34;BTTKeyboardShortcutKeyboardType\u0026#34;: 48000, \u0026#34;BTTUUID\u0026#34;: \u0026#34;6E0BB3B2-6111-4A6B-991B-95ECAA27EFF3\u0026#34;, \u0026#34;BTTTriggerOnDown\u0026#34;: 1, \u0026#34;BTTLayoutIndependentChar\u0026#34;: \u0026#34;꺋\u0026#34;, \u0026#34;BTTEnabled\u0026#34;: 1, \u0026#34;BTTEnabled2\u0026#34;: 1, \u0026#34;BTTShortcutKeyCode\u0026#34;: 86, \u0026#34;BTTShortcutModifierKeys\u0026#34;: 1572864, \u0026#34;BTTOrder\u0026#34;: 5, \u0026#34;BTTAutoAdaptToKeyboardLayout\u0026#34;: 0 }, { \u0026#34;BTTLastUpdatedAt\u0026#34;: 1701048757.90589, \u0026#34;BTTTriggerType\u0026#34;: 0, \u0026#34;BTTTriggerClass\u0026#34;: \u0026#34;BTTTriggerTypeKeyboardShortcut\u0026#34;, \u0026#34;BTTPredefinedActionType\u0026#34;: 84, \u0026#34;BTTPredefinedActionName\u0026#34;: \u0026#34;Restore Old Window Size\u0026#34;, \u0026#34;BTTAdditionalConfiguration\u0026#34;: \u0026#34;1572904\u0026#34;, \u0026#34;BTTKeyboardShortcutKeyboardType\u0026#34;: 48000, \u0026#34;BTTUUID\u0026#34;: \u0026#34;D24AC2A4-DDBD-4784-BE6A-A1B5D91A8B53\u0026#34;, \u0026#34;BTTTriggerOnDown\u0026#34;: 1, \u0026#34;BTTLayoutIndependentChar\u0026#34;: \u0026#34;겋\u0026#34;, \u0026#34;BTTEnabled\u0026#34;: 1, \u0026#34;BTTEnabled2\u0026#34;: 1, \u0026#34;BTTShortcutKeyCode\u0026#34;: 84, \u0026#34;BTTShortcutModifierKeys\u0026#34;: 1572864, \u0026#34;BTTOrder\u0026#34;: 24, \u0026#34;BTTAutoAdaptToKeyboardLayout\u0026#34;: 0 }, { \u0026#34;BTTLastUpdatedAt\u0026#34;: 1701048758.048466, \u0026#34;BTTTriggerType\u0026#34;: 0, \u0026#34;BTTTriggerClass\u0026#34;: \u0026#34;BTTTriggerTypeKeyboardShortcut\u0026#34;, \u0026#34;BTTPredefinedActionType\u0026#34;: 20, \u0026#34;BTTPredefinedActionName\u0026#34;: \u0026#34;Maximize Window Right Half\u0026#34;, \u0026#34;BTTAdditionalConfiguration\u0026#34;: \u0026#34;1572904\u0026#34;, \u0026#34;BTTKeyboardShortcutKeyboardType\u0026#34;: 48000, \u0026#34;BTTUUID\u0026#34;: \u0026#34;C6333D1D-BCBF-41F5-8ABF-EFC743E95488\u0026#34;, \u0026#34;BTTTriggerOnDown\u0026#34;: 1, \u0026#34;BTTLayoutIndependentChar\u0026#34;: \u0026#34;ꂋ\u0026#34;, \u0026#34;BTTEnabled\u0026#34;: 1, \u0026#34;BTTEnabled2\u0026#34;: 1, \u0026#34;BTTShortcutKeyCode\u0026#34;: 88, \u0026#34;BTTShortcutModifierKeys\u0026#34;: 1572864, \u0026#34;BTTOrder\u0026#34;: 8, \u0026#34;BTTAutoAdaptToKeyboardLayout\u0026#34;: 0 }, { \u0026#34;BTTLastUpdatedAt\u0026#34;: 1701048758.0060649, \u0026#34;BTTTriggerType\u0026#34;: 108, \u0026#34;BTTTriggerTypeDescription\u0026#34;: \u0026#34;4 Finger Swipe Up\u0026#34;, \u0026#34;BTTTriggerClass\u0026#34;: \u0026#34;BTTTriggerTypeTouchpadAll\u0026#34;, \u0026#34;BTTPredefinedActionType\u0026#34;: 45, \u0026#34;BTTPredefinedActionName\u0026#34;: \u0026#34;Show Desktop\u0026#34;, \u0026#34;BTTUUID\u0026#34;: \u0026#34;9B9ECB57-F7AD-4263-B67B-118434804C5D\u0026#34;, \u0026#34;BTTEnabled\u0026#34;: 1, \u0026#34;BTTEnabled2\u0026#34;: 1, \u0026#34;BTTOrder\u0026#34;: 1 }, { \u0026#34;BTTLastUpdatedAt\u0026#34;: 1701048757.9825811, \u0026#34;BTTTriggerType\u0026#34;: 0, \u0026#34;BTTTriggerClass\u0026#34;: \u0026#34;BTTTriggerTypeKeyboardShortcut\u0026#34;, \u0026#34;BTTPredefinedActionType\u0026#34;: 269, \u0026#34;BTTPredefinedActionName\u0026#34;: \u0026#34;Save current window layout\u0026#34;, \u0026#34;BTTAdditionalConfiguration\u0026#34;: \u0026#34;1310729\u0026#34;, \u0026#34;BTTKeyboardShortcutKeyboardType\u0026#34;: 48000, \u0026#34;BTTUUID\u0026#34;: \u0026#34;4119AF6B-8E56-46AF-A2E0-ADE9994E9368\u0026#34;, \u0026#34;BTTTriggerOnDown\u0026#34;: 1, \u0026#34;BTTLayoutIndependentChar\u0026#34;: \u0026#34;ཱི\u0026#34;, \u0026#34;BTTEnabled\u0026#34;: 1, \u0026#34;BTTEnabled2\u0026#34;: 1, \u0026#34;BTTShortcutKeyCode\u0026#34;: 1, \u0026#34;BTTShortcutModifierKeys\u0026#34;: 1310720, \u0026#34;BTTOrder\u0026#34;: 15, \u0026#34;BTTAutoAdaptToKeyboardLayout\u0026#34;: 0 }, { \u0026#34;BTTLastUpdatedAt\u0026#34;: 1701048758.001519, \u0026#34;BTTTriggerType\u0026#34;: 0, \u0026#34;BTTTriggerClass\u0026#34;: \u0026#34;BTTTriggerTypeKeyboardShortcut\u0026#34;, \u0026#34;BTTPredefinedActionType\u0026#34;: 48, \u0026#34;BTTPredefinedActionName\u0026#34;: \u0026#34;Maximize Window to Next Monitor\u0026#34;, \u0026#34;BTTAdditionalConfiguration\u0026#34;: \u0026#34;1572904\u0026#34;, \u0026#34;BTTKeyboardShortcutKeyboardType\u0026#34;: 48000, \u0026#34;BTTUUID\u0026#34;: \u0026#34;FF6EEC5F-C5AB-4A45-9082-1D793CDCB655\u0026#34;, \u0026#34;BTTTriggerOnDown\u0026#34;: 1, \u0026#34;BTTLayoutIndependentChar\u0026#34;: \u0026#34;꾋\u0026#34;, \u0026#34;BTTEnabled\u0026#34;: 1, \u0026#34;BTTEnabled2\u0026#34;: 1, \u0026#34;BTTShortcutKeyCode\u0026#34;: 87, \u0026#34;BTTShortcutModifierKeys\u0026#34;: 1572864, \u0026#34;BTTOrder\u0026#34;: 12, \u0026#34;BTTAutoAdaptToKeyboardLayout\u0026#34;: 0 }, { \u0026#34;BTTLastUpdatedAt\u0026#34;: 1701048757.9958291, \u0026#34;BTTTriggerType\u0026#34;: 0, \u0026#34;BTTTriggerClass\u0026#34;: \u0026#34;BTTTriggerTypeKeyboardShortcut\u0026#34;, \u0026#34;BTTPredefinedActionType\u0026#34;: 270, \u0026#34;BTTPredefinedActionName\u0026#34;: \u0026#34;Restore last saved window layout\u0026#34;, \u0026#34;BTTAdditionalConfiguration\u0026#34;: \u0026#34;1310729\u0026#34;, \u0026#34;BTTKeyboardShortcutKeyboardType\u0026#34;: 48000, \u0026#34;BTTUUID\u0026#34;: \u0026#34;929B2E4D-DB15-45EF-8DB0-4D31352D29AC\u0026#34;, \u0026#34;BTTTriggerOnDown\u0026#34;: 1, \u0026#34;BTTLayoutIndependentChar\u0026#34;: \u0026#34;ཱི\u0026#34;, \u0026#34;BTTEnabled\u0026#34;: 1, \u0026#34;BTTEnabled2\u0026#34;: 1, \u0026#34;BTTShortcutKeyCode\u0026#34;: 1, \u0026#34;BTTShortcutModifierKeys\u0026#34;: 1310720, \u0026#34;BTTOrder\u0026#34;: 17, \u0026#34;BTTAutoAdaptToKeyboardLayout\u0026#34;: 0 }, { \u0026#34;BTTLastUpdatedAt\u0026#34;: 1701048758.006705, \u0026#34;BTTTriggerType\u0026#34;: 0, \u0026#34;BTTTriggerClass\u0026#34;: \u0026#34;BTTTriggerTypeKeyboardShortcut\u0026#34;, \u0026#34;BTTPredefinedActionType\u0026#34;: 21, \u0026#34;BTTPredefinedActionName\u0026#34;: \u0026#34;Maximize Window\u0026#34;, \u0026#34;BTTAdditionalConfiguration\u0026#34;: \u0026#34;1572904\u0026#34;, \u0026#34;BTTKeyboardShortcutKeyboardType\u0026#34;: 48000, \u0026#34;BTTUUID\u0026#34;: \u0026#34;A84FE2CF-3382-406B-8509-43EEE188EB8C\u0026#34;, \u0026#34;BTTTriggerOnDown\u0026#34;: 1, \u0026#34;BTTLayoutIndependentChar\u0026#34;: \u0026#34;ꎋ\u0026#34;, \u0026#34;BTTEnabled\u0026#34;: 1, \u0026#34;BTTEnabled2\u0026#34;: 1, \u0026#34;BTTShortcutKeyCode\u0026#34;: 91, \u0026#34;BTTShortcutModifierKeys\u0026#34;: 1572864, \u0026#34;BTTOrder\u0026#34;: 21, \u0026#34;BTTAutoAdaptToKeyboardLayout\u0026#34;: 0 }, { \u0026#34;BTTLastUpdatedAt\u0026#34;: 1701048757.9224138, \u0026#34;BTTTriggerType\u0026#34;: 179, \u0026#34;BTTTriggerTypeDescription\u0026#34;: \u0026#34;2 Finger Double-Tap\u0026#34;, \u0026#34;BTTTriggerClass\u0026#34;: \u0026#34;BTTTriggerTypeTouchpadAll\u0026#34;, \u0026#34;BTTLayoutIndependentActionChar\u0026#34;: \u0026#34;w\u0026#34;, \u0026#34;BTTShortcutToSend\u0026#34;: \u0026#34;55,13\u0026#34;, \u0026#34;BTTUUID\u0026#34;: \u0026#34;FE856BD1-C5DC-4423-AFE0-85CBA530A1BD\u0026#34;, \u0026#34;BTTEnabled\u0026#34;: 1, \u0026#34;BTTEnabled2\u0026#34;: 1, \u0026#34;BTTOrder\u0026#34;: 2 }, { \u0026#34;BTTLastUpdatedAt\u0026#34;: 1701048758.0367999, \u0026#34;BTTTriggerType\u0026#34;: 107, \u0026#34;BTTTriggerTypeDescription\u0026#34;: \u0026#34;4 Finger Swipe Down\u0026#34;, \u0026#34;BTTTriggerClass\u0026#34;: \u0026#34;BTTTriggerTypeTouchpadAll\u0026#34;, \u0026#34;BTTPredefinedActionType\u0026#34;: 5, \u0026#34;BTTPredefinedActionName\u0026#34;: \u0026#34;Mission Control\u0026#34;, \u0026#34;BTTUUID\u0026#34;: \u0026#34;4FD1663C-8013-47C0-83C0-FE37B7ED5CC9\u0026#34;, \u0026#34;BTTEnabled\u0026#34;: 1, \u0026#34;BTTEnabled2\u0026#34;: 1, \u0026#34;BTTOrder\u0026#34;: 0 } ] } ], \u0026#34;BTTPresetSnapAreas\u0026#34;: [] } VScode Profile 폰트 먼저 설치 D2coding\n1 preferences \u0026gt; Setting sync on \u0026gt; github login Karbiner ₩ 무조건 `키(백틱)로 변경\n링크에서 ₩ 키를 `키로 바꿔주는 complex_modifications rules을 받아 Enable\n시스템설정 키보드 \u0026gt; 키보드단축키 \u0026gt; spotlight 검색 보기 - ctrl + space 키보드 - 키반복속도 빠르게, 반복지연시간 짧게 제어센터 \u0026gt; 시계옵션 \u0026gt; 시간에 초를 표시 SwitchResX \u0026gt; hdpi 활성화 되어있는지 체크 ","permalink":"https://cha2hyun.blog/content/posts/mac-brew-list/","summary":"언젠가 다시 보기 위해 작성한 초기 셋업 가이드","title":"프로 개발자처럼 맥 세팅하기 명령어 모음 (설명X)"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/12971\n풀이 시도 1\nDP 점화식을 이용하여 푸는 문제로 첫번째 스티커를 뜯었을때와 아닐때 두가지 점화식으로 max값을 비교한다.\nsolution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 def solution(sticker): if len(sticker) \u0026lt;= 3: return max(sticker) answer = 0 # 첫번째 스티커 dp1 = [0 for _ in range(len(sticker))] dp1[0], dp1[1] = sticker[0], sticker[0] # 첫번째 스티커 아닌 경우 dp2 = [0 for _ in range(len(sticker))] dp2[0], dp2[1] = 0, sticker[1] print(dp1) # [14, 14, 0, 0, 0, 0, 0, 0] print(dp2) # [0, 6, 0, 0, 0, 0, 0, 0] for i in range(2, len(sticker)): if i == len(sticker) - 1: dp2[i] = max(dp2[i - 1], sticker[i] + dp2[i - 2]) else: dp1[i] = max(dp1[i - 1], sticker[i] + dp1[i - 2]) dp2[i] = max(dp2[i - 1], sticker[i] + dp2[i - 2]) print(i, dp1, dp2) # 2 [14, 14, 19, 0, 0, 0, 0, 0] [0, 6, 6, 0, 0, 0, 0, 0] # 3 [14, 14, 19, 25, 0, 0, 0, 0] [0, 6, 6, 17, 0, 0, 0, 0] # 4 [14, 14, 19, 25, 25, 0, 0, 0] [0, 6, 6, 17, 17, 0, 0, 0] # 5 [14, 14, 19, 25, 25, 34, 0, 0] [0, 6, 6, 17, 17, 26, 0, 0] # 6 [14, 14, 19, 25, 25, 34, 34, 0] [0, 6, 6, 17, 17, 26, 26, 0] # 7 [14, 14, 19, 25, 25, 34, 34, 0] [0, 6, 6, 17, 17, 26, 26, 36] answer = max(dp1[len(dp1) - 2], dp2[len(dp2) - 1]) return answer print(solution([14, 6, 5, 11, 3, 9, 2, 10])) # 36 다른사람풀이 대부분 비슷하게 두가지 점화식으로 진행하였다. 3번째 원소까지 구분하냐 안하냐의 차이인 것같다.\nsolution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 def solution(stickers_list): num_of_stickers = len(stickers_list) if num_of_stickers == 1 : return stickers_list[0] stickers_list.insert(0, 0) # no take off the first sticker # score = ( take off sticker value, non-take off sticker value) max_score_list = [None, (0, 0)] for idx in range(2, num_of_stickers + 1): non_taken_off_value = max( max_score_list[idx-1][0], max_score_list[idx-1][1]) do_taken_off_value = max_score_list[idx-1][1] + stickers_list[idx] max_score_list.append( (do_taken_off_value, non_taken_off_value)) no_taken_off_first_maximum_value = max( max_score_list[num_of_stickers][0], max_score_list[num_of_stickers][1]) # take off the first sticker max_score_list = [None, ( stickers_list[1], 0)] for idx in range(2, num_of_stickers): non_taken_off_value = max(max_score_list[idx - 1][0], max_score_list[idx - 1][1]) do_taken_off_value = max_score_list[idx - 1][1] + stickers_list[idx] max_score_list.append((do_taken_off_value, non_taken_off_value)) taken_off_first_maximum_value = max(max_score_list[num_of_stickers-1][0], max_score_list[num_of_stickers-1][1]) answer = max( no_taken_off_first_maximum_value, taken_off_first_maximum_value) return answer reference https://ckd2806.tistory.com/entry/%ED%8C%8C%EC%9D%B4%EC%8D%AC-python-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%A8%B8%EC%8A%A4-%EC%8A%A4%ED%8B%B0%EC%BB%A4-%EB%AA%A8%EC%9C%BC%EA%B8%B02 ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/lv3/12971/","summary":"연습문제","title":"프로그래머스 12971] 스티커모으기(2) - 파이썬"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/12980\n풀이 시도 1\n점화식을 이용해서 푸려고 그림으로 그려보니 두가지 규칙을 찾게 되었다. n개의 칸이 짝수면 n+1개의 칸은 n개를 갔을때의 건전지 + 1개가 소요된다. 그리고 n개의 칸이 2의 제곱승이면 1이 된다.\ndp[1] = 1 (2^0) dp[2] = 1 (2^1) dp[3] = dp[2] + 1 = 2 dp[4] = 1 (2^2) dp[5] = dp[4] + 1 = 2 dp[6] = 2 dp[7] = dp[6] + 1 = 3 dp[8] = 1 (2^3) dp[9] = dp[8] + 1 = 2 그리고 쩜프는 2의배수씩 순간이동하기 떄문에 다음에 오는 dp[10]은 결국 dp[5]에서 순간이동하는 것 과 같으므로 dp[5]와 배터리가 같다\ndp[10] = dp[5] 규칙을 정리하니 다음과 같은 점화식을 얻을 수 있었다.\nn = 홀수인경우 : dp[n] = dp[n-1] + 1 n = 짝수인 경우 : dp[n] = dp[n/2] dp[0] = 0 dp[1] = dp[0] + 1 = 1 dp[2] = dp[1] = 1 dp[3] = dp[2] + 1 = 2 dp[4] = dp[2] = 1 dp[5] = dp[4] + 1 = 2 dp[6] = dp[3] = dp[2] + 1 = 2 dp[7] = dp[6] + 1 = 3 dp[8] = dp[4] = dp[2] = dp[1] = 1 dp[9] = dp[8] + 1 = 2 dp[10] = dp[5] = dp[4] + 1 = dp[2] + 1 = 2 solution.py 1 2 3 4 5 6 7 8 9 10 def solution(n): dp = [0 for _ in range(0, n)] dp[0], dp[1], dp[2] = 0, 1, 1 for i in range(3, n): if i % 2 == 0: dp[i] = dp[int((i + 1) / 2)] else: dp[i] = dp[i - 1] + 1 print(i, dp) return dp[-1] 시도 2\n시도 1이랑 비슷한데 접근을 잘못했었다. 쉬운문제였는데 너무 복잡하게 생각한게 탓이었다\u0026hellip;. 예를들어 5,000으로 생각하면 다음과 같다\nflag = 1 (한칸은 무조건 전진해야하므로) 5000 / 2 = 2500 2500 / 2 = 1250 1250 / 2 = 625 625 / 2 = 312 \u0026hellip; 나머지 있으므로 flag += 1 312 / 2 = 156 156 / 2 = 78 78 / 2 = 39 39 / 2 = 19 \u0026hellip; 나머지 있으므로 flag += 1 19 / 2 = 9 \u0026hellip; 나머지 있으므로 flag += 1 9 / 2 = 4 \u0026hellip; 나머지 있으므로 flag += 1 4 / 2 = 2 2 / 2 = 1 return flag = 5 난이도 있는 점화식을 계속 풀었어서 그런지 문제 자체를 너무 복잡하게 생각했었던 것 같다. 손으로 써보는 버릇을 들여야 겠다.\nsolution.py 1 2 3 4 5 6 7 8 9 10 def solution(n): dp = [0 for _ in range(0, n)] dp[0], dp[1], dp[2] = 0, 1, 1 for i in range(3, n): if i % 2 == 0: dp[i] = dp[int((i + 1) / 2)] else: dp[i] = dp[i - 1] + 1 print(i, dp) return dp[-1] 다른사람풀이 또 곰곰히 보니까 2진수로 전환해서 1이 남은 수랑 똑같다\u0026hellip; 이진수로 변환해서 1을 체크하면 더 쉽게 풀수 있었을텐데 아쉬움이 든다. 나눗셈하면서 2진수 생각을 왜 못했을까 🤣\nsolution.py 1 2 def solution(n): return bin(n).count(\u0026#39;1\u0026#39;) ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/lv2/12980/","summary":"연습문제","title":"프로그래머스 12980] 점프와 순간 이동"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/12985\n풀이 시도 1\nN이 2^20 이하의 자연수라고 되어있어서 완전탐색은 안된다고 생각하고 진행했다. (하지만 결국 N은 아무런 고려할 필요가 없었음) 부전승이 없고 n은 무조건 2의 지수승이라고 하였고 무조건 A, B는 이긴다고 가정하였으므로 A와 B가 계속 이겨서 결국 마주쳤을때를 구하면 된다.\nA가 4일때\n1R A\n2R = A//2 = 2\n3R A = A//2 = 1\nB가 7일때\n1R B = 7\n2R B = B//2 = 3\n3R B = B//2 = 1\nA와 B가 붙는거는(같아지는건) 3 Round 이다.\n문제에서 제시하는 그대로 풀면 된다. 여기서 중요한건 a+1, b+1로 비교하는건데 홀수 vs 짝수가 1, 2가 될 수 있고, 2, 3이 될 수 있다. 이때 1, 2는 지금 붙지만 2, 3은 다음 번에 붙기 때문에 두 쌍을 나누는 단순한 방법으로 1을 더하고 몫을 구하는 //2를 하면된다.\nsolution.py 1 2 3 4 5 6 7 8 9 10 def solution(n, a, b): round = 1 while a != b: a = (a + 1) // 2 b = (b + 1) // 2 # 혹은 한줄로 표현할 수도 있음 # a, b = (a+1)//2, (b+1)//2 round += 1 return round 다른사람풀이 xor 연산 결과의 길이를 리턴해주면 라운드가 나온다..시간복잡도 O(1)로 끝판왕 풀이 인것 같다. 이분은 컴퓨터 그자체 이신것 같다\u0026hellip; 😳\nsolution.py 1 2 def solution(n,a,b): return ((a-1)^(b-1)).bit_length() ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/lv2/12985/","summary":"연습문제","title":"프로그래머스 12985] 예상 대진표"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/120876?language=python3\n풀이 시도 1\ndots에 대한 조합을 구하고 나올수 있는 기울기를 구한다음에 기울기가 중복이 된 경우 평행이라고 가정하였으나, 67/100으로 탈락\n모든 조합을 구한 경우 AB직선과 BC직선은 비교할 필요가 없는데 비교하게 되어 탈락한 것 같다.\nsolution.py 1 2 3 4 5 6 7 8 9 10 11 12 def solution(dots): from itertools import combinations slopes = [] for i in combinations(dots, 2): if i[1][0] - i[0][0] != 0: slope = (i[1][0] - i[0][0]) / (i[1][1] - i[0][1]) if slope in slopes: return 1 else: slopes.append(slope) return 0 시도 2\n각 원소에대한 기울기를 직접 구해서 진행하여 solved\nsolution.py 1 2 3 4 5 6 7 8 9 10 def gradient(a, b): return (a[1] - b[1]) / (a[0] - b[0]) def solution(dots): p1, p2, p3, p4 = dots[:4] if gradient(p3, p1) == gradient(p4, p2) or gradient(p4, p3) == gradient(p2, p1): return 1 else: return 0 다른사람풀이 solution.py 1 2 3 4 5 6 def solution(dots): [[x1, y1], [x2, y2], [x3, y3], [x4, y4]]=dots answer1 = ((y1-y2)*(x3-x4) == (y3-y4)*(x1-x2)) answer2 = ((y1-y3)*(x2-x4) == (y2-y4)*(x1-x3)) answer3 = ((y1-y4)*(x2-x3) == (y2-y3)*(x1-x4)) return 1 if answer1 or answer2 or answer3 else 0 ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/etc/120876/","summary":"연습문제","title":"프로그래머스 120876] 겹치는 선분의 길이 - 파이썬"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/120956?language=python3\n풀이 시도 1\n애기가 옹알이 할 수 있는 단어는 4개뿐이므로 단어 4개를 조합할 수 있는 모든 순열을 구한뒤, babbling 인자로 들어오는게 조합이 가능한지 확인하는 것으로 풀었다. 아이가 옹알이 할 수 있는 단어가 늘어날수록 효울성에서 떨어질 거라 생각하였지만 바로 solved.\nsolution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 def solution(babbling): from itertools import permutations answer = 0 words = [\u0026#34;aya\u0026#34;, \u0026#34;ye\u0026#34;, \u0026#34;woo\u0026#34;, \u0026#34;ma\u0026#34;] words_permutaions = [] for i in range(1, len(words) + 1): for word in permutations(words, i): words_permutaions.append(\u0026#34;\u0026#34;.join(word)) for bab in babbling: if bab in words_permutaions: answer += 1 return answer 다른사람풀이 solution.py 1 2 3 4 5 6 7 8 import re def solution(babbling): regex = re.compile(\u0026#39;^(aya|ye|woo|ma)+$\u0026#39;) cnt=0 for e in babbling: if regex.match(e): cnt+=1 return cnt ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/etc/120956/","summary":"연습문제","title":"프로그래머스 120956] 옹알이(1) - 파이썬"},{"content":"\nkioptrix1 서버 올리기 kioptrix-level-1-1을 받아서 UTM으로 실행해줍니다. 실리콘에서 실행시키기 위해 유튜브를 참고하였습니다. 이때 qemu 사용하기 위해 brew 로 설치가 필요합니다.\n1 brew install qemu Step 1:- Download Kioptrix level 1 vulnerable system Step 2:- Unzip the file through Unzip rar (download from App Store) Step 3:- Open the unzip Folder in terminal Step 4:- qemu-img convert -p -O qcow2 *.vmdk output.qcow2 (write this command in terminal ) Step 5:- Open Utm Step 6: - Create new Virtual machine Step 7:- Click on the Emulate Button Step 8:- Skip ISO BOOT Step 9:- Click Continue Step 10:- Open virtual Setting Step 11:- Click Qemu Step 12:- Tick RNG and force ps/2 controller Step 13:- Delete Drive Step 14:- Import new drive and select .qocw2 file from kioptrix folder 여기에 추가로 네트워크 설정에서 브릿지로 설정해줍니다.\nInformation Gathering ping scan 하여 취약 ip 찾기 information gathering 어떤 ip가 살아있는지 ping scan을 일일이 날리지 않고 한번에 해줌, python이나 shell로 코드를 짜도 되고 nmap을 사용해도 가능 이때 flag는 -sn을 사용한다.\n1 nmap -sn 192.168.0.1-255 위에서 찾은 ip로 취약한 Port 찾기 취약한 ip를 찾았다면 어떤 포트가 열려있는지 확인합니다. flag는 -A을 사용한다. -v 옵션을 주어서 어떤 내용인지 로그를 전부 출력한다\n1 nmap -v -A 192.168.0.X 테스트로 localhost로 웹서버 몇개를 띄웠습니다\nsearchsploit으로 취약점 찾기 위에서 열린 포트중에 어떤 취약점이 있는지 확인할 수 있습니다. 예시) nginx, apache, mod_ssl 등\n1 searchsploit mod_ssl 어떠한 취약점이 있는지 확인할 수 있습니다. 사용할 파일은 복사하여 사용합니다.\n1 cp /usr/share/exploitdb/exploits/{위에서나온PATH} /복사할 path gobuster 사용하기 웹페이지의 경로를 Broute Force 방식으로 무작위 대입하여 어떠한 경로가 있는지 파악하는 툴입니다.\n1 sudo apt install gobuster 기본적으로 dirbuster 에 대한 broute force 내용들이 저장되어 있습니다. 경로는 /usr/share/wordlists/dirbuster/ 하위에 있습니다. 사용 방법은 다음과 같습니다.\n1 gobuster dir -u [웹주소] -w /usr/share/wordlists/dirbuster/[사용할 파일] -t [쓰레드수] --exclude-length [제외할 length string 예시) 100-400] 명령어를 실행하게 되면 적용한 파일에 있는 주소가 있는지 확인하여 결과를 보여줍니다.\n계속\u0026hellip; Reference https://www.youtube.com/@Normaltic ","permalink":"https://cha2hyun.blog/content/hacking/kioptrix1/","summary":"Setup","title":"해킹] kioptrix-level-1"},{"content":"\n들어가며 프로젝트를 진행할 때 내가만든 웹에서 취약점이 있진 않을까 막연하게 생각이 들때가 있습니다. 규모가 작은 개인프로젝트의 경우 크게 상관없지만, 회사에서 진행하는 프로젝트가 서비스 규모가 커지면서 취약점에 대한 고민이 생겼습니다. (최근 랜섬웨어 걸리기도했고\u0026hellip;)\n고등학생 때 정보보안동아리에서 webhacking.kr 문제를 풀었었던 경험과 Normaltic Place님께서 올려주신 강의를 토대로 진행합니다.\n실리콘 맥 + VMware Fusion 13 (Kali Linux)로 진행합니다.\nSetup 가상환경 설치 VMware, UTM, VirtualBox중 한개로 가상환경을 진행합니다. 저의 경우 VMware를 이용했습니다.\nVmawre Fusion VMware Fusion 13을 공식홈페이지에서 받습니다. 라이센스는 회원가입하면 무료로 발급받을 수 있습니다.\nUTM 1 brew install --cask utm brew로 설치되지 않는경우 공식홈페이지에서 받습니다.\nVirtual Box 1 brew install --cask virtualbox brew로 설치되지 않는경우 공식홈페이지에서 받습니다.\n실리콘용으로 받으면 됩니다.\nKali Linux 설치 공식홈페이지에서 silicon 용 공식홈페이지 칼리리눅스 이미지파일을 받습니다.\nKali Linux iso 파일을 Import 하여 설치합니다. 운영체제를 선택하라고 나오면 Kali Linux는 Debian으로 맞는 커널을 선택해주시면됩니다.\nGraphic 설치 언어 및 위치를 한국으로 사용자 추가하고 나머지는 모두 Default 값으로 진행합니다. 마지막에 디스크 파티션하기에서 바뀐 점을 디스크에 쓰시겠습니까?가 나오면 예를 눌러줍니다. 프로그램 선택 및 설치도 default로 체크되어있는 것을 설치합니다. 이후 자동으로 설치가 시작됩니다. (10분정도 소요)\n설치 완료 화면\nKali Linux 설정 apt-get update, upgrade 1 2 sudo apt-get update sudo apt-get upgarde -y 해상도변경 1 xrandr -s 1920x1080 이후 display 가서 apply를 눌러준다. (안하면 재부팅시 다시 돌아와버림), vmware 설정에서 retina 체크해주고 reboot\n한글 설정 1 2 3 sudo apt-get install fcitx-lib* sudo apt-get install fcitx-hangul sudo apt-get install fonts-nanum 클립보드 공유 1 2 sudo apt install spice-vdagent spice-webdavd reboot Network VMware 설정에서 Network Adapter를 열어서 Bridged Networking 으로 설정해줍니다.\nDefault\nBridged Network 선택시\nssh 설정 루트로 로그인하기 위해서 config 를 수정합니다.\n1 vim /etc/ssh/sshd_config 에서 PermitRootLogin yes로 수정 ssh root 패스워드 설정\n1 2 sudo su passwd 1 service ssh start 클라이언트에서 ssh 접속\n1 ssh root@IP주소 재부팅시 service ssh start를 다시 해주어야합니다. cron이나 다른 스케쥴러로 부팅시 자동으로 실행되게끔 설정하는 것도 가능합니다. pwntools, 파이썬 설치 1 sudo apt-get install python3 python3-pip python3-dev git libssl-dev libffi-dev build-essential -y import시 에러가 안나오면 성공\npwndbg 설치 1 2 3 4 sudo apt-get install gdb -y git clone https://github.com/pwndbg/pwndbg cd pwndbg ./setup.sh onegadget 설치 1 2 sudo apt install ruby sudo gem install one_gadget Reference https://velog.io/@younghyun https://www.youtube.com/@Normaltic ","permalink":"https://cha2hyun.blog/content/hacking/hacking1/","summary":"실리콘 맥에 칼리리눅스 가상환경으로 띄우고 초기 설정진행하기","title":"해킹] M1맥에 Kali Linux 설치 (VMware Fusion 13)"},{"content":"들어가며 ✅ 해당 프로젝트는 Boilerplate 템플릿 https://github.com/cha2hyun/nestjs-prisma-starter-kakao-oauth-jwt으로 제작되어있습니다.\nDon\u0026rsquo;t reinvent the wheel! NestJS 공식문서에도 기재되어있는 무려 2,000 스타수가 넘는 notiz-dev/nestjs-prisma-starter를 기반으로 카카오 OAUTH 부분만 추가해두었습니다.\n하다보니 에러가 있어서 pr 보냈더니 merged 되었다🖐️ 스타수 2K 기여했다는 이 뿌듯함..\n카카오 OAUTH는 대부분 한국에서 사용되겠지만, 그래도 범용(?)적으로 처음으로 영문으로 Readme도 적어보았습니다.\nInstructions 🚀 This project is generated from notiz-dev/nestjs-prisma-starter starter template which is referenced in the NestJS official documentation. If you need more information, such as installation and setup, please check README within the template.\n👀 This project provides Kakao Oauth login with Passport JWT authentication.\n📝 Feel free to let me know if encounter any errors or have any questions. Pull requests are welcome.\nFeatures login/signup with kakao account with JWT tokens Overview 1. Setup a kakao sdk in your frontend. Please check kakao documents for setup. In my case i use next.js for the example.\n2. Login with kakao account. Here are some Next.js frontend code examples\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 \u0026lt;Script src=\u0026#34;https://t1.kakaocdn.net/kakao_js_sdk/2.1.0/kakao.min.js\u0026#34; integrity=\u0026#34;sha384-dpu02ieKC6NUeKFoGMOKz6102CLEWi9+5RQjWSV0ikYSFFd8M3Wp2reIcquJOemx\u0026#34; crossOrigin=\u0026#34;anonymous\u0026#34; onReady={() =\u0026gt; { if (!(\u0026#34;Kakao\u0026#34; in window)) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any if ((window as any).Kakao == null) return; if (process.env.NEXT_PUBLIC_KAKAO_JS_KEY == null) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any const kakao = (window as any).Kakao; if (kakao.isInitialized() !== true) { kakao.init(process.env.NEXT_PUBLIC_KAKAO_JS_KEY); } }} /\u0026gt; Add kakao sdk on _app.tsx\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 const handleLogin = useCallback(async () =\u0026gt; { if (!(\u0026#34;Kakao\u0026#34; in window)) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any if ((window as any).Kakao == null) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any const kakao = (window as any).Kakao; kakao.Auth.authorize({ redirectUri: typeof window !== \u0026#34;undefined\u0026#34; ? window.location.origin + \u0026#34;/auth/kakao\u0026#34; : null, prompts: \u0026#34;login\u0026#34;, }); }, []); In this case redirect url is /auth/kakao\n3. Get Code Parameter. If your account passes the login, the browser will redirect to your redirectUri with code parameters. The URL will look like http://localhost:3002/auth/kakao?code=TJx7M1-sTWkrKQgvOTmfvSUnC5bD2GqtWrA....\n4. Perform a Mutation and Await Server Response On your redirect page, initiate a login mutation to the NestJS server using code and redirectUri as variables.\nFor instance, here are some Next.js frontend code snippets.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 /* eslint-disable no-console */ import { Spinner } from \u0026#34;@nextui-org/react\u0026#34;; import { NextPage } from \u0026#34;next\u0026#34;; import { useRouter } from \u0026#34;next/router\u0026#34;; import { useEffect, useState } from \u0026#34;react\u0026#34;; import { useLoginMutation } from \u0026#34;@/src/core/config/graphql\u0026#34;; import Authentication from \u0026#34;@/src/core/function/authentication\u0026#34;; const KakaoOauth: NextPage = () =\u0026gt; { const router = useRouter(); const [login] = useLoginMutation(); const [isFetched, setIsFetched] = useState(false); useEffect(() =\u0026gt; { const params = new URL(document.location.toString()).searchParams; const code = params.get(\u0026#34;code\u0026#34;); const fetchData = async () =\u0026gt; { try { if (code \u0026amp;\u0026amp; typeof window !== undefined \u0026amp;\u0026amp; !isFetched) { await login({ variables: { code: code, redirectUri: window.location.origin + \u0026#34;/auth/kakao\u0026#34;, }, onCompleted: async (res) =\u0026gt; { setIsFetched(true); Authentication.setToken({ accessToken: res.login.accessToken, refreshToken: res.login.refreshToken, }); console.log(\u0026#34;jwt\u0026#34;, res.login.accessToken); }, }); } } catch (err) { console.log(err); } }; fetchData(); }, [isFetched, login, router]); return ( \u0026lt;div className=\u0026#34;bg-defualt my-20 flex w-full justify-center \u0026#34;\u0026gt; \u0026lt;div className=\u0026#34;mx-auto w-full flex-1 text-center \u0026#34;\u0026gt; \u0026lt;Spinner label=\u0026#34;Waiting for server response...\u0026#34; color=\u0026#34;warning\u0026#34; /\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/div\u0026gt; ); }; export default KakaoOauth; auth/kakao.tsx\n5. Returning JWT Token on the NestJS server First, auth.reslover.ts 1 2 3 4 5 6 7 8 9 10 11 @Mutation(() =\u0026gt; Auth) async login( @Args(\u0026#34;code\u0026#34;) code: string, @Args(\u0026#34;redirectUri\u0026#34;) redirectUri: string, ) { const { accessToken, refreshToken } = await this.auth.kakaoLogin(code, redirectUri); return { accessToken, refreshToken, }; src \u0026gt; auth \u0026gt; auth.resolver.ts\nit will call kakaoLogin functions in auth.service.ts\nSecond in auth.service.ts It will fetch the kakao access token using the code and redirectUri parameters. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 async kakaoLogin(code: string, redirectUri: string): Promise\u0026lt;Token\u0026gt; { try { const tokenResponse = await this.kakaoLoginService.getToken(code, redirectUri); const kakaoUser = await this.kakaoLoginService.getUser(tokenResponse.access_token); const isJoined = await this.prisma.user.findUnique({ where: { kakaoId: kakaoUser.id.toString() } }); if (!isJoined) { return this.createUser({ kakaoId: kakaoUser.id.toString(), email: kakaoUser.kakao_account.email, nickname: kakaoUser.properties.nickname, connectedAt: kakaoUser.connected_at, ageRange: kakaoUser.kakao_account.age_range, birthday: kakaoUser.kakao_account.birthday, gender: kakaoUser.kakao_account.gender, profileImageUrl: kakaoUser.properties.profile_image, thumbnailImageUrl: kakaoUser.properties.thumbnail_image, }); } else { const userId = (await this.prisma.user.findUnique({ where: { kakaoId: kakaoUser.id.toString() } })).id; return this.generateTokens({ userId: userId, }); } } catch (e) { throw new Error(e); } } src \u0026gt; auth \u0026gt; auth.service.ts\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 async getToken(code: string, redirectUri: string): Promise\u0026lt;KakaoOauthToken\u0026gt; { try { const url = new URL(\u0026#34;/oauth/token\u0026#34;, KAKAO_AUTH_URL).href; const tokenResponse = await new Promise\u0026lt;KakaoOauthToken\u0026gt;((resolve, reject) =\u0026gt; { this.httpService .post( url, new URLSearchParams({ grant_type: \u0026#34;authorization_code\u0026#34;, client_id: this.configService.get(\u0026#34;KAKAO_API_CLIENT_ID\u0026#34;), client_secret: this.configService.get(\u0026#34;KAKAO_API_CLIENT_SECRET\u0026#34;), code: code, redirect_uri: redirectUri, }), { headers: { \u0026#34;Content-Type\u0026#34;: \u0026#34;application/x-www-form-urlencoded\u0026#34;, }, }, ) .subscribe({ error: err =\u0026gt; reject(err), next: response =\u0026gt; resolve(response.data), }); }); return tokenResponse; } catch (e) { const err = e as AxiosError; // eslint-disable-next-line no-console console.log(err); } } Third, it will attempt to retrieve Kakao user information using the access-code 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 async getUser(token: string) { try { const url = new URL(\u0026#34;/v2/user/me\u0026#34;, KAKAO_API_URL).href; const infoResponse = await new Promise\u0026lt;KakaoV2UserMe\u0026gt;((resolve, reject) =\u0026gt; { this.httpService .get(url, { headers: { Authorization: `Bearer ${token}`, \u0026#34;Content-type\u0026#34;: \u0026#34;Content-type: application/x-www-form-urlencoded;charset=utf-8\u0026#34;, }, params: { secure_resource: true, }, }) .subscribe({ error: err =\u0026gt; reject(err), next: response =\u0026gt; resolve(response.data), }); }); return infoResponse; } catch (e) { throw new UnauthorizedException(); } } Fourth, utilizing the id from kakao user information to validate wheater the user already exist on database. If not it will create a new user and generate a JWT token associated with the userId, returning it. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 async createUser(payload: SignupInput): Promise\u0026lt;Token\u0026gt; { try { const user = await this.prisma.user.create({ data: { kakaoId: payload.kakaoId, email: payload.email, nickname: payload.nickname, ageRange: payload.ageRange, birthday: payload.birthday, gender: payload.gender, role: \u0026#34;USER\u0026#34;, kakaoProfile: { create: { profileImageUrl: payload.profileImageUrl, thumbnailImageUrl: payload.thumbnailImageUrl, connectedAt: payload.connectedAt, }, }, }, }); return this.generateTokens({ userId: user.id, }); } catch (e) { throw new Error(e); } } Fifthly, if the user already exists on database. It will return a JWT token genertated with the userId 6. Saving Returned JWT Token on your frontend The token will be returned to frontend. ensure to save it within the browser.\nToken will be like\n1 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiJjbG95aDBydXIwMDAwM2hvYm9taTl3N21iIiwiaWF0IjoxNzAwMDM2MTA4LCJleHAiOjE3MDAwMzYyMjh9.M1YjIJcKjeUfYo4P8Humh7fAtc4PAxRI54tJAJDP 7. Execute the \u0026lsquo;Me\u0026rsquo; query using JWT tokens. Add Authorization header with your JWT tokens to retrieve \u0026lsquo;Me\u0026rsquo; Authorization : Bearer \u0026quot;YOUR JWT TOKENS\u0026quot;\non success\nwith worng token\n8. Begin building your own projects 🚀 마치며 처음으로 제작한 Boilerplate template. 고작 한 기능이 추가되었지만 하면서 여러 공부도 되었고 공식문서에 적혀있는 기존 템플릿에 PR도 Merged 되어서 매우 뿌듯합니다. 누군가에게 언젠가 사용되길 바랍니다 😀\n템플릿 바로가기 : https://github.com/cha2hyun/nestjs-prisma-starter-kakao-oauth-jwt\n","permalink":"https://cha2hyun.blog/content/posts/nestjs-graphql-kakao-jwt/","summary":"NestJS + Grahpql + Prisma + Postgres + jwt","title":"첫 Boilerplate 템플릿 (NestJS 카카오 OAuth 및 jwt토큰 이용하기)"},{"content":"\n들어가며 회사에서 Slack을 쓰다가 Synology Chat 으로 변경하였습니다. 챗봇의 자유도가 조금 낮다는 단점이 있지만 아무래도 NAS만 있으면 공짜에 기록도 반영구적으로 저장되는 장점이 있습니다. 다만 제일 큰 단점이 바로 안읽은 메세지의 가독성이 영 꽝이라는점..\n잘 안보인다구..\n따로 테마를 커스텀할 수 있게 설정이 오픈되어있지 않아서 포기했던 찰나 NAS 어딘가에 CSS파일이 있지 않을까? 라는 생각이 들었습니다. SSH로 접속하여 파일을 뒤져봅니다. (SSH 설정은 따로 해주어야합니다.) Synology Chat CSS파일 찾기 1 ssh -p 포트번호 계정@NAS주소 SSH 접속 비밀번호 한번틀림\nSynology Chat의 파일들은 다음 경로에 있는걸 찾을 수 있었습니다.\n1 cd /volume1/@appstore/Chat/ui 해당 파일을 우선 에디터로 열기 위해 접근 가능한 디렉토리로 복사합니다. 필요한 파일은 style.css light.css dark.css 입니다\n1 2 3 cp /volume1/@appstore/Chat/ui/style.css /volume1/kimchi-cloud cp /volume1/@appstore/Chat/ui/light.css /volume1/kimchi-cloud cp /volume1/@appstore/Chat/ui/dark.css /volume1/kimchi-cloud 시놀로지 파일 스테이션을 보면 옮겨진 것을 확인 할 수 있습니다. CSS 클래스 분석 Synology Chat 앱으로는 어떤 부분을 수정해야할지 확인하기 힘드므로 웹 버전으로 접속해서 관리자 도구를 이용해서 클래스명을 확인합니다. 웹으로 접근 하기 위해 시놀로지에서 설정이 필요합니다.\n관리자 도구로 확인해보면 안읽은 메세지는 highlight highlight-mention 클래스가 있는 것을 볼 수 있습니다. 아까 복사한 style.css light.css dark.css를 vscode로 열어서 highlight 를 검색해보고 분석해본 결과 style.css 에서 공통적으로 스타일을 지정하고 light.css (라이트테마), dark.css (다크테마)에서 추가적으로 폰트 색상이나 이런것을 따로 적용하는 것을 알 수 있었습니다.\n다음 코드 샘플을 수정하여 light.css, dark.css 뒤에 붙혀넣기 합니다. 저의 경우 라이트테마에선 빨간색, 다크테마에선 노란색으로 표시하게끔 했습니다.\ncss 커스텀 1 2 3 4 5 6 7 8 9 .syno-chat .channel-list-item.highlight .name, .syno-chat.syno-chat-integration .chat-list-main .highlight.chat-list-item .name { font-weight: bold; opacity: 1; color: red !important; /* 표시할 색상 입력 */ } CSS 수정하기 vi 에디터로 light.css, dark.css 파일을 엽니다. 단, 수정하려면 루트권한이 있어야합니다\n1 sudo vi dark.css i를 눌러 insert 모드로 변경후 end를 눌러 맨 하단으로가서 아까 작성한 코드를 붙혀넣습니다. esc눌러서 insert mode에서 빠져나오고 :wq로 저장후 종료합니다. 맨하단에 한줄로 복사하면 된다\n결과 dark.css 와 light.css 모두 저장해주시고 새로고침 하거나 프로그램을 껏다 키면 멘션되는 색상이 잘 변경된 것을 볼 수 있습니다. 👏 안볼래야 안볼수가 없는 노란색\n빠른적용하기 (요약) 시놀로지 업데이트 하면 풀리는 경우가 있습니다. 나중에 다시 적용하기 위해 방법만 간단히 요약합니다.\nssh 1 2 3 4 5 ssh -p 포트번호 계정@NAS주소 cd /volume1/@appstore/Chat/ui # 하단의 코드 각 파일의 맨 뒷줄에 복사 sudo vi light.css sudo vi dark.css light.css 1 .syno-chat .channel-list-item.highlight .name,.syno-chat.syno-chat-integration .chat-list-main .highlight.chat-list-item .name{font-weight:bold;opacity:1;color:red!important;} dark.css 1 .syno-chat .channel-list-item.highlight .name,.syno-chat.syno-chat-integration .chat-list-main .highlight.chat-list-item .name{font-weight:bold;opacity:1;color:yellow!important;} 마치며 해당 방법으로 멘션되는 색상 뿐만아니라 다른 부분들도 다양하게 수정할 수 있습니다. 예를들어 봇을 더 강조한다던지, 글씨체를 바꾼다던지 등 ! (시놀로지 챗이 애초에 지원해줬으면 더 좋았겠지만 !..)\n더 궁금하신 내용이 있으면 아래 댓글을 이용해주시기 바랍니다 :)\n","permalink":"https://cha2hyun.blog/content/posts/synologychat/","summary":"CSS 변경하여 안읽은 메세지 강조하기","title":"Synology Chat 멘션 색상 바꾸기"},{"content":"\n들어가며 유튜브 채널 팩토리 님께서 진행해주신 레전드 컨텐츠 이벤트가 있습니다. (아마도 팩토리님은 개발자일지도) 아직도 안끝나고 댓글이 150만개가 달려서 세계10위에 들었다고 합니다\n배돌이의 당구생활 유튜브에서도 비슷한 다양한 이벤트들을 진행중입니다.\n라이브에서 실시간 추첨 (참고글) 컨텐츠에 댓글달고 오랫동안 버틴사람 컨텐츠 댓글중 좋아요가 가장 많은 사람 컨텐츠 댓글중 답글이 제일 많은 사람 컨텐츠에 댓글을 가장 많이 작성한 사람 위 뿐만 아니라 여러가지 이벤트들을 진행\u0026amp;기획중으로 프로그램을 제작하게 되었습니다. 코드 \u0026amp; 코드설명 constant.py 상수를 모아놓은 파일입니다. constant.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 LINE = \u0026#34;=\u0026#34; * 28 SMALL_LINE = \u0026#39;-\u0026#39; * 65 SYNOLOGY_LINE = \u0026#39;-\u0026#39; * 38 YOUTUBEAPI_KEY = \u0026#34;\u0026#34; YOUTUBEAPI_SUB_KEY = \u0026#34;\u0026#34; YOUTUBEAPI_VIDEOS_URL = \u0026#34;https://www.googleapis.com/youtube/v3/videos\u0026#34; YOUTUBEAPI_COMMENTTHREADS_URL = \u0026#34;https://www.googleapis.com/youtube/v3/commentThreads\u0026#34; YOUTUBEAPI_REQUEST_PER_TIME = 10 YOUTUBEAPI_RESEND_TIMEOUT = 3 MOST_CNT = 5 MAXRESULTS = 100 LIVE_MAXRESULTS = 30 # SYNOLOGY CHAT에 보낼 경우 SYNOLOGYAPI_URL = \u0026#34;\u0026#34; # 메세지를 처리하는 api를 따로 만들어야합니다. SYNOLOGYAPI_REQUEST_PER_TIME = 0.5 SYNOLOGYCHAT_LINE_SPLIT = 5 crwaler.py 프로그램 코어입니다. 설명은 주석으로 달아놓았습니다. crwaler.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 from datetime import datetime, timezone from operator import itemgetter import time import os import requests from constant import * def utc_to_local(utc_dt): # 유튜브 API 로 받아오면 미국 시간으로 받아와짐 # 미국 \u0026gt; 한국으로 변경 return utc_dt.replace(tzinfo=timezone.utc).astimezone(tz=None) def sorter(list, value): # [{},{},..,{}] 리스트에서 딕셔너리의 밸류를 이용해 정렬 return sorted(list, key=itemgetter(value), reverse=True) def listDivider(arr, n): # arr배열을 n개씩 나누어 배열로 감싸 리턴합니다. return [arr[i: i + n] for i in range(0, len(arr), n)] class Printer: # 1. 화면에 출력 # 2. 로컬에 저장 # 3. 시놀로지채팅에 전송 # 위 3가지를 담당하는 클래스 def __init__(self): # 파일 저장할 디렉토리 생성 today = time.strftime(\u0026#39;%Y-%m-%d\u0026#39;, time.localtime(time.time())) start = time.strftime(\u0026#39;%Y-%m-%d %H:%M:%S\u0026#39;, time.localtime(time.time())) current = os.getcwd() path = f\u0026#39;{current}/{today}\u0026#39; try: if not os.path.exists(path): os.makedirs(path) except OSError: print(\u0026#34;Error: Cannot create the directory {}\u0026#34;.format(path)) self.fp = open(f\u0026#39;{path}/{start}.txt\u0026#39;, \u0026#39;w\u0026#39;) def saveOnLocal(self, msg): # 출력하고 파일에 저장합니다. self.fp.write(f\u0026#39;{msg}\\n\u0026#39;) print(msg) def sendToChat(self, msg): # 시놀로지채팅에 보내고, 출력하고, 로컬에 저장합니다. self.saveOnLocal(msg) # 텍스트가 너무 길면 시놀로지챗에서 410 에러를 반환한다. n 줄씩 끊어서 배열로 리턴 (\\n으로) msgs_array = listDivider(msg.split(\u0026#39;\\n\u0026#39;), SYNOLOGYCHAT_LINE_SPLIT) # 시놀로지에서 이쁘게 표시되게 변경합니다. for msgs in msgs_array: text = \u0026#39;\\n\u0026#39;.join(msgs) text = text.replace(SMALL_LINE, SYNOLOGY_LINE) text = text.replace(\u0026#34;\\t\u0026#34;,\u0026#39;\u0026#39;) text = text.replace(\u0026#34; \u0026#34;,\u0026#34; \u0026#34;) text = text.replace(\u0026#34;닉네임\u0026#34;,\u0026#34;\\n닉네임\u0026#34;) text = text.replace(\u0026#34;| @\u0026#34;,\u0026#34;\\n@\u0026#34;) # 해당 텍스트를 시놀로지 챗봇으로 보낼 수 있는 API를 만들어놓았습니다. requests.get(SYNOLOGYAPI_URL, data={\u0026#34;msg\u0026#34;:text}) time.sleep(SYNOLOGYAPI_REQUEST_PER_TIME) def close(self): self.fp.close() class Youtube: apikey = YOUTUBEAPI_KEY videoId = \u0026#39;\u0026#39; videoInfo = { \u0026#34;title\u0026#34;: \u0026#39;\u0026#39;, # 제목 \u0026#34;description\u0026#34;: \u0026#39;\u0026#39;, # 설명 \u0026#34;viewCount\u0026#34;: 0, # 조회수 \u0026#34;likeCount\u0026#34;: 0, # 좋아요 \u0026#34;commentCount\u0026#34;: 0, # 댓글수 } comments = [{ \u0026#34;authorDisplayName\u0026#34;: \u0026#39;\u0026#39;, # 닉네임 \u0026#34;textDisplay\u0026#34;: \u0026#39;\u0026#39;, # 댓글 \u0026#34;publisedAt\u0026#34;: \u0026#39;\u0026#39;, # 작성일 \u0026#34;likeCount\u0026#34;: \u0026#39;\u0026#39;, # 좋아요 \u0026#34;diffTime\u0026#34;: \u0026#39;\u0026#39;, # 다음글 작성시간의 차이 \u0026#34;authorChannelId\u0026#34;: \u0026#39;\u0026#39;, # 유저 채널 아이디 (고유값) \u0026#34;totalReplyCount\u0026#34;: \u0026#39;\u0026#39;, # 답글 수 }] def __init__(self): self.printer = Printer() self.greeting() # 유저한테 동영상 ID를 받아오고 self.videoId = self.getVideoId() # 동영상 정보를 출력하고 self.videoInfo = self.getVideoInfo() # 동영상의 댓글을 저장합니다. self.comments = [] self.comments = self.getVideoComments() # 저장된 댓글을 분석해서 몇분만에 다음 댓글이 달렸는지 저장합니다. self.addDiffTime(self.comments) self.start() def start(self): while True: self.printer.saveOnLocal(f\u0026#39;\\n\\n{LINE}\u0026#39;) self.printer.saveOnLocal(\u0026#34;유튜브 댓글 조회 프로그램\\nby.배돌이의당구생활 채PD\u0026#34;) self.printer.saveOnLocal(f\u0026#39;{LINE}\u0026#39;) self.printer.saveOnLocal(\u0026#34; 0. 종료\u0026#34;) self.printer.saveOnLocal(\u0026#34; 1. 모든 댓글 조회\u0026#34;) self.printer.saveOnLocal(\u0026#34; 2. 가장 많은 좋아요 댓글 조회\u0026#34;) self.printer.saveOnLocal(\u0026#34; 3. 가장 많은 답글수 댓글 조회\u0026#34;) self.printer.saveOnLocal(\u0026#34; 4. 가장 많이 작성한 사람 조회\u0026#34;) self.printer.saveOnLocal(\u0026#34; 5. 가장 오래 버틴 댓글 조회\u0026#34;) self.printer.saveOnLocal(\u0026#34; 6. 닉네임 조회\u0026#34;) self.printer.saveOnLocal(\u0026#34; 7. 댓글 버티기 이벤트 시작\u0026#34;) self.printer.saveOnLocal(\u0026#34; 8. 다른 영상 조회하기\u0026#34;) self.printer.saveOnLocal(f\u0026#39;{LINE}\u0026#39;) command = input(\u0026#34;번호를 입력해주세요 : \u0026#34;) self.printer.saveOnLocal(f\u0026#39; \u0026gt; 입력된 번호 : {command}\u0026#39;) if command == \u0026#34;0\u0026#34;: # 종료 self.printer.saveOnLocal(\u0026#34;프로그램을 종료합니다.\u0026#34;) self.printer.close() break elif command == \u0026#34;1\u0026#34;: # 모든 댓글 조회 os.system(\u0026#39;clear\u0026#39;) self.printComments(self.comments) elif command == \u0026#34;2\u0026#34;: # 가장 많은 좋아요 os.system(\u0026#39;clear\u0026#39;) self.printMostLiker() elif command == \u0026#34;3\u0026#34;: # 가장 많은 답글수 os.system(\u0026#39;clear\u0026#39;) self.printMostReply() elif command == \u0026#34;4\u0026#34;: # 가장 많이 작성한 사람 os.system(\u0026#39;clear\u0026#39;) self.printMostCommenter() elif command == \u0026#34;5\u0026#34;: # 가장 오래 버틴 댓글 os.system(\u0026#39;clear\u0026#39;) self.printMostDiffTime() elif command == \u0026#34;6\u0026#34;: # 유저 검색 os.system(\u0026#39;clear\u0026#39;) username = input(\u0026#34;검색할 닉네임을 적어주세요 : \u0026#34;) self.printUsersComment(username) elif command == \u0026#34;7\u0026#34;: # 버티기 이벤트 시작 os.system(\u0026#39;clear\u0026#39;) print(LINE) print(\u0026#34;🚨 댓글 고정이 없는지 확인해주세요. 되어있다면 고정해제 해주세요.\u0026#34;) print(\u0026#34;🚨 이벤트가 종료될 때 까지 프로그램은 계속 진행됩니다.\u0026#34;) print(LINE) stop = input(\u0026#34; \u0026gt; 몇 분 버티기를 하실건지 입력해주세요 : \u0026#34;) os.system(\u0026#39;clear\u0026#39;) self.startDiffTimeEvent(stop) elif command == \u0026#34;8\u0026#34;: # 다른 동영상 조회 os.system(\u0026#39;clear\u0026#39;) self.__init__() else: os.system(\u0026#39;clear\u0026#39;) def greeting(self): text = f\u0026#39;\\n{LINE}\\n✋ 프로그램이 시작됩니다. {time.strftime(\u0026#34;%y년 %m월 %d일 %H:%M:%S\u0026#34;)}\\n\u0026#39; text += f\u0026#39;\u0026gt; By.배돌이의당구생활 채PD\\n\u0026gt; 영상 댓글 이벤트\\n{LINE}\u0026#39; self.printer.saveOnLocal(text) def youtubeAPIExceed(self): # 유튜브 API가 일일 한도가 정해져 있음. # 일일한도를 초과하면 다른 키로 변경합니다. self.printer.sendToChat(f\u0026#39;🚨 유튜브 API 요청 한도 초과. {YOUTUBEAPI_RESEND_TIMEOUT}초 후 다시 시도합니다.\u0026#39;) if self.apikey == YOUTUBEAPI_KEY: self.apikey = YOUTUBEAPI_SUB_KEY elif self.apikey == YOUTUBEAPI_SUB_KEY: self.apikey = YOUTUBEAPI_KEY time.sleep(YOUTUBEAPI_RESEND_TIMEOUT) def youtubeNotFound(self): # 유튜브 url을 찾지 못했을 경우 print(f\u0026#39;🚨 해당 유튜브 영상 URL을 확인할 수 없습니다. 다시 시도해주세요.\\n{LINE}\u0026#39;) self.__init__() def getVideoId(self): while True: videoId = input(\u0026#34;1️⃣ 영상 URL을 입력해주세요 : \u0026#34;) # 유튜브 URL 인지 확인 try: if \u0026#39;youtu\u0026#39; not in videoId: raise Exception if \u0026#39;watch?v=\u0026#39; in videoId: videoId = videoId.split(\u0026#34;=\u0026#34;)[1] else: videoId = videoId.split(\u0026#34;/\u0026#34;)[-1] return videoId except Exception as e: print(\u0026#34;🚨 해당 영상을 확인할 수 없습니다. 다시 입력해주세요.\u0026#34;, e) print(LINE) def getVideoInfo(self): # 유저로부터 받은 동영상url이 정상적인지 확인하고 정상인 경우 동영상 정보를 리턴합니다. res = requests.get(f\u0026#34;{YOUTUBEAPI_VIDEOS_URL}?key={self.apikey}\u0026amp;part=snippet,statistics\u0026amp;id={self.videoId}\u0026#34;) if res.status_code == 200: data = res.json() items = data[\u0026#34;items\u0026#34;] # url이 잘못되어도 status 200값과 함께 items는 빈 배열을 리턴합니다. if len(items) \u0026lt; 1 : self.youtubeNotFound() title = items[0][\u0026#34;snippet\u0026#34;][\u0026#34;title\u0026#34;] description = items[0][\u0026#34;snippet\u0026#34;][\u0026#34;description\u0026#34;].replace(\u0026#34;\\n\u0026#34;, \u0026#34; \u0026#34;).replace(\u0026#34;\u0026lt;br\u0026gt;\u0026#34;,\u0026#34; \u0026#34;) if len(description) \u0026gt; 25: description = f\u0026#39;{description[:25]}...\u0026#39; commentCount = int(items[0][\u0026#34;statistics\u0026#34;][\u0026#34;commentCount\u0026#34;]) likeCount = int(items[0][\u0026#34;statistics\u0026#34;][\u0026#34;likeCount\u0026#34;]) viewCount = int(items[0][\u0026#34;statistics\u0026#34;][\u0026#34;viewCount\u0026#34;]) msg = f\u0026#39;✅ 영상이 확인되었습니다.\\n\u0026#39; msg += f\u0026#39;\u0026gt; 제목: {title}\\n\u0026#39; msg += f\u0026#39;\u0026gt; 설명: {description}...\\n\u0026#39; msg += f\u0026#34;\u0026gt; 조회수 {format(viewCount, \u0026#39;,\u0026#39;)} | 좋아요 {format(likeCount, \u0026#39;,\u0026#39;)} | 댓글수 {format(commentCount, \u0026#39;,\u0026#39;)}\\n\u0026#34; msg += LINE self.printer.saveOnLocal(msg) return { \u0026#34;title\u0026#34;: title, \u0026#34;description\u0026#34;: description, \u0026#34;viewCount\u0026#34;: viewCount, \u0026#34;likeCount\u0026#34;: likeCount, \u0026#34;commentCount\u0026#34;: commentCount, } else: # 200을 리턴받지 않으면 유튜브 api 일일한도 초과로 403을 리턴받습니다. self.youtubeAPIExceed() def getVideoComments(self): self.printer.saveOnLocal(f\u0026#34;2️⃣ 댓글을 가져옵니다.\u0026#34;) url = f\u0026#34;{YOUTUBEAPI_COMMENTTHREADS_URL}?key={self.apikey}\u0026amp;maxResults={MAXRESULTS}\u0026amp;part=snippet,replies\u0026amp;videoId={self.videoId}\u0026#34; # 동영상의 댓글을 불러옵니다. self.getComments(url, 1) self.comments = sorted(self.comments, key=itemgetter(\u0026#39;publishedAt\u0026#39;)) self.printer.saveOnLocal(f\u0026#39;✅ 모든 댓글이 확인되었습니다.\\n{LINE}\u0026#39;) return self.comments def getComments(self, url, cnt): # 1회 요청에 100개까지 받아올 수 있고 100개가 초과하는 댓글의 동영상은 다음 페이지 토큰이 주어집니다. # 재귀적으로 다음 페이지 토큰이 없을때 까지 실행됩니다. percent = cnt * MAXRESULTS / self.videoInfo[\u0026#34;commentCount\u0026#34;] * 100 if percent \u0026gt; 100: percent = 100 currCnt = cnt * MAXRESULTS self.printer.saveOnLocal(f\u0026#34;- ({percent:6.2f}%) {format(currCnt,\u0026#39;,\u0026#39;)}개 까지 추출중...\u0026#34;) response = requests.get(url).json() try: for item in response[\u0026#34;items\u0026#34;]: self.comments.append(self.validateComment(item)) except Exception as e: # Exception 발생은 api 일일조회를 초과했을 때 나타납니다. # api 키를 바꾸고 현재 댓글을 다시 조회합니다. self.youtubeAPIExceed() self.getComments(url, cnt) if \u0026#34;nextPageToken\u0026#34; in response.keys(): # 다음 댓글이 있으면 재귀로 다시 불러옵니다. nextPageToken = str(response[\u0026#34;nextPageToken\u0026#34;]) self.getComments(f\u0026#34;{url}\u0026amp;pageToken={nextPageToken}\u0026#34;, cnt + 1) def validateComment(self, item): # api로 받은 json 에서 필요한 값만 저장합니다. publisedAt = item[\u0026#34;snippet\u0026#34;][\u0026#34;topLevelComment\u0026#34;][\u0026#34;snippet\u0026#34;][\u0026#34;publishedAt\u0026#34;] publisedAt = datetime.strptime(publisedAt, \u0026#34;%Y-%m-%dT%H:%M:%SZ\u0026#34;) publisedAt = datetime.strftime(utc_to_local(publisedAt), \u0026#34;%y-%m-%d %H:%M:%S\u0026#34;) comment = { \u0026#34;publishedAt\u0026#34; : publisedAt, \u0026#34;authorDisplayName\u0026#34; : item[\u0026#34;snippet\u0026#34;][\u0026#34;topLevelComment\u0026#34;][\u0026#34;snippet\u0026#34;][\u0026#34;authorDisplayName\u0026#34;], # \u0026lt;a href=\u0026#34;about:invalid#zCSafez\u0026#34;\u0026gt;\u0026lt;/a\u0026gt;는 PC에서 유튜브에만 존재하는 이모티콘이 이러게 나옵니다 \u0026#34;textDisplay\u0026#34; : item[\u0026#34;snippet\u0026#34;][\u0026#34;topLevelComment\u0026#34;][\u0026#34;snippet\u0026#34;][\u0026#34;textDisplay\u0026#34;].replace(\u0026#34;\u0026lt;br\u0026gt;\u0026#34;,\u0026#34; \u0026#34;).replace(\u0026#34;\u0026amp;quot;\u0026#34;,\u0026#34; \u0026#34;).replace(\u0026#39;\u0026lt;a href=\u0026#34;about:invalid#zCSafez\u0026#34;\u0026gt;\u0026lt;/a\u0026gt;\u0026#39;,\u0026#39;[이모티콘]\u0026#39;), \u0026#34;likeCount\u0026#34; : item[\u0026#34;snippet\u0026#34;][\u0026#34;topLevelComment\u0026#34;][\u0026#34;snippet\u0026#34;][\u0026#34;likeCount\u0026#34;], \u0026#34;authorChannelId\u0026#34; : f\u0026#39;@{item[\u0026#34;snippet\u0026#34;][\u0026#34;topLevelComment\u0026#34;][\u0026#34;snippet\u0026#34;][\u0026#34;authorChannelId\u0026#34;][\u0026#34;value\u0026#34;]}\u0026#39;, \u0026#34;totalReplyCount\u0026#34; : item[\u0026#34;snippet\u0026#34;][\u0026#34;totalReplyCount\u0026#34;] } return comment def addDiffTime(self, comments): # 배열을 돌면서 현재댓글과 다음댓글의 시간차이를 저장합니다. # 마지막 댓글은 현재시간과 시간차이를 저장합니다. lastCommentDiffTime = (datetime.now() - datetime.strptime(comments[-1][\u0026#34;publishedAt\u0026#34;], \u0026#34;%y-%m-%d %H:%M:%S\u0026#34;)).total_seconds()/60 comments[-1].update({\u0026#34;diffTime\u0026#34;: lastCommentDiffTime}) for idx in range(0, len(comments)-1): curr = datetime.strptime(comments[idx][\u0026#34;publishedAt\u0026#34;], \u0026#34;%y-%m-%d %H:%M:%S\u0026#34;) next = datetime.strptime(comments[idx+1][\u0026#34;publishedAt\u0026#34;], \u0026#34;%y-%m-%d %H:%M:%S\u0026#34;) diffTime = (next-curr).total_seconds()/60 # 삭제되는 댓글이 있을 수 있어서 음수가 나오면 0으로 표시합니다. if diffTime \u0026lt; 0: diffTime = 0.00 comments[idx].update({\u0026#34;diffTime\u0026#34;: diffTime}) def printMostCommenter(self): # 가장 많이 댓글 작성한 사람을 MOST_CNT번 출력합니다. userDict = {} for comment in self.comments: username = f\u0026#39;{comment[\u0026#34;authorDisplayName\u0026#34;]} | {comment[\u0026#34;authorChannelId\u0026#34;]}\u0026#39; if username in userDict: userDict[username] += 1 else: userDict[username] = 1 mostCommenter = sorted(userDict.items(), key=lambda x:x[1], reverse=True) msg = f\u0026#39;\\n{LINE}\\n👑 가장 댓글 많이 작성한 사람 (총 {format(len(mostCommenter),\u0026#34;,\u0026#34;)}명)\\n\u0026#39; if len(mostCommenter) \u0026gt; MOST_CNT: for idx in range(0, MOST_CNT): msg += f\u0026#39;{mostCommenter[idx][1]}회 | {mostCommenter[idx][0]}\\n\u0026#39; else: for idx in range(0, len(mostCommenter)): msg += f\u0026#39;{mostCommenter[idx][1]}회 | {mostCommenter[idx][0]}\\n\u0026#39; self.printer.saveOnLocal(msg) def printMostLiker(self): # 가장 많이 좋아요 받은 댓글을 MOST_CNT번 출력합니다. mostLiker = [] for comment in sorter(self.comments, \u0026#39;likeCount\u0026#39;): if comment[\u0026#39;likeCount\u0026#39;] \u0026gt; 0: mostLiker.append(comment) else: mostLiker = mostLiker[:MOST_CNT] self.printComments(mostLiker, f\u0026#39;{LINE}\\n👑 가장 좋아요 많이 받은 댓글\u0026#39;) def printMostReply(self): # 가장 많이 답글 받은 댓글을 MOST_CNT번 출력합니다. mostReply = [] for comment in sorter(self.comments, \u0026#39;totalReplyCount\u0026#39;): if comment[\u0026#39;totalReplyCount\u0026#39;] \u0026gt; 0: mostReply.append(comment) else: mostReply = mostReply[:MOST_CNT] self.printComments(mostReply, f\u0026#39;{LINE}\\n👑 가장 많이 답글 달린 댓글\u0026#39;) def printMostDiffTime(self): # 가장 많이 버틴 댓글을 MOST_CNT번 출력합니다. mostDiffTime = sorter(self.comments, \u0026#39;diffTime\u0026#39;) self.printComments(mostDiffTime[:MOST_CNT], f\u0026#39;{LINE}\\n👑 가장 오래버틴 댓글 (삭제된 댓글은 집계하지 않습니다.)\u0026#39;) def printUsersComment(self, username): # 특정 유저가 작성한 댓글을 모두 출력합니다. userComment = [] for comment in self.comments: if comment[\u0026#34;authorDisplayName\u0026#34;] == username: userComment.append(comment) if len(userComment) == 0: self.printer.saveOnLocal(f\u0026#39;\\n{LINE}\\n👑 [{username}]님이 작성한 댓글이 없습니다\\n\u0026#39;) else: mostLike = sorter(userComment, \u0026#39;likeCount\u0026#39;)[0][\u0026#34;likeCount\u0026#34;] mostReply = sorter(userComment, \u0026#39;totalReplyCount\u0026#39;)[0][\u0026#34;totalReplyCount\u0026#34;] mostDiffTime = sorter(userComment, \u0026#39;diffTime\u0026#39;)[0][\u0026#34;diffTime\u0026#34;] self.printComments(userComment, f\u0026#39;{LINE}\\n👑 [{username}]이 작성한 댓글 (삭제된 댓글은 집계하지 않습니다.)\u0026#39;) self.printer.saveOnLocal(f\u0026#39;\\n총 [{format(len(userComment), \u0026#34;,\u0026#34;)}]개의 댓글을 작성하셨습니다.\u0026#39;) self.printer.saveOnLocal(f\u0026#39; 💚 [{username}]님이 받은 가장 많은 좋아요 갯수는 [{mostLike}]개 입니다\u0026#39;) self.printer.saveOnLocal(f\u0026#39; 📩 [{username}]님이 받은 가장 많은 답글 갯수는 [{mostReply}]개 입니다\u0026#39;) self.printer.saveOnLocal(f\u0026#39; ⏰ [{username}]님이 가장 오래 버틴 댓글은 [{mostDiffTime:.2f}]분 입니다\u0026#39;) def printComments(self, comments, type=\u0026#39;\u0026#39;): # 댓글들을 출력합니다. self.printer.saveOnLocal(f\u0026#34;\\n{type}\\n{SMALL_LINE}\u0026#34;) msg = \u0026#39;\u0026#39; for comment in comments: msg += self.commentPrettier(comment) msg += \u0026#39;\\n\u0026#39; self.printer.saveOnLocal(msg) def commentPrettier(self, comment): # 댓글을 출력할때 포맷을 정해줍니다. msg = f\u0026#39;{comment[\u0026#34;publishedAt\u0026#34;]}\\t닉네임: {comment[\u0026#34;authorDisplayName\u0026#34;]} | {comment[\u0026#34;authorChannelId\u0026#34;]}\\n\\t\\t\\t댓글: {comment[\u0026#34;textDisplay\u0026#34;]:.33s}\\n\\t\\t\\t좋아요: {comment[\u0026#34;likeCount\u0026#34;]} | 답글수: {comment[\u0026#34;totalReplyCount\u0026#34;]} | 버틴시간: {comment[\u0026#34;diffTime\u0026#34;]:.2f}분\\n{SMALL_LINE}\u0026#39; return msg def startDiffTimeEvent(self, stop): # 앞으로 실시간으로 댓글을 불러옵니다 # 프로그램이 추출 이후 삭제한 댓글들도 저장됩니다 msg = f\u0026#39;{LINE}\\n✋ *{stop}분 버티기 이벤트 시작*\\n\u0026gt; 제목: {self.videoInfo[\u0026#34;title\u0026#34;]}\\n\u0026gt; 지금부터 {YOUTUBEAPI_REQUEST_PER_TIME}초마다 새로운 댓글을 확인합니다.\\n{LINE}\\n\\n{SMALL_LINE}\u0026#39; self.printer.sendToChat(msg) url = f\u0026#34;{YOUTUBEAPI_COMMENTTHREADS_URL}?key={self.apikey}\u0026amp;maxResults={LIVE_MAXRESULTS}\u0026amp;part=snippet,replies\u0026amp;videoId={self.videoId}\u0026#34; # 이미 추출한 댓글의 마지막 댓글은 버틴시간이 없기 때문에 -1 까지 추출합니다. time.sleep(1) self.printer.sendToChat(self.commentPrettier(self.comments[-2])) while True: try: # 이미 추출한 댓글의 publishedAt, authorChannelId 를 합쳐서 고유 key를 생성합니다 prev = self.comments keys = [] for p in prev: key = f\u0026#39;{p[\u0026#34;publishedAt\u0026#34;]}{p[\u0026#34;authorChannelId\u0026#34;]}\u0026#39; keys.append(key) # comments 리스트에 받아놓는데 에러 발생하면 유튜브 api 키 변경 comments = [] response = requests.get(url).json() try: for item in response[\u0026#34;items\u0026#34;]: comments.append(self.validateComment(item)) except Exception as e: print(\u0026#34;ERROR\u0026#34;, e) self.youtubeAPIExceed() # 새로운 댓글이 나오면 New 배열에 저장합니다. found = False new = [] for comment in comments: key = f\u0026#39;{comment[\u0026#34;publishedAt\u0026#34;]}{comment[\u0026#34;authorChannelId\u0026#34;]}\u0026#39; if key not in keys: new.append(comment) self.comments.append(comment) found = True # 새로운 댓글 -1 까지 추출 (끝은 diffTime이 없기 떄문) winner = [] if found: self.addDiffTime(self.comments) for comment in self.comments[len(self.comments)-len(new)-1:-1]: if comment[\u0026#34;diffTime\u0026#34;] \u0026gt; int(stop): winner.append(comment) self.printer.sendToChat(self.commentPrettier(comment)) # 우승자가 있으면 프로그램 종료 if len(winner) \u0026gt; 0: msg = (f\u0026#34;\\n@channel\\n👑 {stop}분 버티기 당첨자가 나왔습니다.\\n\u0026#34;) msg += (f\u0026#39;{SMALL_LINE}\\n{self.commentPrettier(winner[0])}\\n\u0026#39;) msg += (\u0026#34;이벤트를 종료합니다.\u0026#34;) self.printer.sendToChat(msg) break # 계속해서 리퀘스트를 보내면 api 일일횟수가 금방 달성하기 떄문에 sleep # 10초가 적당합니다. time.sleep(YOUTUBEAPI_REQUEST_PER_TIME) except Exception as e: # 에러가 나면 api 일일횟수 초과 api 키 변경 print(\u0026#34;ERROR\u0026#34;, e) self.youtubeAPIExceed() os.system(\u0026#39;clear\u0026#39;) youtube = Youtube() synology api Synology 챗봇으로 텍스트를 전송하기 위한 간단한 API입니다. 장고로 제작했습니다.\nsynology api 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 class Comment(APIView): title = \u0026#34;배돌이의당구생활\u0026#34; headers = {\u0026#39;Content-Type\u0026#39;: \u0026#39;text/text; charset=utf-8\u0026#39;} synology = SynologyChat() baedori_url = BAEDORI_BOT_URL def get(self, request): msg = request.data.get(\u0026#34;msg\u0026#34;) self.synology.send_message(self.baedori_url, self.title, msg) return Response(status=200) class SynologyChat(): headers = {\u0026#39;Content-Type\u0026#39;: \u0026#39;text/text; charset=utf-8\u0026#39;} def send_message(self, url, title, message): message = self.clean_text(message) payload = \u0026#39;payload={\u0026#34;text\u0026#34;: \u0026#34;\u0026#39; + message + \u0026#39;\u0026#34;}\u0026#39; res = requests.post(url, data=payload.encode(\u0026#39;utf-8\u0026#39;), headers=self.headers) time.sleep(1) if res.json()[\u0026#34;success\u0026#34;] != True: self.send_error(res, title) def send_error(self, res, title): LogSave(f\u0026#34;\u0026gt;{ADMIN_NICKNAME} {title} \\nres \u0026gt; {res.json()}\u0026#34;) def clean_text(self, text): text = text.replace(\u0026#34;%\u0026#34;,\u0026#34;퍼센트\u0026#34;) # text = re.sub(\u0026#39;[%+#\\^@*\\\u0026#34;※~ㆍ!\u0026amp;;』‘\\(\\)\\\\\\\u0026#39;…》\\”\\“\\’·]\u0026#39;, \u0026#39; \u0026#39;, text) text = re.sub(\u0026#39;[%+#\\^\\\u0026#34;※~ㆍ!\u0026amp;;』‘\\\\\\\u0026#39;…》\\”\\“\\’·]\u0026#39;, \u0026#39; \u0026#39;, text) text = text.replace(\u0026#34;\u0026lt;/p\u0026gt;\u0026#34;,\u0026#34; \u0026#34;).replace(\u0026#34;\u0026lt;p\u0026gt;\u0026#34;,\u0026#34;\u0026#34;) text = text.replace(\u0026#34;\u0026lt;br\u0026gt;\u0026#34;, \u0026#34; \u0026#34;).replace(\u0026#34;nbsp\u0026#34;, \u0026#34; \u0026#34;) text = text.replace(\u0026#34; \u0026#34;, \u0026#34; \u0026#34;).replace(\u0026#34; \u0026#34;,\u0026#34; \u0026#34;) return text exe 파일로 추출 utils.py 코드를 pyinstaller로 exe파일로 추출합니다. 가상환경을 pipenv를 사용했습니다.\n1 2 pipenv install pyinstaller pipenv run pyinstaller -F utils.py 위 커맨드를 진행하면 프로젝트 상위 폴더에 \u0026lsquo;dist\u0026rsquo; 디렉토리가 생성되고 exe 파일이 생성됩니다.\n크기는 약 7MB며 아이콘은 따로 변경했습니다.\n결과 1. 모든댓글조회 2. 가장 많은 좋아요 댓글 조회 3. 가장 많은 답글수 댓글 조회 4. 가장 많이 작성한 댓글 조회 5. 가장 많이 작성한 댓글 조회 6. 닉네임 조회 7. 댓글 버티기 이벤트 (일정시간마다 계속 새로운 댓글 확인) 로컬 파일 저장 \u0026amp; 시놀로지 챗봇 프로그램에 출력되는 모든 글은 동시에 로컬에도 저장됩니다.\n7번 댓글 버티기 이벤트 진행시 챗봇으로 모든 댓글이 오게 해놓고 당첨자 발생시 채널에 알럿이 오게 했습니다.\n마치며 유튜브에서 제공하는 API로 여러가지 이벤트를 기획할 수 있습니다. 다음번에는 인스타그램 API로 비슷한 이벤트를 진행해볼까 계획중입니다. 궁금하신 점이 있으시면 댓글로 남겨주시기 바랍니다.\n도움이 되셨으면 배돌이의 당구생활 에도 한번 놀러와주세요 ! 다양한 이벤트 진행중 ~💚\n","permalink":"https://cha2hyun.blog/content/projects/%EB%B0%B0%EB%8F%8C%EC%9D%B4%EC%9D%98%EB%8B%B9%EA%B5%AC%EC%83%9D%ED%99%9C/youtube-comment-event/","summary":"유튜브에 댓글이 N분동안 달리지 않으면 승리하게 됩니다. feat)팩토리","title":"유튜브 API를 이용한 댓글 오래 버티기 이벤트"},{"content":"\n들어가며 김치빌리아드, 큐찾사 유튜브채널 배돌이의 당구생활의 컨텐츠인 라이브 개인큐 랜덤 추첨에서 실시간 시청자들의 댓글을 긁어와서 추첨하는게 필요했습니다.\n배당생 개인큐 추첨 라이브 진짜 100% 진짜..\n이벤트는 매우 매우 간단한 방법으로 채팅에 참여한 사람들을 추출해서 돌림판에 넣고 추첨합니다. 초반에는 유튜브에서 제공하는 채팅방 실시간 참여자를 드래그해서 추첨판에 복붙하였습니다.\n저의 부캐 채PD로 제가 직접 방송에 참여하면서 컴퓨터를 조작하면 괜찮지만 방송에 참여하지 못하는 날엔 진행에 어려운 부분이 있어서 클릭 한 번만 하면 자동으로 추첨까지 해주는 실행파일 프로그램을 만들기로 했습니다.\n유튜브 라이브 실시간 댓글 크롤링 결과 미리보기\n구상한 순서는 다음과 같습니다.\n배돌이가 채팅 참여해주세요 라고 말하고 크롤링 프로그램을 킨다. 배돌이가 마감합니다 라고 말하고 아무 키를 누르면 크롤링이 끝난다 채팅에 참여한 모든 사람들을 추출한다. 셀레늄으로 돌림판 제공하는 사이트를 띄우고 추출한 사람들을 자동으로 넣고 돌린다. 라이브러리 유튜브 라이브의 경우 서드파티 라이브러리가 많이 있습니다. Don't reinvent the wheel이란 말처럼 (귀차니즘) 라이브러리를 사용하기로 했고 여기서 사용한 라이브러리는 pychat 입니다.\nThread 추첨을 시작하고 추첨을 끝내기 위해선 어떠한 이벤트가 발생해야 하는데 키보드의 아무 키가 입력되면 멈추게끔 프로그램을 작동시켜야합니다. 그러기 위해서는 멀티쓰레드로 키보드 이벤트를 감지하는 쓰레드가 하나 계속 돌고있어야 합니다. 코드 youtube 크롤링 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 import pyperclip import pytchat import time import threading as th import os from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager # 키보드 이벤트 감지 CAPTURING = True def stop_capture(): global CAPTURING input() CAPTURING = False def start_capture(video_id): # 키보드 이벤트 감지를 쓰레드로 실행 th.Thread(target=stop_capture, args=(), name=\u0026#39;stop_capture\u0026#39;, daemon=True).start() chat = pytchat.create(video_id=video_id) author_name = [] start = time.strftime(\u0026#39;%Y-%m-%d %H-%M-%S\u0026#39;, time.localtime(time.time())) today = time.strftime(\u0026#39;%Y-%m-%d\u0026#39;, time.localtime(time.time())) # 결과값을 저장할 위치 current = os.getcwd() path = f\u0026#39;{current}/{today}\u0026#39; try: if not os.path.exists(path): os.makedirs(path) except OSError: print(\u0026#34;Error: Cannot create the directory {}\u0026#34;.format(path)) fp = open(f\u0026#39;{path}/{start}.txt\u0026#39;, \u0026#39;w\u0026#39;) cnt = 0 # 크롤링 시작 while CAPTURING and chat.is_alive(): try: for c in chat.get().sync_items(): if c.author.name not in author_name: author_name.append(f\u0026#39;{c.author.name} | @{c.author.channelId}\u0026#39;) print(f\u0026#34;[{c.datetime[11:]}] 아이디: {c.author.name} | @{c.author.channelId}\\n\\t 내용: {c.message}\u0026#34;) fp.write(f\u0026#34;[{c.datetime[11:]}] 아이디: {c.author.name} | @{c.author.channelId}\\n\\t 내용: {c.message}\\n\u0026#34;) cnt += 1 except KeyboardInterrupt: break chat.terminate() end = time.strftime(\u0026#39;%Y-%m-%d %H:%M:%S\u0026#39;, time.localtime(time.time())) author_name.sort() author_name = (list(set(author_name))) print(\u0026#39;\\n====================================================================\u0026#39;) print(f\u0026#34;배돌이의당구생활 라이브 추첨 댓글 추출\\n시작 {start} | 종료 {end}\u0026#34;) print(f\u0026#34;채팅에 참여한 사람 {len(list(set(author_name)))}명 (중복제거) | 총 채팅수 : {format(cnt, \u0026#39;,\u0026#39;)}개\u0026#34;) print(author_name) print(\u0026#39;====================================================================\\n\\n\u0026#39;) print(\u0026#39;추출을 종료합니다.\u0026#39;) fp.write(\u0026#39;\\n====================================================================\\n\u0026#39;) fp.write(f\u0026#34;배돌이의당구생활 라이브 추첨 댓글 추출\\n시작 {start} | 종료 {end}\\n\u0026#34;) fp.write(f\u0026#34;채팅에 참여한 사람 {len(list(set(author_name)))}명 (중복제거) | 총 채팅수 : {cnt}개\\n\u0026#34;) fp.write(f\u0026#34;{author_name}\\n\u0026#34;) fp.write(\u0026#39;====================================================================\\n\u0026#39;) fp.close() time.sleep(0.5) picker(author_name) return author_name def list_chuck(arr, n): return [arr[i: i + n] for i in range(0, len(arr), n)] def picker(author_name): try: driver = webdriver.Chrome(service=Service(ChromeDriverManager().install())) url = \u0026#39;https://spinnerwheel.ahaslides.com/?entries=\u0026amp;action=true\u0026#39; driver.get(url) driver.implicitly_wait(3) input_box = driver.find_element(By.XPATH, \u0026#39;//*[@id=\u0026#34;aha-spinner-wheel\u0026#34;]/div[2]/div/div[3]/div[2]/form/div/div/input\u0026#39;) add_button = driver.find_element(By.XPATH, \u0026#39;//*[@id=\u0026#34;aha-spinner-wheel\u0026#34;]/div[2]/div/div[3]/div[2]/form/button\u0026#39;) result = list_chuck(author_name, 30) for r in result: text = \u0026#39;,\u0026#39;.join(r) JS_ADD_TEXT_TO_INPUT = \u0026#34;\u0026#34;\u0026#34; var elm = arguments[0], txt = arguments[1]; elm.value += txt; elm.dispatchEvent(new Event(\u0026#39;change\u0026#39;)); \u0026#34;\u0026#34;\u0026#34; driver.execute_script(JS_ADD_TEXT_TO_INPUT, input_box, text) time.sleep(1) input_box.send_keys(\u0026#39; \u0026#39;) add_button.click() while(1): pass except: pass print(\u0026#39;\\n========================================\u0026#39;) print(f\u0026#34;배돌이의당구생활 라이브 추첨 댓글 추출\u0026#34;) video_id = input(\u0026#34;영상 URL을 입력해주세요 : \u0026#34;) print(f\u0026#34;확인되었습니다. 댓글 추출을 시작합니다.\u0026#34;) print(\u0026#39;========================================\u0026#39;) author_name = start_capture(video_id) 코드설명 pychat 라이브러리를 사용하기 떄문에 코드가 매우 간단합니다. start_capture로 캡쳐가 시작되고 함께 th.Thread(target=stop_capture, args=(), name='stop_capture', daemon=True).start()로 stop_capture 함수를 쓰레드로 시작합니다.\nstop_capture에서 input()을 기다리고 있는데 이는 실행중에 키보드값이 입력되면 global로 선언된 CAPTURING 변수를 false로 변경하여 크롤링을 멈추게 됩니다. 쓰레드를 활용하는 환경에서 전체적으로 참조해야할 변수를 선언하려면 꼭 global을 사용해야합니다.\n키보드 이벤트가 감지되어 추출이 멈추게되면 picker 함수를 실행시킵니다. 해당 함수는 selenium을 이용하여 결과값을 돌림판에 입력하는것 까지 자동으로 진행되게 합니다. 닉네임 중복이 가능하므로 유저의 고유번호까지 추출되어야하며 나중에 비교하기 위해서 텍스트 파일로 결과를 저장합니다. 추출 결과값을 브라우저의 인풋에 넣고 확인을 눌러줘야 하는데 이때 어쩔 수 없이 자바스크립트 문법이 필요하였습니다.\n실행파일 실행파일로 만들고 어떠한 환경에서도 실행되어야 해서 기존의 웹드라이버를 수동으로 다운받아서 옮기지 않고 자동으로 다운로드 받아야하여 다음 명령어로 웹드라이버를 로드하였습니다. webdriver.Chrome(service=Service(ChromeDriverManager().install()))\n실행파일로 만들어주기 위해 pyinstaller 라이브러리를 사용했습니다. 이렇게 하면 최신 드라이버를 받아주고 실행파일 용량압박에도 살아남을 수 있게됩니다.\n이미지도 깜찍하게\n결과 실제로 어떻게 작동되는지 궁금하시면 다음 링크나 아래 영상을 확인해주세요. 도움이 되셨다면 구독과.. 좋..아\u0026hellip;요\u0026hellip;\n","permalink":"https://cha2hyun.blog/content/projects/%EB%B0%B0%EB%8F%8C%EC%9D%B4%EC%9D%98%EB%8B%B9%EA%B5%AC%EC%83%9D%ED%99%9C/youtube-live-crawling/","summary":"exe 실행파일로 추출하여 추첨까지 자동화","title":"유튜브 라이브 실시간채팅 크롤링"},{"content":"\n어느 순간 깃헙 프로필 꾸미기에 관심이 생기기 시작하여 1일 1커밋으로 꾸준히 잔디를 심거나 팔로워들 활동을 보면서 어떤 레포들이 있는지 구경하는게 습관이 되었습니다.\n1일 1커밋은 진짜 어려운 것..\nNext.js를 독학으로 배우면서 프로젝트를 같이 진행 하고 있는데, 도큐멘트나 스택오버플로우를 아무리 봐도 모르겠는 막히는 부분이 생길때면 issue나 discussion을 활용합니다 (거기에 진짜 찐 고수님들이 많습니다). 자연스럽게 거기서 답변해주고 소통하는 문화에 익숙해지면서 저도 답변하거나 쨉쩁이 이슈들을 날리기 시작했습니다\nVercel, nextauthjs, Tanstack 같이 대형 레포들에 이슈를 날려보았지만 대부분 제 코드 문제였습니다 ㅋㅋ\n아무튼!! 그렇게 쨉쨉이들을 날리면서 레포들을 구경하던중 @onesine/react-tailwindcss-select라는 정말 마음에 드는 select box 관련 라이브러리를 찾게 되었고 프로젝트에도 바로 적용하였습니다.\n가장 만만한 Readme에 오타 정도를 쨉쨉이로 날리다가 오타 수정 하나로 다른 사람에게 도움이 된 것을 발견하고 이때부터 뭔가 나도 누군가에 도움이 되었다는 뿌듯함에 issue 작성 매력에 푹 빠지게 되었습니다. Error in Options component issue #8\n\u0026rsquo;l\u0026rsquo;하나 오타였을 뿐인데\n그렇게 매력에 빠져버린 저는 프로젝트에 적용하면서 추가적인 기능들이나 버그들을 수정해서 사용하고 있었는데 혹시나 이런것도 PR을 받아줄까 하고 올려봤습니다. 첫번째로는 검색하는 단어들에 하이라이트 쳐주는것 이었습니다. some idea of label styles (highlighting searchinput value) issue #18\n프로젝트에 실제로 적용했던 하이라이팅\n두번째로는 인풋창이 애니메이션으로 열릴때 세로 길이가 작은 모바일 환경에서 열리는 창이 세로길이에 가려져서 스크롤 되지 않는 버그를 수정하였습니다. scrolling issue on mobile #19 그렇게 issue에서 제작자와 이러쿵 저러쿵 이야기들을 주고받던 중 메일 한통을 받게되는데 깃에 올려놨던 메일에 누군가가 메일준게 처음이라 ..\n제작자에게 Collaborator가 되어달라는 메일을 받게 됩니다 🥹 그렇게 메일을 주고 받고 Collaborator 초대를 승락하니 Contributors에 올라가게 되었습니다.\n첫 collaborator 👏\nStared수는 아직 적지만 npm이나 yarn 으로 받을수 있는 패키지에 기여를 했다는게 뿌듯함을 느끼네요.\n생태계 기여 뿐만 아니라 원작자와 국경을 너머 이런 저런 개발 이야기를 할 수 있다는 점도 뭔가 성장하는 기분입니다. 여러분들도 한번 도전해보세요 👏\n확인해보기 👇\nyarn add react-tailwindcss-select\n🔗 https://github.com/onesine/react-tailwindcss-select\n","permalink":"https://cha2hyun.blog/content/posts/firstcollaborator/","summary":"오픈소스 생태계에 기여하기","title":"처음으로 오픈소스 Collaborator가 되다"},{"content":"들어가며 이전 글에서 FastAPI로 제작한 전국 스키장 슬로프 실시간 정보 (빠르게)크롤링 하기에서 크롤링한 데이터는 RDS서버에 저장되었습니다. 이후 다른 팀원들과 협업하여 Spring(깃헙)으로 API 서버를 제작하였습니다. 이번 글에서는 Next.js의 프로젝트를 서버에 첫 배포하는 과정을 기록하였습니다.\n낭만스키 웹은 어떻게 제작되었나요 ? 낭만스키 프론트는 제가 제일 좋아하는 조합인 Next.js + Typescript + Tailwind CSS 조합으로 제작하였습니다. 스타터팩 theodorusclarence/ts-nextjs-tailwind-starter를 설치하시면 편합니다. 실행은 PM2를 이용하여 클러스터 모드로 여러 코어를 이용할 수 있게 하였습니다.\n낭만스키 PC버전 미리보기 낭만스키 첫번째 웹\n낭만스키 모바일버전 미리보기 당연히 모바일 반응형으로 제작\n","permalink":"https://cha2hyun.blog/content/projects/%EB%82%AD%EB%A7%8C%EC%8A%A4%ED%82%A4/first_deploy/","summary":"Next.js + EC2 + Route53 + ACM\u0026amp;Loadbalancer(https) + CI/CD(github action).","title":"낭만스키 웹 배포 과정"},{"content":"들어가며 다룰 내용 낭만스키의 기능중 하나인 전국의 스키장의 모든 슬로프 현황을 실시간으로 불러와서 동일한 포맷의 형태로 제공해주는 것을 만드는 과정에 대해서 작성합니다.\n슬로프 현황은 0.1초가 중요할 만큼 실시간성이 필요하지 않기 때문에 크롤링만 담당하는 크롤러 서버를 구축해서 일정 시간마다 한번씩 모든 리조트 사이트를 돌면서 DB에 저장하는 방식을 사용했습니다. 프론트에서 슬로프 현황을 요청할때는 메인 API에서 저장한 DB로 부터 데이터를 표시했습니다.\n여러 시행착오 끝에 가장 빠르게, 가장 효과적인 구조로 크롤링을 하는 방법에 대해 작성했습니다.\n실행 환경 로컬에서 테스트용으로 사용되는 환경은 다음과 같습니다.\n1 2 3 OS : MacOS Monterey (Mac Studio) Versions : pipenv(pyenv python3.10.3), FastAPI(0.84) Etc : Talend Api Tester, Mysql(AWS RDS) requests vs grequests vs aiohttp 무엇이 제일 빠른가? 왜 빨라야 할까? [문제점] 전국의 리조트 15곳을 순차적으로 크롤링하기 떄문에 유저가 업데이트된 정보를 요청하거나 문제가 생겼을 때 강제로 다시 크롤링 로직을 도는데 결과를 반환받기 까지 수초가 걸렸습니다.\n크롤링 -\u0026gt; db insert -\u0026gt; 다음 리조트 크롤링 -\u0026gt; ... 반복 -\u0026gt; 메인 서버에서 결과값 반환\n[해결방안] aiohttp를 이용해서 비동기 방식으로 변경하고 DB에 insert 할때도 크롤링 결과를 모아 bulk_update를 이용하는 방식으로 변경하여 걸리는 시간을 0.5초 이내로 낮출 수 있었습니다.\n비동기 크롤링 -\u0026gt; bulk_update로 한번에 insert -\u0026gt; 메인 서버에서 결과값 반환\n[테스트방법] requests, grequests, aiohttp를 로컬에서 돌아가는 테스트 서버에 100번씩 요청을 보냈을때 속도를 비교해보고 가장 빠른 방법을 선택했습니다.\n테스트로 사용할 간단한 FastAPI를 실행시켰습니다. 요청을 0.1초 후에 리턴합니다.\n1 2 3 4 5 6 # url : http://127.0.0.1:8002/test/{num} @app.get(\u0026#34;/test/{num}\u0026#34;) def get_test(num): import time time.sleep(0.1) return {\u0026#34;num\u0026#34;: num} requests 테스트 테스틀 위해서 앞서 만든 url로 get 요청을 100번 보내서 평균을 내어 1개 요청에 몇초가 소요됬는지 확인해봅니다.\nInstall requests\n1 pipenv install requests code\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 def request_test(): import requests import time start = time.time() cnt = 100 for i in range(1, cnt + 1): url = f\u0026#34;http://127.0.0.1:8002/test/{i}\u0026#34; res = requests.get(url) total = round(time.time() - start, 4) average = round(total/cnt, 4) print(f\u0026#34;Total -\u0026gt; {total}sec\u0026#34;) print(f\u0026#34;Avg -\u0026gt; {average}sec\u0026#34;) request_test() result\n1 2 Total -\u0026gt; 11.1368sec Avg -\u0026gt; 0.1114sec grequests 테스트 grequests 는 Gevent를 이용하여 비동기 http request를 할 수 있는 라이브러리입니다. https://github.com/spyoungtech/grequests 마찬가지로 100번의 요청을 보내봤습니다.\ninstall grequests\n1 pipenv install grequests code\n1 2 3 4 5 6 7 8 9 10 11 12 13 def grequest_test(): import grequests import time cnt = 100 urls = [f\u0026#34;http://127.0.0.1:8002/test/{i}\u0026#34; for i in range(1, cnt+1)] start = time.time() rs = (grequests.get(u) for u in urls) total = round(time.time() - start, 4) average = round(total/cnt, 4) print(f\u0026#34;Total -\u0026gt; {total}sec\u0026#34;) print(f\u0026#34;Avg -\u0026gt; {average}sec\u0026#34;) grequest_test() result\n1 2 Total -\u0026gt; 0.4132sec Avg -\u0026gt; 0.0041sec aiohttp 테스트 비동기 크롤링에 가장 보편적으로 많이 사용되는 라이브러리 입니다. 100번의 요청을 보냅니다.\ninstall aiohttp\n1 pipenv install aiohttp 100번의 요청을 보내봅니다.\ncode\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 import asyncio import time from aiohttp import ClientSession async def aiohttp_test(url): async with ClientSession() as session: async with session.get(url) as response: return await response.read() cnt = 100 start = time.time() loop = asyncio.get_event_loop() coroutines = [aiohttp_test(f\u0026#39;http://127.0.0.1:8002/test/{i}\u0026#39;) for i in range(cnt)] results = loop.run_until_complete(asyncio.gather(*coroutines)) total = round(time.time() - start, 4) average = round(total/cnt, 4) print(f\u0026#34;Total -\u0026gt; {total}sec\u0026#34;) print(f\u0026#34;Avg -\u0026gt; {average}sec\u0026#34;) result\n1 2 Total -\u0026gt; 0.3705sec Avg -\u0026gt; 0.0037sec 속도 비교 결과 1개의 요청을 처리하는데 걸리는 평균 시간\nrequests : 0.1114sec grequests : 0.0041sec aiohttp : 0.0037sec 🏆 라이브러리 선택 여러번 계속 테스트 해봐도 aiohttp가 가장 빨랐습니다. 내부망에서 왔다갔다 하는거라 큰 차이가 나진 않았던 것 같습니다. 내부망이 아닌 request 테스트 사이트 requestcatcher에서 테스트해본 결과도 동일하였습니다.\n0.1초후에 응답하는 api에 100번의 요청을 보냈을 때 싱글스레드(requests)는 평균 0.1114sec 초로 한개의 요청을 끝내고 다음 요청을 진행하는 것을 볼 수 있었습니다. 비동기를 이용했을때는 평균 약 0.004초가 걸렸습니다. 제 환경에서는 약 25개의 요청을 동시에 처리할 수 있다는 점을 알 수 있었습니다.\n크롤링하는 서버의 성능에 따라 인터넷 속도나 얼만큼 많은 쓰레드를 사용할 수 있는가에 따라 속도 차이가 좀 더 나겠지만, 전국 스키장의 수가 20개가 되지 않으므로 비동기를 이용하여 모든 스키장을 크롤링을 동시에 진행할 것이고 최소 시간은 가장 크롤링이 오래걸리는 스키장이 될 것으로 예상했습니다.\nFastAPI + aiohttp로 크롤링하기 FastAPI 선택 이유 크롤링 서버는 메인서버에서 분리되어 일정 시간마다 크롤링 \u0026gt; DB Insert만 하는 아주 가볍게 돌아가는 서버이기 떄문에 Django나 Flask보다 가벼운 FastAPI를 선택했습니다. 간혹 오류나 크롤링 실패로 인해서 관리자나 프론트에서 유저가 다시 크롤링하라는 요청을 보내기 위해 get 요청을 받을 수 있게 api로 진행했습니다.\n프로젝트 트리 구조 스키장별로 제공하는 홈페이지가 모두 달랐습니다. 정적페이지로 제공하는지, API가 있는지 등 스키장마다 모두 다른 형태로 리턴받아서 구조와 클린코드를 어떻게 짜야할지 고민이 많았습니다. 같은 형식이 아닌 다양한 여러 페이지를 가장 빠르게 크롤링하고 어떤식으로 구조를 잡았는지, DB에는 어떻게 효과적으로 Insert 했는지 과정을 소개합니다.\nFastAPI 프로젝트 tree\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 . ├── Dockerfile ├── docker-compose.yml ├── .env ├── requirements.txt ├── scripts │ └── db_to_orm.sh └── src ├── config (RDS 연결) │ ├── __init__.py │ └── database.py ├── constant.py (상수값 저장) ├── crawler (크롤링) │ ├── __init__.py │ ├── crawler.py ├── main.py (api) ├── model (DB모델) │ ├── __init__.py │ └── models.py ├── service (DB insert) │ ├── __init__.py │ └── slope_time_service.py └── utils (결과 Discord 전송) └── webhook.py main.py 에서 get 요청을 처리합니다. 크롤링과 관련된 내용은 crawler.py에 저장하고 크롤링에 필요한 상수값 (resort_code, resort_name 등)은 DB에서 불러와도 되지만 상수로 저장해놓고 사용하는게 더 빠르기 때문에 constant.py에 저장했습니다.\n처리순서는 다음과 같습니다.\n1 2 3 4 5 6 7 8 1. 크롤링 요청 2. aiohttp client를 생성하고 3. 코루틴 리스트 배열을 만들고 4. 비동기로 모든 리조트를 크롤링 해서 5. aiohttp client를 종료 후 6. 결과값을 배열에 모아서 7. bulk_update로 DB에 저장하고 8. 크롤링 결과를 Discord에 푸쉬 코드 cralwer \u0026gt; crawlerV3.py 여러번 테스트 결과 aiohttp 적용이 가장 빨랐다. (좌측:requests, 우측:aiohttp)\nclass SingletonAiohttp\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 class SingletonAiohttp: sem: Optional[asyncio.Semaphore] = None aiohttp_client: Optional[aiohttp.ClientSession] = None @classmethod def get_aiohttp_client(cls) -\u0026gt; aiohttp.ClientSession: if cls.aiohttp_client is None: timeout = aiohttp.ClientTimeout(total=5) connector = aiohttp.TCPConnector( family=AF_INET, limit_per_host=SIZE_POOL_AIOHTTP, ssl=False ) cls.aiohttp_client = aiohttp.ClientSession( timeout=timeout, connector=connector, trust_env=True ) return cls.aiohttp_client @classmethod async def close_aiohttp_client(cls) -\u0026gt; None: if cls.aiohttp_client: await cls.aiohttp_client.close() cls.aiohttp_client = None @classmethod async def crawl(cls, resortName: ResortName) -\u0026gt; Any: client = cls.get_aiohttp_client() resort = Resort() try: async with client.get(url=FakeDB[resortName][\u0026#34;url\u0026#34;]) as response: if response.status != 200: return {\u0026#34;ERROR OCCURED\u0026#34; + str(await response.text())} html = await response.text() if resortName == ResortName.jisan: result = resort.jisan(html) # ... elif resortName == ResortName.otwo: result = resort.otwo(html) return result except Exception as e: return {\u0026#34;ERROR\u0026#34;: e} ... async def on_start_up() -\u0026gt; None: fastAPI_logger.info(\u0026#34;on_start_up\u0026#34;) SingletonAiohttp.get_aiohttp_client() async def on_shutdown() -\u0026gt; None: fastAPI_logger.info(\u0026#34;on_shutdown\u0026#34;) await SingletonAiohttp.close_aiohttp_client() class Utility\n스키장에 따라서 어떤 곳은 슬로프명이 x축에, 어떤 곳은 y축에 있었고 어떤 곳은 슬로프 오픈 정보와 슬로프 난이도 길이 등을 같이 표시하는 곳이 있었습니다. 이런 데이터들을 규격화 하는 과정이 필요했고 크롤링 하면 무조건 2차원 배열로 [[구분, 슬로프1, 슬로프2], [시간,O,X,..], .. ,[시간,O,X,..]] 만들어서 크롤링이 성공하면 inSuccess 함수에서 DB에 넣는 형식으로 리턴해주었습니다. 크롤링하다가 Exception 발생시에는 해당 리조트의 크롤링 여부가 실패했음을 inFailed 함수가 실행됩니다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 class Utiliy: def inSuccess(self, resort_name, array): resortContstantDB = ResortConstant[resort_name] # ... DB 형식에 맞게 수정 array 인자는 무조건 2차원배열로 들어온다 return { \u0026#34;fetch_status\u0026#34;: { \u0026#34;resort_code\u0026#34;: resortContstantDB[\u0026#34;resort_code\u0026#34;], \u0026#34;fetch_status\u0026#34;: \u0026#34;O\u0026#34;, }, \u0026#34;slope_open_yn\u0026#34;: result, } def inFailed(self, resort_name): resortContstantDB = ResortConstant[resort_name] return { \u0026#34;fetch_status\u0026#34;: { \u0026#34;resort_code\u0026#34;: resortContstantDB[\u0026#34;resort_code\u0026#34;], \u0026#34;fetch_status\u0026#34;: \u0026#34;X\u0026#34;, }, \u0026#34;slope_open_yn\u0026#34;: [], } class Reosrt\n리조트별로 크롤링 해서 2차원 배열로 리턴합니다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 class Resort(Utiliy): # API로 불러올 경우 JSON def wellyhilly(self, res): resort_name = ResortName.duckyousan response = json.loads(res) # ... try: datas = response[\u0026#34;data\u0026#34;] # ... 형식에 맞게 수정 return self.makeDbInputData(resort_name, array) except Exception as e: print(\u0026#34;ERROR : \u0026#34;, e) return self.fetchFailed(resort_name) # 정적페이지인 경우 def otwo(self, html): resort_name = ResortName.otwo # ... try: html = BeautifulSoup(html, \u0026#34;lxml\u0026#34;) # ... 크롤링 return self.makeDbInputData(resort_name, array) # return self.fetchFailed(resort_name) except Exception as e: print(\u0026#34;ERROR : \u0026#34;, e) return self.fetchFailed(resort_name) config \u0026gt; database.py .env에서 rds 주소를 불러와서 db연결 세션을 만듭니다.\n1 2 3 4 5 6 7 8 9 10 11 from sqlalchemy import create_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker from os import getenv SQLALCHEMY_DATABASE_URL = getenv(\u0026#34;DATABASE_URL\u0026#34;) engine = create_engine(SQLALCHEMY_DATABASE_URL) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base = declarative_base() service \u0026gt; slope_time_service.py\n성공, 실패 여부를 모아놓은 fetch_status_list와 슬로프 현황이 담긴 slope_open_yn_list를 db에 넣어줍니다. bulk_update를 이용하면 한번의 커넥션으로 데이터들을 전송할 수 있습니다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 from sqlalchemy.orm import Session, aliased from sqlalchemy import select from sqlalchemy import and_ from ..config.database import SessionLocal, engine from contextlib import contextmanager from fastapi.concurrency import contextmanager_in_threadpool from ..model import models models.Base.metadata.create_all(bind=engine) def get_db(): db = SessionLocal()4 try: yield db finally: db.close()4 async def update_slope_time_bulk(fetch_status_list: list, slope_open_yn_list: list): async with contextmanager_in_threadpool(contextmanager(get_db)()) as db: try: db.bulk_update_mappings(models.SkiResort, fetch_status_list) db.bulk_update_mappings(models.SlopeTime, slope_open_yn_list) db.commit() except Exception as e: print(\u0026#34;update_slope_time_bulk Exeption :\u0026#34;, e) db.rollback() raise main.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 app = FastAPI(docs_url=\u0026#34;/\u0026#34;, on_startup=[on_start_up], on_shutdown=[on_shutdown]) @app.get(\u0026#34;/slope/update\u0026#34;) async def fasterUpdateSlope() -\u0026gt; Dict[str, int]: start = time.time() # Async async_calls: List[Coroutine[Any, Any, Any]] = list() # store all async operations async_calls.append(SingletonAiohttp.crawl(ResortName.jisan)) # ... async_calls.append(SingletonAiohttp.crawl(ResortName.duckyousan)) results = await asyncio.gather(*async_calls) # wait for all async operations # Make Data .... # DB Push \u0026amp; Discord try: await slope_time_service.update_slope_time_bulk( fetch_status_list, slope_open_yn_list ) # ... if not DEBUG: discord_webhook(discord_title, discord_msg) return { \u0026#34;fetch_status_list\u0026#34;: fetch_status_list, \u0026#34;slope_open_yn_list\u0026#34;: slope_open_yn_list, } except Exception: # ... if not DEBUG: discord_webhook(discord_title, discord_msg + \u0026#34;`\u0026#34;) raise HTTPException(status_code=500, detail=\u0026#34;Server error\u0026#34;) 결과 8개 정도 리조트를 크롤링 했을때 0.3초 ~ 0.4초정도 걸렸습니다. RDS를 아직 저사양 인스턴스를 사용해서 db insert 시간이 더 길었습니다.\n슬로프 오픈 현황 (open_yn) 크롤링 성공 여부 (실패시 마지막 성공 데이터를 보여줍니다)\n꿀팁 디스코드로 결과 보내기 결과를 팀원들에게 실시간으로 공유하고 오류가 있을때는 @channel과 함께 디스코드로 푸쉬를 보냅니다.d\n결과는 discord로 받습니다. Rds 성능이 낮아서 DB Insert 속도가 조금 느립니다.\n1 2 3 4 5 6 7 8 9 10 11 12 13 import requests from os import getenv from dotenv import load_dotenv def discord_webhook(title, msg): load_dotenv() url = getenv(\u0026#34;DISCORD_CRAWLER_MONITOR_URL\u0026#34;) data = { \u0026#34;content\u0026#34;: msg, \u0026#34;username\u0026#34;: title, } requests.post(url, json=data) CSR 페이지 공략 CSR로 만들어져서 브라우져가 렌더링하는 페이지일 경우에 최대한 셀레늄을 사용하지 않으려고 선택한 방법입니다. (\u0026hellip;계속)\n","permalink":"https://cha2hyun.blog/content/projects/%EB%82%AD%EB%A7%8C%EC%8A%A4%ED%82%A4/fastapi_aiohttp/","summary":"서버에서 크롤링을하여 데이터를 가공하여 DB에 저장해야 할때 어떤 방법이 가장 적합했는지 기록합니다.","title":"전국 스키장 슬로프 실시간 정보 (빠르게)크롤링 하기"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/49189\n1 2 3 4 5 6 7 8 9 10 11 12 13 문제 설명 n개의 노드가 있는 그래프가 있습니다. 각 노드는 1부터 n까지 번호가 적혀있습니다. 1번 노드에서 가장 멀리 떨어진 노드의 갯수를 구하려고 합니다. 가장 멀리 떨어진 노드란 최단경로로 이동했을 때 간선의 개수가 가장 많은 노드들을 의미합니다. 노드의 개수 n, 간선에 대한 정보가 담긴 2차원 배열 vertex가 매개변수로 주어질 때, 1번 노드로부터 가장 멀리 떨어진 노드가 몇 개인지를 return 하도록 solution 함수를 작성해주세요. 제한사항 노드의 개수 n은 2 이상 20,000 이하입니다. 간선은 양방향이며 총 1개 이상 50,000개 이하의 간선이 있습니다. vertex 배열 각 행 [a, b]는 a번 노드와 b번 노드 사이에 간선이 있다는 의미입니다. 입출력 예 n\tvertex\treturn 6\t[[3, 6], [4, 3], [3, 2], [1, 3], [1, 2], [2, 4], [5, 2]]\t3 풀이 어떻게 풀 것인가 노드 사이의 갯수를 구하는거는 다익스트라 알고리즘을 사용한다. (다익스트라 참고)\n코드 solution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 import heapq def dikjstra(start, distance, graph): q = [] # 시작노드 정보 우선순위 큐에 삽입 heapq.heappush(q, (0, start)) # 시작노드-\u0026gt;시작노드 거리 기록 distance[start] = 0 while q: dist, now = heapq.heappop(q) # 큐에서 뽑아낸 거리가 이미 갱신된 거리보다 클 경우(=방문한 셈) 무시 if distance[now]\u0026lt;dist: continue # 큐에서 뽑아낸 노드와 연결된 인접노드들 탐색 for i in graph[now]: # 시작-\u0026gt;node거리 + node-\u0026gt;node의인접노드 거리 cost = dist+i[1] # cost \u0026lt; 시작-\u0026gt;node의인접노드 거리 if cost \u0026lt; distance[i[0]]: distance[i[0]] = cost heapq.heappush(q, (cost, i[0])) def solution(n, edge): answer = 0 distance = [n+1] * (n+1) graph = [[] for _ in range(n+1)] # 양방향이므로 그래프 양쪽에 추가한다. for i in edge: graph[i[0]].append((i[1],1)) graph[i[1]].append((i[0],1)) # 다익스트라 알고리즘 dikjstra(1, distance, graph) distance.pop(0) for dis in distance: if dis == max(distance): answer += 1 return answer print(solution(6, [[3, 6], [4, 3], [3, 2], [1, 3], [1, 2], [2, 4], [5, 2]])) #3 다른사람 풀이 BFS를 이용한 풀이.\nsolution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 def solution(n, edge): graph =[ [] for _ in range(n + 1) ] distances = [ 0 for _ in range(n) ] is_visit = [False for _ in range(n)] queue = [0] is_visit[0] = True for (a, b) in edge: graph[a-1].append(b-1) graph[b-1].append(a-1) while queue: i = queue.pop(0) for j in graph[i]: if is_visit[j] == False: is_visit[j] = True queue.append(j) distances[j] = distances[i] + 1 distances.sort(reverse=True) answer = distances.count(distances[0]) return answer solution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 from collections import defaultdict def bfs(graph, start, distances): q = [start] visited = set([start]) while len(q) \u0026gt; 0: current = q.pop(0) for neighbor in graph[current]: if neighbor not in visited: visited.add(neighbor) q.append(neighbor) distances[neighbor] = distances[current] + 1 def solution(n, edge): # 그래프 만들기 graph = defaultdict(list) for e in edge: graph[e[0]].append(e[1]) graph[e[1]].append(e[0]) # bfs 탐색 (최단 거리를 구해야 하므로.) distances = [0]*(n+1) bfs(graph, 1, distances) max_distance = max(distances) answer = 0 for distance in distances: if distance == max_distance: answer += 1 ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/lv3/49189/","summary":"그래프","title":"프로그래머스 49189] 가장 먼 노드 - 파이썬"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/64062\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 문제 설명 [본 문제는 정확성과 효율성 테스트 각각 점수가 있는 문제입니다.] 카카오 초등학교의 \u0026#34;니니즈 친구들\u0026#34;이 \u0026#34;라이언\u0026#34; 선생님과 함께 가을 소풍을 가는 중에 징검다리가 있는 개울을 만나서 건너편으로 건너려고 합니다. \u0026#34;라이언\u0026#34; 선생님은 \u0026#34;니니즈 친구들\u0026#34;이 무사히 징검다리를 건널 수 있도록 다음과 같이 규칙을 만들었습니다. 징검다리는 일렬로 놓여 있고 각 징검다리의 디딤돌에는 모두 숫자가 적혀 있으며 디딤돌의 숫자는 한 번 밟을 때마다 1씩 줄어듭니다. 디딤돌의 숫자가 0이 되면 더 이상 밟을 수 없으며 이때는 그 다음 디딤돌로 한번에 여러 칸을 건너 뛸 수 있습니다. 단, 다음으로 밟을 수 있는 디딤돌이 여러 개인 경우 무조건 가장 가까운 디딤돌로만 건너뛸 수 있습니다. \u0026#34;니니즈 친구들\u0026#34;은 개울의 왼쪽에 있으며, 개울의 오른쪽 건너편에 도착해야 징검다리를 건넌 것으로 인정합니다. \u0026#34;니니즈 친구들\u0026#34;은 한 번에 한 명씩 징검다리를 건너야 하며, 한 친구가 징검다리를 모두 건넌 후에 그 다음 친구가 건너기 시작합니다. 디딤돌에 적힌 숫자가 순서대로 담긴 배열 stones와 한 번에 건너뛸 수 있는 디딤돌의 최대 칸수 k가 매개변수로 주어질 때, 최대 몇 명까지 징검다리를 건널 수 있는지 return 하도록 solution 함수를 완성해주세요. [제한사항] 징검다리를 건너야 하는 니니즈 친구들의 수는 무제한 이라고 간주합니다. stones 배열의 크기는 1 이상 200,000 이하입니다. stones 배열 각 원소들의 값은 1 이상 200,000,000 이하인 자연수입니다. k는 1 이상 stones의 길이 이하인 자연수입니다. [입출력 예] stones\tk\tresult [2, 4, 5, 3, 2, 1, 4, 2, 5, 1]\t3\t3 풀이 1 전략\nstones 를 돌면서 현재원소부터 현재원소 + k 까지 k 갯수만 큼 끊어서 가장 큰 수를 answer가 될 수 있는데 이중 가장 최소값의 answer를 찾는다.\n풀이\nsolution.py 1 2 3 4 5 6 7 8 9 10 11 12 def solution(stones, k): # K를 돌 answer = len(stones) for i in range(len(stones) - k + 1): maximum = 0 for j in range(i, i + k): maximum = max(maximum, stones[j]) answer = min(answer, maximum) return answer print(solution([2, 4, 5, 3, 2, 1, 4, 2, 5, 1], 3)) #3 결과\n48.7 점으로 탈락. 최대 값이 200만 x 20만이기에 효율성에서 탈락할 수 밖에 없다. (안될 걸 알고 있었지만)\n풀이 2 전략\n이분탐색을 이용하여 시간복잡도를 (logN)으로 낮출 수 있다. 배열의 순서가 중요한 것이 아니라 배열의 원소를 기준으로 두어야한다.\n풀이\nsolution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 def jump(stones, k, mid): cnt = k for stone in stones: if stone \u0026lt;= mid: cnt -= 1 if cnt == 0: return False else: cnt = k return True def solution(stones, k): # stones배열 각 원소들의 값은 1이상 200만 이하의 자연수 # max(stones)는 200만이 될 수도 있음 answer, left, right = 0, 1, max(stones) # left가 right 되면 반복문을 멈춘다. while left \u0026lt;= right: # 중간값 mid = (left + right) // 2 print(\u0026#34;left\u0026#34;, left, \u0026#34;right\u0026#34;, right, \u0026#34;mid\u0026#34;, mid) # 만약에 점프가 된다면 if jump(stones, k, mid): print(\u0026#34;\u0026gt;\u0026gt; 여기서 점프됨 !!\u0026#34;) # left 에 mid + 1 answer = left = mid + 1 # 안되면 else: # right 에 mid - 1 right = mid - 1 print(answer, \u0026#34;리턴합니다\u0026#34;) return answer left, right, mid 출력결과\n1 2 3 4 5 6 left 1 right 5 mid 3 left 1 right 2 mid 1 \u0026gt;\u0026gt; 여기서 점프됨 !! left 2 right 2 mid 2 \u0026gt;\u0026gt; 여기서 점프됨 !! 3 리턴합니다 ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/lv3/64062/","summary":"2019 카카오 개발자 겨울 인턴십","title":"프로그래머스 64062] 징검다리건너기 - 파이썬"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/67258\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 문제 설명 [본 문제는 정확성과 효율성 테스트 각각 점수가 있는 문제입니다.] 개발자 출신으로 세계 최고의 갑부가 된 어피치는 스트레스를 받을 때면 이를 풀기 위해 오프라인 매장에 쇼핑을 하러 가곤 합니다. 어피치는 쇼핑을 할 때면 매장 진열대의 특정 범위의 물건들을 모두 싹쓸이 구매하는 습관이 있습니다. 어느 날 스트레스를 풀기 위해 보석 매장에 쇼핑을 하러 간 어피치는 이전처럼 진열대의 특정 범위의 보석을 모두 구매하되 특별히 아래 목적을 달성하고 싶었습니다. 진열된 모든 종류의 보석을 적어도 1개 이상 포함하는 가장 짧은 구간을 찾아서 구매 예를 들어 아래 진열대는 4종류의 보석(RUBY, DIA, EMERALD, SAPPHIRE) 8개가 진열된 예시입니다. 진열대 번호\t1\t2\t3\t4\t5\t6\t7\t8 보석 이름\tDIA\tRUBY\tRUBY\tDIA\tDIA\tEMERALD\tSAPPHIRE\tDIA 진열대의 3번부터 7번까지 5개의 보석을 구매하면 모든 종류의 보석을 적어도 하나 이상씩 포함하게 됩니다. 진열대의 3, 4, 6, 7번의 보석만 구매하는 것은 중간에 특정 구간(5번)이 빠지게 되므로 어피치의 쇼핑 습관에 맞지 않습니다. 진열대 번호 순서대로 보석들의 이름이 저장된 배열 gems가 매개변수로 주어집니다. 이때 모든 보석을 하나 이상 포함하는 가장 짧은 구간을 찾아서 return 하도록 solution 함수를 완성해주세요. 가장 짧은 구간의 시작 진열대 번호와 끝 진열대 번호를 차례대로 배열에 담아서 return 하도록 하며, 만약 가장 짧은 구간이 여러 개라면 시작 진열대 번호가 가장 작은 구간을 return 합니다. [제한사항] gems 배열의 크기는 1 이상 100,000 이하입니다. gems 배열의 각 원소는 진열대에 나열된 보석을 나타냅니다. gems 배열에는 1번 진열대부터 진열대 번호 순서대로 보석이름이 차례대로 저장되어 있습니다. gems 배열의 각 원소는 길이가 1 이상 10 이하인 알파벳 대문자로만 구성된 문자열입니다. 입출력 예 gems\tresult [\u0026#34;DIA\u0026#34;, \u0026#34;RUBY\u0026#34;, \u0026#34;RUBY\u0026#34;, \u0026#34;DIA\u0026#34;, \u0026#34;DIA\u0026#34;, \u0026#34;EMERALD\u0026#34;, \u0026#34;SAPPHIRE\u0026#34;, \u0026#34;DIA\u0026#34;]\t[3, 7] [\u0026#34;AA\u0026#34;, \u0026#34;AB\u0026#34;, \u0026#34;AC\u0026#34;, \u0026#34;AA\u0026#34;, \u0026#34;AC\u0026#34;]\t[1, 3] [\u0026#34;XYZ\u0026#34;, \u0026#34;XYZ\u0026#34;, \u0026#34;XYZ\u0026#34;]\t[1, 1] [\u0026#34;ZZZ\u0026#34;, \u0026#34;YYY\u0026#34;, \u0026#34;NNNN\u0026#34;, \u0026#34;YYY\u0026#34;, \u0026#34;BBB\u0026#34;]\t[1, 5] 풀이 전략\n첫번째 원소일때 결과값 [start, end] ~ 마지막 원소의 [start, end]를 구해서 start - end 의 길이가 최소값을 리턴하면 될 수 있지만 gems의 배열이 10만개 까지므로 O(N^2) 가 되면 효율성에서 탈락할 수 있다. 최대한 안에 끝내는 방법을 생각해야한다. 따라서 완전탐색보단 Greedy한 방법을 고려해야한다.\n풀이\n총 사야하는 보석 종류 target 선언. set으로 중복을 제거해줄 수 있다. answer의 최대값은 [0, 보석의 총 갯수] 이다. deafultdict 를 이용하여 보석의 갯수를 닮은 pocket을 선언한다. 딕셔너리에 키값이 처음 입력될 때 0으로 초기화 할 수 있다. start와 end를 선언해서 커서역활을 한다. 내 주머니(pocket)에 모든 종류의 보석이 다 차면 start 포인터를 한칸씩 앞으로 가게 하여 최소한의 길이를 구하여 answer에 넣어놓으면 된다. 만약 보석이 다 차지 않으면 end를 하나씩 증가해서 모든 종류의 보석이 다찰때 까지 end를 증가시킨다.\nsolution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 def get_small_pocket(arr1, arr2): if arr1[1] - arr1[0] \u0026gt; arr2[1] - arr2[0]: return arr2 return arr1 def solution(gems): from collections import defaultdict target = len(set(gems)) answer = [0, len(gems)] pocket = defaultdict(int) start, end = 0, 0 while end \u0026lt; len(gems): pocket[gems[end]] += 1 end += 1 if len(pocket) == target: while start \u0026lt; end: if pocket[gems[start]] \u0026gt; 1: pocket[gems[start]] -= 1 start += 1 else: answer = get_small_pocket(answer, [start + 1, end]) break return answer 다른사람풀이 더 낮은 시간복잡도를 갖고있는 풀이.\nsolution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 def solution(gems): size = len(set(gems)) dic = {gems[0]:1} temp = [0, len(gems) - 1] start , end = 0, 0 while(start \u0026lt; len(gems) and end \u0026lt; len(gems)): if len(dic) == size: if end - start \u0026lt; temp[1] - temp[0]: temp = [start, end] if dic[gems[start]] == 1: del dic[gems[start]] else: dic[gems[start]] -= 1 start += 1 else: end += 1 if end == len(gems): break if gems[end] in dic.keys(): dic[gems[end]] += 1 else: dic[gems[end]] = 1 return [temp[0]+1, temp[1]+1] ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/lv3/67258/","summary":"2020 카카오 인턴십","title":"프로그래머스 67258] 보석쇼핑 - 파이썬"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/64064\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 문제 설명 개발팀 내에서 이벤트 개발을 담당하고 있는 \u0026#34;무지\u0026#34;는 최근 진행된 카카오이모티콘 이벤트에 비정상적인 방법으로 당첨을 시도한 응모자들을 발견하였습니다. 이런 응모자들을 따로 모아 불량 사용자라는 이름으로 목록을 만들어서 당첨 처리 시 제외하도록 이벤트 당첨자 담당자인 \u0026#34;프로도\u0026#34; 에게 전달하려고 합니다. 이 때 개인정보 보호을 위해 사용자 아이디 중 일부 문자를 \u0026#39;*\u0026#39; 문자로 가려서 전달했습니다. 가리고자 하는 문자 하나에 \u0026#39;*\u0026#39; 문자 하나를 사용하였고 아이디 당 최소 하나 이상의 \u0026#39;*\u0026#39; 문자를 사용하였습니다. \u0026#34;무지\u0026#34;와 \u0026#34;프로도\u0026#34;는 불량 사용자 목록에 매핑된 응모자 아이디를 제재 아이디 라고 부르기로 하였습니다. 예를 들어, 이벤트에 응모한 전체 사용자 아이디 목록이 다음과 같다면 응모자 아이디 frodo fradi crodo abc123 frodoc 다음과 같이 불량 사용자 아이디 목록이 전달된 경우, 불량 사용자 fr*d* abc1** 불량 사용자에 매핑되어 당첨에서 제외되어야 야 할 제재 아이디 목록은 다음과 같이 두 가지 경우가 있을 수 있습니다. 제재 아이디 frodo abc123 제재 아이디 fradi abc123 이벤트 응모자 아이디 목록이 담긴 배열 user_id와 불량 사용자 아이디 목록이 담긴 배열 banned_id가 매개변수로 주어질 때, 당첨에서 제외되어야 할 제재 아이디 목록은 몇가지 경우의 수가 가능한 지 return 하도록 solution 함수를 완성해주세요. [제한사항] - user_id 배열의 크기는 1 이상 8 이하입니다. - user_id 배열 각 원소들의 값은 길이가 1 이상 8 이하인 문자열입니다. - 응모한 사용자 아이디들은 서로 중복되지 않습니다. - 응모한 사용자 아이디는 알파벳 소문자와 숫자로만으로 구성되어 있습니다. - banned_id 배열의 크기는 1 이상 user_id 배열의 크기 이하입니다. - banned_id 배열 각 원소들의 값은 길이가 1 이상 8 이하인 문자열입니다. - 불량 사용자 아이디는 알파벳 소문자와 숫자, 가리기 위한 문자 \u0026#39;*\u0026#39; 로만 이루어져 있습니다. - 불량 사용자 아이디는 \u0026#39;*\u0026#39; 문자를 하나 이상 포함하고 있습니다. - 불량 사용자 아이디 하나는 응모자 아이디 중 하나에 해당하고 같은 응모자 아이디가 중복해서 제재 아이디 목록에 들어가는 경우는 없습니다. - 제재 아이디 목록들을 구했을 때 아이디들이 나열된 순서와 관계없이 아이디 목록의 내용이 동일하다면 같은 것으로 처리하여 하나로 세면 됩니다. [입출력 예] user_id\tbanned_id\tresult [\u0026#34;frodo\u0026#34;, \u0026#34;fradi\u0026#34;, \u0026#34;crodo\u0026#34;, \u0026#34;abc123\u0026#34;, \u0026#34;frodoc\u0026#34;]\t[\u0026#34;fr*d*\u0026#34;, \u0026#34;abc1**\u0026#34;]\t2 [\u0026#34;frodo\u0026#34;, \u0026#34;fradi\u0026#34;, \u0026#34;crodo\u0026#34;, \u0026#34;abc123\u0026#34;, \u0026#34;frodoc\u0026#34;]\t[\u0026#34;*rodo\u0026#34;, \u0026#34;*rodo\u0026#34;, \u0026#34;******\u0026#34;]\t2 [\u0026#34;frodo\u0026#34;, \u0026#34;fradi\u0026#34;, \u0026#34;crodo\u0026#34;, \u0026#34;abc123\u0026#34;, \u0026#34;frodoc\u0026#34;]\t[\u0026#34;fr*d*\u0026#34;, \u0026#34;*rodo\u0026#34;, \u0026#34;******\u0026#34;, \u0026#34;******\u0026#34;]\t3 풀이 시도 1\n순열 조합을 구하는 itertools의 permutations과 zip set을 사용해야 쉽게 풀릴 수 있는 Greedy\u0026hellip;(?) 문제.\n처음에는 위 라이브러리나 함수를 사용하지 않고 첫번째 ban id에 들어갈 수 있는 경우의수 부터 끝 ban id에 들어갈 수 있는 경우의수를 배열형태로 append 하여 배열끼리 조합하였을 때 중복되지 않은 순열을 구하려 했으나 배열끼리 순열을 구하는게 더 어렵다는걸 느끼고 찾아본 결과 permutations combinations등 라이브러리를 알게 되었다.\n알고리즘 풀때 자주 나오는 itertools, deque 등 라이브러리는 따로 정리해놓아야 할 것 같다. solution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 def is_banned_user(string1, string2): if len(string1) != len(string2): return False for i in range(len(string1)): if string2[i] == \u0026#34;*\u0026#34;: continue elif string1[i] != string2[i]: return False return True def solution(user_ids, banned_ids): from itertools import permutations answer = [] # banned list 길이만큼 user_ids의 순열을 구한다. for user_combs in permutations(user_ids, len(banned_ids)): # print(user_combs) # 순열을 돌면서 규칙에 맞는 갯수가 banned_ids의 길이면 응답이 된다. cnt = 0 for user, ban in zip(user_combs, banned_ids): if is_banned_user(user, ban): cnt += 1 if cnt == len(banned_ids): # 중복(순서)) 제거 위해 Set으로 if set(user_combs) not in answer: answer.append(set(user_combs)) return len(answer) 다른사람풀이 set 대신 itertools의 product 함수를 사용했다. 알고리즘 구조는 동일하다.\nsolution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 from itertools import product def check(str1, str2): if len(str1) != len(str2): return False for i in range(len(str1)): if str1[i] == \u0026#34;*\u0026#34;: continue if str1[i] != str2[i]: return False return True def solution(user_id, banned_id): answer = set() result = [[] for i in range(len(banned_id))] for i in range(len(banned_id)): for u in user_id: if check(banned_id[i], u): result[i].append(u) result = list(product(*result)) for r in result: if len(set(r)) == len(banned_id): answer.add(\u0026#34;\u0026#34;.join(sorted(set(r)))) return len(answer) ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/lv3/64064/","summary":"2019 카카오 개발자 겨울 인턴십","title":"프로그래머스 64064] 불량 사용자 - 파이썬"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/12979\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 문제 설명 N개의 아파트가 일렬로 쭉 늘어서 있습니다. 이 중에서 일부 아파트 옥상에는 4g 기지국이 설치되어 있습니다. 기술이 발전해 5g 수요가 높아져 4g 기지국을 5g 기지국으로 바꾸려 합니다. 그런데 5g 기지국은 4g 기지국보다 전달 범위가 좁아, 4g 기지국을 5g 기지국으로 바꾸면 어떤 아파트에는 전파가 도달하지 않습니다. 예를 들어 11개의 아파트가 쭉 늘어서 있고, [4, 11] 번째 아파트 옥상에는 4g 기지국이 설치되어 있습니다. 만약 이 4g 기지국이 전파 도달 거리가 1인 5g 기지국으로 바뀔 경우 모든 아파트에 전파를 전달할 수 없습니다. (전파의 도달 거리가 W일 땐, 기지국이 설치된 아파트를 기준으로 전파를 양쪽으로 W만큼 전달할 수 있습니다.) 이때, 우리는 5g 기지국을 최소로 설치하면서 모든 아파트에 전파를 전달하려고 합니다. 위의 예시에선 최소 3개의 아파트 옥상에 기지국을 설치해야 모든 아파트에 전파를 전달할 수 있습니다. 아파트의 개수 N, 현재 기지국이 설치된 아파트의 번호가 담긴 1차원 배열 stations, 전파의 도달 거리 W가 매개변수로 주어질 때, 모든 아파트에 전파를 전달하기 위해 증설해야 할 기지국 개수의 최솟값을 리턴하는 solution 함수를 완성해주세요 제한사항 N: 200,000,000 이하의 자연수 stations의 크기: 10,000 이하의 자연수 stations는 오름차순으로 정렬되어 있고, 배열에 담긴 수는 N보다 같거나 작은 자연수입니다. W: 10,000 이하의 자연수 입출력 예 N\tstations\tW\tanswer 11\t[4, 11]\t1\t3 16\t[9]\t2\t3 풀이 Greedy한 방법으로 최소한의 시간복잡도로 풀어야하는 문제.\n처음 시도로는 n 길이인 모든 원소가 1인 스택을 선언후 스테이션을 돌면서 이미 커버가 가능한 구간은 0으로 변경한 다음 연속되는 원소의 값이 1인 구간을 뽑아서 최소한의 갯수로 모든 범위를 커버하려 했지만 효율성에서 탈락하였다 🥲\n다시 시도한 방법으로는 우선 전파 1개가 최대한으로 커버할 수 있는 범위는 2w + 1로 정해놓고 제공받은 스테이션 까지 최소 몇개를 깔아야할지 포문으로 한번 돌고 남은 범위를 더해주면 된다. O(N)\nsolution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 def solution(n, stations, w): from math import ceil answer = 0 # 스테이션이 최대 커버할 수 있는 거리는 왼쪽 w + 오른쪽 w + 본인 max_range = w + w + 1 # 시작 위치 cursor = 1 for station in stations: # 제공받은 스테이션까지 최소 몇개를 깔아야하는지 answer += ceil((station - w - cursor) / max_range) # 깔았다면 시작 위치 변경 cursor = station + w + 1 # 기존에 깔린 station 돌은 커서 위치가 전체 길이보다 작을 경우 if n \u0026gt;= cursor: answer += ceil((n - cursor + 1) / max_range) return answer 다른사람풀이 내림을 활용한 풀이\nsolution.py 1 2 3 4 5 6 7 8 def solution(n, arr, w): bef=-w cnt=0 ww=w*2+1 for x in arr: cnt+=(x-bef-1)//ww bef=x return cnt+(n+w-bef)//ww ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/lv3/12979/","summary":"Summer/Winter Coding(~2018)","title":"프로그래머스 12979] 기지국 설치 - 파이썬"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/12987\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 문제 설명 xx 회사의 2xN명의 사원들은 N명씩 두 팀으로 나눠 숫자 게임을 하려고 합니다. 두 개의 팀을 각각 A팀과 B팀이라고 하겠습니다. 숫자 게임의 규칙은 다음과 같습니다. - 먼저 모든 사원이 무작위로 자연수를 하나씩 부여받습니다. - 각 사원은 딱 한 번씩 경기를 합니다. - 각 경기당 A팀에서 한 사원이, B팀에서 한 사원이 나와 서로의 수를 공개합니다. 그때 숫자가 큰 쪽이 승리하게 되고, 승리한 사원이 속한 팀은 승점을 1점 얻게 됩니다. - 만약 숫자가 같다면 누구도 승점을 얻지 않습니다. 전체 사원들은 우선 무작위로 자연수를 하나씩 부여받았습니다. 그다음 A팀은 빠르게 출전순서를 정했고 자신들의 출전 순서를 B팀에게 공개해버렸습니다. B팀은 그것을 보고 자신들의 최종 승점을 가장 높이는 방법으로 팀원들의 출전 순서를 정했습니다. 이때의 B팀이 얻는 승점을 구해주세요. A 팀원들이 부여받은 수가 출전 순서대로 나열되어있는 배열 A와 i번째 원소가 B팀의 i번 팀원이 부여받은 수를 의미하는 배열 B가 주어질 때, B 팀원들이 얻을 수 있는 최대 승점을 return 하도록 solution 함수를 완성해주세요. 제한사항 A와 B의 길이는 같습니다. A와 B의 길이는 1 이상 100,000 이하입니다. A와 B의 각 원소는 1 이상 1,000,000,000 이하의 자연수입니다. 입출력 예 A\tB\tresult [5,1,3,7]\t[2,2,6,8]\t3 [2,2,2,2]\t[1,1,1,1]\t0 풀이 시도 1\n레벨3 난이도는 아닌 것 같은 문제. 최대 승점을 구하는 것이기 때문에 A나 B의 지급순서는 상관없다, 오름차순으로 정렬해주고 최대 승점을 갖을 수 있도록 for문을 돌면서 answer을 더해주면 된다. 중요한 부분은 sort했을때 원소의 값이 같을때에 승점을 얻지 못하기 때문에 아래 코드로 진행할 경우 최대승점을 얻지 못하게 된다.\n1 2 3 4 5 6 7 8 def solution(A, B): A.sort(reverse = True) B.sort(reverse = True) answer = 0 for i in range(len(A)): if A[i] \u0026lt; B[i]: answer += 1 return answer 예를들어 A=[8,8,2,2] B=[8,8,1,1] 일때 위 코드로 돌아버리면 0점을 받는다. A=[8,8,2,2] B=[1,1,8,8]이 될때 최대 승점 2점을 받게된다. 따라서 B의 원소가 A의 원소보다 커질때 다음 B의 원소를 비교해야한다.\nsolution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 def solution(A, B): A.sort(reverse = True) B.sort(reverse = True) answer = 0 # 이렇게 하면 안됨 # for i in range(len(A)): # if A[i] \u0026lt; B[i]: # answer += 1 i = 0 for a in A: # B의 원소가 더 클때만 다음 B의 원소를 비교할 것 if a \u0026lt; B[i]: answer += 1 i += 1 return answer 다른사람풀이 for문 돌면서 B의 원소를 하나씩 제거하는 방식, 시간복잡도 효율은 내 방법이 더 좋을 것 같음 solution.py 1 2 3 4 5 6 7 8 9 10 11 def solution(A, B): score = 0 B = sorted(B, reverse=True) for opponent in sorted(A): while B: if B.pop() \u0026gt; opponent: score += 1 break else: break return score ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/lv3/12987/","summary":"Summer/Winter Coding(~2018)","title":"프로그래머스 12987] 숫자 게임 - 파이썬"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/12973\n풀이 시도 1\n문자열 길이가 1백만이기 때문에 예외처리를 먼저 진행해야한다.\n전체 길이가 홀수면 나눠질 수 없음 문자열의 각 알파벳 갯수가 홀수 이면 나눠질 수 없음 전체길이가 알파벳 종류 갯수의 약수가 아니면 나눠질 수 없음 55.2점으로 탈락\nsolution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 def solution(s): length = len(s) # 홀수 이면 나눠질 수 없음 if length % 2 == 1: return 0 # 문자열의 각 알파벳의 갯수가 홀수 이면 나눠질 수 없음 alpha = [] for i in s: if i not in alpha: alpha.append(i) for i in alpha: if s.count(i) % 2 == 1: return 0 # 전체길이가 알파벳 종류 갯수의 약수가 아니면 나눠질 수 없음 if length % len(alpha) != 0: return 0 # 알파벳 종류만큼 돌면서 for i in range(len(alpha)): for a in alpha: # 문자열에 연속되는 알파벳이 있으면 삭제해줌 s = s.replace(a+a,\u0026#39;\u0026#39;) # 문자열이 비었으면 성공 if s == \u0026#39;\u0026#39;: return 1 return 0 # while True: 시도 2\n스택 사용하여 알파벳을 스택에 넣으면서 이전값과 같으면 팝, 다르면 푸쉬하여 스택이 비어져있으면 1을 리턴한다\nsolution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 def solution(s): stack = [] for i in range(len(s)): # print(stack) if not stack: stack.append(s[i]) else: if s[i] == stack[-1]: stack.pop() else: stack.append(s[i]) if stack: return 0 else: return 1 다른사람 풀이\n스택이 있는지 없는지를 판별해 리턴할때 한줄로 return not(stack)으로 표시가 가능\n1 2 ... return not(stack) ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/lv2/12973/","summary":"2017 팁스타운","title":"프로그래머스 12973] 짝지어 제거하기 - 파이썬"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/12981\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 문제 설명 1부터 n까지 번호가 붙어있는 n명의 사람이 영어 끝말잇기를 하고 있습니다. 영어 끝말잇기는 다음과 같은 규칙으로 진행됩니다. 1번부터 번호 순서대로 한 사람씩 차례대로 단어를 말합니다. 마지막 사람이 단어를 말한 다음에는 다시 1번부터 시작합니다. 앞사람이 말한 단어의 마지막 문자로 시작하는 단어를 말해야 합니다. 이전에 등장했던 단어는 사용할 수 없습니다. 한 글자인 단어는 인정되지 않습니다. 다음은 3명이 끝말잇기를 하는 상황을 나타냅니다. tank → kick → know → wheel → land → dream → mother → robot → tank 위 끝말잇기는 다음과 같이 진행됩니다. 1번 사람이 자신의 첫 번째 차례에 tank를 말합니다. 2번 사람이 자신의 첫 번째 차례에 kick을 말합니다. 3번 사람이 자신의 첫 번째 차례에 know를 말합니다. 1번 사람이 자신의 두 번째 차례에 wheel을 말합니다. (계속 진행) 끝말잇기를 계속 진행해 나가다 보면, 3번 사람이 자신의 세 번째 차례에 말한 tank 라는 단어는 이전에 등장했던 단어이므로 탈락하게 됩니다. 사람의 수 n과 사람들이 순서대로 말한 단어 words 가 매개변수로 주어질 때, 가장 먼저 탈락하는 사람의 번호와 그 사람이 자신의 몇 번째 차례에 탈락하는지를 구해서 return 하도록 solution 함수를 완성해주세요. 제한 사항 끝말잇기에 참여하는 사람의 수 n은 2 이상 10 이하의 자연수입니다. words는 끝말잇기에 사용한 단어들이 순서대로 들어있는 배열이며, 길이는 n 이상 100 이하입니다. 단어의 길이는 2 이상 50 이하입니다. 모든 단어는 알파벳 소문자로만 이루어져 있습니다. 끝말잇기에 사용되는 단어의 뜻(의미)은 신경 쓰지 않으셔도 됩니다. 정답은 [ 번호, 차례 ] 형태로 return 해주세요. 만약 주어진 단어들로 탈락자가 생기지 않는다면, [0, 0]을 return 해주세요. 입출력 예 n\twords\tresult 3\t[\u0026#34;tank\u0026#34;, \u0026#34;kick\u0026#34;, \u0026#34;know\u0026#34;, \u0026#34;wheel\u0026#34;, \u0026#34;land\u0026#34;, \u0026#34;dream\u0026#34;, \u0026#34;mother\u0026#34;, \u0026#34;robot\u0026#34;, \u0026#34;tank\u0026#34;]\t[3,3] 5\t[\u0026#34;hello\u0026#34;, \u0026#34;observe\u0026#34;, \u0026#34;effect\u0026#34;, \u0026#34;take\u0026#34;, \u0026#34;either\u0026#34;, \u0026#34;recognize\u0026#34;, \u0026#34;encourage\u0026#34;, \u0026#34;ensure\u0026#34;, \u0026#34;establish\u0026#34;, \u0026#34;hang\u0026#34;, \u0026#34;gather\u0026#34;, \u0026#34;refer\u0026#34;, \u0026#34;reference\u0026#34;, \u0026#34;estimate\u0026#34;, \u0026#34;executive\u0026#34;]\t[0,0] 2\t[\u0026#34;hello\u0026#34;, \u0026#34;one\u0026#34;, \u0026#34;even\u0026#34;, \u0026#34;never\u0026#34;, \u0026#34;now\u0026#34;, \u0026#34;world\u0026#34;, \u0026#34;draw\u0026#34;]\t[1,3] 풀이 시도 1\ndivided by zero와 list index out of range 를 피해서 풀어야 하는 문제입니다.\nsolution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 def solution(n, words): # out of range 피하기 위해 첫번쨰는 넣어줌 stack = [words[0]] # 탈락 몇번째인지 확인 cnt = 0 # 1부터 시작 for i in range(1, len(words)): cnt += 1 # 중복되지 않고 and 끝글자가 다음 단어 앞글자일 경우 if (words[i] not in stack) and (words[i-1][-1] == words[i][0]): # 스택에 추가 stack.append(words[i]) else: return [cnt%n +1, cnt//n + 1] return [0, 0] print(solution(3, [\u0026#34;tank\u0026#34;, \u0026#34;kick\u0026#34;, \u0026#34;know\u0026#34;, \u0026#34;wheel\u0026#34;, \u0026#34;land\u0026#34;, \u0026#34;dream\u0026#34;, \u0026#34;mother\u0026#34;, \u0026#34;robot\u0026#34;, \u0026#34;tank\u0026#34;])) print(solution(2,[\u0026#34;hello\u0026#34;, \u0026#34;one\u0026#34;, \u0026#34;even\u0026#34;, \u0026#34;never\u0026#34;, \u0026#34;now\u0026#34;, \u0026#34;world\u0026#34;, \u0026#34;draw\u0026#34;])) 다른사람 풀이\nsolution.py 1 2 3 4 5 def solution(n, words): for p in range(1, len(words)): if words[p][0] != words[p-1][-1] or words[p] in words[:p]: return [(p%n)+1, (p//n)+1] else: return [0,0] ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/lv2/12981/","summary":"Summer/Winter Coding(~2018)","title":"프로그래머스 12981] 영어 끝말잇기 - 파이썬"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/12927\n풀이 시도 1\n12938과 비슷한 문제지만 배열의 원소의 합이 최대값이 아닌 제곱의 최소값을 찾아야 하는 문제. 가장 많은 작업을 먼저 처리해야하는 것이 유리하다. heap을 이용한 greedy 알고리즘\nworks를 힙을 구성하면서 모두 음수로 치환한다. (최대값을 pop 하기 위해, 제곱시 어차피 양수로 변함) - heapq 사용 작업량을 1 감소시키고 다시 힙에 push 남은 야근시간 만큼 반복 heap 에 남아있는 원소중 음수는 작업이 필요한 작업량으로 원소에 제곱하여 리턴한다. solution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 def solution(n, works): import heapq # 1 음수로 치환하여 heap을 만든다 works = [-work for work in works] heapq.heapify(works) # 2~3 작업량을 1 감소시키고 다시 힙에 푸쉬 (n만큼) for i in range(n): work = heapq.heappop(works) heapq.heappush(works, work + 1) # 4 남은것중 음수인 원소는 앞으로 해야할 야근임 answer = 0 for i in works: if i \u0026lt; 0: answer += i*i return answer 다른사람풀이 같은 구조지만 파이써닉한 코드\nsolution.py 1 2 3 4 5 6 from heapq import heapify, heappush, heappop def solution(n, works): heapify(works := [-i for i in works]) for i in range(min(n, abs(sum(works)))): heappush(works, heappop(works)+1) return sum([i*i for i in works]) ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/lv3/12927/","summary":"연습문제","title":"프로그래머스 12927] 야근지수 - 파이썬"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/12938\n풀이 시도 1\n각원소의 곱이 최대가 되기 위해서는 최대한 큰 시작값의 원소를 찾아야한다. s를 n으로 나누었을때 나머지는 생각하지 않고 몫을 answer 배열의 n개만큼 채워놓고 for문을 돌면서 원소의 합이 s와 같지 않으면 1씩 더하면 된다.\n어차피 n \u0026gt; s 이므로 포문을 돌면서 1을 더하면 무조건 s의 값까지는 도달할 수 있다.\nsolution.py 1 2 3 4 5 6 7 8 9 10 def solution(n, s): quotient, remainder = divmod(s, n) answer = [quotient] * n if n \u0026gt; s: return [-1] for i in range(len(answer)): if sum(answer) != s: answer[i] += 1 else: return sorted(answer) 정확도 테스트는 전부 통과했지만 효율성 테스트에서 실패하여 75점\n시도 2\n나머지 값만큼만 합이 모잘랐기 때문에 포문은 나머지값만 돌면 되었다. 마지막에 리턴하지 sorted 하지 않고 배열의 끝부터 더하여 주었다.\nsolution.py 1 2 3 4 5 6 7 8 9 10 def solution(n, s): if n \u0026gt; s: return [-1] quotient, remainder = divmod(s, n) answer = [quotient] * n for i in range(remainder): answer[-i -1] += 1 return answer 다른사람풀이 수학적 풀이 방법\nsolution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 def bestSet(n, s): answer = [] a = int(s/n) if a == 0: return [-1] b = s%n for i in range(n-b): answer.append(a) for i in range(b): answer.append(a+1) return answer ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/lv3/12938/","summary":"연습문제","title":"프로그래머스 12938] 최고의 집합 - 파이썬"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/42579\n풀이 시도 1\n해시는 키-밸류 알고리즘을 이용한다. 앨범 배열을 play 많은순, 고유번호 낮은순으로 정렬 앨범 딕셔너리에 저장하여 장르별 플레이 합을 구한후 정렬해서 가장 높은 2개를 구해서 인덱스를 리턴한다. 코드실행은 통과지만 제출 테스트에서 통과되지 못했음. solution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 def solution(genres, plays): # 앨범 배열을 play 많은순, 고유번호 낮은순으로 정렬 album_array = [(genres[i], plays[i], i) for i in range(len(genres))] album_array = sorted(album_array, key=lambda x: (x[0], -x[1], x[2])) # 앨범 딕셔너리에 저장 album_dict = {} for genre, play, index in album_array: if genre not in album_dict.keys(): album_dict[genre] = [(play, index)] else: album_dict[genre].append((play, index)) # 장르별 플레이합을 구하기 위한 배열 선언 played_total_genre = [] for genre in album_dict.keys(): total = 0 for play, index in album_dict[genre]: total += play played_total_genre.append([genre, total]) # 플레이합 기준으로 정렬 played_total_genre = sorted(played_total_genre, key=lambda x: x[1], reverse=True) best_genre = album_dict[played_total_genre[0][0]] second_genre = album_dict[played_total_genre[1][0]] # 인덱스만 리턴 return [best_genre[0][1], best_genre[1][1], second_genre[0][1], second_genre[1][1]] 시도2\ndefaultdict는 키를 지정할때 값을 주지 않으면 디폴트 값을 지정하는 라이브러리\nsolution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 def solution(genres, plays): from collections import defaultdict answer = [] genres_total = defaultdict(int); genres_songs = defaultdict(lambda: []) # sort를 위해 i 선언 (갯수)) i = 0 # 돌면서 존재하지 않으면 defaultdict로 선언된 기본값이 주어짐 for g, p in zip(genres, plays): genres_total[g] += p genres_songs[g].append((i,p)) i += 1 sorted_genres = sorted(genres_total.items(), key=(lambda x:x[1]), reverse = True) # print(\u0026#34;genres_songs\u0026#34;, genres_songs) for g in sorted_genres: sorted_g = sorted(genres_songs[g[0]], key=(lambda x: x[1]), reverse=True) answer.append(sorted_g[0][0]) if len(sorted_g) \u0026gt; 1: answer.append(sorted_g[1][0]) return answer 다른사람풀이 1\nsolution.py 1 2 3 4 5 6 7 8 9 10 def solution(genres, plays): answer = [] d = {e:[] for e in set(genres)} for e in zip(genres, plays, range(len(plays))): d[e[0]].append([e[1] , e[2]]) genreSort =sorted(list(d.keys()), key= lambda x: sum( map(lambda y: y[0],d[x])), reverse = True) for g in genreSort: temp = [e[1] for e in sorted(d[g],key= lambda x: (x[0], -x[1]), reverse = True)] answer += temp[:min(len(temp),2)] return answer 다른사람풀이 2\nsolution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 def solution(genres, plays): answer = [] dic = {} album_list = [] for i in range(len(genres)): dic[genres[i]] = dic.get(genres[i], 0) + plays[i] album_list.append(album(genres[i], plays[i], i)) dic = sorted(dic.items(), key=lambda dic:dic[1], reverse=True) album_list = sorted(album_list, reverse=True) while len(dic) \u0026gt; 0: play_genre = dic.pop(0) print(play_genre) cnt = 0; for ab in album_list: if play_genre[0] == ab.genre: answer.append(ab.track) cnt += 1 if cnt == 2: break return answer class album: def __init__(self, genre, play, track): self.genre = genre self.play = play self.track = track def __lt__(self, other): return self.play \u0026lt; other.play def __le__(self, other): return self.play \u0026lt;= other.play def __gt__(self, other): return self.play \u0026gt; other.play def __ge__(self, other): return self.play \u0026gt;= other.play def __eq__(self, other): return self.play == other.play def __ne__(self, other): return self.play != other.play ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/lv3/42579/","summary":"해시","title":"프로그래머스 42579] 베스트앨범 - 파이썬"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/42884\n풀이 시도 1\nGreedy 탐욕법으로 가장 최적의 해를 구해야하는 문제 나가는 순서를 기준으로 한줄로 정렬시킨다 카메라 위치가 진입보다 작으면 카메라를 1대 더 설치하고 카메라는 나가는 위치에 설치한다 (어차피 나가는 기준으로 정렬했으므로) solution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 def solution(routes): camera = -30001 # 나간 기준으로 정렬 routes.sort(key=lambda x: x[1]) # 카메라 설치댓수 cameraCount = 0 # 자동차들의 루트를 돌면서 for route in routes: # 진입하는 자동차가 카메라보다 앞에있으면 if route[0] \u0026gt; camera: # 카메라 1대 더 설치 cameraCount += 1 # 나가는 순으로 정렬했기 때문에 나가는 위치에 카메라를 놓으면 됨 camera = route[1] print(routes) return cameraCount ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/lv3/42884/","summary":"탐욕법(Greey)","title":"프로그래머스 42884] 단속카메라 - 파이썬"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/42898\n풀이 시도 1\n2차원 테이블의 DP 문제로 점화식을 먼저 찾아야함 점화식 : dp[x][y] = dp[x-1][y] + dp[x][y-1] \u0026gt; 현재좌표는 왼쪽좌표와 위의좌표의 합 index오류 가 생기지 않게 초기화 할때 집이 1,1부터 시작하고 왼쪽좌표와 위의좌표는 0으로 채워줌 웅덩이가 발견되면 0으로 바까서 왼쪽값 + 위에값 = 위에값만 나올 수 있게 처리하면 된다. solution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 def solution(m, n, puddles): # 점화식 : dp[x][y] = dp[x-1][y] + dp[x][y-1] # dp 초기화, 1,1 부터 시작할거고 왼쪽과 위에값을 비교해야하므로 0으로 빈값을 초기화시켜줌 dp = [[0] * (m+1) for i in range(n+1)] # 시작위치 dp[1][1] = 1 # 웅덩이는 -1 for x, y in puddles: dp[y][x] = -1 for x in range(1, n + 1): for y in range(1, m + 1): # 웅덩이면 0으로 바꿔서 다음값이 위에값이 될 수 있게 if dp[x][y] == -1: dp[x][y] = 0 continue # 점화식 dp[x][y] += dp[x-1][y] + dp[x][y-1] # print(dp) return dp[n][m] % 1000000007 output\n1 2 3 4 5 6 7 8 9 10 11 [[0, 0, 0, 0, 0], [0, 1, 0, 0, 0], [0, 0, -1, 0, 0], [0, 0, 0, 0, 0]] [[0, 0, 0, 0, 0], [0, 1, 1, 0, 0], [0, 0, -1, 0, 0], [0, 0, 0, 0, 0]] [[0, 0, 0, 0, 0], [0, 1, 1, 1, 0], [0, 0, -1, 0, 0], [0, 0, 0, 0, 0]] [[0, 0, 0, 0, 0], [0, 1, 1, 1, 1], [0, 0, -1, 0, 0], [0, 0, 0, 0, 0]] [[0, 0, 0, 0, 0], [0, 1, 1, 1, 1], [0, 1, -1, 0, 0], [0, 0, 0, 0, 0]] [[0, 0, 0, 0, 0], [0, 1, 1, 1, 1], [0, 1, 0, 1, 0], [0, 0, 0, 0, 0]] [[0, 0, 0, 0, 0], [0, 1, 1, 1, 1], [0, 1, 0, 1, 2], [0, 0, 0, 0, 0]] [[0, 0, 0, 0, 0], [0, 1, 1, 1, 1], [0, 1, 0, 1, 2], [0, 1, 0, 0, 0]] [[0, 0, 0, 0, 0], [0, 1, 1, 1, 1], [0, 1, 0, 1, 2], [0, 1, 1, 0, 0]] [[0, 0, 0, 0, 0], [0, 1, 1, 1, 1], [0, 1, 0, 1, 2], [0, 1, 1, 2, 0]] [[0, 0, 0, 0, 0], [0, 1, 1, 1, 1], [0, 1, 0, 1, 2], [0, 1, 1, 2, 4]] 다른사람풀이 solution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 def solution(m, n, puddles): answer = 0 info = dict([((2, 1), 1), ((1, 2), 1)]) for puddle in puddles: info[tuple(puddle)] = 0 def func(m, n): if m \u0026lt; 1 or n \u0026lt; 1: return 0 if (m, n) in info: return info[(m, n)] return info.setdefault((m, n), func(m - 1, n) + func(m, n - 1)) return func(m, n) % 1000000007 ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/lv3/42898/","summary":"동적계획법(Dynamic Programing)","title":"프로그래머스 42898] 등굣길 - 파이썬"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/43162\n풀이 시도 1\nBFS (재귀)를 사용한다. 네트워크 수는 트리의 갯수와 같으며 트리의 갯수만큼 visit 했는지 여부를 처음엔 False로 선언하고 BFS를 돌면서 방문 할 때마다 True로 변경해준다 solution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 def DFS(n, computers, node, visited): # 현재 노드를 방문 처리 visited[node] = True # 현재 노드와 연결된 다른 노드를 재귀적으로 방문 for connect in range(n): if connect != node and computers[node][connect] == 1: if visited[connect] == False: DFS(n, computers, connect, visited) def solution(n, computers): visited = [False] * n answer = 0 # 트리의 갯수=n for node in range(n): if visited[node] == False: DFS(n, computers, node, visited) answer += 1 return answer 다른사람풀이 DFS 스택을 이용\nsolution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 def solution(n, computers): answer = 0 visited = [0 for i in range(n)] def dfs(computers, visited, start): stack = [start] while stack: j = stack.pop() if visited[j] == 0: visited[j] = 1 # for i in range(len(computers)-1, -1, -1): for i in range(0, len(computers)): if computers[j][i] ==1 and visited[i] == 0: stack.append(i) i=0 while 0 in visited: if visited[i] ==0: dfs(computers, visited, i) answer +=1 i+=1 return answer ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/lv3/43162/","summary":"깊이/너비 우선 탐색(DFS/BFS)","title":"프로그래머스 43162] 네트워크 - 파이썬"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/43163\n풀이 시도 1\n최소 깊이로 도달하는 것이기 때문에 DFS를 이용해서 진행한다. 큐를 사용하고 target과 같으면 바로 리턴하면 된다. solution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 def solution(begin, target, words): from collections import deque if target not in words: return 0 q = deque() # 현재 단어와 이동횟수를 큐에 담는다 q.append([begin, 0]) # q를 돌면서 while(q): # pop prev, cnt = q.popleft() # pop한 값이 타겟과 같으면 리턴 if prev == target: return cnt # words를 돌면서 for i in range(len(words)): # 다음에 들어갈 수 있는 단어가 있으면 if(isOneAlphabetDiff(prev, words[i])): # 큐에 넣고 카운트 1증가 q.append([words[i], cnt+1]) return 0 def isOneAlphabetDiff(prev, next): cnt = 0 for i in range(len(next)): if prev[i] != next[i]: cnt += 1 if cnt \u0026gt; 1: return False return True ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/lv3/43163/","summary":"깊이/너비 우선 탐색(DFS/BFS)","title":"프로그래머스 43163] 단어변환 - 파이썬"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/43105\n풀이 시도 1 index 에러를 없애기 위해 배열 앞뒤에 0을 추가후 top-down 으로 내려가면서 현재 배열 원소에 최대값을 더해주고 마지막 배열중 가장 최대값을 리턴합니다.\nsolution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 def solution(triangle): # index 에러 방지 위해 앞 뒤에 0인 원소를 추가합니다. triangle = [[0] + line + [0] for line in triangle] # print(triangle) # 맨앞은 0이니깐 1부터 for i in range(1, len(triangle)): # 맨앞은 0이고 2씩 증가하여 가운데 수를 넘어뛰어도 됨 for j in range(1, i+2): # 현재 원소 + 이전 인접하는 두개의 원소중 최대값을 더함 triangle[i][j] += max(triangle[i-1][j-1], triangle[i-1][j]) # 마지막줄 원소중 최대값 리턴 return max(triangle[-1]) s = solution([[7], [3, 8], [8, 1, 0], [2, 7, 4, 4], [4, 5, 2, 6, 5]]) print(s) 다른사람풀이 solution.py 1 solution = lambda t, l = []: max(l) if not t else solution(t[1:], [max(x,y)+z for x,y,z in zip([0]+l, l+[0], t[0])]) ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/lv3/43105/","summary":"동적계획법(Dynamic Programing)","title":"프로그래머스 43105] 정수 삼각형 - 파이썬"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/43268\n풀이 시도 1 python heapq 라이브러리를 사용합니다.\nheapq.heappush(heap, item) : item을 heap에 추가 heapq.heappop(heap) : heap에서 가장 작은 원소 Pop. 비어 있는 경우 IndexError가 호출됨. solution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 def solution(operations): import heapq heap = [] for i in range(len(operations)): alphabet, number = operations[i].split(\u0026#34; \u0026#34;) if alphabet == \u0026#34;I\u0026#34;: # 힙에 추가 heapq.heappush(heap, int(number)) # 비어있는 경우 index error 이기 때문에 and heap elif alphabet == \u0026#34;D\u0026#34; and heap: if number == \u0026#34;1\u0026#34;: # 최대값 삭제 heap.remove(max(heap)) elif number == \u0026#34;-1\u0026#34;: # 가장 작은 원소 pop 후 리턴 heapq.heappop(heap) if heap: return [ max(heap), heap[0] ] else: return [0,0] 다른사람풀이 solution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 import heapq REMOVED = \u0026#34;r\u0026#34; class DoublePriorityQueue: def __init__(self): self.entry_finder = {} self.min_heap = [] self.max_heap = [] self.cnt = 0 def _check_empty(self, q) -\u0026gt; bool: while q and q[0][1] == REMOVED: heapq.heappop(q) if not q: return True return False def insert(self, v): vid = self.cnt min_ele, max_ele = [v, vid], [-v, vid] heapq.heappush(self.min_heap, min_ele) heapq.heappush(self.max_heap, max_ele) self.entry_finder[vid] = [min_ele, max_ele] self.cnt += 1 def pop_min(self): is_empty = self._check_empty(self.min_heap) if not is_empty: value, vid = heapq.heappop(self.min_heap) entries = self.entry_finder.pop(vid) entries[1][1] = REMOVED def pop_max(self): is_empty = self._check_empty(self.max_heap) if not is_empty: value, vid = heapq.heappop(self.max_heap) entries = self.entry_finder.pop(vid) entries[0][1] = REMOVED def get_min(self): if not self._check_empty(self.min_heap): return self.min_heap[0][0] return 0 def get_max(self): if not self._check_empty(self.max_heap): return - self.max_heap[0][0] return 0 def solution(operations): dpq = DoublePriorityQueue() for each in operations: op, num = each.split(\u0026#34; \u0026#34;) num = int(num) if op == \u0026#34;I\u0026#34;: dpq.insert(num) elif op == \u0026#34;D\u0026#34; and num == -1: dpq.pop_min() else: dpq.pop_max() return [dpq.get_max(), dpq.get_min()] ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/lv3/43628/","summary":"힙(Heap)","title":"프로그래머스 43268] 이중우선순위큐 - 파이썬"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/12911\n풀이 시도 1\nwhile문 돌면서 n을 1씩 더해서 2진수로 변환했을때 \u0026ldquo;1\u0026quot;의 갯수가 원래 n의 2진수로 변환했을때 \u0026ldquo;1\u0026quot;의 갯수와 같으면 리턴했습니다. 다음 자연수를 찾는거에 시간복잡도가 크지 않을 거라 생각했습니다.\nsolution.py 1 2 3 4 5 6 7 def solution(n): cnt = format(n, \u0026#34;b\u0026#34;).count(\u0026#34;1\u0026#34;) n += 1 while True: if format(n, \u0026#34;b\u0026#34;).count(\u0026#34;1\u0026#34;) == cnt: return n n += 1 다른사람 풀이\n가독성은 좋지 않지만 pythonic ..\nsolution.py 1 2 def nextBigNumber(n, count = 0): return n if bin(n).count(\u0026#34;1\u0026#34;) is count else nextBigNumber(n+1, bin(n).count(\u0026#34;1\u0026#34;) if count is 0 else count) ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/lv2/12911/","summary":"연습문제","title":"프로그래머스 12911] 다음 큰 숫자 - 파이썬"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/12924\n풀이 시도 1\n1부터 n의 절반까지 하나씩 덧셈하면서 n을 넘어가면 break, n의절반 + n의절반+1은 무조건 n을 넘어가니 break를 걸었습니다. 본인의 수도 포함되야 하므로 리턴할때 1을 더했습니다. 95.8점으로 실패하였습니다.\nsolution.py 1 2 3 4 5 6 7 8 9 10 11 12 def solution(n): answer = 0 for x in range(1, (n + 1)): temp = 0 for y in range(x, (n + 1) // 2 + 1): temp += y if temp == n: answer += 1 break elif temp \u0026gt; n: break return answer + 1 시도 2\n규칙을 찾아봅니다.\n1 2 3 x + (x+1) + ... + (x+k-1) = k(2x+k-1) / 2 = n x = n/k + (1-k)/2 n/k와 (1-k)/2 가 자연수가 되어야하므로 k는 홀수이면서 n의 약수여야 한다. solution.py 1 2 3 4 5 6 def solution(n): answer = 0 for i in range(1, n + 1, 2): # 2만큼 증가하여 짝수는 고려하지 않음 if n % i == 0: answer += 1 return answer 다른사람 풀이\nsolution.py 1 2 def expressions(num): return len([i for i in range(1,num+1,2) if num % i is 0]) ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/lv2/12924/","summary":"스택/큐","title":"프로그래머스 12924] 숫자의 표현 - 파이썬"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/12945\n풀이 시도 1\n재귀를 사용하지 않고 for문으로 돌렸습니다.\nsolution.py 1 2 3 4 5 6 7 8 9 10 def solution(n): fabo = [None] * (n + 1) fabo[0] = 0 fabo[1] = 1 for i in range(n + 1): if i \u0026lt; 2: pass else: fabo[i] = (fabo[i - 1] + fabo[i - 2]) % 1234567 return fabo[n] 다른사람 풀이\nsolution.py 1 2 3 4 5 6 7 def fibonacci(num): a,b = 0,1 for i in range(num): a,b = b,a+b return a print(fibonacci(3)) ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/lv2/12945/","summary":"연습문제","title":"프로그래머스 12945] 피보나치 수 - 파이썬"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/12909\n풀이 시도 1\n스택을 활용합니다. 빈 스택일때 (가 들어오면 바로 False를 리턴해야 시간초과가 나지 않습니다.\nsolution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 def solution(s): stack = [] if s[-1] == \u0026#39;(\u0026#39;: return False for i in s: if i == \u0026#39;(\u0026#39;: stack.append(i) else: if stack == []: return False else: stack.pop() return stack == [] 다른사람 풀이\nsolution.py 1 2 3 4 5 6 7 def is_pair(s): x = 0 for w in s: if x \u0026lt; 0: break x = x+1 if w==\u0026#34;(\u0026#34; else x-1 if w==\u0026#34;)\u0026#34; else x return x==0 ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/lv2/12909/","summary":"스택/큐","title":"프로그래머스 12909] 올바른 괄호 - 파이썬"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/12939\n풀이 시도 1\nstring 형태로 연산자 비교시 음수일 경우에 예외가 발생하게 됩니다. 따라서 배열을 돌면서 Int형으로 변경해준 이후 연산했습니다.\nsolution.py 1 2 3 4 5 6 def solution(s): arr = [] for char in s.split(\u0026#34; \u0026#34;): arr.append(int(char)) answer = str(min(arr)) + \u0026#34; \u0026#34; + str(max(arr)) return answer 다른사람 풀이\nsolution.py 1 2 3 4 5 6 def solution(s): s = list(map(int,s.split())) return str(min(s)) + \u0026#34; \u0026#34; + str(max(s)) def solution(s): return str(min([int(i) for i in s.split(\u0026#39; \u0026#39;)])) + \u0026#39; \u0026#39; + str(max([int(i) for i in s.split(\u0026#39; \u0026#39;)])) ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/lv2/12939/","summary":"연습문제","title":"프로그래머스 12939] Python 최댓값과 최솟값 - 파이썬"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/12941\n풀이 시도 1\nA, B 배열에서 배열길이만큼 A에서 제일 큰수 * B에서 제일 작은수를 돌면서 곱한건 배열에서 삭제했습니다. 효율성 테스트에서 시간초과가 났습니다. 69.6점\nsolution.py 1 2 3 4 5 6 7 8 9 10 11 def solution(A, B): length = len(A) total = 0 for i in range(length): Max = max(A) Min = min(B) A.remove(Max) B.remove(Min) total += Max * Min return total 시도 2\nA는 내림차순, B는 오름차순으로 먼저 정렬후에 같은 원소 번호끼리 곱해서 더했습니다.\nsolution.py 1 2 3 4 5 6 7 def solution(A, B): total = 0 A.sort(reverse = True) # A.reverse() B.sort() for i in range(len(A)): total += A[i] * B[i] return total 다른사람 풀이\nsolution.py 1 2 def getMinSum(A,B): return sum(a*b for a, b in zip(sorted(A), sorted(B, reverse = True))) ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/lv2/12941/","summary":"연습문제","title":"프로그래머스 12941] Python 최솟값 만들기 - 파이썬"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/12951\n풀이 시도 1\n문자열을 공백으로 자르고 맨 앞자리가 숫자인지 확인 후 숫자가 아니면 title()이나 capitalize()로 앞글자만 대문자로 변경해주려 했습니다. 쉽다고 생각했는데 44.4점으로 런타임 에러가 발생했습니다.\nsolution.py 1 2 3 4 5 6 7 8 9 def solution(s): answer = \u0026#39;\u0026#39; arr = s.split(\u0026#34; \u0026#34;) for string in arr: if string[0].isalpha(): answer += string.title() + \u0026#34; \u0026#34; else: answer += string + \u0026#34; \u0026#34; return answer[:-1] 시도 2\n문자열을 돌면서 flag를 이용해서 첫번째가 숫자인지 알파벳인지 확인하고 공백이 있을경우 flag값을 변경했습니다. 66.7점으로 런타임 에러가 발생했습니다.\nsolution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 def solution(s): answer = \u0026#39;\u0026#39; flag = True for char in s: if char.isdigit(): answer += char flag = False elif flag: answer += char.upper() flag = False elif char == \u0026#34; \u0026#34;: answer += \u0026#34; \u0026#34; flag = True else: answer += char.lower() return answer 시도 3\n1번 풀이에서 앞글자가 숫자인지 판별하는 조건문을 뺐습니다.\nsolution.py 1 2 3 4 5 6 7 def solution(s): answer = \u0026#39;\u0026#39; s = s.split(\u0026#39; \u0026#39;) for i in range(len(s)): s[i] = s[i].capitalize() answer=\u0026#39; \u0026#39;.join(s) return answer 다른사람 풀이\nsolution.py 1 2 def solution(s): return \u0026#39; \u0026#39;.join([word.capitalize() for word in s.split(\u0026#34; \u0026#34;)]) ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/lv2/12951/","summary":"연습문제","title":"프로그래머스 12951] JadenCase 문자열 만들기 - 파이썬"},{"content":"문제 https://school.programmers.co.kr/learn/courses/30/lessons/70129\n풀이 시도 1\nx의 모든 0을 제거합니다. 어차피 문자열이 0과 1밖에 없으므로 현재 길이에서 0의 갯수를 빼면된다. (굳이 0이 빠졌을때 어떤 값이 나오는지 값을 저장하지 않아도 된다.) x의 길이를 c라고 하면, x를 \u0026ldquo;c를 2진법으로 표현한 문자열\u0026quot;로 바꿉니다. format(s, \u0026lsquo;b\u0026rsquo;) -\u0026gt; 2진수로 변환하는 내장함수 solution.py 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 def solution(s): delete_zero_cnt = 0 while_cnt = 0 while True: if s == \u0026#34;1\u0026#34;: break while_cnt += 1 zero_cnt = 0 for char in s: if char == \u0026#34;0\u0026#34;: zero_cnt += 1 delete_zero_cnt += zero_cnt s = len(s) - zero_cnt # 이진수로 변환 s = format(s, \u0026#39;b\u0026#39;) return [while_cnt, delete_zero_cnt] 다른사람 풀이\nsolution.py 1 2 3 4 5 6 7 8 def solution(s): a, b = 0, 0 while s != \u0026#39;1\u0026#39;: a += 1 num = s.count(\u0026#39;1\u0026#39;) b += len(s) - num s = bin(num)[2:] return [a, b] ","permalink":"https://cha2hyun.blog/content/algorithm/programmers/lv2/70129/","summary":"월간 코드 챌린지 시즌1","title":"프로그래머스 70129] 이진 변환 반복하기 - 파이썬"},{"content":"Intro This article offers a sample of basic Markdown syntax that can be used in Hugo content files, also it shows whether basic HTML elements are decorated with CSS in a Hugo theme. by Hugo Authors\nHeadings The following HTML \u0026lt;h1\u0026gt;—\u0026lt;h6\u0026gt; elements represent six levels of section headings. \u0026lt;h1\u0026gt; is the highest section level while \u0026lt;h6\u0026gt; is the lowest.\nH1 H2 H3 H4 H5 H6 Paragraph Xerum, quo qui aut unt expliquam qui dolut labo. Aque venitatiusda cum, voluptionse latur sitiae dolessi aut parist aut dollo enim qui voluptate ma dolestendit peritin re plis aut quas inctum laceat est volestemque commosa as cus endigna tectur, offic to cor sequas etum rerum idem sintibus eiur? Quianimin porecus evelectur, cum que nis nust voloribus ratem aut omnimi, sitatur? Quiatem. Nam, omnis sum am facea corem alique molestrunt et eos evelece arcillit ut aut eos eos nus, sin conecerem erum fuga. Ri oditatquam, ad quibus unda veliamenimin cusam et facea ipsamus es exerum sitate dolores editium rerore eost, temped molorro ratiae volorro te reribus dolorer sperchicium faceata tiustia prat.\nItatur? Quiatae cullecum rem ent aut odis in re eossequodi nonsequ idebis ne sapicia is sinveli squiatum, core et que aut hariosam ex eat.\nBlockquotes The blockquote element represents content that is quoted from another source, optionally with a citation which must be within a footer or cite element, and optionally with in-line changes such as annotations and abbreviations.\nBlockquote without attribution Tiam, ad mint andaepu dandae nostion secatur sequo quae. Note that you can use Markdown syntax within a blockquote.\nBlockquote with attribution Don\u0026rsquo;t communicate by sharing memory, share memory by communicating.\n— Rob Pike1\nTables Tables aren\u0026rsquo;t part of the core Markdown spec, but Hugo supports them out-of-the-box.\nName Age Bob 27 Alice 23 Inline Markdown within tables Italics Bold Code italics bold code Code Blocks Inline Code This is Inline Code\nOnly pre This is pre text Code block with backticks 1 2 3 4 5 6 7 8 9 10 \u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html lang=\u0026#34;en\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;utf-8\u0026#34; /\u0026gt; \u0026lt;title\u0026gt;Example HTML5 Document\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;p\u0026gt;Test\u0026lt;/p\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; Code block with backticks and language specified 1 2 3 4 5 6 7 8 9 10 11 12 13 14 \u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html lang=\u0026#34;en\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;utf-8\u0026#34; /\u0026gt; \u0026lt;title\u0026gt;Example HTML5 Document\u0026lt;/title\u0026gt; \u0026lt;meta name=\u0026#34;description\u0026#34; content=\u0026#34;Sample article showcasing basic Markdown syntax and formatting for HTML elements.\u0026#34; /\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;p\u0026gt;Test\u0026lt;/p\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; 1 2 import hello print(hello) Code block indented with four spaces \u0026lt;!doctype html\u0026gt; \u0026lt;html lang=\u0026quot;en\u0026quot;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026quot;utf-8\u0026quot;\u0026gt; \u0026lt;title\u0026gt;Example HTML5 Document\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;p\u0026gt;Test\u0026lt;/p\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; Code block with Hugo\u0026rsquo;s internal highlight shortcode 1 2 3 4 5 6 7 8 9 10 \u0026lt;!doctype html\u0026gt; \u0026lt;html lang=\u0026#34;en\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;utf-8\u0026#34;\u0026gt; \u0026lt;title\u0026gt;Example HTML5 Document\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;p\u0026gt;Test\u0026lt;/p\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; Gist List Types Ordered List First item Second item Third item Unordered List List item Another item And another item Nested list Fruit Apple Orange Banana Dairy Milk Cheese Other Elements — abbr, sub, sup, kbd, mark GIF is a bitmap image format.\nH2O\nXn + Yn = Zn\nPress CTRL+ALT+Delete to end the session.\nMost salamanders are nocturnal, and hunt for insects, worms, and other small creatures.\nThe above quote is excerpted from Rob Pike\u0026rsquo;s talk during Gopherfest, November 18, 2015.\u0026#160;\u0026#x21a9;\u0026#xfe0e;\n","permalink":"https://cha2hyun.blog/content/posts/markdown-syntac-guide/","summary":"마크다운 신택스 가이드 by Hugo Authors","title":"Markdown Syntax Guide"}]