폰에서 본 히즈넷 공지 목록. 왼쪽은 원래 화면, 오른쪽은 북마크를 실행한 뒤

위 왼쪽이 폰으로 연 히즈넷 공지 목록이다. 모바일 페이지가 따로 없어서 데스크톱 화면이 통째로 축소돼 나온다. 글자가 개미만 해서 공지 하나 읽으려면 두 손가락으로 확대하고 좌우로 밀어야 한다. 학기 초에는 공지를 자주 보게 되니 그때마다 짜증이 났다.

읽기 편한 목록으로 다시 그려주는 걸 만들기로 했다. 그런데 “어떻게 만들 것인가”에서 생각보다 오래 헤맸다.

웹앱으로는 안 된다

처음엔 간단히 생각했다. 공지를 가져와서 예쁘게 뿌리는 웹페이지를 하나 만들면 되는 것 아닌가. 그래서 히즈넷을 열어봤다.

curl -sI https://hisnet.handong.edu/

두 가지가 걸렸다.

첫째, 로그인 벽이다. 히즈넷 루트는 755바이트짜리 프레임셋이고 내용은 /login/login.php를 띄우는 게 전부다. 공지 목록이 로그인 뒤에 있다.

<frameset rows="*,1">
  <frame src="/login/login.php" name="MainFrame">

둘째, 응답에 Access-Control-Allow-Origin이 없다. 다른 주소에서 만든 웹앱이 fetch로 히즈넷을 불러오면 브라우저가 막는다. 서버가 응답을 줘도 자바스크립트는 읽지 못한다.

서버를 하나 두고 대신 로그인시켜서 긁어오는 방법도 있긴 하다. 그런데 그러려면 사용자의 히즈넷 아이디와 비밀번호를 내 서버로 받아야 한다. 남의 학교 계정을 받아 보관하는 건 하고 싶지 않았다.

확장 프로그램은 되는데, 모바일이 아니다

다음으로 떠올린 게 브라우저 확장이었다. 콘텐츠 스크립트는 히즈넷 페이지 안에서 실행되기 때문에 앞의 두 문제가 통째로 사라진다. CORS는 애초에 적용되지 않고, 이미 로그인된 세션의 화면을 그대로 읽으니 비밀번호를 물을 일도 없다.

만들어서 크롬에 올려보니 잘 됐다. 그런데 만들고 나서야 이상하다는 걸 깨달았다.

애초에 문제가 “폰에서 불편하다”였는데, 크롬 확장은 데스크톱에서만 된다.

  iOS 안드로이드 데스크톱
크롬 확장 안 됨 안 됨 됨
사파리 확장 됨 — 됨

iOS에서 확장을 지원하는 브라우저는 사파리뿐이다. iOS의 크롬이나 파이어폭스는 껍데기만 자기 브랜드일 뿐 엔진이 WebKit이라 확장을 못 쓴다.

그럼 사파리 확장으로 만들면 되지 않나 싶었는데, 여기서 또 막혔다. 사파리 확장을 남에게 주려면 App Store에 앱으로 올려 심사를 받아야 한다. 나 혼자 쓰자고 만든 게 아니라 학교 사람들도 쓰면 좋겠다 싶었는데, 앱 설치부터 시켜야 하면 아무도 안 쓴다.

북마클릿이 답이었다

돌고 돌아 도착한 게 북마클릿이었다. 주소가 javascript:로 시작하는 북마크를 만들어 두고, 보고 싶은 페이지에서 그걸 누르면 코드가 그 페이지 안에서 실행된다.

확장과 똑같은 이점을 갖는다. 페이지 출처 안에서 도니까 CORS도 로그인도 문제가 없다. 그러면서 설치할 앱이 없다. 북마크 하나면 iOS 사파리, 안드로이드 크롬, 데스크톱에서 전부 돌아간다.

혹시 막힐까 싶어 응답 헤더를 다시 봤는데, 히즈넷은 Content-Security-Policy가 아예 없었다. 인라인 스크립트를 막지 않으니 북마클릿이 그대로 실행된다.

공지 본문도 마찬가지다. 왼쪽은 원래 화면, 오른쪽은 북마크를 누른 뒤다.

폰에서 본 히즈넷 공지 본문. 왼쪽은 원래 화면, 오른쪽은 북마크를 실행한 뒤

여기서부터가 진짜 좌충우돌

방향을 정하고 나니 이제 쉬울 줄 알았다. 착각이었다. 실제 히즈넷에 붙일 때마다 깨졌다.

프레임셋 — 히즈넷 최상위 문서에는 표도 <body>도 없다. 내용이 안쪽 프레임에 들어 있어서 프레임을 타고 내려가야 했다. 그런데 window.frames는 배열이 아니라 Window 객체다. for...of로 돌렸더니 예외가 나면서 아무 반응 없이 조용히 죽었다. 인덱스로 도는 걸로 바꿨다.

euc-kr — 히즈넷은 아직 euc-kr이다. 공지 원문을 fetch해서 res.text()로 읽으면 한글이 전부 깨진다. UTF-8로 가정하고 디코딩하기 때문이다. 바이트로 받아서 직접 풀어야 했다.

const buf = await res.arrayBuffer();
const html = new TextDecoder("euc-kr").decode(buf);

제목이 숫자로 뜸 — 공지 목록에서 제목을 뽑는데 번호가 나왔다. 한 행에 링크가 여러 개인데 내가 첫 번째를 집었기 때문이다. 히즈넷은 번호 칸도 링크다. 행에서 가장 긴 링크를 제목으로 보도록 고쳤다.

본문 대신 배너 — 제일 오래 붙잡은 문제다. “글자가 제일 많은 덩어리가 본문”이라는 단순한 규칙으로 짰는데, 사이드 배너가 본문보다 길었다. 실제로 재보니 배너 175자, 본문 47자였다. 제목 근처에 있으면 점수를 올려주는 식으로 보정해도 못 이겼다.

결국 실제 페이지를 열어 본문 컨테이너를 직접 확인했다.

<td class="readText BoardContent" style="line-height:150%;padding:10px;">

이걸 먼저 찾고, 없으면 그때 추측 규칙으로 넘어가게 했다. 처음부터 이렇게 할 걸 그랬다.

기억이 배너로 굳음 — 자동으로 못 맞출 때를 대비해 “다른 영역 보기”로 후보를 넘겨보는 버튼을 뒀다. 그리고 고른 위치를 기억하게 했는데, 누를 때마다 저장하게 만들어 놨다. 후보를 구경하려고 몇 번 누르면 지나쳐 간 배너까지 기억에 남고, 기억된 것은 다음부터 1번으로 올라온다. 배너에서 한 번 멈추면 그 뒤로 모든 공지에서 배너가 먼저 뜨는 구조였다. 훑어보기와 확정을 분리했다.

마지막 함정: 버튼이 손톱만 했다

기능이 다 돌아가게 만들고 폰에서 열어봤는데 UI가 터무니없이 작았다. 터치 영역을 46px로 키웠는데도 그대로였다.

원인은 엉뚱한 데 있었다. 히즈넷에 viewport 메타가 없다.

<meta name="viewport" content="width=device-width, initial-scale=1">

이게 없으면 폰은 화면을 980px짜리로 그린 다음 통째로 축소해서 보여준다. 내가 만든 오버레이도 같이 축소되니, 46px로 잡은 버튼이 실제로는 18px쯤으로 보인다. 버튼 크기를 아무리 키워도 소용없던 이유였다.

최상위 문서에 메타를 넣어주니 innerWidth가 980에서 390으로 바뀌면서 한 번에 해결됐다. 애초에 히즈넷이 폰에서 불편했던 근본 원인도 이거였다.

결과

북마크 하나 등록하면 끝이다. 히즈넷 공지 목록에서 누르면 읽기 편한 목록이 뜨고, 공지를 누르면 본문까지 그 안에서 읽힌다. 글자 크기도 조절되고 그 설정을 기억한다.

설치 안내는 여기에 정리해 뒀다. 코드는 깃허브에 있다.

배운 것

방향을 정하는 데 오래 걸렸는데, 돌아보면 “누가 어디서 쓰는가”를 먼저 못 박지 않아서 그랬다. 크롬 확장을 다 만들고 나서야 “모바일이 목적인데 데스크톱에서만 되네”를 깨달았으니까.

그리고 남의 페이지를 다루는 코드는 추측으로 짜면 계속 깨진다. “글자 많은 게 본문” 같은 규칙은 그럴듯해 보여도 배너 하나에 무너진다. 실제 마크업을 한 번 열어보는 10초가 며칠을 아껴준다.