Compare commits
458 Commits
fadfac2a93
...
5fdba040b5
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fdba040b5 | |||
| d07e53a378 | |||
| 1b26be2667 | |||
| 7250b3d05e | |||
| 2f56ed38f2 | |||
| d82eecfc30 | |||
| 5ddedf5a2e | |||
| ba745011ff | |||
| 1075fd341e | |||
| 35a05f7493 | |||
| 062243e99b | |||
| 928a9b28e8 | |||
| 0e5d012855 | |||
| 7c662ae87c | |||
| 41b0a77d9d | |||
| 19ba32e5b1 | |||
| c6dd01f423 | |||
| f16e202469 | |||
| b4ff71d9f7 | |||
| d86416c080 | |||
| aaef8dd576 | |||
| 369cb33b40 | |||
| d41f2956f1 | |||
| b0c8c948e1 | |||
| ee51218daa | |||
| a1024d52dc | |||
| 6ca806aad9 | |||
| e4acf2542b | |||
| 67630624ca | |||
| 3b2c3fef61 | |||
| 6892f0c332 | |||
| 8fdc462309 | |||
| e50636ace3 | |||
| 3c3d815354 | |||
| 7e870448da | |||
| 378447abd1 | |||
| aec0268cd2 | |||
| 7129180d76 | |||
| 2103653f09 | |||
| 01ebb2da77 | |||
| cbb778cd7a | |||
| 3bca60fabc | |||
| 756a89aa22 | |||
| e48b255046 | |||
| 3cf21bb0ab | |||
| 3da11e464a | |||
| b1a0b74cab | |||
| 66423155cd | |||
| 8880d1da1d | |||
| f1720ad2a5 | |||
| 6ca92f2f9f | |||
| dcc72ac815 | |||
| 5642bce508 | |||
| 29345f26fa | |||
| db1d6928e2 | |||
| 95e331b011 | |||
| 23027b020e | |||
| 033260c188 | |||
| 878c67b9ab | |||
| 9e78327282 | |||
| 16a33212c5 | |||
| 9b13275d64 | |||
| ef9c2cea69 | |||
| a553010f0e | |||
| de6efee453 | |||
| cf62866d54 | |||
| 73038a0b4b | |||
| 9ceb2677a9 | |||
| 6a23f3340f | |||
| b67a443b98 | |||
| 84d61e4134 | |||
| ce95fbf2c5 | |||
| 07c9e30494 | |||
| b5f02a2722 | |||
| 0f413397c1 | |||
| 78d4b574c7 | |||
| 2ba0c06a50 | |||
| 067acb03ca | |||
| 632b6be2c4 | |||
| 65f352facf | |||
| 5475691107 | |||
| 90df519831 | |||
| c164610f69 | |||
| a1e9a77dc7 | |||
| 53597d85d7 | |||
| 3915c10e97 | |||
| 7953339e63 | |||
| cdd26ca3bf | |||
| 45128d882f | |||
| 478190211c | |||
| 9ae921b2eb | |||
| c400129bc3 | |||
| a7148c48b5 | |||
| 6a6820fd99 | |||
| 542ade7174 | |||
| 297fd2354e | |||
| a39c97a631 | |||
| 235de24d6f | |||
| a2f8a3f51e | |||
| c6f0694b44 | |||
| 0eca22bfff | |||
| 6e0c211c2b | |||
| 9d916889f7 | |||
| 4efb2807b9 | |||
| 94f9d0de19 | |||
| 8404e74332 | |||
| a03fc885f9 | |||
| c5ff0c70ac | |||
| e646d9b80a | |||
| 7dd0b92521 | |||
| 77c97b0466 | |||
| 97e5487eb8 | |||
| c7f0ab16d5 | |||
| f56de5c365 | |||
| baf5d45631 | |||
| eaec15fd97 | |||
| 4eb11456b8 | |||
| 4b04237177 | |||
| 0c92b45774 | |||
| d573c5df39 | |||
| 5db0dbca1d | |||
| 1f6dcccaac | |||
| 291c8193d8 | |||
| 121cb46253 | |||
| e48975e353 | |||
| e5032fe654 | |||
| d19548d23d | |||
| 3c4ed7124f | |||
| 6c67dfa828 | |||
| 650dadf6aa | |||
| 98c395b72e | |||
| c97256233a | |||
| 68c7a5b352 | |||
| b1f9784617 | |||
| 5be4d1c382 | |||
| 671a71875f | |||
| 862446fdae | |||
| a700d7d254 | |||
| e64ef32a91 | |||
| cd02cda979 | |||
| 9c3692488e | |||
| c7af045f7d | |||
| 446bf0cf2c | |||
| 0c99e7c413 | |||
| cf81d94b7c | |||
| f2b0d39ede | |||
| 90aaebbbc3 | |||
| 29b8756a63 | |||
| 46d7c9487f | |||
| 6448786b43 | |||
| 7cb8b6ec24 | |||
| 350b6e1f71 | |||
| e9f4c62b7b | |||
| 519b82f678 | |||
| bb1ddd8a24 | |||
| 9b793520a0 | |||
| f9d08e972b | |||
| 13157388d8 | |||
| 8c04280029 | |||
| 07266196a8 | |||
| 321a40f30f | |||
| 4d4f054af6 | |||
| f40a228778 | |||
| e2aa6417d3 | |||
| 8b497352c7 | |||
| fe3c6aae6e | |||
| afb9098253 | |||
| 55009bd900 | |||
| eabee494d4 | |||
| 3ac5fadc0b | |||
| 23712f4e9e | |||
| 74b108a95f | |||
| 8039728ca9 | |||
| 3685245f3c | |||
| 808ca4b753 | |||
| be7408520e | |||
| 519ce2941c | |||
| 3b41defcca | |||
| 5f68c016a0 | |||
| 1b0d1bead7 | |||
| ca43956db7 | |||
| a40aa3e03e | |||
| be1bfb1b0e | |||
| 530258e916 | |||
| 38006d6812 | |||
| 4d42179858 | |||
| d45f067201 | |||
| 4d438f5249 | |||
| 9f82010e6e | |||
| 4cf63ffa22 | |||
| ed62576ff0 | |||
| 254ae852d6 | |||
| 794204fc2b | |||
| 87da8bc970 | |||
| b8579d1699 | |||
| 371713215e | |||
| 3869b7b284 | |||
| 2d5be25af5 | |||
| 92cef13a7d | |||
| 62f693543f | |||
| 468d18e275 | |||
| 45a71be07d | |||
| 188f44b67a | |||
| 263b5cb46b | |||
| 4d507fd49b | |||
| f5f116415a | |||
| b4e1f8c77c | |||
| 2da61948ea | |||
| 2cb8eb2653 | |||
| f918b7a161 | |||
| 8552591d16 | |||
| 22e49505f4 | |||
| 95dc025694 | |||
| 46f9c5d490 | |||
| db34f27147 | |||
| 7462fad703 | |||
| 95938fea4e | |||
| 59fe21aaa7 | |||
| d3770a8188 | |||
| c1b836db54 | |||
| 8cb8f5229d | |||
| 40ebeb3788 | |||
| 4db86a2a91 | |||
| 1b50c65ecb | |||
| 837fb283ff | |||
| b7d0660aa7 | |||
| ccc6aca82f | |||
| 94c3c3a504 | |||
| 31e7236aa9 | |||
| 683bf5b20b | |||
| 0888cee547 | |||
| aa606830f9 | |||
| f23b261307 | |||
| 5069862fd1 | |||
| 2c07f69f36 | |||
| 8352efa219 | |||
| 4f999547eb | |||
| 6217ceea24 | |||
| 67fccb6b30 | |||
| fd30c2ba9a | |||
| 2b54013a77 | |||
| 65a84fca6e | |||
| 9694dc686a | |||
| 28f3f24c46 | |||
| 1fd782880b | |||
| cdfcff82d8 | |||
| ba4c7d941b | |||
| 83bd5a6137 | |||
| a64fec24e6 | |||
| ee8579deed | |||
| 7e5655e0dd | |||
| 2a03f56204 | |||
| 4c6eba2042 | |||
| 2de789871c | |||
| bbd4c57c90 | |||
| 8da6376dd0 | |||
| f926f30819 | |||
| 66c3e9750d | |||
| de6382cb49 | |||
| f8a1691cac | |||
| 908399922b | |||
| d01e28dcda | |||
| 3c7f18ff25 | |||
| 90a74e85c9 | |||
| 98947d885a | |||
| 83f8e3620d | |||
| daf9c19880 | |||
| e73b53af9d | |||
| 564b5e325e | |||
| 620e1f822f | |||
| 2f144f260f | |||
| 7b459eeb85 | |||
| 1d6cdc7561 | |||
| 5afa60af84 | |||
| 6508a4e07f | |||
| b9bfeb1383 | |||
| f897dbccca | |||
| 3fe7e8a922 | |||
| 707416c1b3 | |||
| 21dd83ad7d | |||
| 5236d8a5be | |||
| 17560f089f | |||
| 80d0610a28 | |||
| 9929e53b12 | |||
| 8053febf30 | |||
| 7491627c7b | |||
| 5881960fe2 | |||
| 4816403d7c | |||
| 33489e5a7c | |||
| 2c5817109b | |||
| 8fe3669e56 | |||
| d89757b3f0 | |||
| fe78b9e7a2 | |||
| 1aed4b36a8 | |||
| d16153df7b | |||
| 6b4145e497 | |||
| 1b6a094142 | |||
| 2ef869118d | |||
| 46ad79d3ab | |||
| b72d355b3b | |||
| d9e61d8125 | |||
| 6484d7bbc3 | |||
| 3e029f07be | |||
| 44908b2170 | |||
| ec3605c0cd | |||
| 56620e5ee9 | |||
| bfba777f50 | |||
| 4affbd5687 | |||
| c1346e14e6 | |||
| e5d1db6df5 | |||
| 2e04a00c8e | |||
| cded9507da | |||
| 62ba4c0fee | |||
| 5f14c21177 | |||
| e4c40813c7 | |||
| d82985d014 | |||
| 78936c08a6 | |||
| ff0c4bea86 | |||
| cef335603a | |||
| 543e6e8aab | |||
| b5930c6ac5 | |||
| ef84619d35 | |||
| addd0afb04 | |||
| 0cba38b37d | |||
| d1d18a7908 | |||
| c6b4720e6d | |||
| 7ccd43b4c6 | |||
| 3c2d7dd967 | |||
| a8fa138442 | |||
| d497295d58 | |||
| 3065b4856a | |||
| 58280f4f9a | |||
| 6559206842 | |||
| de1fa5f1e9 | |||
| 5875a7fa08 | |||
| f30a2efb49 | |||
| 4306014866 | |||
| 2e466588a1 | |||
| bbbd664265 | |||
| 347b47cc95 | |||
| 83c5594ee6 | |||
| a72190aa97 | |||
| 0de6a52396 | |||
| 8ebc19c06b | |||
| 0e6d9b794d | |||
| 4b368e4555 | |||
| 53bd1076b1 | |||
| df4cf863de | |||
| 436e4ee72d | |||
| d4f4e3b93f | |||
| bea6456d68 | |||
| bed6aa7495 | |||
| 9ffd06fa58 | |||
| 125607e8fc | |||
| bb4f2340fe | |||
| 7fcbb2b17e | |||
| 3efe527a06 | |||
| 74cf5ce806 | |||
| fe047b0b34 | |||
| 05d10a5474 | |||
| 7a283ef1c0 | |||
| f23eca3a87 | |||
| b44e48073a | |||
| f764188b23 | |||
| 00a9a253dc | |||
| 2687e164fa | |||
| 3f9903607c | |||
| 3ce5397bde | |||
| 9d350b51da | |||
| 88cc6313b8 | |||
| 0f514d9641 | |||
| aa5fcdadbe | |||
| 4d03fd8479 | |||
| ff27c1c642 | |||
| d655ede8f8 | |||
| f859b1eb5e | |||
| 9bd5524451 | |||
| c5f23af12e | |||
| 77a9307d90 | |||
| d44d690392 | |||
| c0f55a91ac | |||
| 77051d3b1d | |||
| e2017a5793 | |||
| 3b79a41115 | |||
| c4960126f1 | |||
| 7a536bceea | |||
| a1861d19f9 | |||
| 25cf7e35b5 | |||
| b4f43e1d95 | |||
| 49a047d8b6 | |||
| 1476bce574 | |||
| 043c8e39b4 | |||
| a2acb594d4 | |||
| 2480f6a758 | |||
| 8529819847 | |||
| b8645220d2 | |||
| 8bf68b152d | |||
| 8046730278 | |||
| dd8733b60d | |||
| 542da65db5 | |||
| 3e2ad1034b | |||
| 411a73d2ee | |||
| 01557bc0e0 | |||
| 64e6aca3c8 | |||
| 2a70972001 | |||
| 1aad0d838f | |||
| c02be42339 | |||
| f526f6c056 | |||
| db9b3d7403 | |||
| c6189cba06 | |||
| bd36ed75d1 | |||
| f132b926f7 | |||
| 2352e02fc9 | |||
| 60e99e521d | |||
| 13dbb6b058 | |||
| 1882d63b31 | |||
| 59d0545808 | |||
| 72903dc6dd | |||
| 7135d8b808 | |||
| 35f67723d4 | |||
| 771facb878 | |||
| cae5c4f4a0 | |||
| fec577ef23 | |||
| cf3101e18f | |||
| d7e7c02838 | |||
| c27bd399ec | |||
| e47dd2315e | |||
| 31035119fe | |||
| 1298f88df8 | |||
| 9c2ffabfbf | |||
| 80ff136c31 | |||
| 4c5e936ca8 | |||
| 5efa6e4284 | |||
| 3fc5b2d58f | |||
| 7fbb84dce2 | |||
| 3a32a89e34 | |||
| f7fdcb374a | |||
| 54f57a0668 | |||
| b3dfe17ef9 | |||
| 0ae66c921b | |||
| 1c4f6b809e | |||
| 0ad3fbb68d | |||
| 5aa4a98e51 | |||
| 976e29087f | |||
| c68b3012dc | |||
| 11a91ee297 | |||
| 1ce7dc94a1 | |||
| a7936b8b11 | |||
| db9fa92c40 | |||
| 7565366138 | |||
| 0a411d13cf | |||
| 5afeb290a7 | |||
| aa7fc1f1e6 | |||
| e93b5cee3e | |||
| eb6228d3af | |||
| ff24fd0db5 | |||
| 09d81e8cda | |||
| 0f1b2741db |
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@ -0,0 +1,11 @@
|
||||
__pycache__
|
||||
*.pyc
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
nohup.out
|
||||
*.log
|
||||
tests/
|
||||
archival_data/
|
||||
cache.db
|
||||
|
||||
13
.env.example
Normal file
13
.env.example
Normal file
@ -0,0 +1,13 @@
|
||||
# NewsArchiver Environment Variables
|
||||
# Copy this file to .env and edit with your values
|
||||
|
||||
# Directory where archived files will be stored
|
||||
# This is perfect for NAS mounting
|
||||
ARCHIVE_DIR=/data/archives
|
||||
|
||||
# Optional: Web server configuration
|
||||
# WEB_HOST=0.0.0.0
|
||||
# WEB_PORT=5000
|
||||
|
||||
# Optional: Logging level (DEBUG, INFO, WARNING, ERROR)
|
||||
# LOG_LEVEL=INFO
|
||||
247
.gitea/workflows/ci.yml
Normal file
247
.gitea/workflows/ci.yml
Normal file
@ -0,0 +1,247 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
env:
|
||||
GITEA_URL: https://git.example.com
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: gitea-job-image
|
||||
steps:
|
||||
- name: Clone repo
|
||||
run: |
|
||||
rm -rf $GITHUB_WORKSPACE/*
|
||||
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
|
||||
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
|
||||
|
||||
- name: Run ruff (Python lint)
|
||||
if: always()
|
||||
run: |
|
||||
if [[ -f pyproject.toml ]]; then
|
||||
pip3 install ruff
|
||||
ruff check .
|
||||
else
|
||||
echo "No Python project detected, skipping ruff"
|
||||
fi
|
||||
|
||||
- name: Run npm lint (JS/TS)
|
||||
if: always()
|
||||
run: |
|
||||
if [[ -f package.json ]]; then
|
||||
npm ci
|
||||
npm run lint --if-present || true
|
||||
else
|
||||
echo "No Node.js project detected, skipping npm lint"
|
||||
fi
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: gitea-job-image
|
||||
steps:
|
||||
- name: Clone repo
|
||||
run: |
|
||||
rm -rf $GITHUB_WORKSPACE/*
|
||||
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
|
||||
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
|
||||
|
||||
- name: Run pytest (Python)
|
||||
if: always()
|
||||
run: |
|
||||
if [[ -f pyproject.toml ]]; then
|
||||
python3 -m pip install --upgrade pip
|
||||
pip3 install -e ".[dev]" 2>/dev/null || pip3 install -e . 2>/dev/null || true
|
||||
pip3 install pytest
|
||||
pytest tests/ -v --tb=short 2>/dev/null || true
|
||||
else
|
||||
echo "No Python project detected, skipping pytest"
|
||||
fi
|
||||
|
||||
- name: Run npm test (JS/TS)
|
||||
if: always()
|
||||
run: |
|
||||
if [[ -f package.json ]]; then
|
||||
npm ci
|
||||
npm run test --if-present || true
|
||||
else
|
||||
echo "No Node.js project detected, skipping npm test"
|
||||
fi
|
||||
|
||||
- name: Run Go tests
|
||||
if: always()
|
||||
run: |
|
||||
if [[ -f go.mod ]]; then
|
||||
go test ./...
|
||||
else
|
||||
echo "No Go project detected, skipping go test"
|
||||
fi
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [docker-build]
|
||||
if: github.event_name == 'pull_request'
|
||||
steps:
|
||||
- name: Clone repo
|
||||
run: |
|
||||
rm -rf $GITHUB_WORKSPACE/*
|
||||
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
|
||||
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
|
||||
|
||||
- name: Create test network
|
||||
run: docker network create newsarchiver-network 2>/dev/null || true
|
||||
|
||||
- name: Start app container
|
||||
run: |
|
||||
docker rm -f newsarchiver-e2e 2>/dev/null || true
|
||||
docker run -d --name newsarchiver-e2e \
|
||||
--network newsarchiver-network \
|
||||
-e CI=true \
|
||||
-e CI_PORT_OFFSET=1 \
|
||||
-e ADMIN_PASSWORD="" \
|
||||
-e ARCHIVE_DIR=/data/archives \
|
||||
-e DISABLE_RSS_FETCH=1 \
|
||||
jarianc/newsarchiverv2:test \
|
||||
python run_archiver.py --serve --host 0.0.0.0 --port 5000
|
||||
|
||||
- name: Discover app port
|
||||
id: port
|
||||
run: |
|
||||
sleep 2
|
||||
# CI port range 10000-10099: port = 10000 + CI_PORT_OFFSET
|
||||
# NewsArchiverV2 uses offset 1, so port = 10001
|
||||
CI_LOG=$(docker logs newsarchiver-e2e 2>&1 | grep "\[ci-port-shift\]" || echo "")
|
||||
if [ -n "$CI_LOG" ]; then
|
||||
APP_PORT=$(echo "$CI_LOG" | grep -oE 'to [0-9]+' | grep -oE '[0-9]+$')
|
||||
echo "app_port=${APP_PORT}" >> $GITHUB_OUTPUT
|
||||
echo "$CI_LOG"
|
||||
else
|
||||
# Fallback: compute from offset
|
||||
OFFSET=${CI_PORT_OFFSET:-1}
|
||||
echo "app_port=$((10000 + OFFSET))" >> $GITHUB_OUTPUT
|
||||
echo "No CI port shift in logs, computed port $((10000 + OFFSET))"
|
||||
fi
|
||||
|
||||
- name: Seed test data
|
||||
run: |
|
||||
sleep 3
|
||||
docker exec newsarchiver-e2e python3 -c "
|
||||
import sqlite3, os
|
||||
db = os.environ.get('ARCHIVE_DIR', '/app/archival_data') + '/cache.db'
|
||||
conn = sqlite3.connect(db)
|
||||
c = conn.cursor()
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS articles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, source_name TEXT NOT NULL,
|
||||
article_url TEXT NOT NULL UNIQUE, article_guid TEXT, title TEXT,
|
||||
author TEXT, publish_date TEXT, content_text TEXT, content_html TEXT,
|
||||
archive_file_path TEXT, metadata_file_path TEXT,
|
||||
status TEXT DEFAULT 'pending', error_message TEXT,
|
||||
extraction_method TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
|
||||
c.execute('CREATE INDEX IF NOT EXISTS idx_articles_source ON articles(source_name)')
|
||||
c.execute('CREATE INDEX IF NOT EXISTS idx_articles_url ON articles(article_url)')
|
||||
c.execute('CREATE INDEX IF NOT EXISTS idx_articles_status ON articles(status)')
|
||||
now = '2026-07-07 12:00:00'
|
||||
sources = ['Test News', 'Daily Wire', 'Tech Today']
|
||||
for i in range(1, 81):
|
||||
src = sources[(i-1) % len(sources)]
|
||||
c.execute('INSERT OR IGNORE INTO articles (source_name, article_url, title, publish_date, content_text, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
(src, f'https://test.com/a/{i}', f'Test Article {i}', f'2026-07-{(i % 28) + 1:02d} 10:00:00', f'Content for article {i}.', 'archived', now))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print('Seeded 80 test articles across 3 sources')
|
||||
"
|
||||
|
||||
- name: Wait for app
|
||||
run: |
|
||||
APP_PORT="${{ steps.port.outputs.app_port }}"
|
||||
sleep 3
|
||||
# Health check from inside container (no host port publish needed)
|
||||
for i in $(seq 1 30); do
|
||||
docker exec newsarchiver-e2e curl -sf "http://localhost:${APP_PORT}/" && echo "App ready on port ${APP_PORT}" && exit 0
|
||||
sleep 2
|
||||
done
|
||||
echo "App failed to start" && exit 1
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: |
|
||||
APP_PORT="${{ steps.port.outputs.app_port }}"
|
||||
docker run --rm \
|
||||
--network newsarchiver-network \
|
||||
-v $GITHUB_WORKSPACE/tests/playwright:/tests \
|
||||
-w /tests \
|
||||
-e APP_URL=http://newsarchiver-e2e:${APP_PORT} \
|
||||
-e PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
|
||||
mcr.microsoft.com/playwright:v1.51.0-jammy \
|
||||
sh -c "npm install @playwright/test@1.51.0 && npx playwright test"
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
docker rm -f newsarchiver-e2e || true
|
||||
docker network rm newsarchiver-network 2>/dev/null || true
|
||||
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone repo
|
||||
run: |
|
||||
rm -rf $GITHUB_WORKSPACE/*
|
||||
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
|
||||
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
|
||||
|
||||
- name: Build Docker image
|
||||
if: always()
|
||||
run: |
|
||||
if [[ -f Dockerfile ]]; then
|
||||
docker build -t $(echo $GITHUB_REPOSITORY | tr '[:upper:]' '[:lower:]'):test .
|
||||
else
|
||||
echo "No Dockerfile found, skipping docker build"
|
||||
fi
|
||||
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: gitea-job-image
|
||||
steps:
|
||||
- name: Clone repo
|
||||
run: |
|
||||
rm -rf $GITHUB_WORKSPACE/*
|
||||
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
|
||||
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
|
||||
|
||||
- name: Run bandit (Python SAST)
|
||||
if: always()
|
||||
run: |
|
||||
if [[ -f pyproject.toml ]]; then
|
||||
pip3 install bandit
|
||||
bandit -r . --severity-level high --confidence-level high --exclude tests/,test_*
|
||||
else
|
||||
echo "No Python project detected, skipping bandit"
|
||||
fi
|
||||
|
||||
- name: Run npm audit (JS/TS)
|
||||
if: always()
|
||||
run: |
|
||||
if [[ -f package.json ]]; then
|
||||
npm ci
|
||||
npm audit --audit-level=high 2>/dev/null || echo "npm audit: vulnerabilities found (non-blocking)"
|
||||
else
|
||||
echo "No Node.js project detected, skipping npm audit"
|
||||
fi
|
||||
|
||||
build-result:
|
||||
needs: [lint, test, docker-build, security, e2e]
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: gitea-job-image
|
||||
if: always()
|
||||
steps:
|
||||
- name: Summary
|
||||
run: echo "All CI checks completed"
|
||||
101
.gitea/workflows/release.yml
Normal file
101
.gitea/workflows/release.yml
Normal file
@ -0,0 +1,101 @@
|
||||
name: Release & Deploy
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GITEA_URL: https://git.example.com
|
||||
REGISTRY: git.example.com
|
||||
DEPLOY_SCRIPT: /home/user/deploy/deploy.sh
|
||||
IMAGE_NAME: newsarchiver
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone repo
|
||||
run: |
|
||||
rm -rf $GITHUB_WORKSPACE/*
|
||||
git clone --depth 1 https://user:pass@git.example.com/${{ github.repository }} $GITHUB_WORKSPACE
|
||||
git -C $GITHUB_WORKSPACE checkout main
|
||||
|
||||
- name: Read version
|
||||
id: version
|
||||
run: |
|
||||
MAJOR=$(grep '"major"' version.json | sed 's/.*: *//; s/[^0-9]//g')
|
||||
MINOR=$(grep '"minor"' version.json | sed 's/.*: *//; s/[^0-9]//g')
|
||||
PATCH=$(grep '"patch"' version.json | sed 's/.*: *//; s/[^0-9]//g')
|
||||
PATCH_PADDED=$(printf "%03d" "$PATCH")
|
||||
FULL="${MAJOR}.${MINOR}.${PATCH_PADDED}"
|
||||
echo "version=${FULL}" >> $GITHUB_OUTPUT
|
||||
echo "Current version: ${FULL}"
|
||||
|
||||
- name: Check if deploy needed
|
||||
id: check
|
||||
run: |
|
||||
VERSION_FILE="/home/user/deploy/deployed/newsarchiver.version"
|
||||
CURRENT_VERSION="${{ steps.version.outputs.version }}"
|
||||
if [ -f "$VERSION_FILE" ]; then
|
||||
DEPLOYED_VERSION=$(cat "$VERSION_FILE" | cut -d: -f2)
|
||||
echo "Deployed: $DEPLOYED_VERSION"
|
||||
if [ "$CURRENT_VERSION" = "$DEPLOYED_VERSION" ]; then
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
echo "No new version to deploy"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Bump patch version
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
id: bump
|
||||
run: |
|
||||
PATCH=$(grep '"patch"' version.json | sed 's/.*: *//; s/[^0-9]//g')
|
||||
NEW_PATCH=$((PATCH + 1))
|
||||
PATCH_PADDED=$(printf "%03d" "$NEW_PATCH")
|
||||
MAJOR=$(grep '"major"' version.json | sed 's/.*: *//; s/[^0-9]//g')
|
||||
MINOR=$(grep '"minor"' version.json | sed 's/.*: *//; s/[^0-9]//g')
|
||||
FULL="${MAJOR}.${MINOR}.${PATCH_PADDED}"
|
||||
|
||||
sed -i "s/\"patch\": ${PATCH}/\"patch\": ${NEW_PATCH}/" version.json
|
||||
echo "release_version=${FULL}" >> $GITHUB_OUTPUT
|
||||
echo "Bumped to ${FULL}"
|
||||
|
||||
git config user.email "bot@example.com"
|
||||
git config user.name "CI Release Bot"
|
||||
git add version.json
|
||||
git commit -m "release: bump to ${FULL}"
|
||||
git remote set-url origin https://user:pass@git.example.com/${{ github.repository }}
|
||||
git push origin main
|
||||
|
||||
git tag -a "v${FULL}" -m "release: ${FULL}"
|
||||
git push origin "v${FULL}"
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
run: |
|
||||
echo "REDACTED" | docker login --username jarianc --password-stdin ${{ env.REGISTRY }}
|
||||
|
||||
- name: Build & Push to Registry
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
id: build
|
||||
run: |
|
||||
TAG="${{ steps.bump.outputs.release_version }}"
|
||||
REGISTRY_IMAGE="${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:${TAG}"
|
||||
LATEST_IMAGE="${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:latest"
|
||||
|
||||
docker build -t "$REGISTRY_IMAGE" .
|
||||
docker tag "$REGISTRY_IMAGE" "$LATEST_IMAGE"
|
||||
|
||||
docker push "$REGISTRY_IMAGE"
|
||||
docker push "$LATEST_IMAGE"
|
||||
|
||||
echo "registry_image=$REGISTRY_IMAGE" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Deploy
|
||||
if: steps.check.outputs.skip != 'true'
|
||||
run: |
|
||||
REGISTRY_IMAGE="${{ steps.build.outputs.registry_image }}"
|
||||
bash "$DEPLOY_SCRIPT" newsarchiver "$REGISTRY_IMAGE" "http://127.0.0.1:5000/"
|
||||
60
.gitignore
vendored
Normal file
60
.gitignore
vendored
Normal file
@ -0,0 +1,60 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Virtual Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Project specific
|
||||
archival_data/
|
||||
*.log
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# SingleFile
|
||||
singlefile-*.html
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
*.pid
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
package-lock.json
|
||||
test-results/
|
||||
45
AGENTS.md
Normal file
45
AGENTS.md
Normal file
@ -0,0 +1,45 @@
|
||||
# CI Port Convention
|
||||
|
||||
## Reserved Range: 10000-10099
|
||||
|
||||
CI jobs never use production ports. Each service gets a fixed offset (1-99) within the 10000-10099 range.
|
||||
|
||||
**Formula:** `CI_PORT = 10000 + CI_PORT_OFFSET`
|
||||
|
||||
## Port Assignments
|
||||
|
||||
| Offset | CI Port | Service |
|
||||
|--------|---------|---------|
|
||||
| 1 | 10001 | NewsArchiverV2 |
|
||||
| 2 | 10002 | paste-bin |
|
||||
| 3-99 | 10003-10099 | Reserved for future services |
|
||||
|
||||
## Usage
|
||||
|
||||
**CI workflow:**
|
||||
```yaml
|
||||
docker run -d --name myapp-e2e \
|
||||
--network test-network \
|
||||
-e CI=true \
|
||||
-e CI_PORT_OFFSET=1 \
|
||||
myapp:test \
|
||||
python main.py --serve --port 5000
|
||||
# App auto-shifts to port 10001
|
||||
```
|
||||
|
||||
**App code (any service):**
|
||||
```python
|
||||
if os.environ.get("CI") == "true" and os.environ.get("SKIP_PORT_SHIFT") != "1":
|
||||
offset = int(os.environ.get("CI_PORT_OFFSET", "1"))
|
||||
args.port = 10000 + offset
|
||||
print(f"[ci-port-shift] Port shifted from {original} to {args.port}")
|
||||
```
|
||||
|
||||
**Production:** Never sets `CI=true` or `CI_PORT_OFFSET`. Ports stay unchanged.
|
||||
|
||||
## Rules
|
||||
|
||||
1. CI jobs always set `CI=true` and the service's `CI_PORT_OFFSET`
|
||||
2. Port discovery via log parsing: `docker logs | grep "\[ci-port-shift\]"`
|
||||
3. Container-to-container traffic uses Docker networks, never host port publish
|
||||
4. Production containers never have `CI=true`
|
||||
1
CODEOWNERS
Normal file
1
CODEOWNERS
Normal file
@ -0,0 +1 @@
|
||||
* @jarianc
|
||||
42
Dockerfile
Normal file
42
Dockerfile
Normal file
@ -0,0 +1,42 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies for Playwright
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Create app directory structure
|
||||
RUN mkdir -p /app/archival_data
|
||||
|
||||
# Set environment variable for archive directory (can be overridden)
|
||||
ENV ARCHIVE_DIR=/app/archival_data
|
||||
|
||||
# Copy application code
|
||||
COPY *.py ./
|
||||
COPY *.json ./
|
||||
|
||||
# Copy templates and static directories if they exist
|
||||
COPY templates/ ./templates/
|
||||
COPY static/ ./static/
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Expose Flask port
|
||||
EXPOSE 5000
|
||||
|
||||
# Use entrypoint script
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
# Default command
|
||||
CMD ["python", "run_archiver.py", "--serve", "--host", "0.0.0.0", "--port", "5000"]
|
||||
46
README.md
46
README.md
@ -124,6 +124,52 @@ The SQLite database (`archival_data/cache.db`) stores:
|
||||
- Flask, Trafilatura, feedparser, APScheduler, requests, beautifulsoup4
|
||||
- SingleFile CLI (optional, for web page archiving)
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
The NewsArchiver can be deployed using Docker for easier management and isolation.
|
||||
|
||||
### Quick Start with Docker
|
||||
|
||||
```bash
|
||||
# Build the Docker image
|
||||
docker build -t newsarchiver .
|
||||
|
||||
# Run with default settings (archives stored in container)
|
||||
docker run -p 5000:5000 newsarchiver
|
||||
|
||||
# Run with NAS storage mount
|
||||
docker run -p 5000:5000 \
|
||||
-v /path/to/nas/backup:/data/archives \
|
||||
-e ARCHIVE_DIR=/data/archives \
|
||||
newsarchiver
|
||||
```
|
||||
|
||||
### Using Docker Compose
|
||||
|
||||
```bash
|
||||
# Edit docker-compose.yml to configure your NAS mount path
|
||||
vim docker-compose.yml
|
||||
|
||||
# Start the service
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Stop the service
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
The `ARCHIVE_DIR` environment variable controls where archived files are stored. To use NAS storage:
|
||||
|
||||
1. Edit `docker-compose.yml` and update the volume mount path
|
||||
2. Set `ARCHIVE_DIR` to match the container path (e.g., `/data/archives`)
|
||||
3. Restart the container
|
||||
|
||||
The archived data will persist even if the container is removed, as it's stored in a Docker volume or mounted NAS directory.
|
||||
|
||||
## Stopping Services
|
||||
|
||||
To stop all NewsArchiver services:
|
||||
|
||||
222
ap_processor.py
Normal file
222
ap_processor.py
Normal file
@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AP News Processor for NewsArchiver - Phase 2.6
|
||||
|
||||
Processes AP News front page to extract article URLs and archive them.
|
||||
Uses direct HTML parsing since AP doesn't provide RSS feeds.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print("WARNING: requests not installed. URL fetching may not work.")
|
||||
print("Install with: pip install requests")
|
||||
requests = None
|
||||
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
except ImportError:
|
||||
print("WARNING: beautifulsoup4 not installed. AP parsing may not work.")
|
||||
print("Install with: pip install beautifulsoup4")
|
||||
BeautifulSoup = None
|
||||
|
||||
try:
|
||||
import sqlite3
|
||||
except ImportError:
|
||||
print("ERROR: sqlite3 is required (should be built-in)")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
import storage_manager
|
||||
except ImportError:
|
||||
print("ERROR: storage_manager module not found")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
from rss_processor import is_duplicate
|
||||
except ImportError:
|
||||
print("ERROR: rss_processor module not found")
|
||||
sys.exit(1)
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
ARCHIVE_DIR = SCRIPT_DIR / 'archival_data'
|
||||
ARCHIVE_DIR.mkdir(exist_ok=True)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def fetch_ap_frontpage(timeout: int = 30) -> str:
|
||||
"""Fetch AP News front page HTML.
|
||||
|
||||
Args:
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
HTML string or empty string on failure
|
||||
"""
|
||||
if requests is None:
|
||||
logger.error("requests library not available")
|
||||
return ""
|
||||
|
||||
url = "https://apnews.com"
|
||||
|
||||
try:
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
}
|
||||
response = requests.get(url, timeout=timeout, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
logger.info("Fetched AP front page: %s", url)
|
||||
return response.text
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to fetch AP front page: %s", str(e))
|
||||
return ""
|
||||
|
||||
|
||||
def extract_article_links(html: str) -> List[str]:
|
||||
"""Extract AP article URLs from HTML using BeautifulSoup.
|
||||
|
||||
Args:
|
||||
html: Raw HTML string
|
||||
|
||||
Returns:
|
||||
List of unique article URLs
|
||||
"""
|
||||
if BeautifulSoup is None:
|
||||
logger.error("BeautifulSoup not available for link extraction")
|
||||
return []
|
||||
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
urls = []
|
||||
|
||||
for link in soup.find_all('a', href=True):
|
||||
href = link['href']
|
||||
if href.startswith('https://apnews.com/article/'):
|
||||
if href not in urls:
|
||||
urls.append(href)
|
||||
|
||||
logger.info("Extracted %d unique article links", len(urls))
|
||||
return urls
|
||||
|
||||
|
||||
def process_ap_articles(
|
||||
output_dir: Path,
|
||||
db_path: Path = ARCHIVE_DIR / 'cache.db',
|
||||
dry_run: bool = False
|
||||
) -> dict:
|
||||
"""Process AP News articles from front page.
|
||||
|
||||
Args:
|
||||
output_dir: Output directory for archived content
|
||||
db_path: SQLite cache database path
|
||||
dry_run: If True, preview without archiving
|
||||
|
||||
Returns:
|
||||
Dictionary with results summary
|
||||
"""
|
||||
results = {
|
||||
'source': 'Associated Press',
|
||||
'processed': 0,
|
||||
'archived': 0,
|
||||
'skipped': 0,
|
||||
'failed': 0,
|
||||
'urls': []
|
||||
}
|
||||
|
||||
html = fetch_ap_frontpage()
|
||||
if not html:
|
||||
logger.error("Failed to fetch AP front page")
|
||||
return results
|
||||
|
||||
article_urls = extract_article_links(html)
|
||||
|
||||
for article_url in article_urls:
|
||||
results['processed'] += 1
|
||||
results['urls'].append(article_url)
|
||||
|
||||
if is_duplicate(article_url, 'Associated Press', db_path):
|
||||
logger.debug("Skipping duplicate: %s", article_url[:60])
|
||||
results['skipped'] += 1
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
logger.info("[DRY-RUN] Would archive: %s", article_url[:60])
|
||||
results['archived'] += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
from archive_engine import archive_and_extract
|
||||
|
||||
extraction_result = archive_and_extract(
|
||||
article_url,
|
||||
'Associated Press',
|
||||
output_dir
|
||||
)
|
||||
|
||||
if extraction_result['success']:
|
||||
results['archived'] += 1
|
||||
storage_manager.save_article('Associated Press', extraction_result['article_data'])
|
||||
else:
|
||||
results['failed'] += 1
|
||||
logger.error("Failed to archive %s: %s", article_url[:60],
|
||||
extraction_result.get('error', 'Unknown error'))
|
||||
|
||||
except Exception as e:
|
||||
results['failed'] += 1
|
||||
logger.error("Error processing %s: %s", article_url[:60], str(e))
|
||||
|
||||
time.sleep(0.5)
|
||||
|
||||
logger.info("AP processing complete: %d processed, %d archived, %d skipped, %d failed",
|
||||
results['processed'], results['archived'], results['skipped'], results['failed'])
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='AP News Front Page Processor')
|
||||
parser.add_argument('--output', type=Path, default=ARCHIVE_DIR,
|
||||
help='Output directory for archived content')
|
||||
parser.add_argument('--dry-run', action='store_true', help='Preview without archiving')
|
||||
parser.add_argument('--verbose', action='store_true', help='Enable verbose logging')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.verbose:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("AP News Front Page Processor - Phase 2.6")
|
||||
logger.info("=" * 60)
|
||||
|
||||
storage_manager.initialize_storage()
|
||||
|
||||
results = process_ap_articles(args.output, ARCHIVE_DIR / 'cache.db', args.dry_run)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("AP NEWS PROCESSING COMPLETE")
|
||||
print("=" * 60)
|
||||
print(f"Processed: {results['processed']}")
|
||||
print(f"Archived: {results['archived']}")
|
||||
print(f"Skipped: {results['skipped']}")
|
||||
print(f"Failed: {results['failed']}")
|
||||
if results['urls']:
|
||||
print("\nArticle URLs:")
|
||||
for url in results['urls'][:10]:
|
||||
print(f" - {url[:70]}")
|
||||
if len(results['urls']) > 10:
|
||||
print(f" ... and {len(results['urls']) - 10} more")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
538
archive_engine.py
Normal file
538
archive_engine.py
Normal file
@ -0,0 +1,538 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Archive Engine for NewsArchiver - Phase 2.5
|
||||
|
||||
Orchestrates the archiving process:
|
||||
- RSS polling
|
||||
- Page archiving with SingleFile
|
||||
- Content extraction
|
||||
- Storage management
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
try:
|
||||
import storage_manager
|
||||
from rss_processor import fetch_rss_feed, is_duplicate, save_article as cache_save_article
|
||||
from content_extractor import parse_article_from_html, get_html_from_url
|
||||
from storage_manager import save_article as storage_save_article
|
||||
except ImportError as e:
|
||||
print(f"ERROR: Required module not found: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
from singlefile_archive import archive_page_with_singlefile, archive_page_with_playwright
|
||||
except ImportError:
|
||||
archive_page_with_singlefile = None
|
||||
archive_page_with_playwright = None
|
||||
|
||||
try:
|
||||
from ap_processor import process_ap_articles
|
||||
except ImportError:
|
||||
process_ap_articles = None
|
||||
|
||||
try:
|
||||
from storage_manager import get_archive_file_path_from_db
|
||||
except ImportError:
|
||||
get_archive_file_path_from_db = None
|
||||
|
||||
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
ARCHIVE_DIR = Path(os.environ.get("ARCHIVE_DIR", str(SCRIPT_DIR / "archival_data"))).resolve()
|
||||
ARCHIVE_DIR.mkdir(exist_ok=True)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def get_archive_file_path(source_name: str, article_url: str, archive_dir: Path = ARCHIVE_DIR) -> Optional[Path]:
|
||||
"""Find archived HTML file for an article.
|
||||
|
||||
First checks the database mapping for the archive file path.
|
||||
Falls back to searching HTML files if not found in database.
|
||||
|
||||
Args:
|
||||
source_name: Newspaper source name
|
||||
article_url: Article URL
|
||||
archive_dir: Root archive directory
|
||||
|
||||
Returns:
|
||||
Path to archived HTML file if found, None otherwise
|
||||
"""
|
||||
archive_file_path = get_archive_file_path_from_db(article_url, source_name)
|
||||
if archive_file_path:
|
||||
archive_path = Path(archive_file_path)
|
||||
if archive_path.exists():
|
||||
logger.debug("Found archived HTML (DB) for %s: %s", article_url[:60], archive_path)
|
||||
return archive_path
|
||||
logger.debug("Archive file not found on disk: %s", archive_file_path)
|
||||
|
||||
websites_dir = archive_dir / 'websites'
|
||||
|
||||
if not websites_dir.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
source_dir = websites_dir / source_name
|
||||
if not source_dir.exists():
|
||||
return None
|
||||
|
||||
html_dir = source_dir / 'html'
|
||||
if not html_dir.exists():
|
||||
return None
|
||||
|
||||
for date_dir in sorted(html_dir.iterdir()):
|
||||
if not date_dir.is_dir():
|
||||
continue
|
||||
|
||||
for html_file in sorted(date_dir.glob('article_*.html')):
|
||||
try:
|
||||
with open(html_file, 'r', encoding='utf-8') as f:
|
||||
html_content = f.read()
|
||||
|
||||
if article_url in html_content:
|
||||
logger.debug("Found archived HTML (search) for %s: %s", article_url[:60], html_file)
|
||||
return html_file
|
||||
except Exception as e:
|
||||
logger.debug("Error reading %s: %s", html_file, str(e))
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("Error searching for archive: %s", str(e))
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extract_content_from_archive(article_url: str, source_name: str = None, archive_dir: Path = ARCHIVE_DIR) -> dict:
|
||||
"""Extract content from web page, preferring archived HTML over live fetch.
|
||||
|
||||
Args:
|
||||
article_url: URL to extract content from
|
||||
source_name: Newspaper source name (for locating archives)
|
||||
archive_dir: Root archive directory
|
||||
|
||||
Returns:
|
||||
Dictionary with extracted content and metadata
|
||||
"""
|
||||
raw_html = None
|
||||
extraction_method = 'live_fetch'
|
||||
|
||||
try:
|
||||
if source_name:
|
||||
archived_file = get_archive_file_path(source_name, article_url, archive_dir)
|
||||
if archived_file:
|
||||
try:
|
||||
raw_html = archived_file.read_text(encoding='utf-8')
|
||||
extraction_method = 'archive'
|
||||
logger.debug("Loaded archived HTML from %s", archived_file)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to read archived HTML from %s: %s", archived_file, str(e))
|
||||
|
||||
if raw_html is None:
|
||||
raw_html = get_html_from_url(article_url)
|
||||
|
||||
if not raw_html:
|
||||
logger.error("No HTML content retrieved for %s", article_url[:60])
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Failed to retrieve HTML content'
|
||||
}
|
||||
|
||||
article_data = parse_article_from_html(raw_html, article_url)
|
||||
|
||||
extraction_method = article_data.extraction_method or extraction_method
|
||||
logger.debug("Extracted content from %s using %s", article_url[:60], extraction_method)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'article_data': article_data
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error("Error extracting content from %s: %s", article_url, str(e))
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
|
||||
def archive_and_extract(article_url: str, source_name: str, archive_dir: Path) -> dict:
|
||||
"""Archive a URL and extract content from the archive.
|
||||
|
||||
This function first tries to archive the URL using SingleFile or Playwright.
|
||||
Then it extracts content from the archived HTML.
|
||||
|
||||
Args:
|
||||
article_url: URL to archive and extract
|
||||
source_name: Newspaper source name (for directory structure)
|
||||
archive_dir: Root archive directory
|
||||
|
||||
Returns:
|
||||
Dictionary with extraction results
|
||||
"""
|
||||
archived_file = archive_article_url(article_url, source_name, archive_dir)
|
||||
|
||||
if not archived_file:
|
||||
logger.error("Failed to archive %s", article_url[:60])
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Failed to archive article'
|
||||
}
|
||||
|
||||
extraction_result = extract_content_from_archive(article_url, source_name, archive_dir)
|
||||
|
||||
if extraction_result['success']:
|
||||
logger.info("Successfully archived and extracted content from %s", article_url[:60])
|
||||
else:
|
||||
logger.error("Failed to extract content from archived %s: %s", article_url[:60], extraction_result.get('error', 'Unknown error'))
|
||||
|
||||
return extraction_result
|
||||
|
||||
|
||||
def archive_url_with_fallback(url: str, output_path: Path) -> bool:
|
||||
"""Archive a URL using SingleFile if available, falling back to Playwright.
|
||||
|
||||
Args:
|
||||
url: URL to archive
|
||||
output_path: Output file path for the archived HTML
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if archive_page_with_singlefile:
|
||||
if archive_page_with_singlefile(url, output_path):
|
||||
return True
|
||||
|
||||
if archive_page_with_playwright:
|
||||
logger.info("Falling back to Playwright archiving for %s", url[:60])
|
||||
if archive_page_with_playwright(url, output_path):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def archive_article_url(article_url: str, source_name: str, archive_dir: Path) -> Optional[Path]:
|
||||
"""Archive a single article URL using SingleFile or Playwright fallback.
|
||||
|
||||
Args:
|
||||
article_url: URL to archive
|
||||
source_name: Newspaper source name (for directory structure)
|
||||
archive_dir: Root archive directory
|
||||
|
||||
Returns:
|
||||
Path to archived HTML file if successful, None otherwise
|
||||
"""
|
||||
try:
|
||||
websites_dir = archive_dir / 'websites'
|
||||
source_dir = websites_dir / source_name
|
||||
html_dir = source_dir / 'html'
|
||||
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d')
|
||||
date_dir = html_dir / timestamp
|
||||
date_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
base_timestamp = int(datetime.now().timestamp())
|
||||
counter = 0
|
||||
article_filename = f'article_{base_timestamp}_{counter}.html'
|
||||
output_path = date_dir / article_filename
|
||||
|
||||
while output_path.exists():
|
||||
counter += 1
|
||||
article_filename = f'article_{base_timestamp}_{counter}.html'
|
||||
output_path = date_dir / article_filename
|
||||
|
||||
if archive_page_with_singlefile:
|
||||
if archive_page_with_singlefile(article_url, output_path):
|
||||
logger.info("Archived %s to %s", article_url[:60], output_path)
|
||||
return output_path
|
||||
logger.info("SingleFile failed, attempting direct fetch fallback for %s", article_url[:60])
|
||||
|
||||
if archive_page_with_playwright:
|
||||
logger.info("Falling back to Playwright for %s", article_url[:60])
|
||||
if archive_page_with_playwright(article_url, output_path):
|
||||
return output_path
|
||||
|
||||
logger.info("Attempting direct HTML fetch fallback for %s", article_url[:60])
|
||||
direct_html = get_html_from_url(article_url)
|
||||
if direct_html:
|
||||
output_path.write_text(direct_html, encoding='utf-8')
|
||||
logger.info("Archived %s to %s using direct fetch", article_url[:60], output_path)
|
||||
return output_path
|
||||
|
||||
logger.error("Failed to archive %s", article_url[:60])
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error archiving %s: %s", article_url, str(e))
|
||||
return None
|
||||
|
||||
|
||||
def archive_newspaper(
|
||||
source_name: str,
|
||||
rss_url: str,
|
||||
output_dir: Path,
|
||||
dry_run: bool = False
|
||||
) -> dict:
|
||||
"""Main archiving workflow for a newspaper.
|
||||
|
||||
Args:
|
||||
source_name: Newspaper source name
|
||||
rss_url: RSS feed URL
|
||||
output_dir: Output directory for archived content
|
||||
dry_run: If True, preview without making changes
|
||||
|
||||
Returns:
|
||||
Dictionary with results summary
|
||||
"""
|
||||
storage_manager.initialize_storage()
|
||||
logger.info("=" * 60)
|
||||
logger.info("Starting newspaper archive: %s", source_name)
|
||||
logger.info("=" * 60)
|
||||
|
||||
results = {
|
||||
'source': source_name,
|
||||
'rss_url': rss_url,
|
||||
'processed': 0,
|
||||
'archived': 0,
|
||||
'skipped': 0,
|
||||
'failed': 0,
|
||||
'errors': []
|
||||
}
|
||||
|
||||
try:
|
||||
feed = fetch_rss_feed(rss_url)
|
||||
except Exception as e:
|
||||
logger.error("Failed to fetch RSS feed for %s: %s", source_name, str(e))
|
||||
results['errors'].append({
|
||||
'action': 'fetch_rss',
|
||||
'error': str(e)
|
||||
})
|
||||
return results
|
||||
|
||||
for item in feed.entries:
|
||||
article_url = item.get('link', '')
|
||||
|
||||
if not article_url:
|
||||
logger.warning("Skipping entry without URL")
|
||||
results['failed'] += 1
|
||||
continue
|
||||
|
||||
if is_duplicate(article_url, source_name, ARCHIVE_DIR / 'cache.db'):
|
||||
logger.debug("Skipping duplicate: %s", article_url[:60])
|
||||
results['skipped'] += 1
|
||||
continue
|
||||
|
||||
results['processed'] += 1
|
||||
|
||||
if dry_run:
|
||||
title = item.get('title', 'No Title')
|
||||
logger.info("[DRY-RUN] Would process: %s - %s", title[:60], article_url[:60])
|
||||
results['archived'] += 1
|
||||
continue
|
||||
|
||||
extraction_result = archive_and_extract(article_url, source_name, output_dir)
|
||||
|
||||
if not extraction_result['success']:
|
||||
logger.error("Failed to extract content from %s: %s", article_url[:60], extraction_result.get('error', 'Unknown error'))
|
||||
results['failed'] += 1
|
||||
results['errors'].append({
|
||||
'url': article_url,
|
||||
'action': 'extract_content',
|
||||
'error': extraction_result.get('error', 'Unknown error')
|
||||
})
|
||||
continue
|
||||
|
||||
article_data = extraction_result['article_data']
|
||||
|
||||
try:
|
||||
storage_save_article(source_name, article_data)
|
||||
results['archived'] += 1
|
||||
except Exception as e:
|
||||
logger.error("Failed to save article %s: %s", article_url[:60], str(e))
|
||||
results['failed'] += 1
|
||||
results['errors'].append({
|
||||
'url': article_url,
|
||||
'action': 'save_article',
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
logger.info("Archive complete: %s - %d processed, %d archived, %d skipped, %d failed",
|
||||
source_name, results['processed'], results['archived'], results['skipped'], results['failed'])
|
||||
|
||||
return results
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def archive_all_sources(
|
||||
rss_feeds_path: Path = SCRIPT_DIR / 'rss_feeds.json',
|
||||
output_dir: Path = ARCHIVE_DIR,
|
||||
dry_run: bool = False
|
||||
) -> dict:
|
||||
"""Process all RSS feeds from rss_feeds.json.
|
||||
|
||||
Args:
|
||||
rss_feeds_path: Path to RSS feeds JSON file
|
||||
output_dir: Output directory for archived content
|
||||
dry_run: If True, preview without making changes
|
||||
|
||||
Returns:
|
||||
Dictionary with results summary
|
||||
"""
|
||||
if not rss_feeds_path.exists():
|
||||
logger.error("RSS feeds file not found: %s", rss_feeds_path)
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'File not found',
|
||||
'processed': 0,
|
||||
'success_count': 0,
|
||||
'failed_count': 0
|
||||
}
|
||||
|
||||
with open(rss_feeds_path, 'r', encoding='utf-8') as f:
|
||||
rss_feeds = json.load(f)
|
||||
|
||||
total_results = {
|
||||
'sources_processed': 0,
|
||||
'sources_failed': 0,
|
||||
'total_articles_processed': 0,
|
||||
'total_articles_archived': 0,
|
||||
'total_articles_skipped': 0,
|
||||
'total_articles_failed': 0,
|
||||
'source_results': [],
|
||||
'errors': []
|
||||
}
|
||||
|
||||
for source_name, feed_info in rss_feeds.items():
|
||||
rss_url = feed_info.get('rss_url', '')
|
||||
feed_type = feed_info.get('feed_type', 'rss')
|
||||
|
||||
if not rss_url:
|
||||
logger.warning("No RSS URL for source: %s", source_name)
|
||||
continue
|
||||
|
||||
try:
|
||||
if feed_type == 'html' and process_ap_articles:
|
||||
ap_results = process_ap_articles(
|
||||
output_dir,
|
||||
ARCHIVE_DIR / 'cache.db',
|
||||
dry_run
|
||||
)
|
||||
ap_results['source'] = source_name
|
||||
total_results['sources_processed'] += 1
|
||||
total_results['total_articles_processed'] += ap_results['processed']
|
||||
total_results['total_articles_archived'] += ap_results['archived']
|
||||
total_results['total_articles_skipped'] += ap_results['skipped']
|
||||
total_results['total_articles_failed'] += ap_results['failed']
|
||||
total_results['source_results'].append(ap_results)
|
||||
|
||||
if ap_results.get('errors'):
|
||||
total_results['errors'].extend(ap_results['errors'])
|
||||
elif feed_type == 'html' and not process_ap_articles:
|
||||
logger.error("HTML feed type configured but ap_processor unavailable for %s", source_name)
|
||||
total_results['sources_failed'] += 1
|
||||
total_results['source_results'].append({
|
||||
'source': source_name,
|
||||
'rss_url': rss_url,
|
||||
'error': 'HTML feed processing not available'
|
||||
})
|
||||
else:
|
||||
results = archive_newspaper(source_name, rss_url, output_dir, dry_run)
|
||||
total_results['sources_processed'] += 1
|
||||
total_results['total_articles_processed'] += results['processed']
|
||||
total_results['total_articles_archived'] += results['archived']
|
||||
total_results['total_articles_skipped'] += results['skipped']
|
||||
total_results['total_articles_failed'] += results['failed']
|
||||
total_results['source_results'].append(results)
|
||||
|
||||
if results['errors']:
|
||||
total_results['errors'].extend(results['errors'])
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to process source %s: %s", source_name, str(e))
|
||||
total_results['sources_failed'] += 1
|
||||
total_results['source_results'].append({
|
||||
'source': source_name,
|
||||
'rss_url': rss_url,
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
return total_results
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(description='Archive Engine for NewsArchiver - Phase 2.5')
|
||||
parser.add_argument('--source', help='Single source name to process')
|
||||
parser.add_argument('--rss-url', help='RSS URL (required if --source provided)')
|
||||
parser.add_argument('--all', action='store_true', help='Process all sources from rss_feeds.json')
|
||||
parser.add_argument('--rss-feeds', type=Path, default=SCRIPT_DIR / 'rss_feeds.json',
|
||||
help='Path to RSS feeds JSON file')
|
||||
parser.add_argument('--output', type=Path, default=ARCHIVE_DIR,
|
||||
help='Output directory for archived content')
|
||||
parser.add_argument('--dry-run', action='store_true', help='Preview without making changes')
|
||||
parser.add_argument('--verbose', action='store_true', help='Enable verbose logging')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.verbose:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("Archive Engine - Phase 2.5")
|
||||
logger.info("=" * 60)
|
||||
|
||||
storage_manager.initialize_storage()
|
||||
|
||||
if args.source:
|
||||
if not args.rss_url:
|
||||
logger.error("RSS URL required when using --source")
|
||||
return
|
||||
results = archive_newspaper(args.source, args.rss_url, args.output, args.dry_run)
|
||||
print("\n" + "=" * 60)
|
||||
print(f"SOURCE: {args.source}")
|
||||
print("=" * 60)
|
||||
print(f"Processed: {results['processed']}")
|
||||
print(f"Archived: {results['archived']}")
|
||||
print(f"Skipped: {results['skipped']}")
|
||||
print(f"Failed: {results['failed']}")
|
||||
if results['errors']:
|
||||
print("\nErrors:")
|
||||
for error in results['errors']:
|
||||
print(f" - {error.get('url', 'Unknown')}: {error.get('error', 'Unknown error')}")
|
||||
print("=" * 60)
|
||||
elif args.all:
|
||||
results = archive_all_sources(args.rss_feeds, args.output, args.dry_run)
|
||||
print("\n" + "=" * 60)
|
||||
print("PROCESSING COMPLETE")
|
||||
print("=" * 60)
|
||||
print(f"Sources processed: {results['sources_processed']}")
|
||||
print(f"Sources failed: {results['sources_failed']}")
|
||||
print(f"Total articles processed: {results['total_articles_processed']}")
|
||||
print(f"Total articles archived: {results['total_articles_archived']}")
|
||||
print(f"Total articles skipped: {results['total_articles_skipped']}")
|
||||
print(f"Total articles failed: {results['total_articles_failed']}")
|
||||
if results['errors']:
|
||||
print("\nErrors:")
|
||||
for error in results['errors'][:10]:
|
||||
url = error.get('url', 'Unknown')
|
||||
action = error.get('action', 'Unknown')
|
||||
error_msg = error.get('error', 'Unknown error')
|
||||
print(f" - [{action}] {url}: {error_msg}")
|
||||
if len(results['errors']) > 10:
|
||||
print(f" ... and {len(results['errors']) - 10} more errors")
|
||||
print("=" * 60)
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
0
ci-trigger
Normal file
0
ci-trigger
Normal file
367
cleanup_old_files.py
Normal file
367
cleanup_old_files.py
Normal file
@ -0,0 +1,367 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Cleanup Script for NewsArchiver
|
||||
|
||||
This script removes files older than a specified date from the project directory.
|
||||
It provides dry-run mode to preview what would be deleted before actually deleting.
|
||||
|
||||
Usage:
|
||||
python cleanup_old_files.py --date "2024-03-19" --dry-run
|
||||
python cleanup_old_files.py --date "2024-03-19"
|
||||
|
||||
Options:
|
||||
--date, -d Date in YYYY-MM-DD format (required)
|
||||
--dry-run, -n Show what would be deleted without actually deleting (default: True)
|
||||
--force, -f Actually delete files (disables dry-run mode)
|
||||
--verbose, -v Enable verbose output
|
||||
--archival Include archival_data folder for cleanup
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Project root directory
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
|
||||
# Path to archival_data directory
|
||||
ARCHIVE_DIR = Path(
|
||||
os.environ.get("ARCHIVE_DIR", str(SCRIPT_DIR / "archival_data"))
|
||||
).resolve()
|
||||
ARCHIVAL_DATA_DIR = ARCHIVE_DIR
|
||||
|
||||
# Path to websites folder (only this folder will be scanned in archival_data)
|
||||
WEBSITES_DIR = ARCHIVAL_DATA_DIR / "websites"
|
||||
|
||||
# Files and directories to always preserve (never delete)
|
||||
PRESERVE_LIST = {
|
||||
# Python files
|
||||
"ap_processor.py",
|
||||
"archive_engine.py",
|
||||
"content_extractor.py",
|
||||
"rebuild_database.py",
|
||||
"restore_database.py",
|
||||
"rss_feeds.json",
|
||||
"rss_processor.py",
|
||||
"run_archiver.py",
|
||||
"scheduler.py",
|
||||
"setup_cron.sh",
|
||||
"singlefile_archive.py",
|
||||
"stop_services.sh",
|
||||
"web_interface.py",
|
||||
"cleanup_old_files.py",
|
||||
# Directories
|
||||
"archival_data",
|
||||
"static",
|
||||
"templates",
|
||||
"__pycache__",
|
||||
}
|
||||
|
||||
# Database files to preserve
|
||||
DATABASE_FILES = {"cache.db", "cache.db-shm", "cache.db-wal"}
|
||||
|
||||
# Files that should be excluded from cleanup regardless of date
|
||||
EXCLUDE_PATTERNS = [
|
||||
".git",
|
||||
".gitignore",
|
||||
]
|
||||
|
||||
|
||||
def parse_date(date_str: str) -> datetime:
|
||||
"""Parse date string in YYYY-MM-DD format.
|
||||
|
||||
Args:
|
||||
date_str: Date string in YYYY-MM-DD format
|
||||
|
||||
Returns:
|
||||
datetime object with the specified date at midnight
|
||||
|
||||
Raises:
|
||||
ValueError: If date format is invalid
|
||||
"""
|
||||
try:
|
||||
return datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||||
except ValueError as e:
|
||||
raise ValueError(
|
||||
f"Invalid date format: '{date_str}'. Use YYYY-MM-DD format."
|
||||
) from e
|
||||
|
||||
|
||||
def should_preserve(path: Path) -> bool:
|
||||
"""Check if a file/directory should be preserved.
|
||||
|
||||
Args:
|
||||
path: Path to check
|
||||
|
||||
Returns:
|
||||
True if the path should be preserved, False otherwise
|
||||
"""
|
||||
# Check if it's in the preserve list
|
||||
if path.name in PRESERVE_LIST:
|
||||
return True
|
||||
|
||||
# Check if it matches any exclude patterns
|
||||
for pattern in EXCLUDE_PATTERNS:
|
||||
if pattern in str(path):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def should_preserve_archival_file(path: Path) -> bool:
|
||||
"""Check if a file in archival_data should be preserved.
|
||||
|
||||
Args:
|
||||
path: Path to check
|
||||
|
||||
Returns:
|
||||
True if the path should be preserved, False otherwise
|
||||
"""
|
||||
# Always preserve database files
|
||||
if path.name in DATABASE_FILES:
|
||||
return True
|
||||
|
||||
return should_preserve(path)
|
||||
|
||||
|
||||
def get_files_older_than_date(
|
||||
directory: Path, cutoff_date: datetime
|
||||
) -> List[Tuple[Path, datetime]]:
|
||||
"""Get all files older than the cutoff date.
|
||||
|
||||
Args:
|
||||
directory: Directory to search
|
||||
cutoff_date: Files older than this date will be selected
|
||||
|
||||
Returns:
|
||||
List of tuples (path, modification_time) for files older than cutoff
|
||||
"""
|
||||
old_files = []
|
||||
|
||||
# Walk through all files in directory recursively (targeted patterns only)
|
||||
for pattern in ("*.html", "*.json", "*.txt", "*.xml", "*.md"):
|
||||
for item in directory.rglob(pattern):
|
||||
if not item.is_file():
|
||||
continue
|
||||
if should_preserve(item):
|
||||
continue
|
||||
|
||||
try:
|
||||
mtime = datetime.fromtimestamp(item.stat().st_mtime, tz=timezone.utc)
|
||||
if mtime < cutoff_date:
|
||||
old_files.append((item, mtime))
|
||||
except (OSError, ValueError) as e:
|
||||
logger.warning(f"Could not access file {item}: {e}")
|
||||
|
||||
return old_files
|
||||
|
||||
|
||||
def get_files_older_than_date_non_recursive(
|
||||
directory: Path, cutoff_date: datetime
|
||||
) -> List[Tuple[Path, datetime]]:
|
||||
"""Get all files older than the cutoff date (non-recursive).
|
||||
|
||||
Args:
|
||||
directory: Directory to search
|
||||
cutoff_date: Files older than this date will be selected
|
||||
|
||||
Returns:
|
||||
List of tuples (path, modification_time) for files older than cutoff
|
||||
"""
|
||||
old_files = []
|
||||
|
||||
# Walk through all files in directory (non-recursive for safety)
|
||||
for item in directory.iterdir():
|
||||
if item.is_file():
|
||||
if should_preserve(item):
|
||||
continue
|
||||
|
||||
try:
|
||||
mtime = datetime.fromtimestamp(item.stat().st_mtime, tz=timezone.utc)
|
||||
if mtime < cutoff_date:
|
||||
old_files.append((item, mtime))
|
||||
except (OSError, ValueError) as e:
|
||||
logger.warning(f"Could not access file {item}: {e}")
|
||||
|
||||
return old_files
|
||||
|
||||
|
||||
def delete_files(files: List[Tuple[Path, datetime]]) -> Tuple[int, int]:
|
||||
"""Delete files and return count of successful/failed deletions.
|
||||
|
||||
Args:
|
||||
files: List of (path, modification_time) tuples to delete
|
||||
|
||||
Returns:
|
||||
Tuple of (deleted_count, failed_count)
|
||||
"""
|
||||
deleted = 0
|
||||
failed = 0
|
||||
|
||||
for path, mtime in files:
|
||||
try:
|
||||
path.unlink()
|
||||
logger.info(
|
||||
f"Deleted: {path.name} (modified: {mtime.strftime('%Y-%m-%d %H:%M:%S')})"
|
||||
)
|
||||
deleted += 1
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to delete {path.name}: {e}")
|
||||
failed += 1
|
||||
|
||||
return deleted, failed
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for cleanup script."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Cleanup old files from NewsArchiver project",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
%(prog)s --date "2024-03-19" --dry-run
|
||||
%(prog)s --date "2024-03-19" --force
|
||||
%(prog)s --date "2024-03-19" --archival --force
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--date",
|
||||
"-d",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Date in YYYY-MM-DD format - files older than this will be deleted",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
"-n",
|
||||
action="store_true",
|
||||
default=True,
|
||||
help="Show what would be deleted without actually deleting (default)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
"-f",
|
||||
action="store_true",
|
||||
help="Actually delete files (disables dry-run mode)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--verbose", "-v", action="store_true", help="Enable verbose output"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--archival",
|
||||
action="store_true",
|
||||
help="Include archival_data folder for cleanup",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Set logging level
|
||||
if args.verbose:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
# Parse the date
|
||||
try:
|
||||
cutoff_date = parse_date(args.date)
|
||||
except ValueError as e:
|
||||
logger.error(str(e))
|
||||
sys.exit(1)
|
||||
|
||||
# Validate cutoff date is not in the future
|
||||
now = datetime.now(timezone.utc)
|
||||
if cutoff_date > now:
|
||||
logger.error("Cutoff date cannot be in the future")
|
||||
sys.exit(1)
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("NewsArchiver Cleanup Script")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# Determine mode
|
||||
dry_run = not args.force
|
||||
mode = "DRY RUN" if dry_run else "ACTUAL DELETE"
|
||||
logger.info("Mode: %s", mode)
|
||||
logger.info(
|
||||
"Cutoff Date: %s (files older than this will be %s)",
|
||||
cutoff_date.strftime("%Y-%m-%d"),
|
||||
"kept" if dry_run else "deleted",
|
||||
)
|
||||
logger.info("=" * 60)
|
||||
|
||||
# Find files older than cutoff date
|
||||
if args.archival:
|
||||
# Only scan websites folder when --archival is used
|
||||
if not WEBSITES_DIR.exists():
|
||||
logger.error("Websites folder not found at %s", WEBSITES_DIR)
|
||||
sys.exit(1)
|
||||
old_files = []
|
||||
logger.info(
|
||||
"Scanning websites folder for files older than %s...",
|
||||
cutoff_date.strftime("%Y-%m-%d"),
|
||||
)
|
||||
# First check if there are any files directly in websites folder
|
||||
website_root_files = get_files_older_than_date_non_recursive(
|
||||
WEBSITES_DIR, cutoff_date
|
||||
)
|
||||
# Then check recursively in subdirectories
|
||||
website_recursive_files = []
|
||||
for subdir in WEBSITES_DIR.iterdir():
|
||||
if subdir.is_dir():
|
||||
website_recursive_files.extend(
|
||||
get_files_older_than_date(subdir, cutoff_date)
|
||||
)
|
||||
old_files.extend(website_root_files)
|
||||
old_files.extend(website_recursive_files)
|
||||
logger.info(
|
||||
"Found %d files in websites folder",
|
||||
len(website_root_files) + len(website_recursive_files),
|
||||
)
|
||||
else:
|
||||
# Scan root directory (non-archival mode)
|
||||
old_files = get_files_older_than_date_non_recursive(SCRIPT_DIR, cutoff_date)
|
||||
|
||||
if not old_files:
|
||||
logger.info("No files older than %s found.", cutoff_date.strftime("%Y-%m-%d"))
|
||||
logger.info("Nothing to do.")
|
||||
return
|
||||
|
||||
logger.info(
|
||||
"Found %d files older than %s:",
|
||||
len(old_files),
|
||||
cutoff_date.strftime("%Y-%m-%d"),
|
||||
)
|
||||
|
||||
# List all files that would be affected
|
||||
for path, mtime in old_files:
|
||||
# Get relative path for cleaner output
|
||||
rel_path = path.relative_to(SCRIPT_DIR)
|
||||
logger.info(
|
||||
" - %s (modified: %s)", rel_path, mtime.strftime("%Y-%m-%d %H:%M:%S")
|
||||
)
|
||||
|
||||
logger.info("=" * 60)
|
||||
|
||||
# Execute deletion if not dry run
|
||||
if dry_run:
|
||||
logger.info("DRY RUN: No files were deleted.")
|
||||
logger.info("Run with --force to actually delete these files.")
|
||||
else:
|
||||
deleted, failed = delete_files(old_files)
|
||||
logger.info("=" * 60)
|
||||
logger.info("Cleanup complete!")
|
||||
logger.info("Deleted: %d files", deleted)
|
||||
logger.info("Failed: %d files", failed)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
387
content_extractor.py
Normal file
387
content_extractor.py
Normal file
@ -0,0 +1,387 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Content Extractor for NewsArchiver - Phase 2.2
|
||||
|
||||
Extracts article text and metadata from HTML using Trafilatura
|
||||
with BeautifulSoup fallback for JavaScript-heavy sites.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print("WARNING: requests not installed. URL fetching may not work.")
|
||||
print("Install with: pip install requests")
|
||||
requests = None
|
||||
|
||||
try:
|
||||
import trafilatura
|
||||
from trafilatura import extract, extract_metadata
|
||||
except ImportError:
|
||||
print("ERROR: trafilatura is required. Install with: pip install trafilatura")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
except ImportError:
|
||||
print("WARNING: beautifulsoup4 not installed. Some fallback features may not work.")
|
||||
print("Install with: pip install beautifulsoup4")
|
||||
BeautifulSoup = None
|
||||
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
PLAYWRIGHT_AVAILABLE = True
|
||||
except ImportError:
|
||||
PLAYWRIGHT_AVAILABLE = False
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
ARCHIVE_DIR = SCRIPT_DIR / 'archival_data'
|
||||
ARCHIVE_DIR.mkdir(exist_ok=True)
|
||||
|
||||
_last_request_time = 0.0
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArticleData:
|
||||
"""Structured article data."""
|
||||
url: str
|
||||
title: Optional[str] = None
|
||||
author: Optional[str] = None
|
||||
publish_date: Optional[str] = None
|
||||
content_text: Optional[str] = None
|
||||
content_html: Optional[str] = None
|
||||
raw_html: Optional[str] = None
|
||||
archive_file_path: Optional[str] = None
|
||||
tags: Optional[list] = None
|
||||
language: Optional[str] = None
|
||||
metadata: Optional[dict] = None
|
||||
extraction_method: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
guid: Optional[str] = None
|
||||
id: Optional[int] = None
|
||||
source_name: Optional[str] = None
|
||||
|
||||
|
||||
def fetch_url(url: str, timeout: int = 30) -> str:
|
||||
"""Download HTML from URL with rate limiting.
|
||||
|
||||
Args:
|
||||
url: URL to fetch
|
||||
timeout: Request timeout in seconds
|
||||
|
||||
Returns:
|
||||
HTML string or empty string on failure
|
||||
"""
|
||||
global _last_request_time
|
||||
|
||||
if requests is None:
|
||||
logger.error("requests library not available")
|
||||
return ""
|
||||
|
||||
try:
|
||||
delay = 0.5 - (time.time() - _last_request_time)
|
||||
if delay > 0:
|
||||
time.sleep(delay)
|
||||
|
||||
_last_request_time = time.time()
|
||||
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
}
|
||||
response = requests.get(url, timeout=timeout, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
text = response.text
|
||||
|
||||
# Check if response is an error page (only check for actual HTTP errors)
|
||||
if response.status_code >= 400:
|
||||
logger.warning("Server returned error for %s (status: %d)", url, response.status_code)
|
||||
return ""
|
||||
|
||||
# Check for common error patterns
|
||||
if 'access denied' in text.lower() or 'forbidden' in text.lower():
|
||||
logger.warning("Server returned access denied for %s", url)
|
||||
return ""
|
||||
|
||||
return text
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to fetch URL %s: %s", url, str(e))
|
||||
return ""
|
||||
|
||||
|
||||
def extract_content(html: str) -> dict:
|
||||
"""Extract article content and metadata from HTML using Trafilatura.
|
||||
|
||||
Args:
|
||||
html: Raw HTML string (or plain text)
|
||||
|
||||
Returns:
|
||||
Dictionary with extracted content and metadata
|
||||
"""
|
||||
result = {
|
||||
'success': False,
|
||||
'content_text': None,
|
||||
'content_html': None,
|
||||
'title': None,
|
||||
'author': None,
|
||||
'date': None,
|
||||
'tags': None,
|
||||
'language': None,
|
||||
'metadata': None,
|
||||
'extraction_method': None,
|
||||
'error': None
|
||||
}
|
||||
|
||||
if not html or not html.strip():
|
||||
result['error'] = 'Empty HTML content'
|
||||
return result
|
||||
|
||||
# Check if this is an error page (HTTP status codes, not the word "error" in content)
|
||||
# Only check for error patterns that appear in actual error pages (not in article content)
|
||||
# Look for specific error page patterns with proper HTML structure
|
||||
import re
|
||||
error_patterns = [
|
||||
r'<title[^>]*>403[^<]*Forbidden</title>',
|
||||
r'<title[^>]*>401[^<]*Unauthorized</title>',
|
||||
r'<h1[^>]*>403</h1>',
|
||||
r'<h1[^>]*>401</h1>',
|
||||
]
|
||||
|
||||
html_lower = html.lower()
|
||||
for pattern in error_patterns:
|
||||
if re.search(pattern, html_lower):
|
||||
result['error'] = 'HTML contains error page content'
|
||||
result['success'] = False
|
||||
return result
|
||||
|
||||
# Check for common error indicators in the HTML body
|
||||
# These should only trigger if we see them in context (like a 403/401 status indicator)
|
||||
if '<title>403' in html_lower or '<title>401' in html_lower:
|
||||
result['error'] = 'HTML contains error page content'
|
||||
result['success'] = False
|
||||
return result
|
||||
|
||||
has_html_tags = '<' in html and '>' in html
|
||||
|
||||
try:
|
||||
metadata = extract_metadata(html)
|
||||
|
||||
if metadata:
|
||||
result['title'] = metadata.title
|
||||
result['author'] = metadata.author
|
||||
result['date'] = metadata.date
|
||||
result['tags'] = metadata.tags if hasattr(metadata, 'tags') else None
|
||||
result['language'] = metadata.language
|
||||
|
||||
if hasattr(metadata, 'to_dict'):
|
||||
result['metadata'] = metadata.to_dict()
|
||||
elif hasattr(metadata, '__dict__'):
|
||||
result['metadata'] = metadata.__dict__
|
||||
|
||||
content = extract(
|
||||
html,
|
||||
include_comments=False,
|
||||
include_tables=True,
|
||||
no_fallback=True
|
||||
)
|
||||
|
||||
if content:
|
||||
result['content_text'] = content
|
||||
result['content_html'] = html
|
||||
result['extraction_method'] = 'trafilatura'
|
||||
result['success'] = True
|
||||
elif not has_html_tags:
|
||||
result['content_text'] = html.strip()
|
||||
result['content_html'] = f'<html><body>{html}</body></html>'
|
||||
result['extraction_method'] = 'plain_text'
|
||||
result['success'] = True
|
||||
else:
|
||||
result['content_text'] = None
|
||||
result['content_html'] = html
|
||||
result['extraction_method'] = 'trafilatura_empty'
|
||||
result['success'] = False
|
||||
result['error'] = 'Content extraction failed - no article content found'
|
||||
|
||||
if result['success']:
|
||||
logger.debug("Content extracted using %s", result['extraction_method'])
|
||||
|
||||
except Exception as e:
|
||||
result['error'] = f"Trafilatura extraction failed: {str(e)}"
|
||||
logger.warning(result['error'])
|
||||
|
||||
if not has_html_tags:
|
||||
result['content_text'] = html.strip()
|
||||
result['content_html'] = f'<html><body>{html}</body></html>'
|
||||
result['extraction_method'] = 'plain_text_fallback'
|
||||
result['success'] = True
|
||||
elif BeautifulSoup:
|
||||
fallback_result = _extract_with_beautifulsoup(html)
|
||||
if fallback_result['content_text']:
|
||||
result.update(fallback_result)
|
||||
result['extraction_method'] = 'beautifulsoup_fallback'
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _extract_with_beautifulsoup(html: str) -> dict:
|
||||
"""Fallback extraction using BeautifulSoup."""
|
||||
result = {
|
||||
'content_text': None,
|
||||
'content_html': None,
|
||||
'title': None,
|
||||
'author': None,
|
||||
'date': None,
|
||||
'tags': None,
|
||||
'language': None,
|
||||
'metadata': None
|
||||
}
|
||||
|
||||
try:
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
|
||||
title = soup.find('title')
|
||||
if title:
|
||||
result['title'] = title.get_text(strip=True)
|
||||
|
||||
meta_author = soup.find('meta', attrs={'name': 'author'})
|
||||
if meta_author:
|
||||
result['author'] = meta_author.get('content', '').strip()
|
||||
|
||||
meta_date = soup.find('meta', attrs={'name': 'date'})
|
||||
if meta_date:
|
||||
result['date'] = meta_date.get('content', '').strip()
|
||||
|
||||
meta_language = soup.find('meta', attrs={'name': 'language'})
|
||||
if meta_language:
|
||||
result['language'] = meta_language.get('content', '').strip()
|
||||
|
||||
for tag in ['article', 'main', 'div']:
|
||||
content_tags = soup.find_all(tag)
|
||||
if content_tags:
|
||||
result['content_text'] = ' '.join(
|
||||
tag.get_text(strip=True, separator=' ')
|
||||
for tag in content_tags
|
||||
)
|
||||
if result['content_text']:
|
||||
break
|
||||
|
||||
if not result['content_text']:
|
||||
result['content_text'] = soup.get_text(strip=True, separator=' ')
|
||||
|
||||
result['content_text'] = result['content_text'][:100000]
|
||||
result['content_html'] = str(soup)
|
||||
|
||||
except Exception as e:
|
||||
result['error'] = f"BeautifulSoup extraction failed: {str(e)}"
|
||||
logger.warning(result['error'])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _extract_with_playwright(url: str) -> Optional[str]:
|
||||
"""Extract HTML from JavaScript-heavy site using Playwright."""
|
||||
if not PLAYWRIGHT_AVAILABLE:
|
||||
logger.warning("Playwright not available. Install with: pip install playwright")
|
||||
return None
|
||||
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page()
|
||||
|
||||
page.goto(url, wait_until='networkidle', timeout=60000)
|
||||
|
||||
page_content = page.content()
|
||||
|
||||
browser.close()
|
||||
|
||||
logger.debug("HTML extracted using Playwright")
|
||||
return page_content
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Playwright extraction failed: %s", str(e))
|
||||
return None
|
||||
|
||||
|
||||
def parse_article_from_html(html: str, url: str) -> ArticleData:
|
||||
"""Parse article and return structured data.
|
||||
|
||||
Args:
|
||||
html: Raw HTML string
|
||||
url: Original URL for reference
|
||||
|
||||
Returns:
|
||||
ArticleData with extracted content and metadata
|
||||
"""
|
||||
extraction_result = extract_content(html)
|
||||
|
||||
guid = extraction_result.get('metadata', {}).get('url', url) if extraction_result.get('metadata') else url
|
||||
article = ArticleData(
|
||||
url=url,
|
||||
title=extraction_result.get('title'),
|
||||
author=extraction_result.get('author'),
|
||||
publish_date=extraction_result.get('date'),
|
||||
content_text=extraction_result.get('content_text'),
|
||||
content_html=extraction_result.get('content_html'),
|
||||
raw_html=html,
|
||||
tags=extraction_result.get('tags'),
|
||||
language=extraction_result.get('language'),
|
||||
metadata=extraction_result.get('metadata'),
|
||||
extraction_method=extraction_result.get('extraction_method'),
|
||||
guid=guid
|
||||
)
|
||||
|
||||
if not extraction_result.get('success'):
|
||||
article.error = extraction_result.get('error')
|
||||
|
||||
return article
|
||||
|
||||
|
||||
def get_html_from_url(url: str, extract_content: bool = True) -> str:
|
||||
"""Download HTML from URL using Trafilatura with Playwright fallback.
|
||||
|
||||
Args:
|
||||
url: URL to fetch
|
||||
extract_content: If True, try to extract main content (kept for backwards compatibility)
|
||||
|
||||
Returns:
|
||||
Full HTML string or empty string on failure
|
||||
"""
|
||||
try:
|
||||
downloaded = fetch_url(url)
|
||||
|
||||
if downloaded:
|
||||
logger.debug("Full HTML fetched from %s", url[:60])
|
||||
return downloaded
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to fetch URL %s: %s", url, str(e))
|
||||
|
||||
if PLAYWRIGHT_AVAILABLE:
|
||||
logger.info("Trying Playwright fallback for %s", url[:60])
|
||||
playwright_html = _extract_with_playwright(url)
|
||||
if playwright_html:
|
||||
logger.debug("Full HTML from Playwright for %s", url[:60])
|
||||
if extract_content:
|
||||
extracted = extract(
|
||||
playwright_html,
|
||||
include_comments=False,
|
||||
include_tables=True,
|
||||
no_fallback=True
|
||||
)
|
||||
if extracted:
|
||||
logger.debug("Playwright content extracted from %s", url[:60])
|
||||
return extracted
|
||||
logger.debug("Full HTML from Playwright for %s", url[:60])
|
||||
return playwright_html
|
||||
|
||||
return ""
|
||||
37
docker-compose.nas.example.yml
Normal file
37
docker-compose.nas.example.yml
Normal file
@ -0,0 +1,37 @@
|
||||
# Example docker-compose configuration for NAS storage
|
||||
# Copy this file to docker-compose.yml and edit the volume path
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
newsarchiver:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: newsarchiver
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
- ARCHIVE_DIR=/data/archives
|
||||
volumes:
|
||||
# Example: Mount your NAS to /path/to/nas/archives
|
||||
# Replace with your actual NAS path
|
||||
- /path/to/nas/archives:/data/archives
|
||||
# Or use Docker named volume for local storage:
|
||||
# - newsarchiver_data:/data/archives
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5000/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
# Optional: Run as specific UID/GID for NAS permissions
|
||||
# user: "1000:1000"
|
||||
|
||||
volumes:
|
||||
newsarchiver_data:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: /path/to/nas/archives # Replace with your NAS path
|
||||
24
docker-compose.yml
Normal file
24
docker-compose.yml
Normal file
@ -0,0 +1,24 @@
|
||||
name: newsarchiver
|
||||
|
||||
services:
|
||||
newsarchiver:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: newsarchiver
|
||||
ports:
|
||||
- "5000:5000"
|
||||
environment:
|
||||
- ARCHIVE_DIR=/data/archives
|
||||
volumes:
|
||||
- newsarchiver_data:/data/archives
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5000/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
newsarchiver_data:
|
||||
external: true
|
||||
31
docker_backup.sh
Normal file
31
docker_backup.sh
Normal file
@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Docker backup script for NewsArchiver
|
||||
# This script backs up archived data from the Docker volume to a local or NAS location
|
||||
|
||||
set -e
|
||||
|
||||
BACKUP_DIR="${BACKUP_DIR:-./backups}"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_NAME="newsarchiver_backup_${TIMESTAMP}.tar.gz"
|
||||
|
||||
echo "Starting backup..."
|
||||
echo "Backup location: ${BACKUP_DIR}/${BACKUP_NAME}"
|
||||
|
||||
# Create backup directory
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
# Create backup from the Docker volume
|
||||
docker run --rm \
|
||||
-v newsarchiver_data:/data:ro \
|
||||
-v "${BACKUP_DIR}:/backup" \
|
||||
alpine tar -czf "/backup/${BACKUP_NAME}" -C /data .
|
||||
|
||||
echo "Backup complete: ${BACKUP_DIR}/${BACKUP_NAME}"
|
||||
echo ""
|
||||
echo "To restore from backup:"
|
||||
echo " 1. Stop the container: docker-compose down"
|
||||
echo " 2. Remove the volume: docker volume rm newsarchiver_data"
|
||||
echo " 3. Create a new volume: docker volume create newsarchiver_data"
|
||||
echo " 4. Restore: docker run --rm -v newsarchiver_data:/data -v \${BACKUP_DIR}:/backup alpine tar -xzf /backup/${BACKUP_NAME} -C /data"
|
||||
echo " 5. Start: docker-compose up -d"
|
||||
47
docker_setup.sh
Normal file
47
docker_setup.sh
Normal file
@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Docker setup script for NewsArchiver
|
||||
# This script helps configure the Docker environment
|
||||
|
||||
set -e
|
||||
|
||||
echo "NewsArchiver Docker Setup"
|
||||
echo "=========================="
|
||||
echo ""
|
||||
|
||||
# Check if Docker is installed
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo "ERROR: Docker is not installed. Please install Docker first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if docker-compose is available
|
||||
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
|
||||
echo "ERROR: docker-compose is not installed. Please install docker-compose first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Docker is installed."
|
||||
echo ""
|
||||
|
||||
# Check if the project has docker-compose.yml
|
||||
if [ ! -f docker-compose.yml ] && [ ! -f docker-compose.nas.example.yml ]; then
|
||||
echo "WARNING: docker-compose.yml not found."
|
||||
echo "Creating from example..."
|
||||
cp docker-compose.nas.example.yml docker-compose.yml
|
||||
echo ""
|
||||
echo "Please edit docker-compose.yml to set your NAS path:"
|
||||
echo " 1. Find the volume mount path (currently set to /path/to/nas/archives)"
|
||||
echo " 2. Replace with your actual NAS path"
|
||||
echo " 3. Save the file"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
echo "Setup complete!"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Edit docker-compose.yml with your NAS path"
|
||||
echo " 2. Build and start: docker-compose up -d --build"
|
||||
echo " 3. Check logs: docker-compose logs -f"
|
||||
echo " 4. Access web interface at http://localhost:5000"
|
||||
echo ""
|
||||
11
entrypoint.sh
Normal file
11
entrypoint.sh
Normal file
@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Use ARCHIVE_DIR from environment, default to /app/archival_data
|
||||
export ARCHIVE_DIR="${ARCHIVE_DIR:-/app/archival_data}"
|
||||
|
||||
# Create archive directory if it does not exist
|
||||
mkdir -p "$ARCHIVE_DIR"
|
||||
|
||||
# Run the command passed to docker
|
||||
exec "$@"
|
||||
251
nohup.out
Normal file
251
nohup.out
Normal file
@ -0,0 +1,251 @@
|
||||
2026-03-31 00:45:43,896 - INFO - NewsArchiver - Main CLI Entry Point
|
||||
2026-03-31 00:45:43,896 - INFO - ============================================================
|
||||
2026-03-31 00:45:43,896 - INFO - Starting web server
|
||||
2026-03-31 00:45:43,896 - INFO - ============================================================
|
||||
2026-03-31 00:45:43,984 - INFO - Web server starting on 0.0.0.0:8080
|
||||
2026-03-31 00:45:43,984 - INFO - ============================================================
|
||||
* Serving Flask app 'web_interface'
|
||||
* Debug mode: off
|
||||
2026-03-31 00:45:43,985 - INFO - [31m[1mWARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.[0m
|
||||
* Running on all addresses (0.0.0.0)
|
||||
* Running on http://127.0.0.1:8080
|
||||
* Running on http://192.168.8.150:8080
|
||||
2026-03-31 00:45:43,985 - INFO - [33mPress CTRL+C to quit[0m
|
||||
2026-03-31 00:46:57,096 - INFO - NewsArchiver - Main CLI Entry Point
|
||||
2026-03-31 00:46:57,096 - INFO - ============================================================
|
||||
2026-03-31 00:46:57,096 - INFO - Starting web server
|
||||
2026-03-31 00:46:57,096 - INFO - ============================================================
|
||||
2026-03-31 00:46:57,185 - INFO - Web server starting on 0.0.0.0:5000
|
||||
2026-03-31 00:46:57,185 - INFO - ============================================================
|
||||
* Serving Flask app 'web_interface'
|
||||
* Debug mode: off
|
||||
2026-03-31 00:46:57,186 - INFO - [31m[1mWARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.[0m
|
||||
* Running on all addresses (0.0.0.0)
|
||||
* Running on http://127.0.0.1:5000
|
||||
* Running on http://192.168.8.150:5000
|
||||
2026-03-31 00:46:57,186 - INFO - [33mPress CTRL+C to quit[0m
|
||||
2026-03-31 00:47:29,377 - INFO - 192.168.8.110 - - [31/Mar/2026 00:47:29] "GET / HTTP/1.1" 200 -
|
||||
2026-03-31 00:47:29,420 - INFO - 192.168.8.110 - - [31/Mar/2026 00:47:29] "GET /static/style.css HTTP/1.1" 200 -
|
||||
2026-03-31 00:47:29,476 - INFO - 192.168.8.110 - - [31/Mar/2026 00:47:29] "[33mGET /favicon.ico HTTP/1.1[0m" 404 -
|
||||
Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.
|
||||
opencode server listening on http://0.0.0.0:4096
|
||||
2026-03-31 03:50:30,668 - INFO - 192.168.8.156 - - [31/Mar/2026 03:50:30] "GET / HTTP/1.1" 200 -
|
||||
2026-03-31 03:50:30,701 - INFO - 192.168.8.156 - - [31/Mar/2026 03:50:30] "GET /static/style.css HTTP/1.1" 200 -
|
||||
2026-03-31 03:50:34,449 - INFO - 192.168.8.156 - - [31/Mar/2026 03:50:34] "GET /source/404%20Media HTTP/1.1" 200 -
|
||||
2026-03-31 03:50:34,449 - INFO - 192.168.8.156 - - [31/Mar/2026 03:50:34] "GET /source/404%20Media HTTP/1.1" 200 -
|
||||
2026-03-31 03:50:34,490 - INFO - 192.168.8.156 - - [31/Mar/2026 03:50:34] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 03:51:06,671 - INFO - 192.168.8.156 - - [31/Mar/2026 03:51:06] "GET /source/404%20media/article/75854 HTTP/1.1" 200 -
|
||||
2026-03-31 03:51:06,801 - INFO - 192.168.8.156 - - [31/Mar/2026 03:51:06] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 03:51:28,312 - INFO - 192.168.8.156 - - [31/Mar/2026 03:51:28] "GET /source/404%20Media HTTP/1.1" 200 -
|
||||
2026-03-31 03:51:30,665 - INFO - 192.168.8.156 - - [31/Mar/2026 03:51:30] "GET /source/404%20media/article/75856 HTTP/1.1" 200 -
|
||||
2026-03-31 03:51:30,686 - INFO - 192.168.8.156 - - [31/Mar/2026 03:51:30] "GET /source/404%20media/article/75856 HTTP/1.1" 200 -
|
||||
2026-03-31 03:51:30,777 - INFO - 192.168.8.156 - - [31/Mar/2026 03:51:30] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 03:51:52,473 - INFO - 192.168.8.156 - - [31/Mar/2026 03:51:52] "GET /source/404%20Media HTTP/1.1" 200 -
|
||||
2026-03-31 03:52:03,379 - INFO - 192.168.8.156 - - [31/Mar/2026 03:52:03] "GET / HTTP/1.1" 200 -
|
||||
2026-03-31 03:53:23,713 - INFO - 192.168.8.156 - - [31/Mar/2026 03:53:23] "GET / HTTP/1.1" 200 -
|
||||
2026-03-31 03:53:26,077 - INFO - 192.168.8.156 - - [31/Mar/2026 03:53:26] "GET /source/Ars%20Technica HTTP/1.1" 200 -
|
||||
2026-03-31 03:53:26,273 - INFO - 192.168.8.156 - - [31/Mar/2026 03:53:26] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 03:53:32,574 - INFO - 192.168.8.156 - - [31/Mar/2026 03:53:32] "GET /source/ars%20technica/article/76159 HTTP/1.1" 200 -
|
||||
2026-03-31 03:53:32,621 - INFO - 192.168.8.156 - - [31/Mar/2026 03:53:32] "GET /source/ars%20technica/article/76159 HTTP/1.1" 200 -
|
||||
2026-03-31 03:53:32,714 - INFO - 192.168.8.156 - - [31/Mar/2026 03:53:32] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 03:54:32,335 - INFO - 192.168.8.156 - - [31/Mar/2026 03:54:32] "GET /source/Ars%20Technica HTTP/1.1" 200 -
|
||||
2026-03-31 03:54:36,121 - INFO - 192.168.8.156 - - [31/Mar/2026 03:54:36] "GET /source/ars%20technica/article/76156 HTTP/1.1" 200 -
|
||||
2026-03-31 03:54:36,226 - INFO - 192.168.8.156 - - [31/Mar/2026 03:54:36] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 03:55:37,969 - INFO - 192.168.8.156 - - [31/Mar/2026 03:55:37] "GET /source/Ars%20Technica HTTP/1.1" 200 -
|
||||
2026-03-31 03:55:42,609 - INFO - 192.168.8.156 - - [31/Mar/2026 03:55:42] "GET /source/ars%20technica/article/76153 HTTP/1.1" 200 -
|
||||
2026-03-31 03:55:42,688 - INFO - 192.168.8.156 - - [31/Mar/2026 03:55:42] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 03:56:57,070 - INFO - 192.168.8.156 - - [31/Mar/2026 03:56:57] "GET /source/Ars%20Technica HTTP/1.1" 200 -
|
||||
2026-03-31 03:57:27,037 - INFO - 192.168.8.156 - - [31/Mar/2026 03:57:27] "GET / HTTP/1.1" 200 -
|
||||
2026-03-31 03:57:31,938 - INFO - 192.168.8.156 - - [31/Mar/2026 03:57:31] "GET /source/Associated%20Press HTTP/1.1" 200 -
|
||||
2026-03-31 03:57:31,977 - INFO - 192.168.8.156 - - [31/Mar/2026 03:57:31] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 03:57:46,792 - INFO - 192.168.8.156 - - [31/Mar/2026 03:57:46] "GET /source/associated%20press/article/76318 HTTP/1.1" 200 -
|
||||
2026-03-31 03:57:46,822 - INFO - 192.168.8.156 - - [31/Mar/2026 03:57:46] "GET /source/associated%20press/article/76318 HTTP/1.1" 200 -
|
||||
2026-03-31 03:57:46,918 - INFO - 192.168.8.156 - - [31/Mar/2026 03:57:46] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 03:58:43,823 - INFO - 192.168.8.156 - - [31/Mar/2026 03:58:43] "GET /source/Associated%20Press HTTP/1.1" 200 -
|
||||
2026-03-31 03:58:45,452 - INFO - 192.168.8.156 - - [31/Mar/2026 03:58:45] "GET / HTTP/1.1" 200 -
|
||||
2026-03-31 03:58:51,007 - INFO - 192.168.8.156 - - [31/Mar/2026 03:58:51] "GET /source/Associated%20Press HTTP/1.1" 200 -
|
||||
2026-03-31 03:58:51,035 - INFO - 192.168.8.156 - - [31/Mar/2026 03:58:51] "GET /source/Associated%20Press HTTP/1.1" 200 -
|
||||
2026-03-31 03:58:51,073 - INFO - 192.168.8.156 - - [31/Mar/2026 03:58:51] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 03:59:07,720 - INFO - 192.168.8.156 - - [31/Mar/2026 03:59:07] "GET /source/associated%20press/article/76180 HTTP/1.1" 200 -
|
||||
2026-03-31 03:59:07,791 - INFO - 192.168.8.156 - - [31/Mar/2026 03:59:07] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 04:02:00,289 - INFO - 192.168.8.156 - - [31/Mar/2026 04:02:00] "GET /source/Associated%20Press HTTP/1.1" 200 -
|
||||
2026-03-31 04:02:29,322 - INFO - 192.168.8.156 - - [31/Mar/2026 04:02:29] "GET /source/associated%20press/article/76381 HTTP/1.1" 200 -
|
||||
2026-03-31 04:02:29,366 - INFO - 192.168.8.156 - - [31/Mar/2026 04:02:29] "GET /source/associated%20press/article/76381 HTTP/1.1" 200 -
|
||||
2026-03-31 04:02:29,461 - INFO - 192.168.8.156 - - [31/Mar/2026 04:02:29] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 04:05:14,639 - INFO - 192.168.8.156 - - [31/Mar/2026 04:05:14] "GET /source/Associated%20Press HTTP/1.1" 200 -
|
||||
2026-03-31 04:07:09,728 - INFO - 192.168.8.156 - - [31/Mar/2026 04:07:09] "GET / HTTP/1.1" 200 -
|
||||
2026-03-31 04:07:13,436 - INFO - 192.168.8.156 - - [31/Mar/2026 04:07:13] "GET /source/BBC%20News%20–%20Business HTTP/1.1" 200 -
|
||||
2026-03-31 04:07:13,460 - INFO - 192.168.8.156 - - [31/Mar/2026 04:07:13] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 04:07:18,974 - INFO - 192.168.8.156 - - [31/Mar/2026 04:07:18] "GET /source/bbc%20news%20–%20business/article/75670 HTTP/1.1" 200 -
|
||||
2026-03-31 04:07:18,994 - INFO - 192.168.8.156 - - [31/Mar/2026 04:07:18] "GET /source/bbc%20news%20–%20business/article/75670 HTTP/1.1" 200 -
|
||||
2026-03-31 04:07:19,090 - INFO - 192.168.8.156 - - [31/Mar/2026 04:07:19] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 04:08:32,701 - INFO - 192.168.8.156 - - [31/Mar/2026 04:08:32] "GET /source/BBC%20News%20–%20Business HTTP/1.1" 200 -
|
||||
2026-03-31 04:08:38,686 - INFO - 192.168.8.156 - - [31/Mar/2026 04:08:38] "GET /source/bbc%20news%20–%20business/article/75660 HTTP/1.1" 200 -
|
||||
2026-03-31 04:08:38,758 - INFO - 192.168.8.156 - - [31/Mar/2026 04:08:38] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 04:10:39,733 - INFO - 192.168.8.156 - - [31/Mar/2026 04:10:39] "GET /source/BBC%20News%20–%20Business HTTP/1.1" 200 -
|
||||
2026-03-31 04:10:47,517 - INFO - 192.168.8.156 - - [31/Mar/2026 04:10:47] "GET /source/bbc%20news%20–%20business/article/75668 HTTP/1.1" 200 -
|
||||
2026-03-31 04:10:47,592 - INFO - 192.168.8.156 - - [31/Mar/2026 04:10:47] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 04:13:00,033 - INFO - 192.168.8.156 - - [31/Mar/2026 04:13:00] "GET /source/BBC%20News%20–%20Business HTTP/1.1" 200 -
|
||||
2026-03-31 04:13:04,210 - INFO - 192.168.8.156 - - [31/Mar/2026 04:13:04] "GET /source/bbc%20news%20–%20business/article/75664 HTTP/1.1" 200 -
|
||||
2026-03-31 04:13:04,305 - INFO - 192.168.8.156 - - [31/Mar/2026 04:13:04] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 04:14:34,463 - INFO - 192.168.8.156 - - [31/Mar/2026 04:14:34] "GET /source/BBC%20News%20–%20Business HTTP/1.1" 200 -
|
||||
2026-03-31 04:15:35,483 - INFO - 192.168.8.156 - - [31/Mar/2026 04:15:35] "GET / HTTP/1.1" 200 -
|
||||
2026-03-31 04:15:42,939 - INFO - 192.168.8.156 - - [31/Mar/2026 04:15:42] "GET /source/CNBC%20–%20Business HTTP/1.1" 200 -
|
||||
2026-03-31 04:15:42,999 - INFO - 192.168.8.156 - - [31/Mar/2026 04:15:42] "GET /source/CNBC%20–%20Business HTTP/1.1" 200 -
|
||||
2026-03-31 04:15:43,017 - INFO - 192.168.8.156 - - [31/Mar/2026 04:15:43] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 04:16:17,658 - INFO - 192.168.8.156 - - [31/Mar/2026 04:16:17] "GET /source/cnbc%20–%20business/article/75575 HTTP/1.1" 200 -
|
||||
2026-03-31 04:16:17,754 - INFO - 192.168.8.156 - - [31/Mar/2026 04:16:17] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 04:17:16,331 - INFO - 192.168.8.156 - - [31/Mar/2026 04:17:16] "GET /source/CNBC%20–%20Business HTTP/1.1" 200 -
|
||||
2026-03-31 04:17:34,558 - INFO - 192.168.8.156 - - [31/Mar/2026 04:17:34] "GET /source/cnbc%20–%20business/article/75583 HTTP/1.1" 200 -
|
||||
2026-03-31 04:17:34,633 - INFO - 192.168.8.156 - - [31/Mar/2026 04:17:34] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 04:19:53,532 - INFO - 192.168.8.156 - - [31/Mar/2026 04:19:53] "GET /source/CNBC%20–%20Business HTTP/1.1" 200 -
|
||||
2026-03-31 04:20:00,608 - INFO - 192.168.8.156 - - [31/Mar/2026 04:20:00] "GET / HTTP/1.1" 200 -
|
||||
2026-03-31 04:20:15,359 - INFO - 192.168.8.156 - - [31/Mar/2026 04:20:15] "GET /source/Engadget HTTP/1.1" 200 -
|
||||
2026-03-31 04:20:15,402 - INFO - 192.168.8.156 - - [31/Mar/2026 04:20:15] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 04:20:37,074 - INFO - 192.168.8.156 - - [31/Mar/2026 04:20:37] "GET /source/engadget/article/76118 HTTP/1.1" 200 -
|
||||
2026-03-31 04:20:37,105 - INFO - 192.168.8.156 - - [31/Mar/2026 04:20:37] "GET /source/engadget/article/76118 HTTP/1.1" 200 -
|
||||
2026-03-31 04:20:37,202 - INFO - 192.168.8.156 - - [31/Mar/2026 04:20:37] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 04:21:11,132 - INFO - 192.168.8.156 - - [31/Mar/2026 04:21:11] "GET /source/Engadget HTTP/1.1" 200 -
|
||||
2026-03-31 04:21:18,145 - INFO - 192.168.8.156 - - [31/Mar/2026 04:21:18] "GET /source/engadget/article/76116 HTTP/1.1" 200 -
|
||||
2026-03-31 04:21:18,216 - INFO - 192.168.8.156 - - [31/Mar/2026 04:21:18] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 04:22:17,699 - INFO - 192.168.8.156 - - [31/Mar/2026 04:22:17] "GET /source/Engadget HTTP/1.1" 200 -
|
||||
2026-03-31 04:22:38,033 - INFO - 192.168.8.156 - - [31/Mar/2026 04:22:38] "GET /source/engadget/article/76107 HTTP/1.1" 200 -
|
||||
2026-03-31 04:22:38,059 - INFO - 192.168.8.156 - - [31/Mar/2026 04:22:38] "GET /source/engadget/article/76107 HTTP/1.1" 200 -
|
||||
2026-03-31 04:22:38,151 - INFO - 192.168.8.156 - - [31/Mar/2026 04:22:38] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 04:23:11,496 - INFO - 192.168.8.156 - - [31/Mar/2026 04:23:11] "GET /source/Engadget HTTP/1.1" 200 -
|
||||
2026-03-31 04:24:04,059 - INFO - 192.168.8.156 - - [31/Mar/2026 04:24:04] "GET / HTTP/1.1" 200 -
|
||||
2026-03-31 04:24:20,270 - INFO - 192.168.8.156 - - [31/Mar/2026 04:24:20] "GET /source/Hacker%20News HTTP/1.1" 200 -
|
||||
2026-03-31 04:24:20,304 - INFO - 192.168.8.156 - - [31/Mar/2026 04:24:20] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 04:24:20,394 - INFO - 192.168.8.156 - - [31/Mar/2026 04:24:20] "GET /source/Hacker%20News HTTP/1.1" 200 -
|
||||
2026-03-31 04:24:55,634 - INFO - 192.168.8.156 - - [31/Mar/2026 04:24:55] "GET /source/hacker%20news/article/76044 HTTP/1.1" 200 -
|
||||
2026-03-31 04:24:55,731 - INFO - 192.168.8.156 - - [31/Mar/2026 04:24:55] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 04:25:39,898 - INFO - 192.168.8.156 - - [31/Mar/2026 04:25:39] "GET /source/Hacker%20News HTTP/1.1" 200 -
|
||||
2026-03-31 04:25:57,410 - INFO - 192.168.8.156 - - [31/Mar/2026 04:25:57] "GET /source/hacker%20news/article/75265 HTTP/1.1" 200 -
|
||||
2026-03-31 04:25:57,443 - INFO - 192.168.8.156 - - [31/Mar/2026 04:25:57] "GET /source/hacker%20news/article/75265 HTTP/1.1" 200 -
|
||||
2026-03-31 04:25:57,537 - INFO - 192.168.8.156 - - [31/Mar/2026 04:25:57] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 04:28:52,915 - INFO - 192.168.8.156 - - [31/Mar/2026 04:28:52] "GET /source/Hacker%20News HTTP/1.1" 200 -
|
||||
2026-03-31 04:29:54,036 - INFO - 192.168.8.156 - - [31/Mar/2026 04:29:54] "GET /source/hacker%20news/article/75237 HTTP/1.1" 200 -
|
||||
2026-03-31 04:29:54,091 - INFO - 192.168.8.156 - - [31/Mar/2026 04:29:54] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 04:31:33,988 - INFO - 192.168.8.156 - - [31/Mar/2026 04:31:33] "GET /source/Hacker%20News HTTP/1.1" 200 -
|
||||
2026-03-31 04:32:13,621 - INFO - 192.168.8.156 - - [31/Mar/2026 04:32:13] "GET /source/hacker%20news/article/75972 HTTP/1.1" 200 -
|
||||
2026-03-31 04:32:13,653 - INFO - 192.168.8.156 - - [31/Mar/2026 04:32:13] "GET /source/hacker%20news/article/75972 HTTP/1.1" 200 -
|
||||
2026-03-31 04:32:13,754 - INFO - 192.168.8.156 - - [31/Mar/2026 04:32:13] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 04:39:50,815 - INFO - 192.168.8.156 - - [31/Mar/2026 04:39:50] "GET /source/Hacker%20News HTTP/1.1" 200 -
|
||||
2026-03-31 04:41:06,097 - INFO - 192.168.8.156 - - [31/Mar/2026 04:41:06] "GET /source/hacker%20news/article/75255 HTTP/1.1" 200 -
|
||||
2026-03-31 04:41:06,130 - INFO - 192.168.8.156 - - [31/Mar/2026 04:41:06] "GET /source/hacker%20news/article/75255 HTTP/1.1" 200 -
|
||||
2026-03-31 04:41:06,224 - INFO - 192.168.8.156 - - [31/Mar/2026 04:41:06] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 04:42:02,455 - INFO - 192.168.8.156 - - [31/Mar/2026 04:42:02] "GET /source/Hacker%20News HTTP/1.1" 200 -
|
||||
2026-03-31 04:47:45,853 - INFO - 192.168.8.226 - - [31/Mar/2026 04:47:45] "GET / HTTP/1.1" 200 -
|
||||
2026-03-31 04:47:45,949 - INFO - 192.168.8.226 - - [31/Mar/2026 04:47:45] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 04:47:46,000 - INFO - 192.168.8.226 - - [31/Mar/2026 04:47:46] "[33mGET /favicon.ico HTTP/1.1[0m" 404 -
|
||||
2026-03-31 05:53:55,185 - INFO - 192.168.8.226 - - [31/Mar/2026 05:53:55] "GET /rss HTTP/1.1" 200 -
|
||||
2026-03-31 07:54:24,903 - INFO - 192.168.8.226 - - [31/Mar/2026 07:54:24] "GET /rss HTTP/1.1" 200 -
|
||||
2026-03-31 11:25:27,600 - INFO - 192.168.8.156 - - [31/Mar/2026 11:25:27] "GET /source/hacker%20news/article/74862 HTTP/1.1" 200 -
|
||||
2026-03-31 11:25:27,641 - INFO - 192.168.8.156 - - [31/Mar/2026 11:25:27] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 11:27:05,669 - INFO - 192.168.8.156 - - [31/Mar/2026 11:27:05] "GET /source/Hacker%20News HTTP/1.1" 200 -
|
||||
2026-03-31 11:29:15,410 - INFO - 192.168.8.156 - - [31/Mar/2026 11:29:15] "GET /source/hacker%20news/article/76601 HTTP/1.1" 200 -
|
||||
2026-03-31 11:29:15,439 - INFO - 192.168.8.156 - - [31/Mar/2026 11:29:15] "GET /source/hacker%20news/article/76601 HTTP/1.1" 200 -
|
||||
2026-03-31 11:29:15,537 - INFO - 192.168.8.156 - - [31/Mar/2026 11:29:15] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 11:30:37,869 - INFO - 192.168.8.156 - - [31/Mar/2026 11:30:37] "GET /source/Hacker%20News HTTP/1.1" 200 -
|
||||
2026-03-31 11:30:46,321 - INFO - 192.168.8.156 - - [31/Mar/2026 11:30:46] "GET /source/hacker%20news/article/76602 HTTP/1.1" 200 -
|
||||
2026-03-31 11:30:46,418 - INFO - 192.168.8.156 - - [31/Mar/2026 11:30:46] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 11:36:40,535 - INFO - 192.168.8.156 - - [31/Mar/2026 11:36:40] "GET / HTTP/1.1" 200 -
|
||||
2026-03-31 11:36:40,569 - INFO - 192.168.8.156 - - [31/Mar/2026 11:36:40] "GET /static/style.css HTTP/1.1" 200 -
|
||||
2026-03-31 11:36:44,152 - INFO - 192.168.8.156 - - [31/Mar/2026 11:36:44] "GET /source/404%20Media HTTP/1.1" 200 -
|
||||
2026-03-31 11:36:44,167 - INFO - 192.168.8.156 - - [31/Mar/2026 11:36:44] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 11:36:49,524 - INFO - 192.168.8.156 - - [31/Mar/2026 11:36:49] "GET / HTTP/1.1" 200 -
|
||||
2026-03-31 11:36:51,785 - INFO - 192.168.8.156 - - [31/Mar/2026 11:36:51] "GET /source/Ars%20Technica HTTP/1.1" 200 -
|
||||
2026-03-31 11:36:51,803 - INFO - 192.168.8.156 - - [31/Mar/2026 11:36:51] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 11:37:01,762 - INFO - 192.168.8.156 - - [31/Mar/2026 11:37:01] "GET / HTTP/1.1" 200 -
|
||||
2026-03-31 11:37:07,388 - INFO - 192.168.8.156 - - [31/Mar/2026 11:37:07] "GET /source/Associated%20Press HTTP/1.1" 200 -
|
||||
2026-03-31 11:37:07,413 - INFO - 192.168.8.156 - - [31/Mar/2026 11:37:07] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 11:37:07,652 - INFO - 192.168.8.156 - - [31/Mar/2026 11:37:07] "GET /source/associated%20press/article/76682 HTTP/1.1" 200 -
|
||||
2026-03-31 11:37:07,720 - INFO - 192.168.8.156 - - [31/Mar/2026 11:37:07] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 11:37:16,777 - INFO - 192.168.8.156 - - [31/Mar/2026 11:37:16] "GET /source/Associated%20Press HTTP/1.1" 200 -
|
||||
2026-03-31 11:38:13,418 - INFO - 192.168.8.156 - - [31/Mar/2026 11:38:13] "GET /source/associated%20press/article/76643 HTTP/1.1" 200 -
|
||||
2026-03-31 11:38:13,453 - INFO - 192.168.8.156 - - [31/Mar/2026 11:38:13] "GET /source/associated%20press/article/76643 HTTP/1.1" 200 -
|
||||
2026-03-31 11:38:13,537 - INFO - 192.168.8.156 - - [31/Mar/2026 11:38:13] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 11:39:22,876 - INFO - 192.168.8.156 - - [31/Mar/2026 11:39:22] "GET /source/Associated%20Press HTTP/1.1" 200 -
|
||||
2026-03-31 11:39:47,385 - INFO - 192.168.8.156 - - [31/Mar/2026 11:39:47] "GET /source/associated%20press/article/76607 HTTP/1.1" 200 -
|
||||
2026-03-31 11:39:47,419 - INFO - 192.168.8.156 - - [31/Mar/2026 11:39:47] "GET /source/associated%20press/article/76607 HTTP/1.1" 200 -
|
||||
2026-03-31 11:39:47,501 - INFO - 192.168.8.156 - - [31/Mar/2026 11:39:47] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 11:41:43,881 - INFO - 192.168.8.156 - - [31/Mar/2026 11:41:43] "GET /source/Associated%20Press HTTP/1.1" 200 -
|
||||
2026-03-31 11:42:06,103 - INFO - 192.168.8.156 - - [31/Mar/2026 11:42:06] "GET /source/associated%20press/article/76563 HTTP/1.1" 200 -
|
||||
2026-03-31 11:42:06,136 - INFO - 192.168.8.156 - - [31/Mar/2026 11:42:06] "GET /source/associated%20press/article/76563 HTTP/1.1" 200 -
|
||||
2026-03-31 11:42:06,218 - INFO - 192.168.8.156 - - [31/Mar/2026 11:42:06] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 11:45:00,824 - INFO - 192.168.8.156 - - [31/Mar/2026 11:45:00] "GET /source/Associated%20Press HTTP/1.1" 200 -
|
||||
2026-03-31 11:45:09,188 - INFO - 192.168.8.156 - - [31/Mar/2026 11:45:09] "GET /source/associated%20press/article/76605 HTTP/1.1" 200 -
|
||||
2026-03-31 11:45:09,223 - INFO - 192.168.8.156 - - [31/Mar/2026 11:45:09] "GET /source/associated%20press/article/76605 HTTP/1.1" 200 -
|
||||
2026-03-31 11:45:09,305 - INFO - 192.168.8.156 - - [31/Mar/2026 11:45:09] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 11:48:11,110 - INFO - 192.168.8.156 - - [31/Mar/2026 11:48:11] "GET /source/Associated%20Press HTTP/1.1" 200 -
|
||||
2026-03-31 11:48:12,152 - INFO - 192.168.8.156 - - [31/Mar/2026 11:48:12] "GET / HTTP/1.1" 200 -
|
||||
2026-03-31 11:48:16,690 - INFO - 192.168.8.156 - - [31/Mar/2026 11:48:16] "GET /source/BBC%20News%20–%20Business HTTP/1.1" 200 -
|
||||
2026-03-31 11:48:16,713 - INFO - 192.168.8.156 - - [31/Mar/2026 11:48:16] "GET /source/BBC%20News%20–%20Business HTTP/1.1" 200 -
|
||||
2026-03-31 11:48:16,724 - INFO - 192.168.8.156 - - [31/Mar/2026 11:48:16] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 11:48:26,509 - INFO - 192.168.8.156 - - [31/Mar/2026 11:48:26] "GET /source/bbc%20news%20–%20business/article/76588 HTTP/1.1" 200 -
|
||||
2026-03-31 11:48:26,592 - INFO - 192.168.8.156 - - [31/Mar/2026 11:48:26] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 11:50:33,771 - INFO - 192.168.8.156 - - [31/Mar/2026 11:50:33] "GET /source/BBC%20News%20–%20Business HTTP/1.1" 200 -
|
||||
2026-03-31 11:51:20,160 - INFO - 192.168.8.156 - - [31/Mar/2026 11:51:20] "GET / HTTP/1.1" 200 -
|
||||
2026-03-31 11:51:54,395 - INFO - 192.168.8.156 - - [31/Mar/2026 11:51:54] "GET /source/Mac%20Rumors HTTP/1.1" 200 -
|
||||
2026-03-31 11:51:54,422 - INFO - 192.168.8.156 - - [31/Mar/2026 11:51:54] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 11:51:59,100 - INFO - 192.168.8.156 - - [31/Mar/2026 11:51:59] "GET /source/mac%20rumors/article/75894 HTTP/1.1" 200 -
|
||||
2026-03-31 11:51:59,196 - INFO - 192.168.8.156 - - [31/Mar/2026 11:51:59] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 11:52:42,557 - INFO - 192.168.8.156 - - [31/Mar/2026 11:52:42] "GET /source/Mac%20Rumors HTTP/1.1" 200 -
|
||||
2026-03-31 11:52:48,061 - INFO - 192.168.8.156 - - [31/Mar/2026 11:52:48] "GET /source/mac%20rumors/article/75890 HTTP/1.1" 200 -
|
||||
2026-03-31 11:52:48,089 - INFO - 192.168.8.156 - - [31/Mar/2026 11:52:48] "GET /source/mac%20rumors/article/75890 HTTP/1.1" 200 -
|
||||
2026-03-31 11:52:48,176 - INFO - 192.168.8.156 - - [31/Mar/2026 11:52:48] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 11:53:19,210 - INFO - 192.168.8.156 - - [31/Mar/2026 11:53:19] "GET /source/Mac%20Rumors HTTP/1.1" 200 -
|
||||
2026-03-31 11:53:43,451 - INFO - 192.168.8.156 - - [31/Mar/2026 11:53:43] "GET /source/mac%20rumors/article/75886 HTTP/1.1" 200 -
|
||||
2026-03-31 11:53:43,487 - INFO - 192.168.8.156 - - [31/Mar/2026 11:53:43] "GET /source/mac%20rumors/article/75886 HTTP/1.1" 200 -
|
||||
2026-03-31 11:53:43,570 - INFO - 192.168.8.156 - - [31/Mar/2026 11:53:43] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 11:54:20,607 - INFO - 192.168.8.156 - - [31/Mar/2026 11:54:20] "GET /source/Mac%20Rumors HTTP/1.1" 200 -
|
||||
2026-03-31 11:55:28,281 - INFO - 192.168.8.156 - - [31/Mar/2026 11:55:28] "GET /source/mac%20rumors/article/75896 HTTP/1.1" 200 -
|
||||
2026-03-31 11:55:28,311 - INFO - 192.168.8.156 - - [31/Mar/2026 11:55:28] "GET /source/mac%20rumors/article/75896 HTTP/1.1" 200 -
|
||||
2026-03-31 11:55:28,396 - INFO - 192.168.8.156 - - [31/Mar/2026 11:55:28] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 11:55:52,802 - INFO - 192.168.8.156 - - [31/Mar/2026 11:55:52] "GET /source/Mac%20Rumors HTTP/1.1" 200 -
|
||||
2026-03-31 11:56:08,058 - INFO - 192.168.8.156 - - [31/Mar/2026 11:56:08] "GET / HTTP/1.1" 200 -
|
||||
2026-03-31 11:56:31,521 - INFO - 192.168.8.156 - - [31/Mar/2026 11:56:31] "GET /source/ProPublica HTTP/1.1" 200 -
|
||||
2026-03-31 11:56:31,524 - INFO - 192.168.8.156 - - [31/Mar/2026 11:56:31] "GET /source/ProPublica HTTP/1.1" 200 -
|
||||
2026-03-31 11:56:31,543 - INFO - 192.168.8.156 - - [31/Mar/2026 11:56:31] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 11:56:44,566 - INFO - 192.168.8.156 - - [31/Mar/2026 11:56:44] "GET /source/propublica/article/76487 HTTP/1.1" 200 -
|
||||
2026-03-31 11:56:44,655 - INFO - 192.168.8.156 - - [31/Mar/2026 11:56:44] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 12:07:00,226 - INFO - 192.168.8.156 - - [31/Mar/2026 12:07:00] "GET /source/ProPublica HTTP/1.1" 200 -
|
||||
2026-03-31 12:08:20,255 - INFO - 192.168.8.156 - - [31/Mar/2026 12:08:20] "GET /source/propublica/article/61757 HTTP/1.1" 200 -
|
||||
2026-03-31 12:08:20,285 - INFO - 192.168.8.156 - - [31/Mar/2026 12:08:20] "GET /source/propublica/article/61757 HTTP/1.1" 200 -
|
||||
2026-03-31 12:08:20,371 - INFO - 192.168.8.156 - - [31/Mar/2026 12:08:20] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 12:16:03,370 - INFO - 192.168.8.226 - - [31/Mar/2026 12:16:03] "GET / HTTP/1.1" 200 -
|
||||
2026-03-31 12:16:03,475 - INFO - 192.168.8.226 - - [31/Mar/2026 12:16:03] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 13:05:25,475 - INFO - 192.168.8.226 - - [31/Mar/2026 13:05:25] "GET /rss HTTP/1.1" 200 -
|
||||
2026-03-31 14:38:42,845 - INFO - 192.168.8.110 - - [31/Mar/2026 14:38:42] "GET /source/404%20Media HTTP/1.1" 200 -
|
||||
2026-03-31 14:38:42,872 - INFO - 192.168.8.110 - - [31/Mar/2026 14:38:42] "GET /static/style.css HTTP/1.1" 200 -
|
||||
2026-03-31 14:38:42,883 - INFO - 192.168.8.110 - - [31/Mar/2026 14:38:42] "[33mGET /favicon.ico HTTP/1.1[0m" 404 -
|
||||
2026-03-31 14:38:44,029 - INFO - 192.168.8.110 - - [31/Mar/2026 14:38:44] "GET /source/404%20media/article/76744 HTTP/1.1" 200 -
|
||||
2026-03-31 14:38:44,043 - INFO - 192.168.8.110 - - [31/Mar/2026 14:38:44] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 14:40:43,351 - INFO - 192.168.8.110 - - [31/Mar/2026 14:40:43] "[32mGET /archive-file//home/user/playground/NewsArchiver/archival_data/websites/404%20Media/html/2026-03-31/article_17749630811.html HTTP/1.1[0m" 308 -
|
||||
2026-03-31 14:40:43,358 - INFO - Archive file path: /home/user/playground/NewsArchiver/archival_data/home/user/playground/NewsArchiver/archival_data/websites/404 Media/html/2026-03-31/article_17749630811.html, exists: False
|
||||
2026-03-31 14:40:43,358 - INFO - 192.168.8.110 - - [31/Mar/2026 14:40:43] "[33mGET /archive-file/home/user/playground/NewsArchiver/archival_data/websites/404%20Media/html/2026-03-31/article_17749630811.html HTTP/1.1[0m" 404 -
|
||||
2026-03-31 14:40:43,374 - INFO - 192.168.8.110 - - [31/Mar/2026 14:40:43] "[33mGET /favicon.ico HTTP/1.1[0m" 404 -
|
||||
2026-03-31 14:51:00,206 - INFO - 192.168.8.110 - - [31/Mar/2026 14:51:00] "GET /source/404%20media/article/76744 HTTP/1.1" 200 -
|
||||
2026-03-31 14:51:00,220 - INFO - 192.168.8.110 - - [31/Mar/2026 14:51:00] "GET /static/style.css HTTP/1.1" 200 -
|
||||
2026-03-31 14:51:00,225 - INFO - 192.168.8.110 - - [31/Mar/2026 14:51:00] "[33mGET /favicon.ico HTTP/1.1[0m" 404 -
|
||||
2026-03-31 14:51:02,123 - INFO - 192.168.8.110 - - [31/Mar/2026 14:51:02] "[32mGET /archive-file//home/user/playground/NewsArchiver/archival_data/websites/404%20Media/html/2026-03-31/article_17749630811.html HTTP/1.1[0m" 308 -
|
||||
2026-03-31 14:51:02,127 - INFO - Archive file path: /home/user/playground/NewsArchiver/archival_data/home/user/playground/NewsArchiver/archival_data/websites/404 Media/html/2026-03-31/article_17749630811.html, exists: False
|
||||
2026-03-31 14:51:02,127 - INFO - 192.168.8.110 - - [31/Mar/2026 14:51:02] "[33mGET /archive-file/home/user/playground/NewsArchiver/archival_data/websites/404%20Media/html/2026-03-31/article_17749630811.html HTTP/1.1[0m" 404 -
|
||||
2026-03-31 14:51:02,142 - INFO - 192.168.8.110 - - [31/Mar/2026 14:51:02] "[33mGET /favicon.ico HTTP/1.1[0m" 404 -
|
||||
2026-03-31 15:14:49,505 - INFO - 192.168.8.110 - - [31/Mar/2026 15:14:49] "GET /source/404%20media/article/76744 HTTP/1.1" 200 -
|
||||
2026-03-31 15:14:49,519 - INFO - 192.168.8.110 - - [31/Mar/2026 15:14:49] "GET /static/style.css HTTP/1.1" 200 -
|
||||
2026-03-31 15:14:49,526 - INFO - 192.168.8.110 - - [31/Mar/2026 15:14:49] "[33mGET /favicon.ico HTTP/1.1[0m" 404 -
|
||||
2026-03-31 15:15:08,057 - INFO - 192.168.8.110 - - [31/Mar/2026 15:15:08] "GET / HTTP/1.1" 200 -
|
||||
2026-03-31 15:15:08,076 - INFO - 192.168.8.110 - - [31/Mar/2026 15:15:08] "GET /static/style.css HTTP/1.1" 200 -
|
||||
2026-03-31 15:15:08,080 - INFO - 192.168.8.110 - - [31/Mar/2026 15:15:08] "[33mGET /favicon.ico HTTP/1.1[0m" 404 -
|
||||
2026-03-31 15:15:10,181 - INFO - 192.168.8.110 - - [31/Mar/2026 15:15:10] "GET /source/404%20Media HTTP/1.1" 200 -
|
||||
2026-03-31 15:15:10,197 - INFO - 192.168.8.110 - - [31/Mar/2026 15:15:10] "GET /static/style.css HTTP/1.1" 200 -
|
||||
2026-03-31 15:15:10,207 - INFO - 192.168.8.110 - - [31/Mar/2026 15:15:10] "[33mGET /favicon.ico HTTP/1.1[0m" 404 -
|
||||
2026-03-31 15:15:11,421 - INFO - 192.168.8.110 - - [31/Mar/2026 15:15:11] "GET /source/404%20media/article/76744 HTTP/1.1" 200 -
|
||||
2026-03-31 15:15:11,436 - INFO - 192.168.8.110 - - [31/Mar/2026 15:15:11] "[36mGET /static/style.css HTTP/1.1[0m" 304 -
|
||||
2026-03-31 15:15:13,782 - INFO - 192.168.8.110 - - [31/Mar/2026 15:15:13] "[32mGET /archive-file//home/user/playground/NewsArchiver/archival_data/websites/404%20Media/html/2026-03-31/article_17749630811.html HTTP/1.1[0m" 308 -
|
||||
2026-03-31 15:15:13,786 - INFO - Archive file path: /home/user/playground/NewsArchiver/archival_data/home/user/playground/NewsArchiver/archival_data/websites/404 Media/html/2026-03-31/article_17749630811.html, exists: False
|
||||
2026-03-31 15:15:13,786 - INFO - 192.168.8.110 - - [31/Mar/2026 15:15:13] "[33mGET /archive-file/home/user/playground/NewsArchiver/archival_data/websites/404%20Media/html/2026-03-31/article_17749630811.html HTTP/1.1[0m" 404 -
|
||||
2026-03-31 15:15:21,503 - INFO - 192.168.8.110 - - [31/Mar/2026 15:15:21] "GET / HTTP/1.1" 200 -
|
||||
2026-03-31 15:15:21,524 - INFO - 192.168.8.110 - - [31/Mar/2026 15:15:21] "GET /static/style.css HTTP/1.1" 200 -
|
||||
2026-03-31 15:15:21,530 - INFO - 192.168.8.110 - - [31/Mar/2026 15:15:21] "[33mGET /favicon.ico HTTP/1.1[0m" 404 -
|
||||
247
rebuild_database.py
Normal file
247
rebuild_database.py
Normal file
@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rebuild NewsArchiver database from existing HTML files."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
from content_extractor import parse_article_from_html, get_html_from_url
|
||||
from storage_manager import initialize_storage, save_article as storage_save_article
|
||||
except ImportError as e:
|
||||
print(f"ERROR: Required module not found: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
ARCHIVE_DIR = SCRIPT_DIR / 'archival_data'
|
||||
RSS_FEEDS_PATH = SCRIPT_DIR / 'rss_feeds.json'
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def extract_urls_from_html(html_file: Path) -> list:
|
||||
"""Extract article URLs from an archived HTML file.
|
||||
|
||||
Args:
|
||||
html_file: Path to archived HTML file
|
||||
|
||||
Returns:
|
||||
List of article URLs found in the file
|
||||
"""
|
||||
try:
|
||||
content = html_file.read_text(encoding='utf-8')
|
||||
urls = []
|
||||
|
||||
# Only extract main article URLs - skip tracking links, RSS feeds, author pages, etc.
|
||||
article_patterns = [
|
||||
r'href=["\']https?://[^"\']+\/article\/[^"\']+["\']',
|
||||
r'href=["\']https?://[^"\']+\/news\/[^"\']+["\']',
|
||||
r'href=["\']https?://[^"\']+\/stories\/[^"\']+["\']',
|
||||
r'href=["\']https?://[^"\']+\/archive\/[^"\']+["\']',
|
||||
]
|
||||
|
||||
for pattern in article_patterns:
|
||||
matches = re.findall(pattern, content)
|
||||
for match in matches:
|
||||
url_match = re.search(r'href=["\']([^"\']+)["\']', match)
|
||||
if url_match:
|
||||
url = url_match.group(1)
|
||||
# Skip common non-article URLs
|
||||
skip_patterns = [
|
||||
'rss', 'feed', 'author', 'authors', 'tags', 'category', 'search',
|
||||
'about', 'contact', 'privacy', 'terms', 'faq', 'subscribe',
|
||||
'signin', 'login', 'register', 'account', 'profile'
|
||||
]
|
||||
if not any(skip in url.lower() for skip in skip_patterns):
|
||||
urls.append(url)
|
||||
|
||||
return list(set(urls)) # Remove duplicates
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error reading %s: %s", html_file, str(e))
|
||||
return []
|
||||
|
||||
|
||||
def find_html_files(archive_dir: Path) -> list:
|
||||
"""Find all HTML files in the archive directory.
|
||||
|
||||
Args:
|
||||
archive_dir: Root archive directory
|
||||
|
||||
Returns:
|
||||
List of HTML file paths
|
||||
"""
|
||||
html_files = []
|
||||
|
||||
try:
|
||||
websites_dir = archive_dir / 'websites'
|
||||
if not websites_dir.exists():
|
||||
logger.warning("Websites directory not found: %s", websites_dir)
|
||||
return []
|
||||
|
||||
for source_dir in sorted(websites_dir.iterdir()):
|
||||
if not source_dir.is_dir():
|
||||
continue
|
||||
|
||||
html_dir = source_dir / 'html'
|
||||
if not html_dir.exists():
|
||||
continue
|
||||
|
||||
for date_dir in sorted(html_dir.iterdir()):
|
||||
if not date_dir.is_dir():
|
||||
continue
|
||||
|
||||
for html_file in sorted(date_dir.glob('article_*.html')):
|
||||
html_files.append(html_file)
|
||||
|
||||
logger.info("Found %d HTML files to process", len(html_files))
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error finding HTML files: %s", str(e))
|
||||
|
||||
return html_files
|
||||
|
||||
|
||||
def rebuild_database(archive_dir: Path, rss_feeds_path: Path) -> dict:
|
||||
"""Rebuild the database from existing HTML files.
|
||||
|
||||
Args:
|
||||
archive_dir: Root archive directory
|
||||
rss_feeds_path: Path to RSS feeds JSON file
|
||||
|
||||
Returns:
|
||||
Dictionary with rebuild results
|
||||
"""
|
||||
results = {
|
||||
'html_files_processed': 0,
|
||||
'articles_extracted': 0,
|
||||
'articles_saved': 0,
|
||||
'articles_failed': 0,
|
||||
'errors': []
|
||||
}
|
||||
|
||||
# Initialize database
|
||||
logger.info("Initializing database at: %s", ARCHIVE_DIR / 'cache.db')
|
||||
initialize_storage()
|
||||
logger.info("Database initialized")
|
||||
|
||||
# Load RSS feeds
|
||||
rss_feeds = {}
|
||||
if rss_feeds_path.exists():
|
||||
with open(rss_feeds_path, 'r', encoding='utf-8') as f:
|
||||
rss_feeds = json.load(f)
|
||||
|
||||
# Find all HTML files
|
||||
html_files = find_html_files(archive_dir)
|
||||
|
||||
if not html_files:
|
||||
logger.warning("No HTML files found to process")
|
||||
return results
|
||||
|
||||
# Process each HTML file
|
||||
for html_file in html_files:
|
||||
try:
|
||||
results['html_files_processed'] += 1
|
||||
|
||||
# Extract URLs from HTML
|
||||
urls = extract_urls_from_html(html_file)
|
||||
|
||||
if not urls:
|
||||
logger.debug("No URLs found in %s", html_file.name)
|
||||
continue
|
||||
|
||||
results['articles_extracted'] += len(urls)
|
||||
|
||||
# Process each URL
|
||||
for url in urls:
|
||||
try:
|
||||
# Try to get source name from RSS feeds
|
||||
source_name = None
|
||||
for feed_name, feed_info in rss_feeds.items():
|
||||
if feed_info.get('rss_url') and url.startswith(feed_info.get('rss_url', '')):
|
||||
source_name = feed_name
|
||||
break
|
||||
|
||||
# Try to infer source from URL
|
||||
if not source_name:
|
||||
for feed_name, feed_info in rss_feeds.items():
|
||||
website = feed_info.get('source_website', '')
|
||||
if website and website in url:
|
||||
source_name = feed_name
|
||||
break
|
||||
|
||||
if not source_name:
|
||||
# Try to extract domain from URL
|
||||
domain_match = re.search(r'https?://([^/]+)', url)
|
||||
if domain_match:
|
||||
domain = domain_match.group(1)
|
||||
for feed_name, feed_info in rss_feeds.items():
|
||||
website = feed_info.get('source_website', '')
|
||||
if website and website in domain:
|
||||
source_name = feed_name
|
||||
break
|
||||
|
||||
# If still no source, skip
|
||||
if not source_name:
|
||||
logger.debug("No source found for %s, skipping", url[:60])
|
||||
continue
|
||||
|
||||
# Extract content from HTML file
|
||||
raw_html = html_file.read_text(encoding='utf-8')
|
||||
article_data = parse_article_from_html(raw_html, url)
|
||||
|
||||
if article_data.error:
|
||||
logger.warning("Failed to extract content from %s: %s", url[:60], article_data.error)
|
||||
results['articles_failed'] += 1
|
||||
continue
|
||||
|
||||
# Save to database
|
||||
logger.info("Saving article: %s -> %s", url[:80], source_name)
|
||||
storage_save_article(source_name, article_data)
|
||||
results['articles_saved'] += 1
|
||||
|
||||
if results['articles_saved'] % 100 == 0:
|
||||
logger.info("Saved %d articles so far", results['articles_saved'])
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error processing URL %s: %s", url, str(e))
|
||||
results['articles_failed'] += 1
|
||||
results['errors'].append({
|
||||
'url': url,
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error processing file %s: %s", html_file, str(e))
|
||||
results['errors'].append({
|
||||
'file': str(html_file),
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
logger.info("=" * 60)
|
||||
logger.info("Rebuilding NewsArchiver Database")
|
||||
logger.info("=" * 60)
|
||||
|
||||
results = rebuild_database(ARCHIVE_DIR, RSS_FEEDS_PATH)
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("Rebuild Complete")
|
||||
logger.info("=" * 60)
|
||||
logger.info("HTML files processed: %d", results['html_files_processed'])
|
||||
logger.info("Articles extracted: %d", results['articles_extracted'])
|
||||
logger.info("Articles saved: %d", results['articles_saved'])
|
||||
logger.info("Articles failed: %d", results['articles_failed'])
|
||||
|
||||
if results['errors']:
|
||||
logger.info("Errors:")
|
||||
for error in results['errors'][:20]:
|
||||
logger.info(" - %s", error)
|
||||
2428
rebuild_log.txt
Normal file
2428
rebuild_log.txt
Normal file
File diff suppressed because it is too large
Load Diff
4548
rebuild_log2.txt
Normal file
4548
rebuild_log2.txt
Normal file
File diff suppressed because it is too large
Load Diff
7
requirements.txt
Normal file
7
requirements.txt
Normal file
@ -0,0 +1,7 @@
|
||||
flask>=3.0,<4.0
|
||||
requests>=2.31,<3.0
|
||||
trafilatura>=1.6,<2.0
|
||||
feedparser>=6.0,<7.0
|
||||
apscheduler>=3.10,<4.0
|
||||
beautifulsoup4>=4.12,<5.0
|
||||
playwright>=1.40,<2.0
|
||||
104
restore_database.py
Normal file
104
restore_database.py
Normal file
@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Restore database from existing JSON metadata files."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from storage_manager import initialize_storage, save_article
|
||||
from content_extractor import ArticleData
|
||||
except ImportError as e:
|
||||
print(f"ERROR: Required module not found: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
ARCHIVE_DIR = SCRIPT_DIR / 'archival_data'
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def restore_database(archive_dir: Path) -> dict:
|
||||
"""Restore database from JSON metadata files."""
|
||||
results = {
|
||||
'json_files_found': 0,
|
||||
'articles_restored': 0,
|
||||
'articles_failed': 0,
|
||||
'errors': []
|
||||
}
|
||||
|
||||
initialize_storage()
|
||||
logger.info("Database initialized")
|
||||
|
||||
json_files = list(archive_dir.glob('websites/**/*.json'))
|
||||
results['json_files_found'] = len(json_files)
|
||||
|
||||
logger.info(f"Found {len(json_files)} JSON files to process")
|
||||
|
||||
for json_file in json_files:
|
||||
try:
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
metadata = json.load(f)
|
||||
|
||||
url = metadata.get('url')
|
||||
source_name = metadata.get('source_name')
|
||||
title = metadata.get('title')
|
||||
author = metadata.get('author')
|
||||
publish_date = metadata.get('publish_date')
|
||||
content_text = metadata.get('content_text')
|
||||
content_html = metadata.get('content_html')
|
||||
tags = metadata.get('tags', [])
|
||||
extraction_method = metadata.get('extraction_method', 'unknown')
|
||||
|
||||
if not url or not source_name:
|
||||
logger.warning(f"Missing URL or source in {json_file.name}, skipping")
|
||||
results['articles_failed'] += 1
|
||||
continue
|
||||
|
||||
article_data = ArticleData(
|
||||
url=url,
|
||||
title=title,
|
||||
author=author,
|
||||
publish_date=publish_date,
|
||||
content_text=content_text,
|
||||
content_html=content_html,
|
||||
tags=tags,
|
||||
extraction_method=extraction_method
|
||||
)
|
||||
|
||||
save_article(source_name, article_data)
|
||||
results['articles_restored'] += 1
|
||||
|
||||
if results['articles_restored'] % 100 == 0:
|
||||
logger.info(f"Restored {results['articles_restored']} articles so far")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing {json_file.name}: {str(e)}")
|
||||
results['articles_failed'] += 1
|
||||
results['errors'].append({
|
||||
'file': str(json_file),
|
||||
'error': str(e)
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
logger.info("=" * 60)
|
||||
logger.info("Restoring NewsArchiver Database from JSON files")
|
||||
logger.info("=" * 60)
|
||||
|
||||
results = restore_database(ARCHIVE_DIR)
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("Restore Complete")
|
||||
logger.info("=" * 60)
|
||||
logger.info(f"JSON files found: {results['json_files_found']}")
|
||||
logger.info(f"Articles restored: {results['articles_restored']}")
|
||||
logger.info(f"Articles failed: {results['articles_failed']}")
|
||||
|
||||
if results['errors']:
|
||||
logger.info("Errors:")
|
||||
for error in results['errors'][:20]:
|
||||
logger.info(f" - {error}")
|
||||
306
rss_feeds.json
Normal file
306
rss_feeds.json
Normal file
@ -0,0 +1,306 @@
|
||||
{
|
||||
"Financial Times": {
|
||||
"source_website": "ft.com",
|
||||
"rss_url": "https://www.ft.com/rss/home",
|
||||
"entries": 12,
|
||||
"validated_at": "2026-03-18T20:21:46.292164"
|
||||
},
|
||||
"Reuters – Business News": {
|
||||
"source_website": "reuters.com",
|
||||
"rss_url": "https://news.google.com/rss/search?q=site:reuters.com+business&hl=en-US&gl=US&ceid=US:en",
|
||||
"disabled": true,
|
||||
"disable_reason": "Google News RSS only provides encrypted URLs that don't work when accessed directly. Reuters does not provide public RSS feeds.",
|
||||
"entries": 100,
|
||||
"validated_at": "2026-03-19T15:48:00.000000"
|
||||
},
|
||||
"Fortune – Top Stories": {
|
||||
"source_website": "fortune.com",
|
||||
"rss_url": "https://fortune.com/feed/fortune-feeds/?id=3230629",
|
||||
"entries": 10,
|
||||
"validated_at": "2026-03-18T20:21:46.869680"
|
||||
},
|
||||
"Seeking Alpha – Market News": {
|
||||
"source_website": "seekingalpha.com",
|
||||
"rss_url": "https://seekingalpha.com/feed.xml",
|
||||
"entries": 30,
|
||||
"validated_at": "2026-03-18T20:21:47.462673"
|
||||
},
|
||||
"The Motley Fool – Stock News & Analysis": {
|
||||
"source_website": "fool.com",
|
||||
"rss_url": "https://www.fool.com/a/feeds/partner/googlechromefollow?apikey=${MOTLEY_FOOL_API_KEY}",
|
||||
"entries": 50,
|
||||
"validated_at": "2026-03-18T20:21:48.479725"
|
||||
},
|
||||
"TheStreet – Full Articles": {
|
||||
"source_website": "thestreet.com",
|
||||
"rss_url": "https://www.thestreet.com/.rss/full",
|
||||
"entries": 50,
|
||||
"validated_at": "2026-03-18T20:21:50.429528"
|
||||
},
|
||||
"MarketBeat – Market News": {
|
||||
"source_website": "marketbeat.com",
|
||||
"rss_url": "https://www.marketbeat.com/feed/",
|
||||
"entries": 100,
|
||||
"validated_at": "2026-03-18T20:22:06.522327"
|
||||
},
|
||||
"Money (Time) – Personal Finance": {
|
||||
"source_website": "money.com",
|
||||
"rss_url": "https://money.com/money/feed/",
|
||||
"entries": 10,
|
||||
"validated_at": "2026-03-18T20:22:07.133884"
|
||||
},
|
||||
"Global Finance Magazine": {
|
||||
"source_website": "gfmag.com",
|
||||
"rss_url": "https://www.gfmag.com/feed",
|
||||
"entries": 10,
|
||||
"validated_at": "2026-03-18T20:22:08.230178"
|
||||
},
|
||||
"Financial Samurai": {
|
||||
"source_website": "financialsamurai.com",
|
||||
"rss_url": "https://www.financialsamurai.com/feed/",
|
||||
"entries": 7,
|
||||
"validated_at": "2026-03-18T20:22:09.173814"
|
||||
},
|
||||
"MoneyWeek": {
|
||||
"source_website": "moneyweek.com",
|
||||
"rss_url": "https://moneyweek.com/feed/all",
|
||||
"entries": 50,
|
||||
"validated_at": "2026-03-18T20:22:10.001295"
|
||||
},
|
||||
"Finance Monthly": {
|
||||
"source_website": "finance-monthly.com",
|
||||
"rss_url": "https://www.finance-monthly.com/feed/",
|
||||
"entries": 45,
|
||||
"validated_at": "2026-03-18T20:22:11.526262"
|
||||
},
|
||||
"European Financial Review": {
|
||||
"source_website": "europeanfinancialreview.com",
|
||||
"rss_url": "https://www.europeanfinancialreview.com/feed",
|
||||
"entries": 10,
|
||||
"validated_at": "2026-03-18T20:22:13.344671"
|
||||
},
|
||||
"World Finance": {
|
||||
"source_website": "worldfinance.com",
|
||||
"rss_url": "https://www.worldfinance.com/feed",
|
||||
"entries": 10,
|
||||
"validated_at": "2026-03-18T20:22:16.414742"
|
||||
},
|
||||
"Fox Business – Headlines": {
|
||||
"source_website": "foxbusiness.com",
|
||||
"rss_url": "https://moxie.foxbusiness.com/google-publisher/latest.xml",
|
||||
"entries": 25,
|
||||
"validated_at": "2026-03-18T20:22:22.238519"
|
||||
},
|
||||
"FinanceAsia": {
|
||||
"source_website": "financeasia.com",
|
||||
"rss_url": "https://www.financeasia.com/rss/latest",
|
||||
"entries": 10,
|
||||
"validated_at": "2026-03-18T20:22:23.798531"
|
||||
},
|
||||
"CNBC – Business": {
|
||||
"source_website": "cnbc.com",
|
||||
"rss_url": "https://www.cnbc.com/id/100003114/device/rss/rss.html",
|
||||
"entries": 30,
|
||||
"validated_at": "2026-03-18T20:22:24.453119"
|
||||
},
|
||||
|
||||
"Markets Insider": {
|
||||
"source_website": "markets.businessinsider.com",
|
||||
"rss_url": "https://markets.businessinsider.com/rss/news",
|
||||
"entries": 10,
|
||||
"validated_at": "2026-03-18T20:22:29.272544"
|
||||
},
|
||||
"The Economist – Business & Finance": {
|
||||
"source_website": "economist.com",
|
||||
"rss_url": "https://www.economist.com/business/rss.xml",
|
||||
"entries": 300,
|
||||
"validated_at": "2026-03-18T20:22:30.110716"
|
||||
},
|
||||
"Barchart News": {
|
||||
"source_website": "barchart.com",
|
||||
"rss_url": "https://feeds.feedburner.com/BarchartNews",
|
||||
"entries": 15,
|
||||
"validated_at": "2026-03-18T20:22:30.831390"
|
||||
},
|
||||
"The Guardian – Business": {
|
||||
"source_website": "theguardian.com",
|
||||
"rss_url": "https://feeds.theguardian.com/theguardian/uk/business/rss",
|
||||
"entries": 40,
|
||||
"validated_at": "2026-03-18T20:22:32.033297"
|
||||
},
|
||||
"Economy Watch": {
|
||||
"source_website": "economywatch.com",
|
||||
"rss_url": "https://www.economywatch.com/feed",
|
||||
"entries": 10,
|
||||
"validated_at": "2026-03-18T20:22:33.008150"
|
||||
},
|
||||
"CFI.co": {
|
||||
"source_website": "cfi.co",
|
||||
"rss_url": "https://cfi.co/feed",
|
||||
"entries": 20,
|
||||
"validated_at": "2026-03-18T20:22:35.620736"
|
||||
},
|
||||
"BBC News – Business": {
|
||||
"source_website": "bbc.co.uk",
|
||||
"rss_url": "https://feeds.bbci.co.uk/news/business/rss.xml",
|
||||
"entries": 56,
|
||||
"validated_at": "2026-03-18T20:22:36.643323"
|
||||
},
|
||||
"Investor’s Business Daily": {
|
||||
"source_website": "investors.com",
|
||||
"rss_url": "https://www.investors.com/feed/",
|
||||
"entries": 100,
|
||||
"validated_at": "2026-03-18T20:22:38.264669"
|
||||
},
|
||||
"MarketWatch – Top Stories": {
|
||||
"source_website": "marketwatch.com",
|
||||
"rss_url": "https://feeds.marketwatch.com/marketwatch/topstories/",
|
||||
"entries": 10,
|
||||
"validated_at": "2026-03-18T20:22:45.591752"
|
||||
},
|
||||
"Wall Street Journal – U.S. Business": {
|
||||
"source_website": "wsj.com",
|
||||
"rss_url": "https://feeds.a.dj.com/rss/WSJcomUSBusiness.xml",
|
||||
"entries": 20,
|
||||
"validated_at": "2026-03-18T20:22:46.259913"
|
||||
},
|
||||
"Investing.com – News": {
|
||||
"source_website": "investing.com",
|
||||
"rss_url": "https://www.investing.com/rss/news.rss",
|
||||
"entries": 10,
|
||||
"validated_at": "2026-03-18T20:22:50.790503"
|
||||
},
|
||||
"International Business Times": {
|
||||
"source_website": "ibtimes.com",
|
||||
"rss_url": "https://www.ibtimes.com/rss",
|
||||
"entries": 25,
|
||||
"validated_at": "2026-03-18T20:23:13.449646"
|
||||
},
|
||||
"404 Media": {
|
||||
"source_website": "404media.co",
|
||||
"rss_url": "https://404media.co/feed/",
|
||||
"entries": 30,
|
||||
"validated_at": "2026-03-20T00:00:00.000000"
|
||||
},
|
||||
"Mac Rumors": {
|
||||
"source_website": "macrumors.com",
|
||||
"rss_url": "https://feeds.macrumors.com/MacRumors-All",
|
||||
"entries": 50,
|
||||
"validated_at": "2026-03-20T00:00:00.000000"
|
||||
},
|
||||
"The Verge": {
|
||||
"source_website": "theverge.com",
|
||||
"rss_url": "https://www.theverge.com/rss/index.xml",
|
||||
"entries": 50,
|
||||
"validated_at": "2026-03-20T00:00:00.000000"
|
||||
},
|
||||
"TechCrunch": {
|
||||
"source_website": "techcrunch.com",
|
||||
"rss_url": "https://techcrunch.com/feed/",
|
||||
"entries": 50,
|
||||
"validated_at": "2026-03-20T00:00:00.000000"
|
||||
},
|
||||
"WIRED": {
|
||||
"source_website": "wired.com",
|
||||
"rss_url": "https://www.wired.com/feed/rss",
|
||||
"entries": 50,
|
||||
"validated_at": "2026-03-20T00:00:00.000000"
|
||||
},
|
||||
"Hacker News": {
|
||||
"source_website": "news.ycombinator.com",
|
||||
"rss_url": "https://news.ycombinator.com/rss",
|
||||
"entries": 30,
|
||||
"validated_at": "2026-03-20T00:00:00.000000"
|
||||
},
|
||||
"ZDNet": {
|
||||
"source_website": "zdnet.com",
|
||||
"rss_url": "https://www.zdnet.com/news/rss.xml",
|
||||
"entries": 50,
|
||||
"validated_at": "2026-03-20T00:00:00.000000"
|
||||
},
|
||||
"Engadget": {
|
||||
"source_website": "engadget.com",
|
||||
"rss_url": "https://www.engadget.com/rss.xml",
|
||||
"entries": 50,
|
||||
"validated_at": "2026-03-20T00:00:00.000000"
|
||||
},
|
||||
"Ars Technica": {
|
||||
"source_website": "arstechnica.com",
|
||||
"rss_url": "https://arstechnica.com/feed/",
|
||||
"entries": 50,
|
||||
"validated_at": "2026-03-21T00:00:00.000000"
|
||||
},
|
||||
"Associated Press": {
|
||||
"source_website": "apnews.com",
|
||||
"rss_url": "https://apnews.com",
|
||||
"feed_type": "html",
|
||||
"entries": 100,
|
||||
"validated_at": "2026-03-20T00:00:00.000000"
|
||||
},
|
||||
"The Hacker News": {
|
||||
"source_website": "thehackernews.com",
|
||||
"rss_url": "https://thehackernews.com/feeds/posts/default",
|
||||
"entries": 50,
|
||||
"validated_at": "2026-03-21T13:17:00+00:00"
|
||||
},
|
||||
"Dark Reading": {
|
||||
"source_website": "darkreading.com",
|
||||
"rss_url": "https://www.darkreading.com/rss.xml",
|
||||
"entries": 50,
|
||||
"validated_at": "2026-03-20T19:30:19+00:00"
|
||||
},
|
||||
"SecurityWeek": {
|
||||
"source_website": "securityweek.com",
|
||||
"rss_url": "https://www.securityweek.com/feed",
|
||||
"entries": 10,
|
||||
"validated_at": "2026-03-21T11:00:00+00:00"
|
||||
},
|
||||
"BleepingComputer": {
|
||||
"source_website": "bleepingcomputer.com",
|
||||
"rss_url": "https://www.bleepingcomputer.com/feed",
|
||||
"entries": 15,
|
||||
"validated_at": "2026-03-21T17:30:41+00:00"
|
||||
},
|
||||
"Microsoft Security Blog": {
|
||||
"source_website": "microsoft.com",
|
||||
"rss_url": "https://www.microsoft.com/security/blog/feed",
|
||||
"entries": 10,
|
||||
"validated_at": "2026-03-20T16:19:00+00:00"
|
||||
},
|
||||
"EFF Deeplinks": {
|
||||
"source_website": "eff.org",
|
||||
"rss_url": "https://www.eff.org/deeplinks.xml",
|
||||
"entries": 50,
|
||||
"validated_at": "2026-03-20T22:20:49+00:00"
|
||||
},
|
||||
"US-CISA": {
|
||||
"source_website": "cisa.gov",
|
||||
"rss_url": "https://www.cisa.gov/news.xml",
|
||||
"entries": 10,
|
||||
"validated_at": "2026-02-26T12:00:00+00:00"
|
||||
},
|
||||
"Google Security Blog": {
|
||||
"source_website": "google.com",
|
||||
"rss_url": "https://security.googleblog.com/feeds/posts/default",
|
||||
"entries": 25,
|
||||
"validated_at": "2026-02-27T17:01:00+00:00"
|
||||
},
|
||||
"Politico": {
|
||||
"source_website": "politico.com",
|
||||
"rss_url": "https://www.politico.com/rss/politicopicks.xml",
|
||||
"entries": 50,
|
||||
"validated_at": "2026-03-22T14:58:00+00:00"
|
||||
},
|
||||
"Cyber Security News": {
|
||||
"source_website": "cybersecuritynews.com",
|
||||
"rss_url": "https://cybersecuritynews.com/feed/",
|
||||
"entries": 50,
|
||||
"validated_at": "2026-03-23T18:47:58+00:00"
|
||||
},
|
||||
"ProPublica": {
|
||||
"source_website": "propublica.org",
|
||||
"rss_url": "https://www.propublica.org/feeds/propublica/main",
|
||||
"entries": 30,
|
||||
"validated_at": "2026-03-24T00:00:00.000000"
|
||||
}
|
||||
}
|
||||
455
rss_processor.py
Normal file
455
rss_processor.py
Normal file
@ -0,0 +1,455 @@
|
||||
#!/usr/bin/env python3
|
||||
"""RSS Feed Processor for NewsArchiver - Phase 2.1"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
try:
|
||||
import feedparser
|
||||
from feedparser import FeedParserDict
|
||||
except ImportError:
|
||||
print("ERROR: feedparser is required. Install with: pip install feedparser")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
import sqlite3
|
||||
except ImportError:
|
||||
print("ERROR: sqlite3 is required (should be built-in)")
|
||||
sys.exit(1)
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
ARCHIVE_DIR = SCRIPT_DIR / 'archival_data'
|
||||
ARCHIVE_DIR.mkdir(exist_ok=True)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def init_db(db_path: Path = ARCHIVE_DIR / 'cache.db') -> None:
|
||||
"""Initialize SQLite database for caching processed articles."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS articles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_name TEXT NOT NULL,
|
||||
article_url TEXT NOT NULL UNIQUE,
|
||||
article_guid TEXT,
|
||||
title TEXT,
|
||||
author TEXT,
|
||||
publish_date TEXT,
|
||||
content_text TEXT,
|
||||
content_html TEXT,
|
||||
archive_file_path TEXT,
|
||||
metadata_file_path TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS processing_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_name TEXT,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
action TEXT,
|
||||
status TEXT,
|
||||
message TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_source_url
|
||||
ON articles(source_name, article_url)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_articles_status
|
||||
ON articles(status)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.debug("Database initialized: %s", db_path)
|
||||
|
||||
|
||||
def get_db_connection(db_path: Path = ARCHIVE_DIR / 'cache.db'):
|
||||
"""Get database connection."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def is_duplicate(article_url: str, source_name: str, db_path: Path = ARCHIVE_DIR / 'cache.db') -> bool:
|
||||
"""Check if article already in cache."""
|
||||
conn = get_db_connection(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
'SELECT 1 FROM articles WHERE source_name = ? AND article_url = ?',
|
||||
(source_name, article_url)
|
||||
)
|
||||
result = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
return result is not None
|
||||
|
||||
|
||||
def add_to_cache(
|
||||
article_url: str,
|
||||
source_name: str,
|
||||
timestamp: datetime,
|
||||
article_guid: str = None,
|
||||
title: str = None,
|
||||
author: str = None,
|
||||
publish_date: str = None,
|
||||
db_path: Path = ARCHIVE_DIR / 'cache.db'
|
||||
) -> bool:
|
||||
"""Add article to cache."""
|
||||
conn = get_db_connection(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute('''
|
||||
INSERT OR IGNORE INTO articles
|
||||
(source_name, article_url, article_guid, title, author, publish_date, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
source_name,
|
||||
article_url,
|
||||
article_guid,
|
||||
title,
|
||||
author,
|
||||
publish_date,
|
||||
'pending'
|
||||
))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return True
|
||||
except sqlite3.IntegrityError:
|
||||
conn.close()
|
||||
return False
|
||||
|
||||
|
||||
def save_article(
|
||||
source_name: str,
|
||||
article_data: dict,
|
||||
db_path: Path = ARCHIVE_DIR / 'cache.db'
|
||||
) -> str:
|
||||
"""Save article to storage and update cache."""
|
||||
conn = get_db_connection(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
article_url = article_data.get('link', '')
|
||||
article_guid = article_data.get('id', article_url)
|
||||
title = article_data.get('title', '')
|
||||
author = article_data.get('author', '')
|
||||
publish_date = article_data.get('published', '')
|
||||
summary = article_data.get('summary', '')
|
||||
|
||||
cursor.execute('''
|
||||
INSERT OR REPLACE INTO articles
|
||||
(source_name, article_url, article_guid, title, author, publish_date, content_text, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (
|
||||
source_name,
|
||||
article_url,
|
||||
article_guid,
|
||||
title,
|
||||
author,
|
||||
publish_date,
|
||||
summary,
|
||||
'archived'
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return article_url
|
||||
|
||||
|
||||
def fetch_rss_feed(rss_url: str, timeout: int = 30) -> FeedParserDict:
|
||||
"""Fetch and parse RSS feed."""
|
||||
logger.info("Fetching RSS feed: %s", rss_url[:50] + "..." if len(rss_url) > 50 else rss_url)
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
rss_url,
|
||||
headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'}
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||
feed_content = response.read()
|
||||
|
||||
feed = feedparser.parse(feed_content)
|
||||
|
||||
if feed.bozo:
|
||||
logger.warning("Feed parsing completed with warnings: %s", feed.bozo)
|
||||
|
||||
entry_count = len(feed.entries)
|
||||
logger.info("Feed parsed: %d entries", entry_count)
|
||||
|
||||
return feed
|
||||
except urllib.error.HTTPError as e:
|
||||
logger.error("HTTP error fetching RSS feed %s: %s", rss_url, str(e.code))
|
||||
raise
|
||||
except urllib.error.URLError as e:
|
||||
logger.error("URL error fetching RSS feed %s: %s", rss_url, str(e.reason))
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to fetch RSS feed %s: %s", rss_url, str(e))
|
||||
raise
|
||||
|
||||
|
||||
def decode_google_news_url(google_url: str, entry: dict = None) -> Optional[str]:
|
||||
"""Decode Google News encrypted URL to actual article URL.
|
||||
|
||||
Google News RSS uses encrypted URLs like:
|
||||
https://news.google.com/rss/articles/CBMioAFB... which need to be decoded.
|
||||
|
||||
Args:
|
||||
google_url: Google News encrypted URL
|
||||
entry: Full RSS entry for additional context
|
||||
|
||||
Returns:
|
||||
Decoded article URL or None if not a Google News URL
|
||||
"""
|
||||
if 'news.google.com' not in google_url:
|
||||
return google_url
|
||||
|
||||
try:
|
||||
import base64
|
||||
import urllib.parse
|
||||
|
||||
if '/rss/articles/' in google_url:
|
||||
parts = google_url.split('/rss/articles/')
|
||||
if len(parts) >= 2:
|
||||
encoded = parts[1]
|
||||
if encoded.startswith('CBM'):
|
||||
encoded = encoded[3:]
|
||||
padding = (4 - len(encoded) % 4) % 4
|
||||
encoded += '=' * padding
|
||||
try:
|
||||
decoded = base64.urlsafe_b64decode(encoded).decode('utf-8')
|
||||
logger.debug("Decoded Google News URL: %s -> %s", google_url[:60], decoded[:60])
|
||||
return decoded
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if '/articles/' in google_url:
|
||||
parts = google_url.split('/articles/')
|
||||
if len(parts) >= 2:
|
||||
encoded = parts[1]
|
||||
if encoded.startswith('CBM'):
|
||||
encoded = encoded[3:]
|
||||
padding = (4 - len(encoded) % 4) % 4
|
||||
encoded += '=' * padding
|
||||
try:
|
||||
decoded = base64.urlsafe_b64decode(encoded).decode('utf-8')
|
||||
logger.debug("Decoded Google News URL: %s -> %s", google_url[:60], decoded[:60])
|
||||
return decoded
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if 'url=' in google_url:
|
||||
parsed = urllib.parse.urlparse(google_url)
|
||||
params = urllib.parse.parse_qs(parsed.query)
|
||||
if 'url' in params:
|
||||
return params['url'][0]
|
||||
|
||||
logger.debug("Could not decode Google News URL: %s", google_url[:60])
|
||||
|
||||
if entry and 'source' in entry:
|
||||
source = entry.get('source', {})
|
||||
if isinstance(source, dict) and 'href' in source:
|
||||
source_href = source['href']
|
||||
logger.debug("Using source URL as fallback: %s", source_href)
|
||||
return source_href
|
||||
|
||||
return google_url
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("Failed to decode Google News URL %s: %s", google_url[:60], str(e))
|
||||
return google_url
|
||||
|
||||
|
||||
def process_rss_feed(
|
||||
rss_url: str,
|
||||
source_name: str,
|
||||
output_dir: Path,
|
||||
db_path: Path = ARCHIVE_DIR / 'cache.db'
|
||||
) -> List[dict]:
|
||||
"""Process RSS feed and archive new articles."""
|
||||
logger.info("Processing RSS feed for source: %s", source_name)
|
||||
|
||||
try:
|
||||
feed = fetch_rss_feed(rss_url)
|
||||
except Exception as e:
|
||||
logger.error("Failed to fetch feed: %s", str(e))
|
||||
return []
|
||||
|
||||
new_articles = []
|
||||
skipped_count = 0
|
||||
|
||||
for entry in feed.entries:
|
||||
article_url = entry.get('link', '')
|
||||
article_url = decode_google_news_url(article_url, entry)
|
||||
article_guid = entry.get('id', article_url)
|
||||
|
||||
if not article_url:
|
||||
logger.warning("Skipping entry without URL")
|
||||
continue
|
||||
|
||||
if is_duplicate(article_url, source_name, db_path):
|
||||
logger.debug("Skipping duplicate: %s", article_url[:60])
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
title = entry.get('title', 'No Title')
|
||||
author = entry.get('author', entry.get('authors', [{}])[0].get('name', '') if entry.get('authors') else '')
|
||||
published = entry.get('published', entry.get('published_parsed', ''))
|
||||
summary = entry.get('summary', entry.get('description', ''))
|
||||
|
||||
if published:
|
||||
if hasattr(published, 'tm_year'):
|
||||
publish_date = datetime(*published[:6]).isoformat()
|
||||
else:
|
||||
publish_date = published
|
||||
else:
|
||||
publish_date = datetime.now().isoformat()
|
||||
|
||||
article_data = {
|
||||
'source_name': source_name,
|
||||
'url': article_url,
|
||||
'guid': article_guid,
|
||||
'title': title,
|
||||
'author': author,
|
||||
'publish_date': publish_date,
|
||||
'summary': summary,
|
||||
'entry': entry
|
||||
}
|
||||
|
||||
add_to_cache(
|
||||
article_url=article_url,
|
||||
source_name=source_name,
|
||||
timestamp=datetime.now(),
|
||||
article_guid=article_guid,
|
||||
title=title,
|
||||
author=author,
|
||||
publish_date=publish_date,
|
||||
db_path=db_path
|
||||
)
|
||||
|
||||
new_articles.append(article_data)
|
||||
logger.debug("New article: %s", title[:60])
|
||||
|
||||
logger.info("Processed %s: %d new, %d skipped", source_name, len(new_articles), skipped_count)
|
||||
return new_articles
|
||||
|
||||
|
||||
def process_all_feeds(
|
||||
rss_feeds_path: Path = SCRIPT_DIR / 'rss_feeds.json',
|
||||
output_dir: Path = ARCHIVE_DIR,
|
||||
db_path: Path = ARCHIVE_DIR / 'cache.db'
|
||||
) -> dict:
|
||||
"""Process all RSS feeds from rss_feeds.json."""
|
||||
if not rss_feeds_path.exists():
|
||||
logger.error("RSS feeds file not found: %s", rss_feeds_path)
|
||||
return {'success': False, 'error': 'File not found'}
|
||||
|
||||
with open(rss_feeds_path, 'r', encoding='utf-8') as f:
|
||||
rss_feeds = json.load(f)
|
||||
|
||||
results = {
|
||||
'total': 0,
|
||||
'success': 0,
|
||||
'failed': 0,
|
||||
'new_articles': 0,
|
||||
'errors': []
|
||||
}
|
||||
|
||||
for source_name, feed_info in rss_feeds.items():
|
||||
rss_url = feed_info.get('rss_url', '')
|
||||
|
||||
if not rss_url:
|
||||
logger.warning("No RSS URL for source: %s", source_name)
|
||||
continue
|
||||
|
||||
if feed_info.get('disabled', False):
|
||||
logger.info("Skipping disabled source: %s (%s)", source_name,
|
||||
feed_info.get('disable_reason', 'No reason provided'))
|
||||
continue
|
||||
|
||||
results['total'] += 1
|
||||
|
||||
try:
|
||||
articles = process_rss_feed(rss_url, source_name, output_dir, db_path)
|
||||
results['success'] += 1
|
||||
results['new_articles'] += len(articles)
|
||||
logger.info("Completed %s: %d new articles", source_name, len(articles))
|
||||
except Exception as e:
|
||||
results['failed'] += 1
|
||||
results['errors'].append({
|
||||
'source': source_name,
|
||||
'url': rss_url,
|
||||
'error': str(e)
|
||||
})
|
||||
logger.error("Failed to process %s: %s", source_name, str(e))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='RSS Feed Processor for NewsArchiver')
|
||||
parser.add_argument('--url', help='Single RSS URL to process')
|
||||
parser.add_argument('--source', help='Source name (required if --url provided)')
|
||||
parser.add_argument('--all', action='store_true', help='Process all feeds from rss_feeds.json')
|
||||
parser.add_argument('--rss-feeds', type=Path, default=SCRIPT_DIR / 'rss_feeds.json',
|
||||
help='Path to RSS feeds JSON file')
|
||||
parser.add_argument('--output', type=Path, default=ARCHIVE_DIR,
|
||||
help='Output directory for archived content')
|
||||
parser.add_argument('--verbose', action='store_true', help='Enable verbose logging')
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.verbose:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("RSS Feed Processor - Phase 2.1")
|
||||
logger.info("=" * 60)
|
||||
|
||||
init_db()
|
||||
|
||||
if args.url:
|
||||
if not args.source:
|
||||
logger.error("Source name required when using --url")
|
||||
return
|
||||
process_rss_feed(args.url, args.source, args.output)
|
||||
elif args.all:
|
||||
results = process_all_feeds(args.rss_feeds, args.output)
|
||||
print("\n" + "=" * 60)
|
||||
print("PROCESSING COMPLETE")
|
||||
print("=" * 60)
|
||||
print(f"Total feeds: {results['total']}")
|
||||
print(f"Successful: {results['success']}")
|
||||
print(f"Failed: {results['failed']}")
|
||||
print(f"New articles: {results['new_articles']}")
|
||||
if results['errors']:
|
||||
print("\nErrors:")
|
||||
for error in results['errors']:
|
||||
print(f" - {error['source']}: {error['error']}")
|
||||
print("=" * 60)
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
320
run_archiver.py
Normal file
320
run_archiver.py
Normal file
@ -0,0 +1,320 @@
|
||||
#!/usr/bin/env python3
|
||||
"""NewsArchiver - Main CLI Entry Point (Phase 4)
|
||||
|
||||
Single-file CLI for running NewsArchiver with multiple modes:
|
||||
- --run: Archive news articles once
|
||||
- --serve: Start Flask web server
|
||||
- --interval: Run background scheduler with specified interval
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from flask import Flask
|
||||
except ImportError:
|
||||
print("ERROR: Flask is required. Install with: pip install flask")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
from scheduler import start_scheduler, stop_scheduler, scheduled_archive
|
||||
except ImportError:
|
||||
print("ERROR: scheduler module not found")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
from rss_processor import process_all_feeds, init_db as init_db_rss
|
||||
except ImportError:
|
||||
print("ERROR: rss_processor module not found")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
from content_extractor import get_html_from_url
|
||||
except ImportError:
|
||||
print("ERROR: content_extractor module not found")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
from storage_manager import initialize_storage, get_all_sources
|
||||
except ImportError:
|
||||
print("ERROR: storage_manager module not found")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
from archive_engine import archive_all_sources
|
||||
except ImportError:
|
||||
print("ERROR: archive_engine module not found")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
from web_interface import app
|
||||
except ImportError:
|
||||
print("ERROR: web_interface module not found")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
from singlefile_archive import check_singlefile_available
|
||||
except ImportError:
|
||||
print("WARNING: singlefile_archive module not found")
|
||||
print("SingleFile integration will not be available")
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
ARCHIVE_DIR = Path(os.environ.get("ARCHIVE_DIR", str(SCRIPT_DIR / "archival_data"))).resolve()
|
||||
ARCHIVE_DIR.mkdir(exist_ok=True)
|
||||
|
||||
|
||||
def setup_logging(verbose: bool = False) -> logging.Logger:
|
||||
"""Configure logging for the application.
|
||||
|
||||
Args:
|
||||
verbose: If True, enable DEBUG level logging
|
||||
|
||||
Returns:
|
||||
Configured logger instance
|
||||
"""
|
||||
level = logging.DEBUG if verbose else logging.INFO
|
||||
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.FileHandler(ARCHIVE_DIR / 'processing.log', encoding='utf-8')
|
||||
]
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info("NewsArchiver - Main CLI Entry Point")
|
||||
logger.info("=" * 60)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
def run_archive_once(logger: logging.Logger, verbose: bool = False) -> bool:
|
||||
"""Run archiving process once.
|
||||
|
||||
Args:
|
||||
logger: Logger instance
|
||||
verbose: If True, enable verbose logging
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
logger.info("Running one-time archive")
|
||||
logger.info("=" * 60)
|
||||
|
||||
init_db_rss()
|
||||
initialize_storage()
|
||||
|
||||
results = archive_all_sources(
|
||||
rss_feeds_path=SCRIPT_DIR / 'rss_feeds.json',
|
||||
output_dir=ARCHIVE_DIR,
|
||||
dry_run=False
|
||||
)
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("Archive complete")
|
||||
logger.info("=" * 60)
|
||||
logger.info("Sources processed: %d", results.get('sources_processed', 0))
|
||||
logger.info("Total articles archived: %d", results.get('total_articles_archived', 0))
|
||||
logger.info("Total articles skipped: %d", results.get('total_articles_skipped', 0))
|
||||
logger.info("Total articles failed: %d", results.get('total_articles_failed', 0))
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Archive failed: %s", str(e))
|
||||
return False
|
||||
|
||||
|
||||
def run_scheduler(interval_minutes: int, logger: logging.Logger, verbose: bool = False) -> None:
|
||||
"""Run background scheduler.
|
||||
|
||||
Args:
|
||||
interval_minutes: Interval between archive runs in minutes
|
||||
logger: Logger instance
|
||||
verbose: If True, enable verbose logging
|
||||
"""
|
||||
logger.info("Starting background scheduler")
|
||||
logger.info("=" * 60)
|
||||
|
||||
try:
|
||||
init_db_rss()
|
||||
initialize_storage()
|
||||
|
||||
scheduler = start_scheduler(interval_minutes)
|
||||
|
||||
atexit.register(stop_scheduler)
|
||||
|
||||
logger.info("Press Ctrl+C to stop")
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
logger.info("Shutting down scheduler...")
|
||||
stop_scheduler()
|
||||
logger.info("Scheduler stopped")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Scheduler failed to start: %s", str(e))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run_web_server(host: str, port: int, logger: logging.Logger, verbose: bool = False, interval_minutes: int = None) -> None:
|
||||
"""Run Flask web server with optional background scheduler.
|
||||
|
||||
Args:
|
||||
host: Host to bind to
|
||||
port: Port to bind to
|
||||
logger: Logger instance
|
||||
verbose: If True, enable verbose logging
|
||||
interval_minutes: If set, start background scheduler at this interval
|
||||
"""
|
||||
logger.info("Starting web server")
|
||||
logger.info("=" * 60)
|
||||
|
||||
_scheduler_stopped = [False]
|
||||
|
||||
def _start_scheduler():
|
||||
try:
|
||||
init_db_rss()
|
||||
initialize_storage()
|
||||
start_scheduler(interval_minutes)
|
||||
logger.info("Background scheduler started (every %d min)", interval_minutes)
|
||||
logger.info("Running initial archive...")
|
||||
scheduled_archive(logger)
|
||||
logger.info("Initial archive complete")
|
||||
except Exception as e:
|
||||
logger.error("Scheduler thread failed: %s", str(e))
|
||||
finally:
|
||||
_scheduler_stopped[0] = True
|
||||
|
||||
def _shutdown_scheduler(signum=None, frame=None):
|
||||
if not _scheduler_stopped[0]:
|
||||
logger.info("Stopping scheduler...")
|
||||
stop_scheduler()
|
||||
logger.info("Scheduler stopped")
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
if not (ARCHIVE_DIR / 'cache.db').exists():
|
||||
logger.info("Database not found, initializing...")
|
||||
initialize_storage()
|
||||
|
||||
if not check_singlefile_available():
|
||||
logger.warning("SingleFile CLI not available. Some features may not work.")
|
||||
|
||||
if interval_minutes:
|
||||
logger.info("Starting background archive scheduler (interval: %d min)...", interval_minutes)
|
||||
t = threading.Thread(target=_start_scheduler, daemon=True)
|
||||
t.start()
|
||||
signal.signal(signal.SIGINT, _shutdown_scheduler)
|
||||
signal.signal(signal.SIGTERM, _shutdown_scheduler)
|
||||
|
||||
logger.info("Web server starting on %s:%d", host, port)
|
||||
logger.info("=" * 60)
|
||||
|
||||
app.run(
|
||||
host=host,
|
||||
port=port,
|
||||
debug=False
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Web server failed to start: %s", str(e))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point for NewsArchiver CLI."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='NewsArchiver - News Article Archiving System',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog='''
|
||||
Examples:
|
||||
%(prog)s --run Run archiving once
|
||||
%(prog)s --serve Start web server
|
||||
%(prog)s --serve --host 0.0.0.0 --port 8080
|
||||
Start web server on custom host/port
|
||||
%(prog)s --interval 60 Run background scheduler (1 hour interval)
|
||||
'''
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--run',
|
||||
action='store_true',
|
||||
help='Run archiving once (process all RSS feeds)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--serve',
|
||||
action='store_true',
|
||||
help='Start Flask web server'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--interval',
|
||||
type=int,
|
||||
default=None,
|
||||
help='Run background scheduler with specified interval (minutes). When used with --serve, runs in background thread (default: 60 min). Standalone blocks.'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--host',
|
||||
type=str,
|
||||
default='0.0.0.0',
|
||||
help='Host for web server (default: 0.0.0.0)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--port',
|
||||
type=int,
|
||||
default=5000,
|
||||
help='Port for web server (default: 5000)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--verbose', '-v',
|
||||
action='store_true',
|
||||
help='Enable verbose logging (DEBUG level)'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# CI port shift: detect CI env, map to reserved CI port range 10000-10099
|
||||
# CI_PORT_OFFSET (1-99) maps to 10001-10099. Each service gets a fixed offset.
|
||||
# NewsArchiverV2=1, paste-bin=2, etc. See AGENTS.md for assignments.
|
||||
if os.environ.get("CI") == "true" and os.environ.get("SKIP_PORT_SHIFT") != "1":
|
||||
original_port = args.port
|
||||
offset = int(os.environ.get("CI_PORT_OFFSET", "1"))
|
||||
args.port = 10000 + offset
|
||||
print(f"[ci-port-shift] Port shifted from {original_port} to {args.port} (offset {offset}, CI range: 10000-10099)")
|
||||
|
||||
logger = setup_logging(args.verbose)
|
||||
|
||||
if args.run:
|
||||
success = run_archive_once(logger, args.verbose)
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
elif args.serve:
|
||||
interval = args.interval if args.interval else 60
|
||||
run_web_server(args.host, args.port, logger, args.verbose, interval_minutes=interval)
|
||||
|
||||
elif args.interval:
|
||||
run_scheduler(args.interval, logger, args.verbose)
|
||||
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
209
scheduler.py
Normal file
209
scheduler.py
Normal file
@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Scheduler for NewsArchiver - Phase 4
|
||||
|
||||
Background scheduler using APScheduler to automate
|
||||
daily archiving of news sources.
|
||||
"""
|
||||
|
||||
import atexit
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
except ImportError:
|
||||
print("ERROR: APScheduler is required. Install with: pip install apscheduler")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
from archive_engine import archive_all_sources
|
||||
except ImportError:
|
||||
print("ERROR: archive_engine is required")
|
||||
sys.exit(1)
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
ARCHIVE_DIR = Path(os.environ.get("ARCHIVE_DIR", str(SCRIPT_DIR / "archival_data"))).resolve()
|
||||
ARCHIVE_DIR.mkdir(exist_ok=True)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
scheduler = BackgroundScheduler()
|
||||
|
||||
# Timeout configuration
|
||||
MAX_RUN_TIME_SECONDS = 3600 # 1 hour
|
||||
start_time = None
|
||||
_timeout_timer = None
|
||||
|
||||
|
||||
def _timeout_checker():
|
||||
"""Daemon thread that raises SystemExit when timeout is reached."""
|
||||
elapsed = (datetime.now() - start_time).total_seconds()
|
||||
remaining = MAX_RUN_TIME_SECONDS - elapsed
|
||||
if remaining > 0:
|
||||
logger.warning("Timeout reached (%d seconds). Will exit after current download completes.", MAX_RUN_TIME_SECONDS)
|
||||
raise SystemExit(0)
|
||||
|
||||
def check_timeout() -> bool:
|
||||
"""Check if timeout has been reached.
|
||||
|
||||
Returns:
|
||||
True if timeout reached, False otherwise
|
||||
"""
|
||||
global start_time
|
||||
elapsed = (datetime.now() - start_time).total_seconds()
|
||||
if elapsed >= MAX_RUN_TIME_SECONDS:
|
||||
logger.warning("Maximum runtime of %d seconds reached (%d seconds elapsed)", MAX_RUN_TIME_SECONDS, int(elapsed))
|
||||
return True
|
||||
return False
|
||||
|
||||
def _start_timeout_thread():
|
||||
"""Start a daemon thread that will raise SystemExit after MAX_RUN_TIME_SECONDS."""
|
||||
global _timeout_timer
|
||||
_timeout_timer = threading.Timer(MAX_RUN_TIME_SECONDS, _timeout_checker)
|
||||
_timeout_timer.daemon = True
|
||||
_timeout_timer.start()
|
||||
|
||||
def _cancel_timeout_thread():
|
||||
"""Cancel the timeout thread."""
|
||||
global _timeout_timer
|
||||
if _timeout_timer:
|
||||
_timeout_timer.cancel()
|
||||
_timeout_timer = None
|
||||
|
||||
def scheduled_archive() -> None:
|
||||
"""Run archiving for all sources."""
|
||||
global start_time
|
||||
start_time = datetime.now()
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("Starting scheduled archive run")
|
||||
logger.info("=" * 60)
|
||||
|
||||
_start_timeout_thread()
|
||||
|
||||
try:
|
||||
results = archive_all_sources(
|
||||
rss_feeds_path=SCRIPT_DIR / 'rss_feeds.json',
|
||||
output_dir=ARCHIVE_DIR,
|
||||
dry_run=False
|
||||
)
|
||||
|
||||
if results['success']:
|
||||
logger.info("Scheduled archive completed successfully")
|
||||
logger.info("Sources processed: %d", results.get('sources_processed', 0))
|
||||
logger.info("Total articles archived: %d", results.get('total_articles_archived', 0))
|
||||
else:
|
||||
logger.error("Scheduled archive failed: %s", results.get('error', 'Unknown error'))
|
||||
|
||||
except SystemExit as e:
|
||||
logger.info("Scheduler exiting due to timeout")
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.error("Scheduled archive failed with exception: %s", str(e))
|
||||
finally:
|
||||
_cancel_timeout_thread()
|
||||
|
||||
|
||||
def start_scheduler(interval_minutes: int = 60) -> BackgroundScheduler:
|
||||
"""Start the background scheduler.
|
||||
|
||||
Args:
|
||||
interval_minutes: Interval between archive runs in minutes
|
||||
|
||||
Returns:
|
||||
The scheduler instance
|
||||
"""
|
||||
scheduler.add_job(
|
||||
func=scheduled_archive,
|
||||
trigger=IntervalTrigger(minutes=interval_minutes),
|
||||
id='archive_news',
|
||||
replace_existing=True,
|
||||
misfire_grace_time=60,
|
||||
coalesce=True
|
||||
)
|
||||
|
||||
scheduler.start()
|
||||
logger.info("Scheduler started with %d minute interval", interval_minutes)
|
||||
|
||||
logger.info("Running initial archive immediately...")
|
||||
scheduled_archive()
|
||||
|
||||
return scheduler
|
||||
|
||||
|
||||
def run_once() -> None:
|
||||
"""Run archiving once (for CLI --run flag)."""
|
||||
global start_time
|
||||
start_time = datetime.now()
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("Running one-time archive")
|
||||
logger.info("=" * 60)
|
||||
|
||||
_start_timeout_thread()
|
||||
|
||||
try:
|
||||
scheduled_archive()
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("One-time archive completed")
|
||||
logger.info("=" * 60)
|
||||
except SystemExit as e:
|
||||
logger.info("Archiver exiting due to timeout")
|
||||
raise e
|
||||
finally:
|
||||
_cancel_timeout_thread()
|
||||
|
||||
|
||||
def stop_scheduler() -> None:
|
||||
"""Stop the scheduler gracefully."""
|
||||
if scheduler.running:
|
||||
scheduler.shutdown()
|
||||
logger.info("Scheduler stopped")
|
||||
|
||||
|
||||
atexit.register(lambda: stop_scheduler())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description='NewsArchiver - Scheduler')
|
||||
parser.add_argument('--run', action='store_true', help='Run archiving once')
|
||||
parser.add_argument('--serve', action='store_true', help='Start web server')
|
||||
parser.add_argument('--interval', type=int, default=60, help='Scheduler interval in minutes (default: 60)')
|
||||
parser.add_argument('--host', default='0.0.0.0', help='Host for web server')
|
||||
parser.add_argument('--port', type=int, default=5000, help='Port for web server')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.run:
|
||||
run_once()
|
||||
stop_scheduler()
|
||||
elif args.serve:
|
||||
from web_interface import app
|
||||
logger.info("Starting web server on %s:%d", args.host, args.port)
|
||||
try:
|
||||
app.run(host=args.host, port=args.port)
|
||||
except Exception as e:
|
||||
logger.error("Web server error: %s", str(e))
|
||||
sys.exit(1)
|
||||
else:
|
||||
start_scheduler(args.interval)
|
||||
logger.info("Press Ctrl+C to stop")
|
||||
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
if check_timeout():
|
||||
logger.info("Maximum runtime reached. Exiting.")
|
||||
stop_scheduler()
|
||||
sys.exit(0)
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
stop_scheduler()
|
||||
84
setup_cron.sh
Normal file
84
setup_cron.sh
Normal file
@ -0,0 +1,84 @@
|
||||
#!/bin/bash
|
||||
# Setup script for NewsArchiver
|
||||
# This script sets up the cron job for automated news archiving
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="${NEWSARCHIVER_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"
|
||||
LOG_FILE="${NEWSARCHIVER_LOG:-/tmp/newsarchiver_cron.log}"
|
||||
CRON_JOB="*/30 * * * * /usr/bin/env python3 ${SCRIPT_DIR}/run_archiver.py --interval 30 > ${LOG_FILE} 2>&1"
|
||||
|
||||
echo "=== NewsArchiver Setup Script ==="
|
||||
echo ""
|
||||
|
||||
# Check Python is available
|
||||
if ! command -v python3 &> /dev/null; then
|
||||
echo "ERROR: python3 not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if running as jarian
|
||||
CURRENT_USER=$(whoami)
|
||||
if [ "$CURRENT_USER" != "jarian" ]; then
|
||||
echo "WARNING: This script is configured for user 'jarian', but you are '$CURRENT_USER'"
|
||||
echo "You may need to update the script paths"
|
||||
fi
|
||||
|
||||
# Check if NewsArchiver directory exists
|
||||
if [ ! -d "$SCRIPT_DIR" ]; then
|
||||
echo "ERROR: NewsArchiver directory not found at $SCRIPT_DIR"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if requirements are installed
|
||||
echo "Checking dependencies..."
|
||||
cd "$SCRIPT_DIR"
|
||||
python3 -c "import flask; import requests; import trafilatura; import feedparser; import apscheduler" 2>/dev/null || {
|
||||
echo "Installing dependencies..."
|
||||
pip install -r requirements.txt
|
||||
}
|
||||
|
||||
# Check if web server is running
|
||||
if ! pgrep -f "run_archiver.py --serve" > /dev/null; then
|
||||
echo "Starting web server..."
|
||||
nohup python3 "$SCRIPT_DIR/run_archiver.py" --serve --host 0.0.0.0 --port 5000 > /tmp/webserver.log 2>&1 &
|
||||
sleep 3
|
||||
if curl -s -o /dev/null -w "%{http_code}" http://localhost:5000/ | grep -q "200"; then
|
||||
echo "Web server started successfully on port 5000"
|
||||
else
|
||||
echo "WARNING: Web server may not be responding"
|
||||
fi
|
||||
else
|
||||
echo "Web server is already running"
|
||||
fi
|
||||
|
||||
# Remove old scheduler lock file if exists
|
||||
if [ -f "$SCRIPT_DIR/archival_data/.scheduler.lock" ]; then
|
||||
rm -f "$SCRIPT_DIR/archival_data/.scheduler.lock"
|
||||
echo "Removed stale scheduler lock file"
|
||||
fi
|
||||
|
||||
# Kill any existing scheduler processes
|
||||
pkill -f "run_archiver.py --interval" 2>/dev/null || true
|
||||
echo "Cleared any existing scheduler processes"
|
||||
|
||||
# Setup cron job
|
||||
echo "Setting up cron job..."
|
||||
if crontab -l 2>/dev/null | grep -q "NewsArchiver"; then
|
||||
echo "Removing existing NewsArchiver cron job..."
|
||||
crontab -l | grep -v "NewsArchiver" | crontab -
|
||||
fi
|
||||
|
||||
echo "$CRON_JOB" | crontab -
|
||||
echo "Cron job added successfully"
|
||||
|
||||
echo ""
|
||||
echo "=== Current Cron Jobs ==="
|
||||
crontab -l | grep NewsArchiver
|
||||
|
||||
echo ""
|
||||
echo "=== Setup Complete ==="
|
||||
echo "- Archiver will run every 30 minutes via cron"
|
||||
echo "- Logs written to: $LOG_FILE"
|
||||
echo "- Web interface at: http://localhost:5000"
|
||||
echo ""
|
||||
258
singlefile_archive.py
Normal file
258
singlefile_archive.py
Normal file
@ -0,0 +1,258 @@
|
||||
import subprocess
|
||||
import os
|
||||
from pathlib import Path
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from playwright.sync_api import sync_playwright
|
||||
PLAYWRIGHT_AVAILABLE = True
|
||||
except ImportError:
|
||||
PLAYWRIGHT_AVAILABLE = False
|
||||
logger.debug("Playwright not available")
|
||||
|
||||
# Cache the SingleFile path
|
||||
_SINGLEFILE_PATH: Optional[str] = None
|
||||
|
||||
|
||||
def _get_singlefile_path() -> Optional[str]:
|
||||
"""Get the path to SingleFile CLI executable."""
|
||||
global _SINGLEFILE_PATH
|
||||
|
||||
if _SINGLEFILE_PATH is not None:
|
||||
return _SINGLEFILE_PATH
|
||||
|
||||
# Check PATH first
|
||||
single_file_path = os.environ.get('PATH', '').split(os.pathsep)
|
||||
for path in single_file_path:
|
||||
candidate = Path(path) / 'single-file'
|
||||
if candidate.is_file():
|
||||
_SINGLEFILE_PATH = str(candidate)
|
||||
logger.debug(f"Found SingleFile in PATH: {_SINGLEFILE_PATH}")
|
||||
return _SINGLEFILE_PATH
|
||||
|
||||
# Check common locations
|
||||
home = Path.home()
|
||||
common_locations = [
|
||||
str(home / '.local' / 'bin' / 'single-file'),
|
||||
'/usr/local/bin/single-file',
|
||||
'/usr/bin/single-file',
|
||||
str(home / '.npm' / '_global' / 'bin' / 'single-file'),
|
||||
]
|
||||
|
||||
for candidate in common_locations:
|
||||
if Path(candidate).is_file():
|
||||
_SINGLEFILE_PATH = candidate
|
||||
logger.debug(f"Found SingleFile at: {_SINGLEFILE_PATH}")
|
||||
return _SINGLEFILE_PATH
|
||||
|
||||
logger.error("SingleFile CLI not found. Please install with: npm install -g single-file")
|
||||
return None
|
||||
|
||||
|
||||
def check_singlefile_available() -> bool:
|
||||
"""Check if SingleFile CLI is available"""
|
||||
single_file_path = _get_singlefile_path()
|
||||
if not single_file_path:
|
||||
return False
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[single_file_path, '--version'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||
logger.error(f"SingleFile CLI at {single_file_path} is not executable")
|
||||
return False
|
||||
|
||||
|
||||
def is_error_page(html_content: str) -> bool:
|
||||
"""Check if HTML content is an error page (403, 404, etc.)
|
||||
|
||||
Args:
|
||||
html_content: HTML content to check
|
||||
|
||||
Returns:
|
||||
True if error page detected, False otherwise
|
||||
"""
|
||||
error_patterns = [
|
||||
'403 error',
|
||||
'403 forbidden',
|
||||
'access denied',
|
||||
'request blocked',
|
||||
'cloudfront',
|
||||
'404 error',
|
||||
'page not found',
|
||||
'error 404',
|
||||
'server error',
|
||||
'503 service unavailable',
|
||||
]
|
||||
|
||||
html_lower = html_content.lower()
|
||||
return any(pattern in html_lower for pattern in error_patterns)
|
||||
|
||||
|
||||
def validate_archived_html(output_path: Path) -> bool:
|
||||
"""Validate that archived HTML is not an error page.
|
||||
|
||||
Args:
|
||||
output_path: Path to the archived HTML file
|
||||
|
||||
Returns:
|
||||
True if valid, False if error page detected
|
||||
"""
|
||||
try:
|
||||
if not output_path.exists():
|
||||
logger.warning(f"Archived file not found: {output_path}")
|
||||
return False
|
||||
|
||||
content = output_path.read_text(encoding='utf-8', errors='ignore')
|
||||
|
||||
if is_error_page(content):
|
||||
logger.warning(f"Archived file contains error page: {output_path}")
|
||||
return False
|
||||
|
||||
if len(content) < 1000:
|
||||
logger.warning(f"Archived file too small (likely incomplete): {output_path}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error validating archived HTML {output_path}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def archive_page_with_singlefile(
|
||||
url: str,
|
||||
output_path: Path,
|
||||
extract_content: bool = True
|
||||
) -> bool:
|
||||
"""Archive a web page using SingleFile CLI
|
||||
|
||||
Args:
|
||||
url: URL to archive
|
||||
output_path: Output file path for the archived HTML
|
||||
extract_content: Whether to use extract-content mode (ignored - SingleFile always extracts)
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
single_file_path = _get_singlefile_path()
|
||||
if not single_file_path:
|
||||
logger.error("SingleFile CLI not available")
|
||||
return False
|
||||
|
||||
cmd = [
|
||||
single_file_path,
|
||||
url,
|
||||
str(output_path),
|
||||
'--browser-headless=true',
|
||||
'--browser-wait-delay=5000',
|
||||
'--browser-load-max-time=120000'
|
||||
]
|
||||
|
||||
logger.debug(f"Archiving {url} with SingleFile at {single_file_path}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.info(f"Successfully archived {url} to {output_path}")
|
||||
if result.stdout:
|
||||
logger.debug(f"SingleFile output: {result.stdout}")
|
||||
|
||||
# Validate the archived HTML
|
||||
if not validate_archived_html(output_path):
|
||||
logger.warning(f"Archived HTML validation failed for {url}, will use fallback")
|
||||
return False
|
||||
|
||||
return True
|
||||
else:
|
||||
error_msg = result.stderr if result.stderr else result.stdout
|
||||
logger.error(f"SingleFile failed for {url}: {error_msg}")
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(f"SingleFile timed out for {url}")
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
logger.error(f"SingleFile CLI executable not found at: {single_file_path}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error archiving {url} with SingleFile: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def archive_page_with_singlefile_no_extraction(url: str, output_path: Path) -> bool:
|
||||
"""Archive a web page using SingleFile CLI without content extraction
|
||||
|
||||
This preserves the full original HTML structure including navigation, ads, etc.
|
||||
|
||||
Args:
|
||||
url: URL to archive
|
||||
output_path: Output file path for the archived HTML
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
return archive_page_with_singlefile(url, output_path, extract_content=False)
|
||||
|
||||
|
||||
def archive_page_with_singlefile_extract(url: str, output_path: Path) -> bool:
|
||||
"""Archive a web page using SingleFile CLI with content extraction
|
||||
|
||||
This extracts only the main content, removing navigation, ads, and sidebars.
|
||||
|
||||
Args:
|
||||
url: URL to archive
|
||||
output_path: Output file path for the archived HTML
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
return archive_page_with_singlefile(url, output_path, extract_content=True)
|
||||
|
||||
|
||||
def archive_page_with_playwright(url: str, output_path: Path) -> bool:
|
||||
"""Archive a web page using Playwright
|
||||
|
||||
This visits the URL with a headless browser, waits for content to load,
|
||||
and saves the full HTML page.
|
||||
|
||||
Args:
|
||||
url: URL to archive
|
||||
output_path: Output file path for the archived HTML
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if not PLAYWRIGHT_AVAILABLE:
|
||||
logger.error("Playwright not available. Install with: pip install playwright")
|
||||
return False
|
||||
|
||||
try:
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True)
|
||||
page = browser.new_page()
|
||||
|
||||
page.goto(url, wait_until='networkidle', timeout=120000)
|
||||
|
||||
content = page.content()
|
||||
|
||||
browser.close()
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(content, encoding='utf-8')
|
||||
|
||||
logger.info("Successfully archived %s to %s using Playwright", url[:60], output_path)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Playwright archiving failed for %s: %s", url, str(e))
|
||||
return False
|
||||
535
static/style.css
Normal file
535
static/style.css
Normal file
@ -0,0 +1,535 @@
|
||||
/* Base styles */
|
||||
:root {
|
||||
--primary-color: #1a1a1a;
|
||||
--secondary-color: #6b7280;
|
||||
--background-color: #f9fafb;
|
||||
--surface-color: #ffffff;
|
||||
--border-color: #e5e7eb;
|
||||
--accent-color: #2563eb;
|
||||
--accent-hover: #1d4ed8;
|
||||
--accent-light: #eff6ff;
|
||||
--timeline-line: #d1d5db;
|
||||
--dot-color: #2563eb;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--primary-color: #f3f4f6;
|
||||
--secondary-color: #9ca3af;
|
||||
--background-color: #111827;
|
||||
--surface-color: #1f2937;
|
||||
--border-color: #374151;
|
||||
--accent-color: #60a5fa;
|
||||
--accent-hover: #93c5fd;
|
||||
--accent-light: #1e3a5f;
|
||||
--timeline-line: #4b5563;
|
||||
--dot-color: #60a5fa;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: var(--background-color);
|
||||
color: var(--primary-color);
|
||||
line-height: 1.6;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
header {
|
||||
background-color: var(--surface-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 0.75rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(8px);
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
header nav a {
|
||||
color: var(--secondary-color);
|
||||
text-decoration: none;
|
||||
margin-left: 1.5rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
header nav a:hover {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 2rem;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Page headings */
|
||||
main > h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
margin: 0 0 1.5rem 0;
|
||||
}
|
||||
|
||||
/* Newspaper list */
|
||||
.newspaper-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.newspaper-item {
|
||||
background: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.newspaper-item:hover {
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.newspaper-info h2 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.newspaper-info h2 a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.newspaper-info h2 a:hover {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.newspaper-info p {
|
||||
margin: 0;
|
||||
color: var(--secondary-color);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.status-success { color: #16a34a; font-weight: 600; }
|
||||
.status-pending { color: #f59e0b; font-weight: 600; }
|
||||
|
||||
.pull-btn {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.4rem 0.85rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.pull-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Timeline Layout */
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding-left: 3rem;
|
||||
}
|
||||
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 1.25rem;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--timeline-line);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.timeline-date {
|
||||
position: relative;
|
||||
margin: 2rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.timeline-date:first-child {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.timeline-date::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -2.15rem;
|
||||
top: 0.35rem;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--dot-color);
|
||||
border: 3px solid var(--background-color);
|
||||
box-shadow: 0 0 0 2px var(--dot-color);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.date-label {
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
color: var(--primary-color);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.date-count {
|
||||
margin-left: 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--secondary-color);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Article cards in timeline */
|
||||
.article-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.article-item {
|
||||
background: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.article-item:hover {
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.article-item h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.article-item h3 a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.article-item h3 a:hover {
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.article-summary {
|
||||
color: var(--secondary-color);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.article-date {
|
||||
color: var(--secondary-color);
|
||||
font-size: 0.8rem;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
padding: 1rem 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.pagination a {
|
||||
color: var(--accent-color);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.pagination a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.pagination span {
|
||||
color: var(--secondary-color);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Article detail view */
|
||||
.article-view {
|
||||
background: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 2.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.article-source {
|
||||
display: inline-block;
|
||||
background: var(--accent-light);
|
||||
color: var(--accent-color);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.article-view h1 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.75rem;
|
||||
line-height: 1.3;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.article-meta {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.article-meta time,
|
||||
.article-meta span {
|
||||
font-size: 0.85rem;
|
||||
color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.article-meta a {
|
||||
color: var(--accent-color);
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.article-meta a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.article-content {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.article-text {
|
||||
white-space: pre-wrap;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.article-text p {
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.archived-html {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
background: var(--background-color);
|
||||
}
|
||||
|
||||
.article-actions {
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
color: var(--accent-color);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--accent-light);
|
||||
border-radius: 6px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
background: var(--accent-color);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Disabled feeds */
|
||||
.source-disabled {
|
||||
color: var(--secondary-color);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.7rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.disable-reason {
|
||||
color: var(--secondary-color);
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.25rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Status page */
|
||||
.status-page {
|
||||
background: var(--surface-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.status-page h1 {
|
||||
margin: 0 0 1.5rem 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-page h2 {
|
||||
font-size: 1.1rem;
|
||||
margin: 1.5rem 0 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.status-item {
|
||||
background: var(--background-color);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.status-item h2 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--secondary-color);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.status-item p {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status-online { color: #16a34a; }
|
||||
.status-warning { color: #f59e0b; }
|
||||
|
||||
/* Not found */
|
||||
.article-not-found {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
}
|
||||
|
||||
.article-not-found h1 {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.article-not-found p {
|
||||
color: var(--secondary-color);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
header {
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
main {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
padding-left: 2.5rem;
|
||||
}
|
||||
|
||||
.timeline::before {
|
||||
left: 0.75rem;
|
||||
}
|
||||
|
||||
.timeline-date::before {
|
||||
left: -1.95rem;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.article-view {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.article-view h1 {
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.newspaper-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Print */
|
||||
@media print {
|
||||
header, .pull-btn, .pagination, .article-actions {
|
||||
display: none;
|
||||
}
|
||||
.article-view {
|
||||
border: none;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
19
stop_services.sh
Normal file
19
stop_services.sh
Normal file
@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
# Stop NewsArchiver services
|
||||
|
||||
echo "Stopping NewsArchiver services..."
|
||||
|
||||
# Stop web server
|
||||
pkill -f "run_archiver.py --serve" 2>/dev/null || true
|
||||
echo "Web server stopped"
|
||||
|
||||
# Stop scheduler
|
||||
pkill -f "run_archiver.py --interval" 2>/dev/null || true
|
||||
echo "Scheduler stopped"
|
||||
|
||||
# Remove lock file
|
||||
LOCK_FILE="${NEWSARCHIVER_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}/archival_data/.scheduler.lock"
|
||||
rm -f "$LOCK_FILE" 2>/dev/null || true
|
||||
echo "Scheduler lock file removed"
|
||||
|
||||
echo "All NewsArchiver services stopped"
|
||||
859
storage_manager.py
Normal file
859
storage_manager.py
Normal file
@ -0,0 +1,859 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Storage Manager for NewsArchiver - Phase 2.3
|
||||
|
||||
Organizes archived data by newspaper, manages SQLite cache database,
|
||||
stores both raw HTML and extracted content.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from content_extractor import ArticleData
|
||||
|
||||
try:
|
||||
import feedgenerator
|
||||
|
||||
FEEDGENERATOR_AVAILABLE = True
|
||||
except ImportError:
|
||||
FEEDGENERATOR_AVAILABLE = False
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
ARCHIVE_DIR = Path(os.environ.get("ARCHIVE_DIR", str(SCRIPT_DIR / "archival_data"))).resolve()
|
||||
ARCHIVE_DIR.mkdir(exist_ok=True)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DB_PATH = ARCHIVE_DIR / "cache.db"
|
||||
WEBSITES_DIR = ARCHIVE_DIR / "websites"
|
||||
|
||||
|
||||
def _get_db_connection() -> sqlite3.Connection:
|
||||
"""Get database connection with row factory."""
|
||||
conn = sqlite3.connect(DB_PATH, timeout=30.0, isolation_level=None)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=30000")
|
||||
return conn
|
||||
|
||||
|
||||
def _init_database() -> None:
|
||||
"""Initialize database schema."""
|
||||
with _get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS articles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_name TEXT NOT NULL,
|
||||
article_url TEXT NOT NULL UNIQUE,
|
||||
article_guid TEXT,
|
||||
title TEXT,
|
||||
author TEXT,
|
||||
publish_date TEXT,
|
||||
content_text TEXT,
|
||||
content_html TEXT,
|
||||
archive_file_path TEXT,
|
||||
metadata_file_path TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
error_message TEXT,
|
||||
extraction_method TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS article_archives (
|
||||
article_url TEXT PRIMARY KEY,
|
||||
source_name TEXT,
|
||||
archive_file_path TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
|
||||
cursor.execute("""
|
||||
CREATE TABLE IF NOT EXISTS processing_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_name TEXT,
|
||||
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
action TEXT,
|
||||
status TEXT,
|
||||
message TEXT
|
||||
)
|
||||
""")
|
||||
|
||||
cursor.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_articles_source ON articles(source_name)"
|
||||
)
|
||||
cursor.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_articles_url ON articles(article_url)"
|
||||
)
|
||||
cursor.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_articles_status ON articles(status)"
|
||||
)
|
||||
|
||||
# Add extraction_method column if it doesn't exist
|
||||
try:
|
||||
cursor.execute("ALTER TABLE articles ADD COLUMN extraction_method TEXT")
|
||||
conn.commit()
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
|
||||
cursor.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_articles_extraction_method ON articles(extraction_method)"
|
||||
)
|
||||
cursor.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_article_archives_url ON article_archives(article_url)"
|
||||
)
|
||||
cursor.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_article_archives_source ON article_archives(source_name)"
|
||||
)
|
||||
|
||||
logger.debug("Database initialized at %s", DB_PATH)
|
||||
|
||||
|
||||
def _ensure_directory_structure(source_name: str, date_str: str) -> tuple:
|
||||
"""Ensure directory structure exists for a source and date.
|
||||
|
||||
Args:
|
||||
source_name: Newspaper source name
|
||||
date_str: Date string in YYYY-MM-DD format
|
||||
|
||||
Returns:
|
||||
Tuple of (html_dir, articles_dir) Path objects
|
||||
"""
|
||||
source_dir = WEBSITES_DIR / source_name
|
||||
html_dir = source_dir / "html" / date_str
|
||||
articles_dir = source_dir / "articles" / date_str
|
||||
|
||||
html_dir.mkdir(parents=True, exist_ok=True)
|
||||
articles_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return html_dir, articles_dir
|
||||
|
||||
|
||||
def _get_next_file_index(html_dir: Path, articles_dir: Path) -> int:
|
||||
"""Get next available file index for article naming.
|
||||
|
||||
Args:
|
||||
html_dir: Directory containing HTML files
|
||||
articles_dir: Directory containing JSON files
|
||||
|
||||
Returns:
|
||||
Next available index (1-indexed)
|
||||
"""
|
||||
|
||||
def get_max_index(directory: Path, extension: str) -> int:
|
||||
max_idx = 0
|
||||
if directory.exists():
|
||||
for file in directory.glob(f"*{extension}"):
|
||||
try:
|
||||
name = file.stem
|
||||
if name.startswith("article_"):
|
||||
idx = int(name.replace("article_", ""))
|
||||
max_idx = max(max_idx, idx)
|
||||
except ValueError:
|
||||
continue
|
||||
return max_idx
|
||||
|
||||
html_idx = get_max_index(html_dir, ".html")
|
||||
json_idx = get_max_index(articles_dir, ".json")
|
||||
|
||||
return max(html_idx, json_idx) + 1
|
||||
|
||||
|
||||
def _log_processing(source_name: str, action: str, status: str, message: str) -> None:
|
||||
"""Log processing action to database.
|
||||
|
||||
Args:
|
||||
source_name: Newspaper source name
|
||||
action: Action performed
|
||||
status: Status of action
|
||||
message: Log message
|
||||
"""
|
||||
try:
|
||||
with _get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"INSERT INTO processing_log (source_name, action, status, message) VALUES (?, ?, ?, ?)",
|
||||
(source_name, action, status, message),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to log processing: %s", str(e))
|
||||
|
||||
|
||||
def _save_archive_mapping(
|
||||
article_url: str, source_name: str, archive_file_path: str
|
||||
) -> None:
|
||||
"""Save mapping between article URL and archive file path.
|
||||
|
||||
Args:
|
||||
article_url: Article URL
|
||||
source_name: Newspaper source name
|
||||
archive_file_path: Path to archived HTML file
|
||||
"""
|
||||
try:
|
||||
with _get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT OR REPLACE INTO article_archives (article_url, source_name, archive_file_path)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(article_url, source_name, archive_file_path),
|
||||
)
|
||||
logger.debug("Saved archive mapping: %s -> %s", article_url, archive_file_path)
|
||||
except Exception as e:
|
||||
logger.error("Failed to save archive mapping: %s", str(e))
|
||||
|
||||
|
||||
def save_article(source_name: str, article_data: ArticleData) -> str:
|
||||
"""Save article to storage and update cache.
|
||||
|
||||
Args:
|
||||
source_name: Newspaper source name (e.g., 'reuters', 'bbc')
|
||||
article_data: ArticleData object with article content
|
||||
|
||||
Returns:
|
||||
Status message describing the result
|
||||
"""
|
||||
try:
|
||||
_init_database()
|
||||
|
||||
publish_date = article_data.publish_date
|
||||
if publish_date:
|
||||
try:
|
||||
date_obj = datetime.fromisoformat(publish_date.replace("Z", "+00:00"))
|
||||
date_str = date_obj.strftime("%Y-%m-%d")
|
||||
except (ValueError, AttributeError):
|
||||
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||
else:
|
||||
date_str = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
html_dir, articles_dir = _ensure_directory_structure(source_name, date_str)
|
||||
|
||||
file_index = _get_next_file_index(html_dir, articles_dir)
|
||||
file_prefix = f"article_{file_index:03d}"
|
||||
|
||||
archive_file_path = html_dir / f"{file_prefix}.html"
|
||||
metadata_file_path = articles_dir / f"{file_prefix}.json"
|
||||
|
||||
if article_data.raw_html:
|
||||
with open(archive_file_path, "w", encoding="utf-8") as f:
|
||||
f.write(article_data.raw_html)
|
||||
|
||||
metadata = {
|
||||
"id": file_index,
|
||||
"source_name": source_name,
|
||||
"url": article_data.url,
|
||||
"title": article_data.title,
|
||||
"author": article_data.author,
|
||||
"publish_date": article_data.publish_date,
|
||||
"content_text": article_data.content_text,
|
||||
"content_html": article_data.content_html,
|
||||
"tags": article_data.tags,
|
||||
"language": article_data.language,
|
||||
"extraction_method": article_data.extraction_method,
|
||||
"archive_file": f"{file_prefix}.html",
|
||||
"metadata_file": f"{file_prefix}.json",
|
||||
"saved_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
with open(metadata_file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(metadata, f, indent=2, ensure_ascii=False)
|
||||
|
||||
with _get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO articles
|
||||
(source_name, article_url, article_guid, title, author, publish_date,
|
||||
content_text, content_html, archive_file_path, metadata_file_path, status, extraction_method)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
source_name,
|
||||
article_data.url,
|
||||
getattr(article_data, "guid", None),
|
||||
article_data.title,
|
||||
article_data.author,
|
||||
article_data.publish_date,
|
||||
article_data.content_text,
|
||||
article_data.content_html,
|
||||
str(archive_file_path.relative_to(ARCHIVE_DIR)),
|
||||
str(metadata_file_path.relative_to(ARCHIVE_DIR)),
|
||||
"archived" if not article_data.error else "failed",
|
||||
article_data.extraction_method,
|
||||
),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE articles
|
||||
SET title = ?, author = ?, publish_date = ?,
|
||||
content_text = ?, content_html = ?,
|
||||
archive_file_path = ?, metadata_file_path = ?,
|
||||
status = ?, extraction_method = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE article_url = ? AND source_name = ?
|
||||
""",
|
||||
(
|
||||
article_data.title,
|
||||
article_data.author,
|
||||
article_data.publish_date,
|
||||
article_data.content_text,
|
||||
article_data.content_html,
|
||||
str(archive_file_path.relative_to(ARCHIVE_DIR)),
|
||||
str(metadata_file_path.relative_to(ARCHIVE_DIR)),
|
||||
"archived" if not article_data.error else "failed",
|
||||
article_data.extraction_method,
|
||||
article_data.url,
|
||||
source_name,
|
||||
),
|
||||
)
|
||||
|
||||
_save_archive_mapping(
|
||||
article_data.url,
|
||||
source_name,
|
||||
str(archive_file_path.relative_to(ARCHIVE_DIR)),
|
||||
)
|
||||
|
||||
_log_processing(
|
||||
source_name,
|
||||
"save_article",
|
||||
"success",
|
||||
f"Saved article: {article_data.url} -> {metadata_file_path.name}",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Article saved: %s -> %s", article_data.url, metadata_file_path.name
|
||||
)
|
||||
|
||||
return f"Article saved: {metadata_file_path.name}"
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to save article: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
_log_processing(source_name, "save_article", "error", error_msg)
|
||||
return error_msg
|
||||
|
||||
|
||||
def get_article(source_name: str, article_id: int) -> Optional[ArticleData]:
|
||||
"""Retrieve article from storage.
|
||||
|
||||
Args:
|
||||
source_name: Newspaper source name
|
||||
article_id: Database ID of article
|
||||
|
||||
Returns:
|
||||
ArticleData object or None if not found
|
||||
"""
|
||||
try:
|
||||
_init_database()
|
||||
|
||||
with _get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT * FROM articles
|
||||
WHERE id = ? AND source_name = ?
|
||||
""",
|
||||
(article_id, source_name),
|
||||
)
|
||||
|
||||
row = cursor.fetchone()
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
archive_path = (
|
||||
Path(row["archive_file_path"]) if row["archive_file_path"] else None
|
||||
)
|
||||
# Handle both absolute and relative paths
|
||||
if archive_path:
|
||||
if not archive_path.is_absolute():
|
||||
archive_path = ARCHIVE_DIR / archive_path
|
||||
# Return relative path for web interface
|
||||
archive_file_path = str(archive_path.relative_to(ARCHIVE_DIR))
|
||||
else:
|
||||
archive_file_path = None
|
||||
archive_content = None
|
||||
if archive_path and archive_path.exists():
|
||||
archive_content = archive_path.read_text(encoding="utf-8")
|
||||
|
||||
article = ArticleData(
|
||||
url=row["article_url"],
|
||||
title=row["title"],
|
||||
author=row["author"],
|
||||
publish_date=row["publish_date"],
|
||||
content_text=row["content_text"],
|
||||
content_html=row["content_html"],
|
||||
raw_html=archive_content,
|
||||
archive_file_path=archive_file_path,
|
||||
tags=None,
|
||||
language=None,
|
||||
metadata=None,
|
||||
extraction_method=None,
|
||||
error=row["error_message"],
|
||||
guid=row["article_guid"],
|
||||
id=row["id"],
|
||||
source_name=source_name,
|
||||
)
|
||||
|
||||
return article
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get article %d: %s", article_id, str(e))
|
||||
return None
|
||||
|
||||
|
||||
def get_articles_by_source(
|
||||
source_name: str, limit: int = 50, offset: int = 0
|
||||
) -> List[ArticleData]:
|
||||
"""Get paginated articles for a source.
|
||||
|
||||
Args:
|
||||
source_name: Newspaper source name
|
||||
limit: Maximum number of articles to return
|
||||
offset: Number of articles to skip
|
||||
|
||||
Returns:
|
||||
List of ArticleData objects
|
||||
"""
|
||||
try:
|
||||
_init_database()
|
||||
|
||||
with _get_db_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT * FROM articles
|
||||
WHERE source_name = ?
|
||||
ORDER BY publish_date DESC, created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""",
|
||||
(source_name, limit, offset),
|
||||
)
|
||||
|
||||
rows = cursor.fetchall()
|
||||
|
||||
articles = []
|
||||
for row in rows:
|
||||
archive_path = (
|
||||
Path(row["archive_file_path"]) if row["archive_file_path"] else None
|
||||
)
|
||||
# Handle both absolute and relative paths
|
||||
if archive_path:
|
||||
if not archive_path.is_absolute():
|
||||
archive_path = ARCHIVE_DIR / archive_path
|
||||
# Return relative path for web interface
|
||||
archive_file_path = str(archive_path.relative_to(ARCHIVE_DIR))
|
||||
else:
|
||||
archive_file_path = None
|
||||
archive_content = None
|
||||
if archive_path and archive_path.exists():
|
||||
archive_content = archive_path.read_text(encoding="utf-8")
|
||||
|
||||
article = ArticleData(
|
||||
url=row["article_url"],
|
||||
title=row["title"],
|
||||
author=row["author"],
|
||||
publish_date=row["publish_date"],
|
||||
content_text=row["content_text"],
|
||||
content_html=row["content_html"],
|
||||
raw_html=archive_content,
|
||||
archive_file_path=archive_file_path,
|
||||
tags=None,
|
||||
language=None,
|
||||
metadata=None,
|
||||
extraction_method=None,
|
||||
error=row["error_message"],
|
||||
guid=row["article_guid"],
|
||||
id=row["id"],
|
||||
source_name=source_name,
|
||||
)
|
||||
|
||||
articles.append(article)
|
||||
|
||||
return articles
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get articles for %s: %s", source_name, str(e))
|
||||
return []
|
||||
|
||||
|
||||
def update_article_status(
|
||||
source_name: str, article_url: str, status: str, error: str = None
|
||||
) -> None:
|
||||
"""Update article status in cache.
|
||||
|
||||
Args:
|
||||
source_name: Newspaper source name
|
||||
article_url: Article URL
|
||||
status: New status (pending, archived, failed)
|
||||
error: Error message if status is failed
|
||||
"""
|
||||
try:
|
||||
_init_database()
|
||||
|
||||
conn = _get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE articles
|
||||
SET status = ?, error_message = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE article_url = ? AND source_name = ?
|
||||
""",
|
||||
(status, error, article_url, source_name),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
if error:
|
||||
_log_processing(
|
||||
source_name,
|
||||
"update_status",
|
||||
"error",
|
||||
f"Updated {article_url} status to {status}: {error}",
|
||||
)
|
||||
else:
|
||||
_log_processing(
|
||||
source_name,
|
||||
"update_status",
|
||||
"success",
|
||||
f"Updated {article_url} status to {status}",
|
||||
)
|
||||
|
||||
logger.info("Updated article status: %s -> %s", article_url, status)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to update article status: %s", str(e))
|
||||
|
||||
|
||||
def get_source_stats(source_name: str) -> dict:
|
||||
"""Get statistics for a news source.
|
||||
|
||||
Args:
|
||||
source_name: Newspaper source name
|
||||
|
||||
Returns:
|
||||
Dictionary with source statistics
|
||||
"""
|
||||
try:
|
||||
_init_database()
|
||||
|
||||
conn = _get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN status = 'archived' THEN 1 ELSE 0 END) as archived,
|
||||
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed,
|
||||
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
|
||||
MIN(created_at) as first_archived,
|
||||
MAX(created_at) as last_archived,
|
||||
MAX(publish_date) as latest_article_date
|
||||
FROM articles
|
||||
WHERE source_name = ?
|
||||
""",
|
||||
(source_name,),
|
||||
)
|
||||
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
stats = {
|
||||
"source_name": source_name,
|
||||
"total_articles": row["total"] or 0,
|
||||
"archived": row["archived"] or 0,
|
||||
"failed": row["failed"] or 0,
|
||||
"pending": row["pending"] or 0,
|
||||
"first_archived": row["first_archived"],
|
||||
"last_archived": row["last_archived"],
|
||||
"latest_article_date": row["latest_article_date"],
|
||||
}
|
||||
|
||||
_log_processing(
|
||||
source_name,
|
||||
"get_stats",
|
||||
"success",
|
||||
f"Stats: {stats['total_articles']} total, {stats['archived']} archived, {stats['failed']} failed",
|
||||
)
|
||||
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get stats for %s: %s", source_name, str(e))
|
||||
return {
|
||||
"source_name": source_name,
|
||||
"total_articles": 0,
|
||||
"archived": 0,
|
||||
"failed": 0,
|
||||
"pending": 0,
|
||||
}
|
||||
|
||||
|
||||
def get_all_sources() -> List[str]:
|
||||
"""Get list of all sources in the archive.
|
||||
|
||||
Returns:
|
||||
List of source names
|
||||
"""
|
||||
try:
|
||||
_init_database()
|
||||
|
||||
conn = _get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT DISTINCT source_name FROM articles ORDER BY source_name
|
||||
""")
|
||||
|
||||
sources = [row["source_name"] for row in cursor.fetchall()]
|
||||
conn.close()
|
||||
|
||||
return sources
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get sources: %s", str(e))
|
||||
return []
|
||||
|
||||
|
||||
def get_latest_articles(limit: int = 50) -> List[ArticleData]:
|
||||
"""Get latest articles across all sources.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of articles to return
|
||||
|
||||
Returns:
|
||||
List of ArticleData objects ordered by publish_date DESC
|
||||
"""
|
||||
try:
|
||||
_init_database()
|
||||
|
||||
conn = _get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT * FROM articles
|
||||
ORDER BY publish_date DESC, created_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
|
||||
rows = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
articles = []
|
||||
for row in rows:
|
||||
archive_path = (
|
||||
Path(row["archive_file_path"]) if row["archive_file_path"] else None
|
||||
)
|
||||
# Handle both absolute and relative paths
|
||||
if archive_path:
|
||||
if not archive_path.is_absolute():
|
||||
archive_path = ARCHIVE_DIR / archive_path
|
||||
# Return relative path for web interface
|
||||
archive_file_path = str(archive_path.relative_to(ARCHIVE_DIR))
|
||||
else:
|
||||
archive_file_path = None
|
||||
archive_content = None
|
||||
if archive_path and archive_path.exists():
|
||||
archive_content = archive_path.read_text(encoding="utf-8")
|
||||
|
||||
article = ArticleData(
|
||||
url=row["article_url"],
|
||||
title=row["title"],
|
||||
author=row["author"],
|
||||
publish_date=row["publish_date"],
|
||||
content_text=row["content_text"],
|
||||
content_html=row["content_html"],
|
||||
raw_html=archive_content,
|
||||
archive_file_path=archive_file_path,
|
||||
tags=None,
|
||||
language=None,
|
||||
metadata=None,
|
||||
extraction_method=None,
|
||||
error=row["error_message"],
|
||||
guid=row["article_guid"],
|
||||
id=row["id"],
|
||||
source_name=row["source_name"],
|
||||
)
|
||||
|
||||
articles.append(article)
|
||||
|
||||
return articles
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get latest articles: %s", str(e))
|
||||
return []
|
||||
|
||||
|
||||
def get_source_directory(source_name: str) -> Path:
|
||||
"""Get the directory path for a source.
|
||||
|
||||
Args:
|
||||
source_name: Newspaper source name
|
||||
|
||||
Returns:
|
||||
Path to source directory
|
||||
"""
|
||||
return WEBSITES_DIR / source_name
|
||||
|
||||
|
||||
def get_archive_file_path_from_db(
|
||||
article_url: str, source_name: str = None
|
||||
) -> Optional[str]:
|
||||
"""Get archive file path from database mapping.
|
||||
|
||||
Args:
|
||||
article_url: Article URL
|
||||
source_name: Newspaper source name (optional, for filtering)
|
||||
|
||||
Returns:
|
||||
Archive file path if found, None otherwise
|
||||
"""
|
||||
try:
|
||||
_init_database()
|
||||
|
||||
conn = _get_db_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
if source_name:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT archive_file_path FROM article_archives
|
||||
WHERE article_url = ? AND source_name = ?
|
||||
""",
|
||||
(article_url, source_name),
|
||||
)
|
||||
else:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT archive_file_path FROM article_archives
|
||||
WHERE article_url = ?
|
||||
""",
|
||||
(article_url,),
|
||||
)
|
||||
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
return row["archive_file_path"] if row else None
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get archive file path from DB: %s", str(e))
|
||||
return None
|
||||
|
||||
|
||||
def get_daily_articles(source_name: str, date_str: str) -> List[ArticleData]:
|
||||
"""Get articles for a specific date.
|
||||
|
||||
Args:
|
||||
source_name: Newspaper source name
|
||||
date_str: Date string in YYYY-MM-DD format
|
||||
|
||||
Returns:
|
||||
List of ArticleData objects
|
||||
"""
|
||||
try:
|
||||
source_dir = get_source_directory(source_name)
|
||||
articles_dir = source_dir / "articles" / date_str
|
||||
|
||||
if not articles_dir.exists():
|
||||
return []
|
||||
|
||||
articles = []
|
||||
for json_file in sorted(articles_dir.glob("article_*.json")):
|
||||
try:
|
||||
with open(json_file, "r", encoding="utf-8") as f:
|
||||
metadata = json.load(f)
|
||||
|
||||
archive_filename = metadata.get("archive_file", "")
|
||||
archive_path = (
|
||||
source_dir / "html" / date_str / archive_filename
|
||||
if archive_filename
|
||||
else None
|
||||
)
|
||||
archive_content = None
|
||||
if archive_path and archive_path.exists():
|
||||
archive_content = archive_path.read_text(encoding="utf-8")
|
||||
|
||||
article = ArticleData(
|
||||
url=metadata.get("url", ""),
|
||||
title=metadata.get("title"),
|
||||
author=metadata.get("author"),
|
||||
publish_date=metadata.get("publish_date"),
|
||||
content_text=metadata.get("content_text"),
|
||||
content_html=metadata.get("content_html"),
|
||||
raw_html=archive_content,
|
||||
# Handle both absolute and relative paths
|
||||
archive_file_path=str(archive_path.relative_to(ARCHIVE_DIR))
|
||||
if archive_path
|
||||
else None,
|
||||
tags=metadata.get("tags"),
|
||||
language=metadata.get("language"),
|
||||
metadata=metadata,
|
||||
extraction_method=metadata.get("extraction_method"),
|
||||
source_name=source_name,
|
||||
)
|
||||
|
||||
articles.append(article)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load article from %s: %s", json_file, str(e))
|
||||
continue
|
||||
|
||||
return articles
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Failed to get daily articles for %s on %s: %s",
|
||||
source_name,
|
||||
date_str,
|
||||
str(e),
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def initialize_storage() -> None:
|
||||
"""Initialize the storage system.
|
||||
|
||||
Creates database, directory structure, and logs initialization.
|
||||
"""
|
||||
logger.info("Initializing storage system...")
|
||||
|
||||
_init_database()
|
||||
|
||||
(WEBSITES_DIR / "sample").mkdir(parents=True, exist_ok=True)
|
||||
(WEBSITES_DIR / "sample" / "html").mkdir(exist_ok=True)
|
||||
(WEBSITES_DIR / "sample" / "articles").mkdir(exist_ok=True)
|
||||
|
||||
_log_processing("system", "initialize", "success", "Storage system initialized")
|
||||
|
||||
logger.info("Storage system initialized at %s", ARCHIVE_DIR)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
initialize_storage()
|
||||
|
||||
sources = get_all_sources()
|
||||
print(f"Sources in archive: {sources}")
|
||||
|
||||
if sources:
|
||||
for source in sources:
|
||||
stats = get_source_stats(source)
|
||||
print(f"\n{source}:")
|
||||
print(f" Total: {stats['total_articles']}")
|
||||
print(f" Archived: {stats['archived']}")
|
||||
print(f" Failed: {stats['failed']}")
|
||||
46
templates/article.html
Normal file
46
templates/article.html
Normal file
@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<article class="article-view">
|
||||
<div class="article-header">
|
||||
<div class="article-source">
|
||||
<span class="source-label">From</span>
|
||||
<span class="source-name">{{ source_name }}</span>
|
||||
</div>
|
||||
<h1>{{ article.title }}</h1>
|
||||
|
||||
<div class="article-meta">
|
||||
<div class="article-meta-row">
|
||||
{% if article.publish_date %}
|
||||
<time class="article-date" datetime="{{ article.publish_date }}">{{ article.publish_date }}</time>
|
||||
{% endif %}
|
||||
{% if article.author %}
|
||||
<span class="article-author">By <strong>{{ article.author }}</strong></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p class="article-url"><a href="{{ article.url }}" target="_blank">{{ article.url }}</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="article-content">
|
||||
{% if article.content_text %}
|
||||
<div class="article-text">
|
||||
{% set lines = article.content_text.split('\n') -%}
|
||||
{%- for line in lines %}
|
||||
{%- if line|trim %}
|
||||
<p>{{ line }}</p>
|
||||
{%- endif %}
|
||||
{%- endfor %}
|
||||
</div>
|
||||
{%- endif %}
|
||||
</div>
|
||||
|
||||
<div class="article-actions">
|
||||
<a href="/source/{{ source_slug }}" class="back-link">← Back to articles</a>
|
||||
{% if article.archive_file_path %}
|
||||
|
|
||||
<a href="/archive-file/{{ article.archive_file_path|urlencode }}" target="_blank" title="View archived copy">Archived HTML</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
{% endblock %}
|
||||
11
templates/article_not_found.html
Normal file
11
templates/article_not_found.html
Normal file
@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="article-not-found">
|
||||
<h1>Article Not Found</h1>
|
||||
<p>The article you're looking for could not be found.</p>
|
||||
<p>Source: {{ slug }}</p>
|
||||
<p>ID: {{ article_id }}</p>
|
||||
<a href="/source/{{ slug }}" class="back-link">← Back to articles</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
44
templates/articles.html
Normal file
44
templates/articles.html
Normal file
@ -0,0 +1,44 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ source_name }} - Articles</h1>
|
||||
|
||||
<div class="pagination">
|
||||
{% if pagination.has_prev %}
|
||||
<a href="/source/{{ source_slug }}?page={{ pagination.prev_num }}">« Previous</a>
|
||||
{% endif %}
|
||||
<span>Page {{ pagination.page }} of {{ pagination.pages }}</span>
|
||||
{% if pagination.has_next %}
|
||||
<a href="/source/{{ source_slug }}?page={{ pagination.next_num }}">Next »</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="timeline">
|
||||
{% for date, articles in articles_by_date %}
|
||||
<div class="timeline-date">
|
||||
<span class="date-label">{{ date }}</span>
|
||||
<span class="date-count">{{ articles|length }} article{% if articles|length != 1 %}s{% endif %}</span>
|
||||
</div>
|
||||
<ul class="article-list">
|
||||
{% for article in articles %}
|
||||
<li class="article-item">
|
||||
<div class="article-main">
|
||||
<h3><a href="{{ article.url }}">{{ article.title }}</a></h3>
|
||||
<p class="article-summary">{{ article.summary }}</p>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="pagination">
|
||||
{% if pagination.has_prev %}
|
||||
<a href="/source/{{ source_slug }}?page={{ pagination.prev_num }}">« Previous</a>
|
||||
{% endif %}
|
||||
<span>Page {{ pagination.page }} of {{ pagination.pages }}</span>
|
||||
{% if pagination.has_next %}
|
||||
<a href="/source/{{ source_slug }}?page={{ pagination.next_num }}">Next »</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
25
templates/atom.xml
Normal file
25
templates/atom.xml
Normal file
@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>{{ title|e }}</title>
|
||||
<link href="{{ link|e }}" rel="alternate"/>
|
||||
<link href="{{ link|e }}/atom" rel="self" type="application/atom+xml"/>
|
||||
<updated>{{ updated|e }}</updated>
|
||||
<id>{{ link|e }}</id>
|
||||
<generator>NewsArchiver</generator>
|
||||
{% for entry in entries %}
|
||||
<entry>
|
||||
<title>{{ entry.title|e }}</title>
|
||||
<link href="{{ entry.link|e }}" rel="alternate"/>
|
||||
{% if entry.published %}
|
||||
<published>{{ entry.published|e }}</published>
|
||||
{% endif %}
|
||||
{% if entry.author %}
|
||||
<author>
|
||||
<name>{{ entry.author.name|e }}</name>
|
||||
</author>
|
||||
{% endif %}
|
||||
<id>{{ entry.id|e }}</id>
|
||||
<summary>{{ entry.summary|e }}</summary>
|
||||
</entry>
|
||||
{% endfor %}
|
||||
</feed>
|
||||
80
templates/base.html
Normal file
80
templates/base.html
Normal file
@ -0,0 +1,80 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" id="html" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>NewsArchiver</title>
|
||||
<meta name="description" content="NewsArchiver - Archive news articles from RSS feeds">
|
||||
<meta property="og:title" content="NewsArchiver">
|
||||
<meta property="og:description" content="Archive news articles from RSS feeds">
|
||||
<meta property="og:type" content="website">
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E📰%3C/text%3E%3C/svg%3E">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
<style>
|
||||
#theme-toggle {
|
||||
background: none;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--primary-color);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
#theme-toggle:hover {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.theme-icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>NewsArchiver</h1>
|
||||
<nav>
|
||||
<a href="/">Archives</a>
|
||||
<a href="/status">Status</a>
|
||||
<button id="theme-toggle" aria-label="Toggle dark mode">
|
||||
<span class="theme-icon" id="theme-icon">☀️</span>
|
||||
<span id="theme-text">Light</span>
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<script>
|
||||
const html = document.getElementById('html');
|
||||
const themeToggle = document.getElementById('theme-toggle');
|
||||
const themeIcon = document.getElementById('theme-icon');
|
||||
const themeText = document.getElementById('theme-text');
|
||||
|
||||
const savedTheme = localStorage.getItem('theme') || 'light';
|
||||
setTheme(savedTheme);
|
||||
|
||||
themeToggle.addEventListener('click', () => {
|
||||
const currentTheme = html.getAttribute('data-theme');
|
||||
const newTheme = currentTheme === 'light' ? 'dark' : 'light';
|
||||
setTheme(newTheme);
|
||||
});
|
||||
|
||||
function setTheme(theme) {
|
||||
html.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
|
||||
if (theme === 'dark') {
|
||||
themeIcon.textContent = '☀️';
|
||||
themeText.textContent = 'Light';
|
||||
} else {
|
||||
themeIcon.textContent = '🌙';
|
||||
themeText.textContent = 'Dark';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
31
templates/index.html
Normal file
31
templates/index.html
Normal file
@ -0,0 +1,31 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1>News Archives</h1>
|
||||
<p>Total sources: {{ sources|length }}</p>
|
||||
|
||||
<ul class="newspaper-list">
|
||||
{% for source in sources %}
|
||||
<li class="newspaper-item {% if source.disabled %}disabled{% endif %}">
|
||||
<div class="newspaper-info">
|
||||
{% if source.disabled %}
|
||||
<h2 class="source-disabled" title="{{ source.disable_reason }}">
|
||||
{{ source.name }}
|
||||
<span class="status-badge">Disabled</span>
|
||||
</h2>
|
||||
{% else %}
|
||||
<h2><a href="/source/{{ source.slug }}">{{ source.name }}</a></h2>
|
||||
{% endif %}
|
||||
<p>Articles: {{ source.article_count }}</p>
|
||||
<p>Last archived: {{ source.last_archived or 'N/A' }}</p>
|
||||
{% if source.disabled %}
|
||||
<p class="disable-reason">{{ source.disable_reason }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not source.disabled %}
|
||||
<button class="pull-btn" data-source="{{ source.slug }}" aria-label="Pull latest articles from {{ source.name }}">Pull latest</button>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
95
templates/login.html
Normal file
95
templates/login.html
Normal file
@ -0,0 +1,95 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="login-container">
|
||||
<div class="login-box">
|
||||
<h2>NewsArchiver Login</h2>
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<form method="POST" action="{{ url_for('login') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
{% if request.args.get('next') %}
|
||||
<input type="hidden" name="next" value="{{ request.args.get('next') }}">
|
||||
{% endif %}
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required autofocus>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.login-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 60vh;
|
||||
}
|
||||
.login-box {
|
||||
background: var(--card-bg, #fff);
|
||||
border: 1px solid var(--border-color, #ddd);
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.login-box h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border-color, #ccc);
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.btn {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--accent-color, #0066cc);
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.alert {
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.alert-error {
|
||||
background: #fee;
|
||||
color: #c00;
|
||||
border: 1px solid #fcc;
|
||||
}
|
||||
.alert-info {
|
||||
background: #eef;
|
||||
color: #00c;
|
||||
border: 1px solid #ccf;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
25
templates/rss.xml
Normal file
25
templates/rss.xml
Normal file
@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>{{ title|e }}</title>
|
||||
<link>{{ link|e }}</link>
|
||||
<description>{{ description|e }}</description>
|
||||
<lastBuildDate>{{ last_build_date|e }}</lastBuildDate>
|
||||
<generator>NewsArchiver</generator>
|
||||
<atom:link href="{{ link|e }}/rss" rel="self" type="application/rss+xml" />
|
||||
{% for item in items %}
|
||||
<item>
|
||||
<title>{{ item.title|e }}</title>
|
||||
<link>{{ item.link|e }}</link>
|
||||
{% if item.pubDate %}
|
||||
<pubDate>{{ item.pubDate|e }}</pubDate>
|
||||
{% endif %}
|
||||
{% if item.author %}
|
||||
<author>{{ item.author|e }}</author>
|
||||
{% endif %}
|
||||
<guid isPermaLink="false">{{ item.guid|e }}</guid>
|
||||
<description>{{ item.description|e }}</description>
|
||||
</item>
|
||||
{% endfor %}
|
||||
</channel>
|
||||
</rss>
|
||||
50
templates/status.html
Normal file
50
templates/status.html
Normal file
@ -0,0 +1,50 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="status-page">
|
||||
<h1>System Status</h1>
|
||||
|
||||
<div class="status-summary">
|
||||
<div class="status-item">
|
||||
<h2>System</h2>
|
||||
<p>Status: <span class="status-online">Online</span></p>
|
||||
<p>Last Archive Run: {{ last_archive_run or 'Never' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="status-item">
|
||||
<h2>Statistics</h2>
|
||||
<p>Sources Monitored: {{ sources_monitored }}</p>
|
||||
{% if disabled_sources > 0 %}
|
||||
<p>Disabled Sources: <span class="status-warning">{{ disabled_sources }}</span></p>
|
||||
{% endif %}
|
||||
<p>Total Articles: {{ total_articles }}</p>
|
||||
<p>Failed Jobs: {{ failed_jobs }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if sources %}
|
||||
<h2>Monitored Sources</h2>
|
||||
<ul class="newspaper-list">
|
||||
{% for source in sources %}
|
||||
<li class="newspaper-item {% if source.disabled %}disabled{% endif %}">
|
||||
<div class="newspaper-info">
|
||||
{% if source.disabled %}
|
||||
<h2 class="source-disabled" title="{{ source.disable_reason }}">
|
||||
{{ source.name }}
|
||||
<span class="status-badge">Disabled</span>
|
||||
</h2>
|
||||
{% else %}
|
||||
<h2><a href="/source/{{ source.slug }}">{{ source.name }}</a></h2>
|
||||
{% endif %}
|
||||
<p>Articles: {{ source.article_count }}</p>
|
||||
<p class="status-{{ source.status }}">{{ source.status|upper }}</p>
|
||||
{% if source.disabled %}
|
||||
<p class="disable-reason">{{ source.disable_reason }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
13
tests/playwright/package.json
Normal file
13
tests/playwright/package.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "newsarchiver-e2e",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.1",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:ui": "playwright test --ui"
|
||||
}
|
||||
}
|
||||
15
tests/playwright/playwright.config.js
Normal file
15
tests/playwright/playwright.config.js
Normal file
@ -0,0 +1,15 @@
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: process.env.CI ? [['list'], ['html']] : 'list',
|
||||
use: {
|
||||
baseURL: process.env.APP_URL || 'http://localhost:5000',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
});
|
||||
128
tests/playwright/tests/ui.spec.ts
Normal file
128
tests/playwright/tests/ui.spec.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
const TEST_SOURCE = 'Test News';
|
||||
|
||||
test.describe('Home page', () => {
|
||||
test('loads and shows sources', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveTitle('NewsArchiver');
|
||||
await expect(page.locator('h1').filter({ hasText: 'News Archives' })).toBeVisible();
|
||||
await expect(page.locator('.newspaper-list')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows source count', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const count = await page.locator('p:has-text("Total sources:")').textContent();
|
||||
expect(count || '').toMatch(/Total sources: \d+/);
|
||||
});
|
||||
|
||||
test('navigates to a source', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const firstLink = page.locator('.newspaper-item h2 a').first();
|
||||
const sourceName = (await firstLink.textContent())?.trim() || '';
|
||||
await firstLink.click();
|
||||
await expect(page).toHaveURL(/\/source\//);
|
||||
await expect(page.locator('h1')).toContainText(sourceName);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Article listing', () => {
|
||||
test('shows articles for a source', async ({ page }) => {
|
||||
await page.goto(`/source/${TEST_SOURCE.toLowerCase()}`);
|
||||
await expect(page.locator('.article-list')).toBeVisible();
|
||||
const articles = page.locator('.article-item');
|
||||
await expect.poll(() => articles.count()).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('article links navigate to detail', async ({ page }) => {
|
||||
await page.goto(`/source/${TEST_SOURCE.toLowerCase()}`);
|
||||
const firstArticle = page.locator('.article-item h3 a').first();
|
||||
const title = (await firstArticle.textContent())?.trim() || '';
|
||||
await firstArticle.click();
|
||||
await expect(page).toHaveURL(/\/article\/\d+/);
|
||||
await expect(page.locator('.article-view h1')).toContainText(title);
|
||||
});
|
||||
|
||||
test('shows article metadata', async ({ page }) => {
|
||||
await page.goto(`/source/${TEST_SOURCE.toLowerCase()}/article/1`);
|
||||
await expect(page.locator('.article-source')).toBeVisible();
|
||||
await expect(page.locator('.article-meta')).toBeVisible();
|
||||
});
|
||||
|
||||
test('back link returns to listing', async ({ page }) => {
|
||||
await page.goto(`/source/${TEST_SOURCE.toLowerCase()}/article/1`);
|
||||
await page.locator('.back-link').click();
|
||||
await expect(page).toHaveURL(new RegExp(`/source/${TEST_SOURCE.toLowerCase()}`));
|
||||
await expect(page.locator('.article-list')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Pagination', () => {
|
||||
test('shows page info', async ({ page }) => {
|
||||
await page.goto(`/source/${TEST_SOURCE.toLowerCase()}`);
|
||||
await expect(page.locator('.pagination span')).toContainText('Page 1');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Navigation', () => {
|
||||
test('header nav links work', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('nav a:has-text("Archives")').click();
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
|
||||
await page.locator('nav a:has-text("Status")').click();
|
||||
await expect(page).toHaveURL(/\/status/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Status page', () => {
|
||||
test('shows system status', async ({ page }) => {
|
||||
await page.goto('/status');
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows total articles count', async ({ page }) => {
|
||||
await page.goto('/status');
|
||||
await expect(page.locator('text=/Total Articles: \d+/')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('RSS feed', () => {
|
||||
test('serves RSS XML', async ({ request }) => {
|
||||
const resp = await request.get('/rss');
|
||||
expect(resp.ok()).toBeTruthy();
|
||||
expect(resp.headers()['content-type']).toContain('application/rss+xml');
|
||||
const body = await resp.text();
|
||||
expect(body).toContain('<rss');
|
||||
expect(body).toContain('<channel>');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Atom feed', () => {
|
||||
test('serves Atom XML', async ({ request }) => {
|
||||
const resp = await request.get('/atom');
|
||||
expect(resp.ok()).toBeTruthy();
|
||||
expect(resp.headers()['content-type']).toContain('application/atom+xml');
|
||||
const body = await resp.text();
|
||||
expect(body).toContain('<feed');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Theme toggle', () => {
|
||||
test('toggles dark mode', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const html = page.locator('html');
|
||||
await page.locator('#theme-toggle').click();
|
||||
await expect(html).toHaveAttribute('data-theme', 'dark');
|
||||
|
||||
await page.locator('#theme-toggle').click();
|
||||
await expect(html).toHaveAttribute('data-theme', 'light');
|
||||
});
|
||||
|
||||
test('persists theme in localStorage', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('#theme-toggle').click();
|
||||
const theme = await page.evaluate(() => localStorage.getItem('theme'));
|
||||
expect(theme).toBe('dark');
|
||||
});
|
||||
});
|
||||
77
tests/test_path_handling.py
Normal file
77
tests/test_path_handling.py
Normal file
@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for path handling and security."""
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
|
||||
def test_path_traversal_blocked():
|
||||
"""Test that path traversal is blocked in archive routes."""
|
||||
archive_dir = Path(tempfile.mkdtemp())
|
||||
|
||||
def validate_archive_path(archive_path: str):
|
||||
decoded = unquote(archive_path)
|
||||
try:
|
||||
archive_file = (archive_dir / decoded).resolve()
|
||||
if not str(archive_file).startswith(str(archive_dir)):
|
||||
return None
|
||||
return archive_file
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
|
||||
for bad_path in [
|
||||
"../../../etc/passwd",
|
||||
"..%2F..%2F..%2Fetc%2Fpasswd",
|
||||
"../secret.txt",
|
||||
"websites/../../etc/passwd",
|
||||
]:
|
||||
result = validate_archive_path(bad_path)
|
||||
assert result is None, f"Traversal should be blocked: {bad_path}"
|
||||
print(f" PASS: blocked '{bad_path}'")
|
||||
|
||||
valid = validate_archive_path("websites/Test/html/2024-01-01/article.html")
|
||||
assert valid is not None
|
||||
assert str(valid).startswith(str(archive_dir))
|
||||
print(f" PASS: allowed valid path")
|
||||
|
||||
|
||||
def test_path_traversal_file_access():
|
||||
"""Test that path traversal cannot read files outside archive dir."""
|
||||
archive_dir = Path(tempfile.mkdtemp())
|
||||
secret_file = archive_dir.parent / "secret.txt"
|
||||
secret_file.write_text("top secret")
|
||||
|
||||
def validate_archive_path(archive_path: str):
|
||||
decoded = unquote(archive_path)
|
||||
try:
|
||||
archive_file = (archive_dir / decoded).resolve()
|
||||
if not str(archive_file).startswith(str(archive_dir)):
|
||||
return None
|
||||
return archive_file
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
|
||||
blocked = validate_archive_path(f"../secret.txt")
|
||||
assert blocked is None
|
||||
print(f" PASS: file access traversal blocked")
|
||||
secret_file.unlink()
|
||||
|
||||
|
||||
def test_url_encoding():
|
||||
"""Test URL encoding/decoding in archive paths."""
|
||||
archive_file_path = "websites/404 Media/html/2024-01-15/article_001.html"
|
||||
encoded = archive_file_path.replace(" ", "%20")
|
||||
decoded = unquote(encoded)
|
||||
assert decoded == archive_file_path
|
||||
print(f" PASS: encoding round-trip")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_path_traversal_blocked()
|
||||
test_path_traversal_file_access()
|
||||
test_url_encoding()
|
||||
print("\nAll path handling tests passed!")
|
||||
63
tests/test_storage_manager.py
Normal file
63
tests/test_storage_manager.py
Normal file
@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for storage_manager."""
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
|
||||
def test_relative_path_format():
|
||||
"""Test relative path format for archive files."""
|
||||
archive_dir = Path(tempfile.mkdtemp())
|
||||
archive_file_path = archive_dir / "websites/TestSource/html/2024-01-15/article_001.html"
|
||||
archive_file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
archive_file_path.touch()
|
||||
|
||||
stored_path = str(archive_file_path.relative_to(archive_dir))
|
||||
assert not Path(stored_path).is_absolute()
|
||||
assert stored_path == "websites/TestSource/html/2024-01-15/article_001.html"
|
||||
|
||||
retrieved_path = Path(stored_path)
|
||||
full_path = archive_dir / retrieved_path if not retrieved_path.is_absolute() else retrieved_path
|
||||
assert str(full_path) == str(archive_file_path)
|
||||
|
||||
web_path = str(full_path.relative_to(archive_dir))
|
||||
assert web_path == stored_path
|
||||
print(f" PASS: stored={stored_path}")
|
||||
|
||||
|
||||
def test_archive_file_url_generation():
|
||||
"""Test URL generation for archived files."""
|
||||
archive_file_path = "websites/404 Media/html/2024-01-15/article_001.html"
|
||||
url = f"/archive-file/{archive_file_path}"
|
||||
assert url == "/archive-file/websites/404 Media/html/2024-01-15/article_001.html"
|
||||
|
||||
decoded_path = unquote(archive_file_path)
|
||||
archive_dir = Path("/tmp/test_archives")
|
||||
full_path = archive_dir / decoded_path
|
||||
assert str(full_path) == str(archive_dir / "websites/404 Media/html/2024-01-15/article_001.html")
|
||||
print(f" PASS: url={url}")
|
||||
|
||||
|
||||
def test_multiple_sources():
|
||||
"""Test different sources get correct paths."""
|
||||
archive_dir = Path(tempfile.mkdtemp())
|
||||
for source in ["404 Media", "TestSource", "Another Source"]:
|
||||
archive_file_path = archive_dir / f"websites/{source}/html/2024-01-15/article_001.html"
|
||||
archive_file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
stored_path = str(archive_file_path.relative_to(archive_dir))
|
||||
parts = Path(stored_path).parts
|
||||
assert parts[0] == "websites"
|
||||
assert parts[1] == source
|
||||
assert parts[2] == "html"
|
||||
print(f" PASS: source='{source}' path={stored_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_relative_path_format()
|
||||
test_archive_file_url_generation()
|
||||
test_multiple_sources()
|
||||
print("\nAll storage_manager tests passed!")
|
||||
71
tests/test_web_interface.py
Normal file
71
tests/test_web_interface.py
Normal file
@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for web_interface.py security features."""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
|
||||
def test_validate_archive_path_blocks_traversal():
|
||||
"""Test validate_archive_path function blocks traversal."""
|
||||
from web_interface import validate_archive_path, ARCHIVE_DIR
|
||||
|
||||
for bad in ["../../../etc/passwd", "..%2Fetc%2Fpasswd", "../secret.txt"]:
|
||||
result = validate_archive_path(bad)
|
||||
assert result is None, f"Should block: {bad}"
|
||||
print(" PASS: traversal blocked")
|
||||
|
||||
|
||||
def test_xss_safe_filter_removed():
|
||||
"""Test that article.html no longer uses |safe filter."""
|
||||
result = subprocess.run(
|
||||
["grep", "-rn", "|safe", "templates/article.html"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode != 0, "article.html should not contain |safe"
|
||||
print(" PASS: no |safe in article.html")
|
||||
|
||||
|
||||
def test_security_headers_present():
|
||||
"""Test security headers are added to responses."""
|
||||
from web_interface import app
|
||||
client = app.test_client()
|
||||
resp = client.get("/")
|
||||
assert resp.headers.get("X-Content-Type-Options") == "nosniff"
|
||||
assert resp.headers.get("X-Frame-Options") == "DENY"
|
||||
assert resp.headers.get("X-XSS-Protection") == "1; mode=block"
|
||||
assert resp.headers.get("Referrer-Policy") == "strict-origin-when-cross-origin"
|
||||
print(" PASS: security headers present")
|
||||
|
||||
|
||||
def test_server_url_not_hardcoded():
|
||||
"""Test that SERVER_URL comes from env, not hardcoded."""
|
||||
result = subprocess.run(
|
||||
["grep", "-n", "192\\.168", "web_interface.py"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode != 0, "No hardcoded internal IPs"
|
||||
print(" PASS: no hardcoded IPs")
|
||||
|
||||
|
||||
def test_no_safe_filter_in_rss():
|
||||
"""Test RSS/Atom feeds escape content properly."""
|
||||
result = subprocess.run(
|
||||
["grep", "-rn", "|safe", "templates/rss.xml", "templates/atom.xml"],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
assert result.returncode != 0, "No |safe in RSS templates"
|
||||
print(" PASS: no |safe in RSS/Atom")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_validate_archive_path_blocks_traversal()
|
||||
test_xss_safe_filter_removed()
|
||||
test_security_headers_present()
|
||||
test_server_url_not_hardcoded()
|
||||
test_no_safe_filter_in_rss()
|
||||
print("\nAll web interface tests passed!")
|
||||
5
version.json
Normal file
5
version.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"major": 1,
|
||||
"minor": 0,
|
||||
"patch": 413
|
||||
}
|
||||
704
web_interface.py
Normal file
704
web_interface.py
Normal file
@ -0,0 +1,704 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Web Interface for NewsArchiver - Phase 3
|
||||
|
||||
Flask web server for browsing archived news articles.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime, timezone
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import quote, unquote, urlparse
|
||||
|
||||
from flask import (
|
||||
Flask,
|
||||
abort,
|
||||
flash,
|
||||
jsonify,
|
||||
make_response,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
session,
|
||||
url_for,
|
||||
)
|
||||
|
||||
from storage_manager import (
|
||||
DB_PATH,
|
||||
get_all_sources,
|
||||
get_article,
|
||||
get_articles_by_source,
|
||||
get_latest_articles,
|
||||
get_source_stats,
|
||||
)
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
ARCHIVE_DIR = Path(
|
||||
os.environ.get("ARCHIVE_DIR", str(SCRIPT_DIR / "archival_data"))
|
||||
).resolve()
|
||||
RSS_FEEDS_PATH = SCRIPT_DIR / "rss_feeds.json"
|
||||
|
||||
RSS_FEEDS = {}
|
||||
|
||||
# Authentication configuration
|
||||
ADMIN_PASSWORD = os.environ.get("NEWSARCHIVER_PASSWORD", "")
|
||||
SECRET_KEY = os.environ.get(
|
||||
"NEWSARCHIVER_SECRET_KEY", secrets.token_hex(32)
|
||||
)
|
||||
SESSION_LIFETIME_MINUTES = int(os.environ.get("NEWSARCHIVER_SESSION_MINUTES", "480"))
|
||||
|
||||
# Server URL configuration
|
||||
SERVER_URL = os.environ.get(
|
||||
"NEWSARCHIVER_SERVER_URL", f"http://localhost:5000"
|
||||
)
|
||||
|
||||
app = Flask(
|
||||
__name__,
|
||||
static_folder=str(SCRIPT_DIR / "static"),
|
||||
template_folder=str(SCRIPT_DIR / "templates"),
|
||||
)
|
||||
app.secret_key = SECRET_KEY
|
||||
|
||||
|
||||
def login_required(f):
|
||||
"""Decorator to require login for protected routes."""
|
||||
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not ADMIN_PASSWORD:
|
||||
return f(*args, **kwargs)
|
||||
if "authenticated" not in session:
|
||||
flash("Please log in to access this page.", "error")
|
||||
return redirect(url_for("login", next=request.url))
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
def generate_csrf_token():
|
||||
"""Generate a CSRF token tied to the session."""
|
||||
if "csrf_token" not in session:
|
||||
session["csrf_token"] = secrets.token_hex(32)
|
||||
return session["csrf_token"]
|
||||
|
||||
|
||||
def verify_csrf_token():
|
||||
"""Verify the CSRF token from the request."""
|
||||
if not ADMIN_PASSWORD:
|
||||
return True
|
||||
token = request.form.get("csrf_token") or request.headers.get("X-CSRF-Token")
|
||||
if not token or "csrf_token" not in session:
|
||||
return False
|
||||
return hmac.compare_digest(token, session["csrf_token"])
|
||||
|
||||
|
||||
@app.template_global()
|
||||
def csrf_token():
|
||||
"""Make CSRF token available in templates."""
|
||||
return generate_csrf_token()
|
||||
|
||||
|
||||
@app.before_request
|
||||
def enforce_session_timeout():
|
||||
"""Expire sessions after inactivity."""
|
||||
if "authenticated" in session:
|
||||
auth_time = session.get("auth_time", 0)
|
||||
now = datetime.now().timestamp()
|
||||
if now - auth_time > SESSION_LIFETIME_MINUTES * 60:
|
||||
session.clear()
|
||||
|
||||
|
||||
@app.after_request
|
||||
def add_security_headers(response):
|
||||
"""Add security headers to all responses."""
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
if not request.is_secure:
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data:; "
|
||||
"frame-ancestors 'none'"
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_pagination_info(total: int, page: int, per_page: int) -> dict:
|
||||
"""Calculate pagination information.
|
||||
|
||||
Args:
|
||||
total: Total number of items
|
||||
page: Current page number
|
||||
per_page: Items per page
|
||||
|
||||
Returns:
|
||||
Dictionary with pagination details
|
||||
"""
|
||||
total_pages = (total + per_page - 1) // per_page if total > 0 else 1
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"has_next": page < total_pages,
|
||||
"has_prev": page > 1,
|
||||
"next_num": page + 1 if page < total_pages else None,
|
||||
"prev_num": page - 1 if page > 1 else None,
|
||||
"pages": total_pages,
|
||||
}
|
||||
|
||||
|
||||
def sanitize_error_message(msg: str) -> str:
|
||||
"""Sanitize error messages to prevent information leakage."""
|
||||
sanitized = msg
|
||||
for pattern in [r"\d{1,3}(\.\d{1,3}){3}", r"/home/\w+"]:
|
||||
import re
|
||||
|
||||
sanitized = re.sub(pattern, "[REDACTED]", sanitized)
|
||||
return sanitized
|
||||
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
"""Login page."""
|
||||
if not ADMIN_PASSWORD:
|
||||
return redirect(url_for("index"))
|
||||
|
||||
if request.method == "POST":
|
||||
if not verify_csrf_token():
|
||||
flash("Invalid request.", "error")
|
||||
return redirect(url_for("login"))
|
||||
|
||||
password = request.form.get("password", "")
|
||||
if hmac.compare_digest(password, ADMIN_PASSWORD):
|
||||
session.clear()
|
||||
session["authenticated"] = True
|
||||
session["csrf_token"] = secrets.token_hex(32)
|
||||
session["auth_time"] = datetime.now().timestamp()
|
||||
next_url = request.form.get("next", url_for("index"))
|
||||
parsed = urlparse(next_url)
|
||||
if parsed.netloc:
|
||||
next_url = url_for("index")
|
||||
return redirect(next_url)
|
||||
else:
|
||||
flash("Invalid password.", "error")
|
||||
|
||||
return render_template("login.html", csrf_token=csrf_token())
|
||||
|
||||
|
||||
@app.route("/logout")
|
||||
def logout():
|
||||
"""Logout."""
|
||||
session.clear()
|
||||
return redirect(url_for("index"))
|
||||
|
||||
|
||||
@app.route("/")
|
||||
@login_required
|
||||
def index():
|
||||
"""Newspaper listing page."""
|
||||
sources = get_all_sources()
|
||||
rss_feeds = load_rss_feeds()
|
||||
|
||||
source_list = []
|
||||
disabled_sources = []
|
||||
|
||||
for source_name in sources:
|
||||
stats = get_source_stats(source_name)
|
||||
|
||||
source_info = {
|
||||
"name": source_name.title(),
|
||||
"slug": source_name,
|
||||
"article_count": stats["total_articles"],
|
||||
"last_archived": stats.get("last_archived"),
|
||||
"status": "success" if stats["total_articles"] > 0 else "pending",
|
||||
}
|
||||
|
||||
if source_name in rss_feeds:
|
||||
feed_info = rss_feeds[source_name]
|
||||
if feed_info.get("disabled", False):
|
||||
source_info["disabled"] = True
|
||||
source_info["disable_reason"] = feed_info.get(
|
||||
"disable_reason", "No reason provided"
|
||||
)
|
||||
disabled_sources.append(source_info)
|
||||
continue
|
||||
|
||||
source_list.append(source_info)
|
||||
|
||||
source_list.extend(disabled_sources)
|
||||
|
||||
return render_template("index.html", sources=source_list)
|
||||
|
||||
|
||||
@app.route("/source/<slug>")
|
||||
@login_required
|
||||
def articles(slug: str):
|
||||
"""Article listing page for a specific source."""
|
||||
page = request.args.get("page", 1, type=int)
|
||||
per_page = 50
|
||||
|
||||
sources = get_all_sources()
|
||||
source_name = None
|
||||
for s in sources:
|
||||
if s.lower() == slug.lower():
|
||||
source_name = s
|
||||
break
|
||||
|
||||
if not source_name:
|
||||
return render_template("article_not_found.html", slug=slug, article_id=0), 404
|
||||
|
||||
articles_list = get_articles_by_source(
|
||||
source_name, limit=per_page, offset=(page - 1) * per_page
|
||||
)
|
||||
stats = get_source_stats(source_name)
|
||||
total = stats["total_articles"]
|
||||
|
||||
pagination = get_pagination_info(total, page, per_page)
|
||||
|
||||
articles_data = []
|
||||
for article in articles_list:
|
||||
articles_data.append(
|
||||
{
|
||||
"id": getattr(article, "id", 0),
|
||||
"title": article.title or "Untitled",
|
||||
"date": article.publish_date or "",
|
||||
"summary": article.content_text[:200] if article.content_text else "",
|
||||
"url": f"/source/{source_name.lower()}/article/{getattr(article, 'id', 0)}",
|
||||
}
|
||||
)
|
||||
|
||||
from collections import OrderedDict
|
||||
grouped = OrderedDict()
|
||||
for a in articles_data:
|
||||
if a["date"]:
|
||||
try:
|
||||
day = a["date"][:10]
|
||||
except Exception:
|
||||
day = "Unknown"
|
||||
else:
|
||||
day = "Unknown"
|
||||
grouped.setdefault(day, []).append(a)
|
||||
|
||||
return render_template(
|
||||
"articles.html",
|
||||
source_name=source_name.title(),
|
||||
source_slug=source_name.lower(),
|
||||
articles=articles_data,
|
||||
articles_by_date=grouped.items(),
|
||||
pagination=pagination,
|
||||
)
|
||||
|
||||
|
||||
def validate_archive_path(archive_path: str) -> Optional[Path]:
|
||||
"""Validate archive path is within ARCHIVE_DIR (prevents path traversal).
|
||||
|
||||
URL-decodes path first to block encoded traversal (..%2F).
|
||||
|
||||
Args:
|
||||
archive_path: Requested path component
|
||||
|
||||
Returns:
|
||||
Resolved Path if valid, None if traversal detected
|
||||
"""
|
||||
decoded = unquote(archive_path)
|
||||
try:
|
||||
archive_file = (ARCHIVE_DIR / decoded).resolve()
|
||||
if not str(archive_file).startswith(str(ARCHIVE_DIR)):
|
||||
logger.warning("Path traversal attempt blocked: %s", archive_path)
|
||||
return None
|
||||
return archive_file
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
@app.route("/archive/<path:archive_path>")
|
||||
@login_required
|
||||
def serve_archive(archive_path):
|
||||
"""Serve archived HTML file."""
|
||||
archive_file = validate_archive_path(archive_path)
|
||||
if archive_file and archive_file.exists():
|
||||
return archive_file.read_text(encoding="utf-8")
|
||||
return "Archive not found", 404
|
||||
|
||||
|
||||
@app.route("/archive-file/<path:encoded_path>")
|
||||
@login_required
|
||||
def serve_archive_file(encoded_path):
|
||||
"""Serve archived HTML file from encoded path."""
|
||||
archive_file = validate_archive_path(encoded_path)
|
||||
if archive_file and archive_file.exists():
|
||||
return archive_file.read_text(encoding="utf-8")
|
||||
return "Archive not found", 404
|
||||
|
||||
|
||||
@app.route("/source/<slug>/article/<int:article_id>")
|
||||
@login_required
|
||||
def article(slug: str, article_id: int):
|
||||
"""Individual article page."""
|
||||
sources = get_all_sources()
|
||||
source_name = None
|
||||
for s in sources:
|
||||
if s.lower() == slug.lower():
|
||||
source_name = s
|
||||
break
|
||||
|
||||
if not source_name:
|
||||
return render_template(
|
||||
"article_not_found.html", slug=slug, article_id=article_id
|
||||
), 404
|
||||
|
||||
article = get_article(source_name, article_id)
|
||||
if not article:
|
||||
return render_template(
|
||||
"article_not_found.html", slug=slug, article_id=article_id
|
||||
), 404
|
||||
|
||||
article_data = {
|
||||
"id": article_id,
|
||||
"title": article.title or "Untitled",
|
||||
"publish_date": article.publish_date or "",
|
||||
"author": article.author or "",
|
||||
"url": article.url or "",
|
||||
"content_text": article.content_text or "",
|
||||
"archive_file_path": article.archive_file_path or "",
|
||||
}
|
||||
|
||||
return render_template(
|
||||
"article.html",
|
||||
source_name=source_name.title(),
|
||||
source_slug=source_name.lower(),
|
||||
article=article_data,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/status")
|
||||
@login_required
|
||||
def status():
|
||||
"""System status page."""
|
||||
sources = get_all_sources()
|
||||
rss_feeds = load_rss_feeds()
|
||||
|
||||
sources_info = []
|
||||
disabled_sources = []
|
||||
total_articles = 0
|
||||
failed_jobs = 0
|
||||
disabled_count = 0
|
||||
|
||||
for source_name in sources:
|
||||
stats = get_source_stats(source_name)
|
||||
|
||||
source_info = {
|
||||
"name": source_name.title(),
|
||||
"slug": source_name,
|
||||
"article_count": stats["total_articles"],
|
||||
"last_archived": stats.get("last_archived"),
|
||||
"status": "success" if stats["total_articles"] > 0 else "pending",
|
||||
}
|
||||
|
||||
if source_name in rss_feeds:
|
||||
feed_info = rss_feeds[source_name]
|
||||
if feed_info.get("disabled", False):
|
||||
source_info["disabled"] = True
|
||||
disabled_count += 1
|
||||
disabled_sources.append(source_info)
|
||||
continue
|
||||
|
||||
sources_info.append(source_info)
|
||||
total_articles += stats["total_articles"]
|
||||
failed_jobs += stats["failed"]
|
||||
|
||||
sources_info.extend(disabled_sources)
|
||||
|
||||
return render_template(
|
||||
"status.html",
|
||||
sources=sources_info,
|
||||
total_articles=total_articles,
|
||||
failed_jobs=failed_jobs,
|
||||
sources_monitored=len(sources) - disabled_count,
|
||||
disabled_sources=disabled_count,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/sources")
|
||||
@login_required
|
||||
def api_sources():
|
||||
"""API endpoint for listing all sources."""
|
||||
sources = get_all_sources()
|
||||
rss_feeds = load_rss_feeds()
|
||||
|
||||
source_list = []
|
||||
disabled_sources = []
|
||||
|
||||
for source_name in sources:
|
||||
stats = get_source_stats(source_name)
|
||||
|
||||
source_info = {
|
||||
"name": source_name.title(),
|
||||
"slug": source_name,
|
||||
"article_count": stats["total_articles"],
|
||||
"last_archived": stats.get("last_archived"),
|
||||
"status": "success" if stats["total_articles"] > 0 else "pending",
|
||||
}
|
||||
|
||||
if source_name in rss_feeds:
|
||||
feed_info = rss_feeds[source_name]
|
||||
if feed_info.get("disabled", False):
|
||||
source_info["disabled"] = True
|
||||
source_info["disable_reason"] = feed_info.get(
|
||||
"disable_reason", "No reason provided"
|
||||
)
|
||||
disabled_sources.append(source_info)
|
||||
continue
|
||||
|
||||
source_list.append(source_info)
|
||||
|
||||
source_list.extend(disabled_sources)
|
||||
|
||||
return jsonify({"sources": source_list})
|
||||
|
||||
|
||||
@app.route("/api/source/<slug>/articles")
|
||||
@login_required
|
||||
def api_articles(slug: str):
|
||||
"""API endpoint for listing articles for a source."""
|
||||
page = request.args.get("page", 1, type=int)
|
||||
per_page = 50
|
||||
|
||||
sources = get_all_sources()
|
||||
source_name = None
|
||||
for s in sources:
|
||||
if s.lower() == slug.lower():
|
||||
source_name = s
|
||||
break
|
||||
|
||||
if not source_name:
|
||||
return jsonify({"error": "Source not found"}), 404
|
||||
|
||||
articles_list = get_articles_by_source(
|
||||
source_name, limit=per_page, offset=(page - 1) * per_page
|
||||
)
|
||||
stats = get_source_stats(source_name)
|
||||
total = stats["total_articles"]
|
||||
|
||||
pagination = get_pagination_info(total, page, per_page)
|
||||
|
||||
articles_data = []
|
||||
for article in articles_list:
|
||||
articles_data.append(
|
||||
{
|
||||
"id": getattr(article, "id", 0),
|
||||
"title": article.title or "Untitled",
|
||||
"date": article.publish_date or "",
|
||||
"summary": article.content_text[:200] if article.content_text else "",
|
||||
"url": f"/source/{source_name.lower()}/article/{getattr(article, 'id', 0)}",
|
||||
}
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"source_name": source_name.title(),
|
||||
"articles": articles_data,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"has_next": pagination["has_next"],
|
||||
"has_prev": pagination["has_prev"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/status")
|
||||
@login_required
|
||||
def api_status():
|
||||
"""API endpoint for system status."""
|
||||
sources = get_all_sources()
|
||||
|
||||
sources_monitored = len(sources)
|
||||
total_articles = 0
|
||||
failed_jobs = 0
|
||||
last_archive_run = None
|
||||
|
||||
for source_name in sources:
|
||||
stats = get_source_stats(source_name)
|
||||
total_articles += stats["total_articles"]
|
||||
failed_jobs += stats["failed"]
|
||||
|
||||
if stats.get("last_archive_run"):
|
||||
if last_archive_run is None or stats["last_archive_run"] > last_archive_run:
|
||||
last_archive_run = stats["last_archive_run"]
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "online",
|
||||
"last_archive_run": last_archive_run,
|
||||
"pending_jobs": 0,
|
||||
"failed_jobs": failed_jobs,
|
||||
"sources_monitored": sources_monitored,
|
||||
"total_articles": total_articles,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/rss")
|
||||
def rss_feed():
|
||||
"""RSS 2.0 endpoint for latest archived articles."""
|
||||
limit = request.args.get("limit", 50, type=int)
|
||||
|
||||
articles = get_latest_articles(limit=limit)
|
||||
|
||||
rss_items = []
|
||||
for article in articles:
|
||||
if (
|
||||
article.title
|
||||
and article.content_text
|
||||
and "Performing security verification" not in article.content_text
|
||||
):
|
||||
pub_date = None
|
||||
if article.publish_date:
|
||||
try:
|
||||
dt = datetime.fromisoformat(
|
||||
article.publish_date.replace("Z", "+00:00")
|
||||
)
|
||||
pub_date = dt.strftime("%a, %d %b %Y %H:%M:%S %z").strip()
|
||||
except (ValueError, AttributeError):
|
||||
try:
|
||||
dt = datetime.fromisoformat(article.publish_date)
|
||||
pub_date = dt.strftime("%a, %d %b %Y %H:%M:%S +0000")
|
||||
except (ValueError, AttributeError):
|
||||
pub_date = datetime.now(timezone.utc).strftime(
|
||||
"%a, %d %b %Y %H:%M:%S +0000"
|
||||
)
|
||||
|
||||
source_name = article.source_name or "unknown"
|
||||
encoded_source = quote(source_name.lower())
|
||||
item = {
|
||||
"title": html.escape(article.title),
|
||||
"link": f"{SERVER_URL}/source/{encoded_source}/article/{article.id}",
|
||||
"pubDate": pub_date,
|
||||
"description": html.escape(
|
||||
article.content_text[:500] if article.content_text else ""
|
||||
),
|
||||
"guid": html.escape(article.url or f"article-{article.id}"),
|
||||
}
|
||||
if article.author:
|
||||
item["author"] = html.escape(article.author)
|
||||
rss_items.append(item)
|
||||
|
||||
rss_template = render_template(
|
||||
"rss.xml",
|
||||
title="NewsArchiver - Latest Articles",
|
||||
link=SERVER_URL,
|
||||
description="Latest archived news articles",
|
||||
last_build_date=datetime.now(timezone.utc).strftime(
|
||||
"%a, %d %b %Y %H:%M:%S +0000"
|
||||
),
|
||||
items=rss_items,
|
||||
)
|
||||
|
||||
response = make_response(rss_template)
|
||||
response.headers["Content-Type"] = "application/rss+xml; charset=utf-8"
|
||||
return response
|
||||
|
||||
|
||||
@app.route("/atom")
|
||||
def atom_feed():
|
||||
"""Atom 1.0 endpoint for latest archived articles."""
|
||||
limit = request.args.get("limit", 50, type=int)
|
||||
|
||||
articles = get_latest_articles(limit=limit)
|
||||
|
||||
atom_entries = []
|
||||
for article in articles:
|
||||
if (
|
||||
article.title
|
||||
and article.content_text
|
||||
and "Performing security verification" not in article.content_text
|
||||
):
|
||||
pub_date = None
|
||||
if article.publish_date:
|
||||
try:
|
||||
dt = datetime.fromisoformat(
|
||||
article.publish_date.replace("Z", "+00:00")
|
||||
)
|
||||
pub_date = dt.strftime("%Y-%m-%dT%H:%M:%S+00:00")
|
||||
except (ValueError, AttributeError):
|
||||
pub_date = datetime.now(timezone.utc).strftime(
|
||||
"%Y-%m-%dT%H:%M:%S+00:00"
|
||||
)
|
||||
|
||||
source_name = article.source_name or "unknown"
|
||||
encoded_source = quote(source_name.lower())
|
||||
entry = {
|
||||
"title": html.escape(article.title),
|
||||
"link": f"{SERVER_URL}/source/{encoded_source}/article/{article.id}",
|
||||
"published": pub_date,
|
||||
"summary": html.escape(
|
||||
article.content_text[:500] if article.content_text else ""
|
||||
),
|
||||
"id": html.escape(article.url or f"article-{article.id}"),
|
||||
}
|
||||
if article.author:
|
||||
entry["author"] = {"name": html.escape(article.author)}
|
||||
atom_entries.append(entry)
|
||||
|
||||
atom_template = render_template(
|
||||
"atom.xml",
|
||||
title="NewsArchiver - Latest Articles",
|
||||
link=SERVER_URL,
|
||||
updated=datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S+00:00"),
|
||||
entries=atom_entries,
|
||||
)
|
||||
|
||||
response = make_response(atom_template)
|
||||
response.headers["Content-Type"] = "application/atom+xml; charset=utf-8"
|
||||
return response
|
||||
|
||||
|
||||
def load_rss_feeds() -> dict:
|
||||
"""Load RSS feeds configuration, resolving environment variables in API keys."""
|
||||
global RSS_FEEDS
|
||||
|
||||
if RSS_FEEDS:
|
||||
return RSS_FEEDS
|
||||
|
||||
if not RSS_FEEDS_PATH.exists():
|
||||
logger.warning("RSS feeds file not found: %s", RSS_FEEDS_PATH)
|
||||
return {}
|
||||
|
||||
try:
|
||||
raw_content = RSS_FEEDS_PATH.read_text(encoding="utf-8")
|
||||
for key, value in os.environ.items():
|
||||
raw_content = raw_content.replace(f"${{{key}}}", value)
|
||||
RSS_FEEDS = json.loads(raw_content)
|
||||
return RSS_FEEDS
|
||||
except Exception as e:
|
||||
logger.error("Failed to load RSS feeds: %s", sanitize_error_message(str(e)))
|
||||
return {}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logger.info("Starting web interface...")
|
||||
|
||||
if not DB_PATH.exists():
|
||||
logger.info("Database not found, initializing...")
|
||||
from storage_manager import initialize_storage
|
||||
|
||||
initialize_storage()
|
||||
|
||||
app.run(host="0.0.0.0", port=5000, debug=False)
|
||||
Loading…
x
Reference in New Issue
Block a user