KIM-JINO5 3 روز پیش
والد
کامیت
a9394ca607
14فایلهای تغییر یافته به همراه772 افزوده شده و 64 حذف شده
  1. 8 2
      config/constants.go
  2. 4 4
      config/database.json
  3. 3 3
      config/movie.json
  4. 60 0
      controller/company.go
  5. 68 39
      controller/cron.go
  6. 3 0
      controller/movie.go
  7. 435 0
      docs/KOBIS-영화API.md
  8. 1 0
      log/kobis/error.txt
  9. 1 1
      log/kobis/page.txt
  10. 62 0
      model/company.go
  11. 97 0
      model/kobis.go
  12. 15 7
      route/api.go
  13. 14 7
      service/db.go
  14. 1 1
      service/rest.go

+ 8 - 2
config/constants.go

@@ -59,9 +59,15 @@ const (
 	// 영화 박스오피스 (통계)
 	MOVIE_BOX_OFFICE_STATS = "http://api.kcisa.kr/openapi/service/rest/meta5/getKFCC0502"
 
+	// 영화사 목록 API
+	COMPANY_LIST = "http://www.kobis.or.kr/kobisopenapi/webservice/rest/company/searchCompanyList.json"
+
+	// 영화사 상세 정보 API
+	COMPANY_INFO = "http://www.kobis.or.kr/kobisopenapi/webservice/rest/company/searchCompanyInfo.json"
+
 	// 암호화, 복호화 Key, IV
-	ENCRYPT_KEY = "@@20120726CHrong"
-	ENCRYPT_IV  = "6c2df00f75470f67"
+	ENCRYPT_KEY = "8ae4993ba0b0f65ec306a3842b2ab58165006260c5c2a6a3d159e4a18b5679f9"
+	ENCRYPT_IV  = "b3700bce18978aa5152da279619bd6caedcab49c287e50bb0f84cd59696b1759"
 )
 
 // 환경설정값

+ 4 - 4
config/database.json

@@ -6,15 +6,15 @@
     "local": {
         "driver": "mysql",
         "user": "admin",
-        "password": "bluescreen!!",
-        "address": "192.168.0.100:3306",
+        "password": "@@17125942KKh",
+        "address": "192.168.0.10:3306",
         "name": "movie"
     },
     "dev": {
         "driver": "mysql",
         "user": "admin",
-        "password": "bluescreen!!",
-        "address": "192.168.0.100:3306",
+        "password": "@@20120726KKh",
+        "address": "192.168.0.202:3306",
         "name": "movie"
     }
 }

+ 3 - 3
config/movie.json

@@ -1,7 +1,7 @@
 {
 	"kobis": {
-		"apiKey_1": "d470c8a1fa881cd5945b747a6dfadd53",
-		"apiKey_2": "52c8406a33ffe497f041868b2c3be552",
-		"apiKey_3": "f5eef3421c602c6cb7ea224104795888"
+		"apiKey_1": "b453d855c8cb458236dc89f4e7897995",
+		"apiKey_2": "cf7ea46f636ff9f9014e2b014bbd3006",
+		"apiKey_3": "82ca741a2844c5c180a208137bb92bd7"
 	}
 }

+ 60 - 0
controller/company.go

@@ -0,0 +1,60 @@
+package controller
+
+import (
+	"crawler/model"
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+)
+
+type Company struct {
+	Kobis model.Kobis
+}
+
+/**
+ * 영화사 목록 조회
+ * /company/searchCompanyList
+ */
+func (this *Company) SearchCompanyList(c *gin.Context) {
+	var req model.SearchCompanyListParams
+
+	if err := c.ShouldBind(&req); err != nil {
+		c.JSON(http.StatusBadRequest, err.Error())
+		return
+	}
+
+	data, err := this.Kobis.CompanyListAPI(req)
+	if err != nil {
+		c.JSON(http.StatusBadRequest, err.Error())
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"total": data.CompanyListResult.TotCnt,
+		"rows":  len(data.CompanyListResult.CompanyList),
+		"list":  data.CompanyListResult.CompanyList,
+	})
+}
+
+/**
+ * 영화사 상세 조회
+ * /company/searchCompanyInfo
+ */
+func (this *Company) SearchCompanyInfo(c *gin.Context) {
+	var req model.SearchCompanyInfoParams
+
+	if err := c.ShouldBind(&req); err != nil {
+		c.JSON(http.StatusBadRequest, err.Error())
+		return
+	}
+
+	data, err := this.Kobis.CompanyInfoAPI(req)
+	if err != nil {
+		c.JSON(http.StatusBadRequest, err.Error())
+		return
+	}
+
+	c.JSON(http.StatusOK, gin.H{
+		"info": data.CompanyInfoResult.CompanyInfo,
+	})
+}

+ 68 - 39
controller/cron.go

@@ -13,6 +13,7 @@ import (
 	"net/http"
 	"os"
 	"strconv"
+	"strings"
 	"time"
 )
 
