|
|
# 1단계: 메타데이터 통일
────────────────────────────────
원본 구조 (불일치):
├─ Le Faso: date, title, content, url
├─ Sidwaya: publish_date, headline, body_text
├─ RFI: createdAt, title, description, text
└─ RT: published, name, article_text
통합 구조 (일치):
├─ collected_date (수집일)
├─ published_date (발행일)
├─ country (국가 코드)
├─ media_name (매체명)
├─ media_type (민간/국영/국외)
├─ media_orientation (친-AES / 중립 / 비판)
├─ title (제목)
├─ summary (요약)
├─ full_text (본문)
├─ category (분류)
└─ source_url (원본 URL)
# 2단계: 중복 제거 (Deduplication)
────────────────────────────────
같은 기사가 여러 매체에서 보도된 경우:
├─ 원 기사 식별 (1차 보도)
├─ 재보도 마크 (재게재)
├─ 인용 기사 마크 (요약/인용)
└─ 분석: 프레임이 어떻게 변형되는가?
# 3단계: 감정 태그 재검증
────────────────────────────────
기존 "neutral" 태그 재분류:
├─ 정확성 검증 (특히 암묵적 감정)
├─ 다중 감정 태그 허용
│ └─ "neutral_but_pro_AES"
│ └─ "positive_but_skeptical"
└─ 강도 점수화 (-1.0 ~ +1.0)
# 4단계: 저자 정보 추가
────────────────────────────────
신규 메타데이터:
├─ author_name (저자명)
├─ is_official_statement (공식 성명서 여부)
├─ editorial_vs_report (사설 vs 보도)
└─ source_credibility (신뢰도 태그)
레이어 1: 국내 비교 (1~3국 내부)
├─ 민간 매체(Le Faso) vs 국영 매체(Sidwaya)
│ └─ 동일 국가 내 프레임 차이 측정
│ └─ 검열/통제 정도 정량화
├─ 국가별 비교
│ └─ 부르키나파소 vs 말리 vs 니제르
│ └─ AES 연대 내 담론 수렴도 측정
└─ 목표: "AES 공식 담론" 규명
레이어 2: 국외 비교 (대조축)
├─ 서방(RFI) vs 비서방(RT) vs AES(Le Faso)
│ └─ 3자 관점의 프레임 충돌
├─ 서방 내 차이(RFI vs France24)
│ └─ 프랑스 언론 내 다원성 검증
└─ 목표: "글로벌 담론 격돌" 구조화
레이어 3: 시간대별 추이 (진화 분석)
├─ 2024.08 (프랑스군 철수 직전)
└─ 기준점: 어떤 서사가 지배적?
├─ 2024.09~2026.06 (중간 기간)
└─ 변화: 프레임이 어떻게 진화?
├─ 2026.07~08 (현재 분석 기간)
└─ 확정: 최종 프레임 구조
└─ 목표: "담론 전환점" 식별
레이어 4: 사건 기반 추적 (Event Analysis)
├─ 주요 사건 10-15개 선정
│ └─ 쿠데타, 국경 폐쇄, 광산 협약, 러시아 합의 등
├─ 각 사건별로 모든 매체의 보도 수집
│ └─ "누가 먼저 보도했는가?"
│ └─ "보도 내용이 어떻게 다른가?"
│ └─ "시간 경과에 따라 수정/강화되었는가?"
└─ 목표: "프레임 전파 경로" 규명
┌─────────────────────────────────────┐
│ 최적 데이터 구성 (총 ~2,700건) │
└─────────────────────────────────────┘
그룹 A: 국내 민간 매체 (기존 + 신규)
├─ Le Faso (부르키나파소): 900건 ✓ 기보유
├─ Maliweb (말리): 350건 ✓ 기보유
├─ ActuNiger (니제르): 150건 ✓ 기보유
└─ 소계: 1,400건
그룹 B: 국내 국영 매체 (신규 필수)
├─ Sidwaya (부르키나파소): 350건 ← 필수 수집
├─ Lessor (말리): 180건 ← 필수 수집
├─ Lesahel (니제르): 120건 ← 필수 수집
└─ 소계: 650건
그룹 C: 국외 서방 매체 (신규 추가)
├─ RFI Afrique: 140건 ← 필수 수집
├─ France24 Afrique: 110건 ← 옵션
└─ 소계: 250건
그룹 D: 국외 비서방 매체 (신규 추가)
├─ RT Afrique: 140건 ← 필수 수집
├─ Sputnik Afrique: 90건 ← 옵션
└─ 소계: 230건
─────────────────────────────────────
합계: 2,530건
분석 구조:
├─ 민간 vs 국영: 1,400건 vs 650건 (직접 비교)
├─ AES vs 서방: 2,050건 vs 250건 (프레임 대조)
├─ AES vs 비서방: 2,050건 vs 230건 (영향도 분석)
└─ 서방 vs 비서방: 250건 vs 230건 (글로벌 경쟁)
분산 DB (매체별/국가별 분리)
디렉토리 구조:
├─ burkina_faso/
│ ├─ lefaso_민간.db
│ ├─ sidwaya_국영.db
│ └─ analysis_burkina.db
├─ mali/
│ ├─ maliweb_민간.db
│ ├─ lessor_국영.db
│ └─ analysis_mali.db
├─ niger/
│ ├─ actuniger_민간.db
│ ├─ lesahel_국영.db
│ └─ analysis_niger.db
└─ international/
├─ rfi_서방.db
├─ rt_비서방.db
└─ comparison_국외.db
또는 타입별 분리:
├─ domestic_민간.db (3국 민간)
├─ domestic_국영.db (3국 국영)
├─ international_서방.db
├─ international_비서방.db
└─ analysis_통합.db (분석 결과만)
3계층 DB 아키텍처 (가장 실무적)
┌─────────────────────────────────────────────┐
│ Tier 1: 수집 DB (Raw Data) │
├─────────────────────────────────────────────┤
│ ├─ raw_lefaso_2026_07_10~08_20.db │
│ ├─ raw_sidwaya_2026_07_10~08_20.db │
│ ├─ raw_maliweb_2026_07_10~08_20.db │
│ ├─ raw_lessor_2026_07_10~08_20.db │
│ ├─ raw_actuniger_2026_07_10~08_20.db │
│ ├─ raw_lesahel_2026_07_10~08_20.db │
│ ├─ raw_rfi_2026_07_10~08_20.db │
│ └─ raw_rt_2026_07_10~08_20.db │
│ │
│ 특징: │
│ • 매체별 독립 DB (데이터 오염 방지) │
│ • 최소한의 정제만 수행 (크롤링 후 저장) │
│ • 원본 보존 (이후 재분석 가능) │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ Tier 2: 정제 DB (Cleaned Data) │
├─────────────────────────────────────────────┤
│ ├─ cleaned_domestic_private.db │
│ │ └─ lefaso, maliweb, actuniger
│ ├─ cleaned_domestic_public.db │
│ │ └─ sidwaya, lessor, lesahel
│ ├─ cleaned_international_west.db │
│ │ └─ rfi
│ └─ cleaned_international_nonwest.db │
│ └─ rt
│ │
│ 작업: │
│ • 메타데이터 통일 │
│ • 중복 제거 │
│ • 결측치 처리 │
│ • 인코딩 정규화 │
│ • 타입 캐스팅 (date, category) │
└─────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────┐
│ Tier 3: 분석 DB (Unified Data) │
├─────────────────────────────────────────────┤
│ articles_unified (모든 매체 통합) │
│ ├─ id, date, country, media_name, │
│ ├─ title, full_text, sentiment, │
│ ├─ frame_primary, frame_secondary, │
│ ├─ keywords_extracted, entities_ner, │
│ └─ network_edges (프레임 네트워크) │
│ │
│ views: │
│ ├─ view_domestic_comparison │
│ ├─ view_international_comparison │
│ ├─ view_frame_evolution │
│ └─ view_event_tracking │
│ │
│ 특징: │
│ • 모든 매체 포함 (통합) │
│ • NLP 분석 결과 포함 (프레임 태그) │
│ • 뷰(VIEW) 활용 (다각적 분석) │
│ • 인덱싱 최적화 (쿼리 속도) │
└─────────────────────────────────────────────┘
구체적 구현 설계
🔧 Tier 1: Raw Data DB 스키마
-- 각 매체별 독립 테이블
CREATE TABLE lefaso_raw (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scrape_date DATETIME,
url TEXT UNIQUE,
title TEXT,
author TEXT,
publish_date DATETIME,
html_content TEXT, -- 원본 HTML (정제 안 함)
inserted_at DATETIME DEFAULT CURRENT_TIMESTAMP,
processed BOOLEAN DEFAULT 0
);
-- 다른 모든 매체도 동일 구조
CREATE TABLE sidwaya_raw { ... };
CREATE TABLE maliweb_raw { ... };
CREATE TABLE lessor_raw { ... };
CREATE TABLE actuniger_raw { ... };
CREATE TABLE lesahel_raw { ... };
CREATE TABLE rfi_raw { ... };
CREATE TABLE rt_raw { ... };
Tier 2: Cleaned Data DB 스키마
-- 국내 민간 매체 (통합)
CREATE TABLE cleaned_domestic_private (
id INTEGER PRIMARY KEY AUTOINCREMENT,
media_name TEXT, -- lefaso / maliweb / actuniger
country TEXT,
original_url TEXT UNIQUE,
publish_date DATETIME,
title TEXT,
summary TEXT,
full_text TEXT,
author TEXT,
-- 품질 지표
text_length INTEGER,
missing_fields INTEGER,
encoding_checked BOOLEAN,
-- 중복 체크
is_duplicate BOOLEAN,
duplicate_of_id INTEGER,
inserted_at DATETIME
);
-- 국내 국영 매체 (통합)
CREATE TABLE cleaned_domestic_public {
id INTEGER PRIMARY KEY,
media_name TEXT, -- sidwaya / lessor / lesahel
country TEXT,
... (동일 구조)
};
-- 국제 서방 매체
CREATE TABLE cleaned_international_west {
id INTEGER PRIMARY KEY,
media_name TEXT, -- rfi
original_country TEXT, -- France
... (동일 구조)
};
-- 국제 비서방 매체
CREATE TABLE cleaned_international_nonwest {
id INTEGER PRIMARY KEY,
media_name TEXT, -- rt
original_country TEXT, -- Russia
... (동일 구조)
};
Tier 3: Unified Analysis DB 스키마
-- 최종 통합 테이블
CREATE TABLE articles_unified (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- 기본 정보
original_id INTEGER,
media_name TEXT, -- lefaso, sidwaya, rfi, rt 등
country TEXT,
media_type TEXT, -- 'domestic_private', 'domestic_public',
-- 'international_west', 'international_nonwest'
-- 출판 정보
publish_date DATETIME,
collected_date DATETIME,
-- 콘텐츠
title TEXT,
summary TEXT,
full_text TEXT,
author TEXT,
-- 기본 NLP 분석
category TEXT,
sector TEXT,
sentiment TEXT,
sentiment_score FLOAT, -- -1.0 ~ +1.0
-- 프레임 분석 (NLP 결과)
frame_primary TEXT, -- 주 프레임
frame_secondary TEXT, -- 부 프레임
frame_strength FLOAT, -- 프레임 강도
-- 키워드 (JSON)
keywords_extracted JSON, -- ["프랑스", "테러", "주권"]
keyword_count INTEGER,
-- 개체명 인식 (JSON)
entities_ner JSON, -- {persons: [...],
-- organizations: [...],
-- locations: [...]}
-- 네트워크 분석용
mentions_france BOOLEAN,
mentions_russia BOOLEAN,
mentions_ukraine BOOLEAN,
mentions_aes BOOLEAN,
mentions_ecowas BOOLEAN,
-- 메타데이터
quality_score FLOAT,
is_duplicate BOOLEAN,
duplicate_of_id INTEGER,
notes TEXT,
-- 타임스탬프
inserted_at DATETIME,
last_updated DATETIME,
-- 분석 상태
nlp_processed BOOLEAN,
network_processed BOOLEAN
);
인덱싱:
CREATE INDEX idx_media_name ON articles_unified(media_name);
CREATE INDEX idx_country ON articles_unified(country);
CREATE INDEX idx_publish_date ON articles_unified(publish_date);
CREATE INDEX idx_media_type ON articles_unified(media_type);
CREATE INDEX idx_frame_primary ON articles_unified(frame_primary);
CREATE INDEX idx_sentiment ON articles_unified(sentiment);
분석 뷰:
-- 뷰 1: 국내 민간 vs 국영 비교
CREATE VIEW view_domestic_comparison AS
SELECT
country,
media_type,
frame_primary,
COUNT(*) as count,
AVG(sentiment_score) as avg_sentiment
FROM articles_unified
WHERE media_type IN ('domestic_private', 'domestic_public')
GROUP BY country, media_type, frame_primary;
-- 뷰 2: 국제 비교 (AES vs 서방 vs 비서방)
CREATE VIEW view_international_comparison AS
SELECT
media_type,
frame_primary,
COUNT(*) as count,
AVG(sentiment_score) as avg_sentiment
FROM articles_unified
GROUP BY media_type, frame_primary;
-- 뷰 3: 프레임 진화 (시간대별)
CREATE VIEW view_frame_evolution AS
SELECT
DATE_TRUNC('week', publish_date) as week,
frame_primary,
COUNT(*) as count
FROM articles_unified
GROUP BY week, frame_primary
ORDER BY week;
데이터 흐름 (실제 작업 절차)
📋 Step 1: 수집 → Tier 1 (Raw)
# 각 매체별 크롤러
import sqlite3
def scrape_lefaso():
conn = sqlite3.connect('raw_lefaso.db')
cursor = conn.cursor()
articles = fetch_from_lefaso() # 크롤링
for article in articles:
cursor.execute('''
INSERT INTO lefaso_raw
(scrape_date, url, title, html_content)
VALUES (?, ?, ?, ?)
''', (datetime.now(), article['url'],
article['title'], article['raw_html']))
conn.commit()
conn.close()
# 각 매체별로 독립 실행
scrape_lefaso()
scrape_sidwaya()
scrape_maliweb()
# ... etc
📋 Step 2: 정제 → Tier 2 (Cleaned)
import sqlite3
import pandas as pd
def clean_and_merge():
"""
Tier 1의 각 매체별 raw DB를 읽어서
Tier 2의 타입별 cleaned DB로 통합
"""
# 국내 민간 매체
lefaso = pd.read_sql_table('lefaso_raw', 'sqlite:///raw_lefaso.db')
maliweb = pd.read_sql_table('maliweb_raw', 'sqlite:///raw_maliweb.db')
actuniger = pd.read_sql_table('actuniger_raw', 'sqlite:///raw_actuniger.db')
# 정제 함수
def clean_article(df, media_name):
df['media_name'] = media_name
df['country'] = get_country_from_media(media_name)
df['full_text'] = df['html_content'].apply(parse_html)
df['text_length'] = df['full_text'].apply(len)
return df[['media_name', 'country', 'url', 'title',
'full_text', 'text_length']]
# 각 매체 정제
lefaso_clean = clean_article(lefaso, 'lefaso')
maliweb_clean = clean_article(maliweb, 'maliweb')
actuniger_clean = clean_article(actuniger, 'actuniger')
# 통합
domestic_private = pd.concat([lefaso_clean, maliweb_clean,
actuniger_clean])
# Tier 2에 저장
domestic_private.to_sql('cleaned_domestic_private',
'sqlite:///cleaned_domestic_private.db',
if_exists='append', index=False)
# 국내 국영, 국제 서방/비서방도 동일 방식
# ...
clean_and_merge()
📋 Step 3: NLP 분석 + 통합 → Tier 3 (Unified)
import sqlite3
import pandas as pd
from nlp_pipeline import extract_frame, extract_entities, analyze_sentiment
def create_unified_db():
"""
Tier 2의 4개 cleaned DB를 읽어서
Tier 3 unified DB에 NLP 분석 결과 포함하여 저장
"""
# 4개 테이블 로드
domestic_private = pd.read_sql_table('cleaned_domestic_private',
'sqlite:///cleaned_domestic_private.db')
domestic_public = pd.read_sql_table('cleaned_domestic_public',
'sqlite:///cleaned_domestic_public.db')
international_west = pd.read_sql_table('cleaned_international_west',
'sqlite:///cleaned_international_west.db')
international_nonwest = pd.read_sql_table('cleaned_international_nonwest',
'sqlite:///cleaned_international_nonwest.db')
# 타입 추가
domestic_private['media_type'] = 'domestic_private'
domestic_public['media_type'] = 'domestic_public'
international_west['media_type'] = 'international_west'
international_nonwest['media_type'] = 'international_nonwest'
# 통합
unified = pd.concat([domestic_private, domestic_public,
international_west, international_nonwest])
# NLP 분석 (전체 2,700건)
unified['frame_primary'] = unified['full_text'].apply(extract_frame)
unified['entities_ner'] = unified['full_text'].apply(extract_entities)
unified['sentiment_score'] = unified['full_text'].apply(analyze_sentiment)
# Tier 3에 저장
unified.to_sql('articles_unified',
'sqlite:///analysis_unified.db',
if_exists='replace', index=False)
create_unified_db()
분석 쿼리 예시 (Unified DB에서)
📊 예시 1: 국내 민간 vs 국영 비교
SELECT
country,
media_type,
frame_primary,
COUNT(*) as article_count,
AVG(sentiment_score) as avg_sentiment,
COUNT(CASE WHEN mentions_france THEN 1 END) as france_mentions
FROM articles_unified
WHERE media_type IN ('domestic_private', 'domestic_public')
GROUP BY country, media_type, frame_primary
ORDER BY country, media_type, article_count DESC;
-- 결과 예시:
-- Burkina Faso | domestic_private | anti_french_rhetoric | 240 | 0.45 | 185
-- Burkina Faso | domestic_public | anti_french_rhetoric | 60 | 0.67 | 52
-- Mali | domestic_private | sovereignty | 95 | 0.38 | 25
-- Mali | domestic_public | sovereignty | 40 | 0.72 | 18
📊 예시 2: AES vs 서방 비교
SELECT
media_type,
frame_primary,
COUNT(*) as count,
AVG(sentiment_score) as sentiment
FROM articles_unified
WHERE media_type IN ('domestic_private', 'domestic_public',
'international_west', 'international_nonwest')
GROUP BY media_type, frame_primary
ORDER BY frame_primary, media_type;
📊 예시 3: 프레임 네트워크 (공동 출현)
-- frame_primary 동시 출현 분석
SELECT
a.frame_primary as frame1,
b.frame_primary as frame2,
COUNT(*) as co_occurrence
FROM articles_unified a
JOIN articles_unified b
ON a.media_name = b.media_name
AND a.publish_date = b.publish_date
AND a.id < b.id
GROUP BY frame1, frame2
HAVING co_occurrence > 5
ORDER BY co_occurrence DESC;
