Compare commits
3 Commits
6bdb9b5932
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c9d466b8f | |||
| 16c7e78686 | |||
| 87e9265b22 |
141
docs/scheduled-update.md
Normal file
141
docs/scheduled-update.md
Normal 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.
|
||||||
17
migration/add_kittina_ivory_alias.sql
Normal file
17
migration/add_kittina_ivory_alias.sql
Normal 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;
|
||||||
@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user