Compare commits

..

3 Commits

Author SHA1 Message Date
1c9d466b8f Add Kittina Ivory alias 2026-07-21 20:24:07 +02:00
16c7e78686 Document scheduled WCX updates 2026-07-20 16:39:11 +02:00
87e9265b22 Discover all site pages automatically 2026-07-20 12:29:43 +02:00
3 changed files with 218 additions and 13 deletions

141
docs/scheduled-update.md Normal file
View File

@ -0,0 +1,141 @@
# Schemalagd WCX-uppdatering
## Syfte
Systemd-jobbet uppdaterar regelbundet WCX-projektets lokala sajtindex och
facitdatabas samt behandlar väntande OCR-poster. Jobbet körs som användaren
`urban`.
Systemd startar först:
- `/storage/disk1/WCX/scripts/scheduled_update_wcx.sh`
Wrappern avgör om en uppdatering ska göras och anropar därefter:
- `/storage/disk1/WCX/scripts/update_wcx.sh`
`update_wcx.sh` hämtar sajtindexet, importerar metadata till SQLite och kör
OCR-flödet.
## Installerade systemd-filer
Konfigurationen består av:
- service: `/etc/systemd/system/wcx-update.service`
- timer: `/etc/systemd/system/wcx-update.timer`
- miljöfil: `/etc/wcx/update-wcx.env`
Servicen körs som `urban`. Miljöfilen innehåller
`GOOGLE_VISION_API_KEY` och ska ägas av `root:root` med rättigheten
`0600`. Den faktiska API-nyckeln får aldrig läggas i Git, skrivas in i detta
dokument eller visas i terminalutdata och loggar.
Exempel på miljöfilens format, utan verkligt nyckelvärde:
```text
GOOGLE_VISION_API_KEY=<hemligt värde>
```
## Schema och körningsskydd
Timern kontrollerar varje natt klockan 03:30 om jobbet ska startas:
```ini
OnCalendar=*-*-* 03:30:00
Persistent=true
```
`scheduled_update_wcx.sh` kontrollerar tidsstämpeln i
`database/last_scheduled_update`. Den fullständiga uppdateringen körs endast
om minst 47 timmar har gått sedan den senaste lyckade schemalagda körningen.
Tidsstämpeln uppdateras först efter att `update_wcx.sh` har lyckats.
`Persistent=true` innebär att systemd försöker ta igen en missad
kalenderkörning efter att datorn har varit avstängd. Wrapperns 47-timmarskontroll
avgör då om själva uppdateringen behöver köras.
`update_wcx.sh` tar ett exklusivt, icke-blockerande `flock`-lås via
`database/update_wcx.lock`. En ny körning avbryts om en annan uppdatering
redan pågår.
## Kontroll och validering
Kontrollera service och timer:
```bash
sudo systemctl status wcx-update.service
sudo systemctl status wcx-update.timer
sudo systemctl list-timers --all wcx-update.timer
```
Validera unit-filerna efter en konfigurationsändring:
```bash
sudo systemd-analyze verify \
/etc/systemd/system/wcx-update.service \
/etc/systemd/system/wcx-update.timer
```
Starta en manuell körning genom systemd:
```bash
sudo systemctl start wcx-update.service
```
Den manuella servicekörningen går genom `scheduled_update_wcx.sh`. Om mindre
än 47 timmar har gått sedan senaste lyckade schemalagda körning avslutas den
utan att starta en ny fullständig uppdatering.
Aktivera och starta timern:
```bash
sudo systemctl enable --now wcx-update.timer
```
Stoppa och inaktivera timern:
```bash
sudo systemctl disable --now wcx-update.timer
```
Att stoppa timern stoppar inte automatiskt en servicekörning som redan pågår.
Kontrollera därför servicens status separat innan en pågående körning stoppas.
## Loggar
Scriptens standardutdata och felutdata samlas av systemd-journalen. Jobbet
skriver ingen separat loggfil.
Visa hela serviceloggen:
```bash
sudo journalctl -u wcx-update.service
```
Följ en pågående körning:
```bash
sudo journalctl -u wcx-update.service -f
```
Visa loggar från den aktuella uppstarten:
```bash
sudo journalctl -b -u wcx-update.service
```
## Filer som jobbet skriver
En fullständig uppdatering kan skriva eller ersätta:
- `/storage/disk1/WCX/import/wcx_site_index.json.tmp`
- `/storage/disk1/WCX/import/wcx_site_index.json`
- `/storage/disk1/WCX/database/wcx.db`
- SQLite-journal- eller WAL-filer bredvid databasen
- `/storage/disk1/WCX/database/update_wcx.lock`
- `/storage/disk1/WCX/database/last_scheduled_update`
Sajtindexet skrivs först till en temporär fil och ersätter sedan den ordinarie
JSON-filen. Import- och OCR-stegen uppdaterar facitdatabasen. Lockfilen används
för att förhindra överlappande körningar, och tidsstämpelfilen används av
wrappern för 47-timmarskontrollen.

View File

@ -0,0 +1,17 @@
PRAGMA foreign_keys = ON;
INSERT INTO movie_name_alias (
movie_id,
alias,
normalized_alias,
source
)
SELECT
id,
'Kittina Ivory',
'kittinaivory',
'manual'
FROM movie
WHERE id = 'kittina-clairette_7801'
AND name = 'Kittina Clairette'
ON CONFLICT (movie_id, normalized_alias) DO NOTHING;

View File