@@ -75,8 +76,9 @@ func (this *Cron) List(c *gin.Context) {
 		data, err := this.Kobis.MovieListAPI(req)
 
 		if err != nil {
+			SetLastPage(page)
 			c.JSON(http.StatusBadRequest, err.Error())
-			break
+			return
 		}
 
 		// 더 이상 값이 없다면 중지
@@ -136,6 +138,8 @@ func (this *Cron) Info(c *gin.Context) {
 		start                                 = time.Now()
 		codes                                 = this.MovieListModel.MovieInfoExcludeCodes()
 		total, errors, insertRows, updateRows = 0, 0, 0, 0
+		failedCodes                           = make([]string, 0)
+		consecutive                           = 0
 		output                                = func(n ...int) string {
 			s := fmt.Sprintf("Total : %d\n", n[0])
 			s += fmt.Sprintf("Error: %d\n", n[1])
@@ -165,15 +169,32 @@ func (this *Cron) Info(c *gin.Context) {
 		data, err := this.Kobis.MovieInfoAPI(req)
 
 		if err != nil {
-			c.JSON(http.StatusBadRequest, err.Error())
-			break
+			errors++
+			consecutive++
+			failedCodes = append(failedCodes, movieCd)
+
+			// 연속 오류(무효 키, 쿼터 초과 등) 시 쿼터 낭비 방지를 위해 중단
+			if consecutive >= 10 {
+				c.JSON(http.StatusBadRequest, gin.H{
+					"message":     "연속 오류 10회로 중단: " + err.Error(),
+					"total":       total,
+					"error":       errors,
+					"insertRows":  insertRows,
+					"updateRows":  updateRows,
+					"failedCodes": failedCodes,
+				})
+				return
+			}
+			continue
 		}
+		consecutive = 0
 
 		row := data.MovieInfoResult.MovieInfo
 
 		if row.MovieCd == "" {
 			errors++
-			break
+			failedCodes = append(failedCodes, movieCd)
+			continue
 		}
 
 		if this.MovieInfoModel.IsExists(row.MovieCd) == true {
@@ -200,10 +221,11 @@ func (this *Cron) Info(c *gin.Context) {
 	//utility.SendMessage(msg)
 
 	c.JSON(http.StatusOK, gin.H{
-		"total":      total,
-		"error":      errors,
-		"insertRows": insertRows,
-		"updateRows": updateRows,
+		"total":       total,
+		"error":       errors,
+		"insertRows":  insertRows,
+		"updateRows":  updateRows,
+		"failedCodes": failedCodes,
 	})
 }
 
@@ -262,6 +284,37 @@ func (this *Cron) Detail(c *gin.Context) {
 		scraped++
 	})
 
+	// 상세 페이지 파싱 — 루프 밖 1회 등록 (루프 내 등록 시 콜백이 영화 수만큼 누적됨)
+	var current *model.MovieDetail
+	c1.OnHTML(".item_tab.basic", func(e *colly.HTMLElement) {
+		if current == nil {
+			return
+		}
+		var host = config.KOBIS_HOST
+		current.MainImg = e.ChildAttr("a.fl.thumb", "href")
+		if current.MainImg != "" && current.MainImg != "#" {
+			current.MainImg = host + current.MainImg
+		}
+		current.ThumbImg = e.ChildAttr("a.fl.thumb > img", "src")
+		if current.ThumbImg != "" && current.ThumbImg != "#" {
+			current.ThumbImg = host + current.ThumbImg
+		}
+		current.Synopsis = e.ChildText("div.info.info2 p.desc_info")
+
+		e.ForEach("div#post > input", func(_ int, ee *colly.HTMLElement) {
+			current.Poster = append(current.Poster, model.Poster{
+				Thumb:  host + ee.Attr("thn_img"),
+				Origin: host + ee.Attr("img"),
+			})
+		})
+		e.ForEach("div#stl > input", func(_ int, ee *colly.HTMLElement) {
+			current.StillCut = append(current.StillCut, model.StillCut{
+				Thumb:  host + ee.Attr("thn_img"),
+				Origin: host + ee.Attr("img"),
+			})
+		})
+	})
+
 	/*
 	 관객 수, 누적 매출액 조회
 	*/
@@ -294,32 +347,7 @@ func (this *Cron) Detail(c *gin.Context) {
 		}
 		movieDetail := this.MovieDetailModel.MovieDetail
 		movieDetail.MovieCd = movieCd
-
-		c1.OnHTML(".item_tab.basic", func(e *colly.HTMLElement) {
-			var host = config.KOBIS_HOST
-			movieDetail.MainImg = e.ChildAttr("a.fl.thumb", "href")
-			if movieDetail.MainImg != "" && movieDetail.MainImg != "#" {
-				movieDetail.MainImg = host + movieDetail.MainImg
-			}
-			movieDetail.ThumbImg = e.ChildAttr("a.fl.thumb > img", "src")
-			if movieDetail.ThumbImg != "" && movieDetail.ThumbImg != "#" {
-				movieDetail.ThumbImg = host + movieDetail.ThumbImg
-			}
-			movieDetail.Synopsis = e.ChildText("div.info.info2 p.desc_info")
-
-			e.ForEach("div#post > input", func(_ int, ee *colly.HTMLElement) {
-				movieDetail.Poster = append(movieDetail.Poster, model.Poster{
-					Thumb:  host + ee.Attr("thn_img"),
-					Origin: host + ee.Attr("img"),
-				})
-			})
-			e.ForEach("div#stl > input", func(_ int, ee *colly.HTMLElement) {
-				movieDetail.StillCut = append(movieDetail.StillCut, model.StillCut{
-					Thumb:  host + ee.Attr("thn_img"),
-					Origin: host + ee.Attr("img"),
-				})
-			})
-		})
+		current = &movieDetail
 
 		//c2.OnHTML("body", func(e *colly.HTMLElement) {
 		//	var (
@@ -490,12 +518,13 @@ func (this *Cron) Stats(c *gin.Context) {
 */
 
 func GetKey(c *gin.Context) string {
-	if c.Query("key") == "1" {
+	switch c.Query("key") {
+	case "1":
 		return config.Movie.Kobis.ApiKey_1
-	} else if c.Query("key") == "2" {
+	case "2":
 		return config.Movie.Kobis.ApiKey_2
-	} else {
-		return "f5eef3421c602c6cb7ea224104795888"
+	default:
+		return config.Movie.Kobis.ApiKey_3
 	}
 }
 
@@ -523,7 +552,7 @@ func GetLastPage() int {
 		fmt.Println(err)
 	}
 
-	page, _ := strconv.Atoi(string(byte))
+	page, _ := strconv.Atoi(strings.TrimSpace(string(byte)))
 
 	if page == 0 {
 		page = 1

+ 3 - 0
controller/movie.go

@@ -74,6 +74,7 @@ func (this *Movie) SearchDailyBoxOfficeList(c *gin.Context) {
 		data, err := this.Kobis.MovieDailyBoxOfficeListAPI(req)
 		if err != nil {
 			c.JSON(http.StatusBadRequest, err.Error())
+			return
 		}
 
 		// 데이터 새로 입력
@@ -173,6 +174,7 @@ func (this *Movie) SearchWeeklyBoxOfficeList(c *gin.Context) {
 
 		if err != nil {
 			c.JSON(http.StatusBadRequest, err.Error())
+			return
 		}
 
 		// 데이터 새로 입력
@@ -251,6 +253,7 @@ func (this *Movie) SearchMovieList(c *gin.Context) {
 	data, err := this.Kobis.MovieListAPI(req)
 	if err != nil {
 		c.JSON(http.StatusBadRequest, err.Error())
+		return
 	}
 
 	// 데이터 새로 입력

+ 435 - 0
docs/KOBIS-영화API.md

@@ -0,0 +1,435 @@
+# KOBIS 영화진흥위원회 OpenAPI 정리
+
+영화진흥위원회(KOBIS) 통합전산망 오픈API 요약 문서입니다.
+crawler 프로젝트에서 사용하는 영화 관련 API 9종을 한 곳에 정리했습니다.
+
+> 원본: `docs/*.txt` (KOBIS 공식 API 명세)
+
+---
+
+## 공통 사항
+
+- **호출 방식**: REST / SOAP 선택 가능 (본 문서는 REST 기준)
+- **응답 형식**: URI 확장자로 구분 — `.xml` 또는 `.json`
+- **HTTP 메서드**: `GET`
+- **REST 베이스 URL**: `http://www.kobis.or.kr/kobisopenapi/webservice/rest`
+- **SOAP 베이스 URL**: `http://www.kobis.or.kr/kobisopenapi/webservice/soap`
+- **인증**: 모든 요청에 발급키 `key` 파라미터 필수
+- 예시 URL의 `key=82ca741a2844c5c180a208137bb92bd7` 는 KOBIS 공식 문서의 **샘플 키**입니다. 실제 호출 시 발급받은 키로 교체하세요.
+
+### 엔드포인트 한눈에 보기
+
+| 구분 | API | Operation | REST 경로 (`.json` / `.xml`) |
+|------|-----|-----------|------------------------------|
+| 박스오피스 | 일별 박스오피스 | `searchDailyBoxOfficeList` | `/boxoffice/searchDailyBoxOfficeList` |
+| 박스오피스 | 주간/주말 박스오피스 | `searchWeeklyBoxOfficeList` | `/boxoffice/searchWeeklyBoxOfficeList` |
+| 영화 | 영화 목록 | `searchMovieList` | `/movie/searchMovieList` |
+| 영화 | 영화 상세 | `searchMovieInfo` | `/movie/searchMovieInfo` |
+| 영화사 | 영화사 목록 | `searchCompanyList` | `/company/searchCompanyList` |
+| 영화사 | 영화사 상세 | `searchCompanyInfo` | `/company/searchCompanyInfo` |
+| 영화인 | 영화인 목록 | `searchPeopleList` | `/people/searchPeopleList` |
+| 영화인 | 영화인 상세 | `searchPeopleInfo` | `/people/searchPeopleInfo` |
+| 공통 | 공통코드 조회 | `searchCodeList` | `/code/searchCodeList` |
+
+### 자주 쓰는 상위코드 (`comCode`, 공통코드 조회 API로 조회)
+
+| comCode | 용도 | 사용하는 API |
+|---------|------|--------------|
+| `2201` | 영화유형코드 | 영화 목록 (`movieTypeCd`) |
+| `2204` | 국적코드 | 영화 목록 (`repNationCd`) |
+| `2601` | 영화사 분류코드 | 영화사 목록 (`companyPartCd`) |
+| `0105000000` | 상영지역코드 | 박스오피스 (`wideAreaCd`) |
+
+---
+
+## 1. 일별 박스오피스 — `searchDailyBoxOfficeList`
+
+특정 일자 상영작들의 박스오피스 정보를 영화구분(다양성/상업), 한국/외국, 상영지역 등으로 조회.
+
+- **REST**: `GET /boxoffice/searchDailyBoxOfficeList.json` (또는 `.xml`)
+- **SOAP**: `.../soap/boxoffice` · Operation `searchDailyBoxOfficeList`
+
+### 요청 파라미터
+
+| 변수 | 필수 | 설명 |
+|------|:----:|------|
+| `key` | ✅ | 발급받은 키 값 |
+| `targetDt` | ✅ | 조회 날짜 `yyyymmdd` |
+| `itemPerPage` | | 결과 ROW 개수 (default `10`, 최대 `10`) |
+| `multiMovieYn` | | `Y`: 다양성 영화 / `N`: 상업영화 (default 전체) |
+| `repNationCd` | | `K`: 한국영화 / `F`: 외국영화 (default 전체) |
+| `wideAreaCd` | | 상영지역코드(공통코드 `0105000000`) (default 전체) |
+
+### 응답 필드
+
+| 필드 | 설명 |
+|------|------|
+| `boxofficeType` | 박스오피스 종류 |
+| `showRange` | 박스오피스 조회 일자 |
+| `rnum` | 순번 |
+| `rank` | 해당일자 박스오피스 순위 |
+| `rankInten` | 전일 대비 순위 증감분 |
+| `rankOldAndNew` | 신규 진입 여부 (`OLD` 기존 / `NEW` 신규) |
+| `movieCd` | 영화 대표코드 |
+| `movieNm` | 영화명(국문) |
+| `openDt` | 개봉일 |
+| `salesAmt` | 해당일 매출액 |
+| `salesShare` | 해당일 상영작 매출총액 대비 매출 비율 |
+| `salesInten` | 전일 대비 매출액 증감분 |
+| `salesChange` | 전일 대비 매출액 증감 비율 |
+| `salesAcc` | 누적 매출액 |
+| `audiCnt` | 해당일 관객수 |
+| `audiInten` | 전일 대비 관객수 증감분 |
+| `audiChange` | 전일 대비 관객수 증감 비율 |
+| `audiAcc` | 누적 관객수 |
+| `scrnCnt` | 해당일자 상영 스크린수 |
+| `showCnt` | 해당일자 상영 횟수 |
+
+### 예시
+
+```
+http://kobis.or.kr/kobisopenapi/webservice/rest/boxoffice/searchDailyBoxOfficeList.json?key={KEY}&targetDt=20120101
+```
+
+---
+
+## 2. 주간/주말 박스오피스 — `searchWeeklyBoxOfficeList`
+
+특정 일자가 속한 주차의 주간/주말/주중 상영작 박스오피스 정보를 조회.
+
+- **REST**: `GET /boxoffice/searchWeeklyBoxOfficeList.json` (또는 `.xml`)
+- **SOAP**: `.../soap/boxoffice` · Operation `searchWeeklyBoxOfficeList`
+
+### 요청 파라미터
+
+| 변수 | 필수 | 설명 |
+|------|:----:|------|
+| `key` | ✅ | 발급받은 키 값 |
+| `targetDt` | ✅ | 조회 날짜 `yyyymmdd` |
+| `weekGb` | | `0`: 주간(월~일) / `1`: 주말(금~일, **default**) / `2`: 주중(월~목) |
+| `itemPerPage` | | 결과 ROW 개수 (default `10`, 최대 `10`) |
+| `multiMovieYn` | | `Y`: 다양성 영화 / `N`: 상업영화 (default 전체) |
+| `repNationCd` | | `K`: 한국영화 / `F`: 외국영화 (default 전체) |
+| `wideAreaCd` | | 상영지역코드(공통코드 `0105000000`) (default 전체) |
+
+### 응답 필드
+
+일별 박스오피스와 동일하며, 아래 필드가 추가됩니다.
+
+| 필드 | 설명 |
+|------|------|
+| `boxofficeType` | 박스오피스 종류 |
+| `showRange` | 대상 상영기간 |
+| `yearWeekTime` | 조회일자에 해당하는 연도·주차 (`YYYYIW`) |
+| `rnum` | 순번 |
+| `rank` | 박스오피스 순위 |
+| `rankInten` | 전일 대비 순위 증감분 |
+| `rankOldAndNew` | 신규 진입 여부 (`OLD` / `NEW`) |
+| `movieCd` | 영화 대표코드 |
+| `movieNm` | 영화명(국문) |
+| `openDt` | 개봉일 |
+| `salesAmt` / `salesShare` / `salesInten` / `salesChange` / `salesAcc` | 매출액 / 점유율 / 증감분 / 증감율 / 누적 |
+| `audiCnt` / `audiInten` / `audiChange` / `audiAcc` | 관객수 / 증감분 / 증감율 / 누적 |
+| `scrnCnt` / `showCnt` | 상영 스크린수 / 상영 횟수 |
+
+### 예시
+
+```
+http://kobis.or.kr/kobisopenapi/webservice/rest/boxoffice/searchWeeklyBoxOfficeList.json?key={KEY}&targetDt=20120101
+```
+
+---
+
+## 3. 영화 목록 — `searchMovieList`
+
+영화명, 감독명 등의 조건으로 영화 목록을 조회.
+
+- **REST**: `GET /movie/searchMovieList.json` (또는 `.xml`)
+- **SOAP**: `.../soap/movie` · Operation `searchMovieList`
+
+### 요청 파라미터
+
+| 변수 | 필수 | 설명 |
+|------|:----:|------|
+| `key` | ✅ | 발급받은 키 값 |
+| `curPage` | | 현재 페이지 (default `1`) |
+| `itemPerPage` | | 결과 ROW 개수 (default `10`) |
+| `movieNm` | | 영화명으로 조회 (UTF-8) |
+| `directorNm` | | 감독명으로 조회 (UTF-8) |
+| `openStartDt` | | 조회 시작 개봉연도 `YYYY` |
+| `openEndDt` | | 조회 종료 개봉연도 `YYYY` |
+| `prdtStartYear` | | 조회 시작 제작연도 `YYYY` |
+| `prdtEndYear` | | 조회 종료 제작연도 `YYYY` |
+| `repNationCd` | | 국적코드(공통코드 `2204`), N개 가능 (default 전체) |
+| `movieTypeCd` | | 영화유형코드(공통코드 `2201`), N개 가능 (default 전체) |
+
+### 응답 필드
+
+| 필드 | 설명 |
+|------|------|
+| `movieCd` | 영화코드 |
+| `movieNm` | 영화명(국문) |
+| `movieNmEn` | 영화명(영문) |
+| `prdtYear` | 제작연도 |
+| `openDt` | 개봉일 |
+| `typeNm` | 영화유형 |
+| `prdtStatNm` | 제작상태 |
+| `nationAlt` | 제작국가(전체) |
+| `genreAlt` | 영화장르(전체) |
+| `repNationNm` | 대표 제작국가명 |
+| `repGenreNm` | 대표 장르명 |
+| `directors` › `peopleNm` | 영화감독(배열) › 감독명 |
+| `companys` › `companyCd`, `companyNm` | 제작사(배열) › 제작사 코드, 제작사명 |
+
+### 예시
+
+```
+http://kobis.or.kr/kobisopenapi/webservice/rest/movie/searchMovieList.json?key={KEY}
+```
+
+---
+
+## 4. 영화 상세 — `searchMovieInfo`
+
+영화코드로 영화 상세정보를 조회.
+
+- **REST**: `GET /movie/searchMovieInfo.json` (또는 `.xml`)
+- **SOAP**: `.../soap/movie` · Operation `searchMovieInfo`
+
+### 요청 파라미터
+
+| 변수 | 필수 | 설명 |
+|------|:----:|------|
+| `key` | ✅ | 발급받은 키 값 |
+| `movieCd` | ✅ | 영화코드 |
+
+### 응답 필드
+
+**기본 정보**
+
+| 필드 | 설명 |
+|------|------|
+| `movieCd` | 영화코드 |
+| `movieNm` / `movieNmEn` / `movieNmOg` | 영화명 국문 / 영문 / 원문 |
+| `prdtYear` | 제작연도 |
+| `showTm` | 상영시간 |
+| `openDt` | 개봉연도 |
+| `prdtStatNm` | 제작상태명 |
+| `typeNm` | 영화유형명 |
+
+**중첩 구조 (배열)**
+
+| 그룹 | 하위 필드 | 설명 |
+|------|-----------|------|
+| `nations` | `nationNm` | 제작국가명 |
+| (genre) | `genreNm` | 장르명 |
+| `directors` | `peopleNm`, `peopleNmEn` | 감독명 / 감독명(영문) |
+| `actors` | `peopleNm`, `peopleNmEn`, `cast`, `castEn` | 배우명 / 배우명(영문) / 배역명 / 배역명(영문) |
+| `showTypes` | `showTypeGroupNm`, `showTypeNm` | 상영형태 구분 / 상영형태명 |
+| `audits` | `auditNo`, `watchGradeNm` | 심의번호 / 관람등급 명칭 |
+| `companys` | `companyCd`, `companyNm`, `companyNmEn`, `companyPartNm` | 영화사 코드 / 명 / 명(영문) / 참여 분야명 |
+| `staffs` | `peopleNm`, `peopleNmEn`, `staffRoleNm` | 스텝명 / 스텝명(영문) / 스텝역할명 |
+
+### 예시
+
+```
+http://www.kobis.or.kr/kobisopenapi/webservice/rest/movie/searchMovieInfo.json?key={KEY}&movieCd=20124079
+```
+
+---
+
+## 5. 영화사 목록 — `searchCompanyList`
+
+영화사명, 대표자명 등의 조건으로 영화사 목록을 조회.
+
+- **REST**: `GET /company/searchCompanyList.json` (또는 `.xml`)
+- **SOAP**: `.../soap/company` · Operation `searchCompanyList`
+
+### 요청 파라미터
+
+| 변수 | 필수 | 설명 |
+|------|:----:|------|
+| `key` | ✅ | 발급받은 키 값 |
+| `curPage` | | 현재 페이지 (default `1`) |
+| `itemPerPage` | | 결과 ROW 개수 (default `10`) |
+| `companyNm` | | 영화사명으로 조회 |
+| `ceoNm` | | 대표자명으로 조회 |
+| `companyPartCd` | | 분류코드(공통코드 `2601`), N개 가능 (default 전체) |
+
+### 응답 필드
+
+| 필드 | 설명 |
+|------|------|
+| `companyCd` | 영화사 코드 |
+| `companyNm` / `companyNmEn` | 영화사명 / 영화사명(영문) |
+| `companyPartNames` | 영화사 분류 |
+| `ceoNm` | 대표자명 |
+| `filmoNames` | 필모리스트 |
+
+### 예시
+
+```
+http://kobis.or.kr/kobisopenapi/webservice/rest/company/searchCompanyList.json?key={KEY}
+```
+
+---
+
+## 6. 영화사 상세 — `searchCompanyInfo`
+
+영화사코드로 영화사 상세정보를 조회.
+
+- **REST**: `GET /company/searchCompanyInfo.json` (또는 `.xml`)
+- **SOAP**: `.../soap/company` · Operation `searchCompanyInfo`
+
+### 요청 파라미터
+
+| 변수 | 필수 | 설명 |
+|------|:----:|------|
+| `key` | ✅ | 발급받은 키 값 |
+| `companyCd` | | 영화사코드 |
+
+### 응답 필드
+
+| 그룹 | 필드 | 설명 |
+|------|------|------|
+| 기본 | `companyCd` | 영화사 코드 |
+| 기본 | `companyNm` / `companyNmEn` | 영화사명 / 영화사명(영문) |
+| 기본 | `ceoNm` | 대표자명 |
+| `parts` | `companyPartNm` | 영화사 분류명 |
+| `filmos` | `movieCd`, `movieNm`, `companyPartNm` | 참여 영화코드 / 참여 영화명 / 참여 분류명 |
+
+### 예시
+
+```
+http://www.kobis.or.kr/kobisopenapi/webservice/rest/company/searchCompanyInfo.json?key={KEY}&companyCd=20122497
+```
+
+---
+
+## 7. 영화인 목록 — `searchPeopleList`
+
+영화인명, 필모그래피 조건으로 영화인 목록을 조회.
+
+- **REST**: `GET /people/searchPeopleList.json` (또는 `.xml`)
+- **SOAP**: `.../soap/people` · Operation `searchPeopleList`
+
+### 요청 파라미터
+
+| 변수 | 필수 | 설명 |
+|------|:----:|------|
+| `key` | ✅ | 발급받은 키 값 |
+| `curPage` | | 현재 페이지 (default `1`) |
+| `itemPerPage` | | 결과 ROW 개수 (default `10`) |
+| `peopleNm` | | 영화인명으로 조회 |
+| `filmoNames` | | 필모리스트로 조회 |
+
+### 응답 필드
+
+| 필드 | 설명 |
+|------|------|
+| `peopleCd` | 영화인 코드 |
+| `peopleNm` / `peopleNmEn` | 영화인명 / 영화인명(영문) |
+| `repRoleNm` | 분야 |
+| `filmoNames` | 필모리스트 |
+
+### 예시
+
+```
+http://kobis.or.kr/kobisopenapi/webservice/rest/people/searchPeopleList.json?key={KEY}
+```
+
+---
+
+## 8. 영화인 상세 — `searchPeopleInfo`
+
+영화인코드로 영화인 상세정보를 조회.
+
+- **REST**: `GET /people/searchPeopleInfo.json` (또는 `.xml`)
+- **SOAP**: `.../soap/people` · Operation `searchPeopleInfo`
+  > ⚠️ SOAP Operation 표기가 원문에서 `searchpeopleInfo`(소문자 p)로 되어 있음 — SOAP 사용 시 주의.
+
+### 요청 파라미터
+
+| 변수 | 필수 | 설명 |
+|------|:----:|------|
+| `key` | ✅ | 발급받은 키 값 |
+| `peopleCd` | | 영화인코드 |
+
+### 응답 필드
+
+| 그룹 | 필드 | 설명 |
+|------|------|------|
+| 기본 | `peopleCd` | 영화인 코드 |
+| 기본 | `peopleNm` / `peopleNmEn` | 영화인명 / 영화인명(영문) |
+| 기본 | `sex` | 성별 |
+| 기본 | `repRoleNm` | 영화인 분류명 |
+| `filmos` | `movieCd`, `movieNm`, `moviePartNm` | 참여 영화코드 / 참여 영화명 / 참여분야 |
+| 기본 | `homepages` | 관련 URL |
+
+### 예시
+
+```
+http://kobis.or.kr/kobisopenapi/webservice/rest/people/searchPeopleInfo.json?key={KEY}&peopleCd=20164556
+```
+
+---
+
+## 9. 공통코드 조회 — `searchCodeList`
+
+특정 상위 코드값을 조건으로 하위 코드정보를 조회. (국적/유형/분류/지역 코드 등)
+
+- **REST**: `GET /code/searchCodeList.json` (또는 `.xml`)
+- **SOAP**: `.../soap/code` · Operation `searchCodeList`
+
+### 요청 파라미터
+
+| 변수 | 필수 | 설명 |
+|------|:----:|------|
+| `key` | ✅ | 발급받은 키 값 |
+| `comCode` | ✅ | 조회하고자 하는 상위 코드 (예: `2204`, `2201`, `2601`, `0105000000`) |
+
+### 응답 필드
+
+| 필드 | 설명 |
+|------|------|
+| `fullCd` | 상위코드 |
+| `korNm` | 코드 국문명 |
+| `engNm` | 코드 영문명 |
+
+### 예시
+
+```
+http://kobis.or.kr/kobisopenapi/webservice/rest/code/searchCodeList.json?key={KEY}&comCode=0105000000
+```
+
+---
+
+## 구현 현황 (crawler ↔ movie 사이트) — 2026-07-22 기준
+
+| KOBIS API | crawler 구현 | crawler 엔드포인트 | movie 사이트 사용처 |
+|---|:---:|---|---|
+| 일별 박스오피스 | ✅ | `GET /movie/searchDailyBoxOfficeList` | /movie/rank (평일) |
+| 주간/주말 박스오피스 | ✅ | `GET /movie/searchWeeklyBoxOfficeList` | /movie/rank (주말) |
+| 영화 목록 | ✅ | `GET /movie/searchMovieList` + `/cron/list` | (수집 전용 — 검색 페이지는 tb_movie 직조회) |
+| 영화 상세 | ✅ | `GET /movie/searchMovieInfo` + `/cron/info` | /movie/rank/{id}, /movie/search/{id} |
+| 영화사 목록 | ✅ | `GET /company/searchCompanyList` | /movie/company |
+| 영화사 상세 | ✅ | `GET /company/searchCompanyInfo` | /movie/company/{cd} |
+| 영화인 목록/상세 | ❌ 미구현 | - | - |
+| 공통코드 조회 | ❌ 미구현 | - | - |
+
+- 영화사 API는 **DB 캐싱 없이 KOBIS 프록시**로 동작한다 (tb_company 테이블 없음).
+- KOBIS는 오류도 HTTP 200 + `faultInfo`로 반환하므로 crawler `model/kobis.go`의 `checkFault()`가 error로 승격한다 (→ HTTP 400).
+
+### 키 운영 정책
+
+- 키는 crawler `config/movie.json`(cron 수집용)과 movie `config/constants.php` `KOBIS_API_1~3`(사이트 → crawler 전달용) **양쪽에 동일하게 유지**해야 한다.
+- `apiKey_3` 자리의 `82ca741a...`는 KOBIS 공식 문서 샘플 키로 **무효**(320010) — 실제 사용 시 KOBIS 계정에서 발급한 키로 교체할 것. 사이트는 `KOBIS_API_1`만 실제 사용한다.
+- 키당 일 호출 한도가 있으므로 cron 수집 시 `?key=1|2` 파라미터로 분산한다.
+
+### 운영 절차 (수집)
+
+1. 박스오피스(일별/주간): 사이트 조회 시 **on-demand 수집** — 별도 스케줄 불필요. 키만 유효하면 날짜별 첫 조회 때 tb_movie_daily/weekly에 적재된다.
+2. 영화 목록/기본정보/상세: `GET /cron/list?key=1` → `GET /cron/info?key=2` → `GET /cron/detail` 순서로 주기 실행 (마지막 수집 페이지는 `log/kobis/page.txt`에 저장, 목록은 최신순 정렬이라 전량 스윕은 page.txt를 `1`로 리셋 후 실행).
+   - `/cron/info`는 단건 실패 시 해당 영화만 건너뛰고(`failedCodes`로 리포트) **연속 오류 10회면 중단**한다(무효 키·쿼터 초과 보호). 실패 원인은 `log/kobis/error.txt`에서 확인.
+   - 2026-07-22 기준 백로그: tb_movie 101,810 vs KOBIS 120,000(목록 ~18k편 부족), 기본정보 미수집 ~31k건 — info 수집은 키당 일 3,000회 제한으로 수일 분산 필요.
+3. 누적 매출/관객(`tb_movie_detail.sale_acc/audi_acc`) 수집기(c2)는 주석 처리 상태 — 재활성화 시 `searchMovieDtl.do sType=stat` 셀렉터 재검증 필요.

+ 1 - 0
log/kobis/error.txt

@@ -32,3 +32,4 @@
 2025/08/08 13:51:46 <nil>
 2025/08/08 14:27:57 <nil>
 2025/08/08 14:27:57 <nil>
+2026/07/22 11:37:47 <nil>

+ 1 - 1
log/kobis/page.txt

@@ -1 +1 @@
-194
+1

+ 62 - 0
model/company.go

@@ -0,0 +1,62 @@
+package model
+
+// 영화사 목록 검색 변수
+type SearchCompanyListParams struct {
+	Key           string `form:"key" url:"key" binding:"required"`
+	CurPage       int    `form:"curPage" url:"curPage,omitempty"`
+	ItemPerPage   int    `form:"itemPerPage" url:"itemPerPage,omitempty"`
+	CompanyNm     string `form:"companyNm" url:"companyNm,omitempty"`
+	CeoNm         string `form:"ceoNm" url:"ceoNm,omitempty"`
+	CompanyPartCd string `form:"companyPartCd" url:"companyPartCd,omitempty"`
+}
+
+// 영화사 목록 응답 변수
+type SearchCompanyList struct {
+	CompanyListResult struct {
+		TotCnt      int               `json:"totCnt"`
+		Source      string            `json:"source"`
+		CompanyList []CompanyListInfo `json:"companyList"`
+	} `json:"companyListResult"`
+}
+
+type CompanyListInfo struct {
+	CompanyCd        string `json:"companyCd"`
+	CompanyNm        string `json:"companyNm"`
+	CompanyNmEn      string `json:"companyNmEn"`
+	CompanyPartNames string `json:"companyPartNames"`
+	CeoNm            string `json:"ceoNm"`
+	FilmoNames       string `json:"filmoNames"`
+}
+
+// 영화사 상세 검색 변수
+type SearchCompanyInfoParams struct {
+	Key       string `form:"key" url:"key" binding:"required"`
+	CompanyCd string `form:"companyCd" url:"companyCd" binding:"required"`
+}
+
+// 영화사 상세 응답 변수
+type SearchCompanyInfo struct {
+	CompanyInfoResult struct {
+		Source      string      `json:"source"`
+		CompanyInfo CompanyInfo `json:"companyInfo"`
+	} `json:"companyInfoResult"`
+}
+
+type CompanyInfo struct {
+	CompanyCd   string         `json:"companyCd"`
+	CompanyNm   string         `json:"companyNm"`
+	CompanyNmEn string         `json:"companyNmEn"`
+	CeoNm       string         `json:"ceoNm"`
+	Parts       []CompanyPart  `json:"parts"`
+	Filmos      []CompanyFilmo `json:"filmos"`
+}
+
+type CompanyPart struct {
+	CompanyPartNm string `json:"companyPartNm"`
+}
+
+type CompanyFilmo struct {
+	MovieCd       string `json:"movieCd"`
+	MovieNm       string `json:"movieNm"`
+	CompanyPartNm string `json:"companyPartNm"`
+}

+ 97 - 0
model/kobis.go

@@ -4,6 +4,8 @@ import (
 	"crawler/config"
 	"crawler/service"
 	"encoding/json"
+	"fmt"
+
 	"github.com/google/go-querystring/query"
 )
 
@@ -11,6 +13,25 @@ type Kobis struct {
 	Rest service.Rest
 }
 
+// KOBIS 오류 응답 (HTTP 200으로 반환되므로 body 검사 필수)
+type KobisFault struct {
+	FaultInfo *struct {
+		Message   string `json:"message"`
+		ErrorCode string `json:"errorCode"`
+	} `json:"faultInfo"`
+}
+
+// KOBIS faultInfo 응답 확인 (무효 키, 쿼터 초과 등)
+func (this *Kobis) checkFault(data []byte) error {
+	var fault KobisFault
+	if json.Unmarshal(data, &fault) == nil && fault.FaultInfo != nil {
+		err := fmt.Errorf("KOBIS fault %s: %s", fault.FaultInfo.ErrorCode, fault.FaultInfo.Message)
+		this.Rest.Check(err)
+		return err
+	}
+	return nil
+}
+
 func (this *Kobis) MovieDailyBoxOfficeListAPI(req SearchDailyBoxOfficeParams) (SearchDailyBoxOfficeList, error) {
 	var (
 		url      = config.MOVIE_DAILY_BOX_OFFICE_LIST
@@ -27,6 +48,10 @@ func (this *Kobis) MovieDailyBoxOfficeListAPI(req SearchDailyBoxOfficeParams) (S
 		return result, err
 	}
 
+	if err := this.checkFault(data); err != nil {
+		return result, err
+	}
+
 	err = json.Unmarshal(data, &result)
 	if this.Rest.Check(err) {
 		return result, err
@@ -51,6 +76,10 @@ func (this *Kobis) MovieWeeklyBoxOfficeListAPI(req SearchWeeklyBoxOfficeParams)
 		return result, err
 	}
 
+	if err := this.checkFault(data); err != nil {
+		return result, err
+	}
+
 	err = json.Unmarshal(data, &result)
 	if this.Rest.Check(err) {
 		return result, err
@@ -75,6 +104,10 @@ func (this *Kobis) MovieListAPI(req SearchMovieListParams) (SearchMovieList, err
 		return result, err
 	}
 
+	if err := this.checkFault(data); err != nil {
+		return result, err
+	}
+
 	err = json.Unmarshal(data, &result)
 	if this.Rest.Check(err) {
 		return result, err
@@ -99,6 +132,66 @@ func (this *Kobis) MovieInfoAPI(req SearchMovieInfoParams) (SearchMovieInfo, err
 		return result, err
 	}
 
+	if err := this.checkFault(data); err != nil {
+		return result, err
+	}
+
+	err = json.Unmarshal(data, &result)
+	if this.Rest.Check(err) {
+		return result, err
+	}
+
+	return result, nil
+}
+
+func (this *Kobis) CompanyListAPI(req SearchCompanyListParams) (SearchCompanyList, error) {
+	var (
+		url      = config.COMPANY_LIST
+		query, _ = query.Values(req)
+		result   SearchCompanyList
+	)
+
+	if query.Encode() != "" {
+		url += "?" + query.Encode()
+	}
+
+	data, err := this.Rest.CallRestGetAPI(url)
+	if this.Rest.Check(err) {
+		return result, err
+	}
+
+	if err := this.checkFault(data); err != nil {
+		return result, err
+	}
+
+	err = json.Unmarshal(data, &result)
+	if this.Rest.Check(err) {
+		return result, err
+	}
+
+	return result, nil
+}
+
+func (this *Kobis) CompanyInfoAPI(req SearchCompanyInfoParams) (SearchCompanyInfo, error) {
+	var (
+		url      = config.COMPANY_INFO
+		query, _ = query.Values(req)
+		result   SearchCompanyInfo
+	)
+
+	if query.Encode() != "" {
+		url += "?" + query.Encode()
+	}
+
+	data, err := this.Rest.CallRestGetAPI(url)
+	if this.Rest.Check(err) {
+		return result, err
+	}
+
+	if err := this.checkFault(data); err != nil {
+		return result, err
+	}
+
 	err = json.Unmarshal(data, &result)
 	if this.Rest.Check(err) {
 		return result, err
@@ -123,6 +216,10 @@ func (this *Kobis) MovieBoxOfficeAPI(req SearchBoxOfficeParams) (SearchBoxOffice
 		return result, err
 	}
 
+	if err := this.checkFault(data); err != nil {
+		return result, err
+	}
+
 	err = json.Unmarshal(data, &result)
 	if this.Rest.Check(err) {
 		return result, err

+ 15 - 7
route/api.go

@@ -10,17 +10,19 @@ import (
 )
 
 var (
-	MovieController = new(controller.Movie)
-	CronController  = new(controller.Cron)
-	G2AController   = new(controller.G2A)
+	MovieController   = new(controller.Movie)
+	CompanyController = new(controller.Company)
+	CronController    = new(controller.Cron)
+	G2AController     = new(controller.G2A)
 )
 
 // 라우터 설정
 func SetRoute(app *gin.Engine) {
-	NoRoute(app)    // 404
-	CronRoute(app)  // Cron schedule
-	MovieRoute(app) // 영화 API
-	G2ARoute(app)   // G2A API
+	NoRoute(app)      // 404
+	CronRoute(app)    // Cron schedule
+	MovieRoute(app)   // 영화 API
+	CompanyRoute(app) // 영화사 API
+	G2ARoute(app)     // G2A API
 
 	defer func() {
 		if c := recover(); c != nil {
@@ -58,6 +60,12 @@ func MovieRoute(app *gin.Engine) {
 	r.GET("/searchMovieInfo", MovieController.SearchMovieInfo)
 }
 
+func CompanyRoute(app *gin.Engine) {
+	r := app.Group("/company")
+	r.GET("/searchCompanyList", CompanyController.SearchCompanyList)
+	r.GET("/searchCompanyInfo", CompanyController.SearchCompanyInfo)
+}
+
 func CronRoute(app *gin.Engine) {
 	r := app.Group("/cron")
 	r.GET("/list", CronController.List)

+ 14 - 7
service/db.go

@@ -145,13 +145,20 @@ func SetDatabase(dbName string) DB {
 	fmt.Printf("%s\n", dsn)
 	SQLConnd, err := sql.Open(account.Driver, dsn)
 
-	if err != nil || SQLConnd.Ping() != nil {
-		fmt.Printf("%s\n", account.Driver)
-		fmt.Printf("%s\n", dsn)
-		fmt.Printf("%v\n", SQLConnd)
-		fmt.Printf("sqlDB에 연결에 실패하였습니다.\n %v", err.Error())
-		fmt.Println(err)
-		SQLConnd.Close()
+	// sql.Open 은 실제 접속을 하지 않으므로 Ping 으로 확인한다.
+	// Ping 에러를 err 에 담아, 아래에서 err 이 nil 이 아님을 보장한다. (기존: err.Error() nil 역참조 패닉)
+	if err == nil {
+		err = SQLConnd.Ping()
+	}
+
+	if err != nil {
+		fmt.Printf("driver : %s\n", account.Driver)
+		fmt.Printf("dsn : %s\n", dsn)
+		fmt.Printf("sqlDB에 연결에 실패하였습니다.\n %v\n", err)
+		if SQLConnd != nil {
+			SQLConnd.Close()
+		}
+		return DB{}
 	}
 
 	SQLConnd.SetConnMaxLifetime(time.Duration(db.MaxLifetime) * time.Second)

+ 1 - 1
service/rest.go

@@ -97,5 +97,5 @@ func (this *Rest) WriteLog(msg string) {
 	}()
 
 	log.SetOutput(data)
-	log.Println(err)
+	log.Println(msg)
 }