@ -9,12 +9,12 @@ import urllib.error
import urllib.request import urllib.request
from html.parser import HTMLParser from html.parser import HTMLParser
from pathlib import Path from pathlib import Path
from urllib.parse import urljoin, urlparse from urllib.parse import parse_qs, urljoin, urlparse
BASE_URL = "https://www.woodmancastingx.com" BASE_URL = "https://www.woodmancastingx.com"
LIST_PATH = "/casting-xxx/" LIST_PATH = "/casting-xxx/"
DEFAULT_OUT = "/storage/disk1/X/wcx_index.json" DEFAULT_OUT = "/storage/disk1/X/wcx_index.json"
DEFAULT_PAGES = 48 MAX_PAGES = 200
DEFAULT_SLEEP_MS = 300 DEFAULT_SLEEP_MS = 300
USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) wcx_sync.py/1.0" USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) wcx_sync.py/1.0"
@ -217,6 +217,41 @@ def parse_list_page(data):
return items return items
def parse_last_page(data):
parser = TreeParser()
parser.feed(data)
base = urlparse(BASE_URL)
page_numbers = []
for node in walk(parser.root):
if node.name != "a":
continue
href = (node.attrs.get("href") or "").strip()
if not href:
continue
target = urlparse(urljoin(BASE_URL, href))
if target.netloc != base.netloc or target.path != LIST_PATH:
continue
for value in parse_qs(target.query).get("page", []):
if value.isdigit() and int(value) >= 1:
page_numbers.append(int(value))
if not page_numbers:
raise RuntimeError("Could not detect a numeric last page from page 1 pagination")
last_page = max(page_numbers)
if last_page > MAX_PAGES:
raise RuntimeError(
f"Detected last page {last_page}, exceeding safety limit {MAX_PAGES}"
)
return last_page
class DetailDateParser(HTMLParser): class DetailDateParser(HTMLParser):
def __init__(self): def __init__(self):
super().__init__(convert_charrefs=True) super().__init__(convert_charrefs=True)
@ -344,7 +379,7 @@ def write_json_one_object_per_line(path, items):
def main(): def main():
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("--out", default=DEFAULT_OUT) ap.add_argument("--out", default=DEFAULT_OUT)
ap.add_argument("--pages", "--end-page", dest="pages", type=int, default=DEFAULT_PAGES) ap.add_argument("--pages", "--end-page", dest="pages", type=int)
ap.add_argument("--sleep-ms", type=int, default=DEFAULT_SLEEP_MS) ap.add_argument("--sleep-ms", type=int, default=DEFAULT_SLEEP_MS)
ap.add_argument("--timeout", type=int, default=20) ap.add_argument("--timeout", type=int, default=20)
ap.add_argument("--retries", type=int, default=3) ap.add_argument("--retries", type=int, default=3)
@ -354,29 +389,41 @@ def main():
out_path = Path(args.out) out_path = Path(args.out)
print(f"Start. pages=1..{args.pages}, out={out_path}, dry_run={args.dry_run}") requested_pages = args.pages if args.pages is not None else "auto"
print(f"Start. pages={requested_pages}, out={out_path}, dry_run={args.dry_run}")
if args.pages is not None and not 1 <= args.pages <= MAX_PAGES:
ap.error(f"--pages must be between 1 and {MAX_PAGES}")
list_items = [] list_items = []
pages_fetched = 0 pages_fetched = 0
for page in range(1, args.pages + 1): first_url = f"{BASE_URL}{LIST_PATH}?page=1"
url = f"{BASE_URL}{LIST_PATH}?page={page}" print(f"Fetching list page 1: {first_url}")
print(f"Fetching list page {page}: {url}") first_data = fetch_url(first_url, args.timeout, args.retries, args.debug)
try: last_page = args.pages if args.pages is not None else parse_last_page(first_data)
print(f"Using last list page: {last_page}")
for page in range(1, last_page + 1):
url = f"{BASE_URL}{LIST_PATH}?page={page}"
if page == 1:
data = first_data
else:
print(f"Fetching list page {page}: {url}")
data = fetch_url(url, args.timeout, args.retries, args.debug) data = fetch_url(url, args.timeout, args.retries, args.debug)
except RuntimeError as e:
print(f"WARNING: {e}", file=sys.stderr)
continue
page_items = parse_list_page(data) page_items = parse_list_page(data)
if not page_items:
raise RuntimeError(f"List page {page} returned zero results: {url}")
list_items.extend(page_items) list_items.extend(page_items)
pages_fetched += 1 pages_fetched += 1
if args.debug: if args.debug:
print(f"DEBUG: page {page} items={len(page_items)}, accumulated={len(list_items)}") print(f"DEBUG: page {page} items={len(page_items)}, accumulated={len(list_items)}")
if page < args.pages and args.sleep_ms > 0: if page < last_page and args.sleep_ms > 0:
time.sleep(args.sleep_ms / 1000) time.sleep(args.sleep_ms / 1000)
list_items = unique_by_title(list_items) list_items = unique_by_title(list_items)
@ -418,7 +465,7 @@ def main():
merged = merge_items(old_items, enriched_items) merged = merge_items(old_items, enriched_items)
merged = sort_items(merged) merged = sort_items(merged)
print(f"Pages fetched: {pages_fetched} / {args.pages}") print(f"Pages fetched: {pages_fetched} / {last_page}")
print(f"new_items: {len(enriched_items)} | old_items: {len(old_items)} | merged: {len(merged)}") print(f"new_items: {len(enriched_items)} | old_items: {len(old_items)} | merged: {len(merged)}")
if args.dry_run: if args.dry_run: